Add Alt-detach hints to wall endpoint move controls
This commit is contained in:
@@ -21,7 +21,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { Move } from 'lucide-react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
@@ -59,6 +59,7 @@ export function FloatingActionMenu() {
|
||||
const groupRef = useRef<THREE.Group>(null)
|
||||
const startEndpointGroupRef = useRef<THREE.Group>(null)
|
||||
const endEndpointGroupRef = useRef<THREE.Group>(null)
|
||||
const [altPressed, setAltPressed] = useState(false)
|
||||
|
||||
// Only show for single selection of specific types
|
||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
||||
@@ -77,6 +78,34 @@ export function FloatingActionMenu() {
|
||||
return false
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Alt') {
|
||||
setAltPressed(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Alt') {
|
||||
setAltPressed(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
setAltPressed(false)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
window.addEventListener('blur', handleBlur)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('blur', handleBlur)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useFrame(() => {
|
||||
if (!(selectedId && isValidType && groupRef.current)) return
|
||||
|
||||
@@ -384,9 +413,14 @@ export function FloatingActionMenu() {
|
||||
<Html center style={{ pointerEvents: 'auto', touchAction: 'none' }} zIndexRange={[100, 0]}>
|
||||
<button
|
||||
aria-label="Move wall start"
|
||||
className="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-muted-foreground shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-foreground"
|
||||
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
||||
altPressed
|
||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
}`}
|
||||
onClick={(e) => handleEndpointMove('start', e)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Move wall start (Alt to detach)"
|
||||
type="button"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
@@ -397,9 +431,14 @@ export function FloatingActionMenu() {
|
||||
<Html center style={{ pointerEvents: 'auto', touchAction: 'none' }} zIndexRange={[100, 0]}>
|
||||
<button
|
||||
aria-label="Move wall end"
|
||||
className="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-muted-foreground shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-foreground"
|
||||
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
||||
altPressed
|
||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
}`}
|
||||
onClick={(e) => handleEndpointMove('end', e)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Move wall end (Alt to detach)"
|
||||
type="button"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
|
||||
@@ -2101,6 +2101,7 @@ function buildWallEndpointDragDraft(
|
||||
'wallId' | 'endpoint' | 'fixedPoint' | 'originalStart' | 'originalEnd' | 'linkedWalls'
|
||||
>,
|
||||
movingPoint: WallPlanPoint,
|
||||
detachLinkedWalls = false,
|
||||
): WallEndpointDraft {
|
||||
const nextDraft = buildWallEndpointDraft(
|
||||
dragState.wallId,
|
||||
@@ -2111,13 +2112,15 @@ function buildWallEndpointDragDraft(
|
||||
|
||||
return {
|
||||
...nextDraft,
|
||||
linkedUpdates: getLinkedWallUpdates(
|
||||
dragState.linkedWalls,
|
||||
dragState.originalStart,
|
||||
dragState.originalEnd,
|
||||
nextDraft.start,
|
||||
nextDraft.end,
|
||||
),
|
||||
linkedUpdates: detachLinkedWalls
|
||||
? []
|
||||
: getLinkedWallUpdates(
|
||||
dragState.linkedWalls,
|
||||
dragState.originalStart,
|
||||
dragState.originalEnd,
|
||||
nextDraft.start,
|
||||
nextDraft.end,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4971,6 +4974,7 @@ export function FloorplanPanel() {
|
||||
const [floorplanCursorPosition, setFloorplanCursorPosition] = useState<SvgPoint | null>(null)
|
||||
const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
|
||||
const [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
|
||||
const [altPressed, setAltPressed] = useState(false)
|
||||
const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)
|
||||
const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null)
|
||||
const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
|
||||
@@ -6740,6 +6744,9 @@ export function FloorplanPanel() {
|
||||
if (event.key === 'Shift') {
|
||||
setShiftPressed(true)
|
||||
}
|
||||
if (event.key === 'Alt') {
|
||||
setAltPressed(true)
|
||||
}
|
||||
|
||||
if (isStairBuildActive && (event.key === 'r' || event.key === 'R')) {
|
||||
setStairBuildPreviewRotation((current) => current + Math.PI / 4)
|
||||
@@ -6762,11 +6769,15 @@ export function FloorplanPanel() {
|
||||
if (event.key === 'Shift') {
|
||||
setShiftPressed(false)
|
||||
}
|
||||
if (event.key === 'Alt') {
|
||||
setAltPressed(false)
|
||||
}
|
||||
|
||||
setRotationModifierPressed(event.metaKey || event.ctrlKey)
|
||||
}
|
||||
const handleBlur = () => {
|
||||
setShiftPressed(false)
|
||||
setAltPressed(false)
|
||||
setRotationModifierPressed(false)
|
||||
}
|
||||
|
||||
@@ -6832,7 +6843,7 @@ export function FloorplanPanel() {
|
||||
dragState.currentPoint = snappedPoint
|
||||
setCursorPoint(snappedPoint)
|
||||
setWallEndpointDraft((previousDraft) => {
|
||||
const nextDraft = buildWallEndpointDragDraft(dragState, snappedPoint)
|
||||
const nextDraft = buildWallEndpointDragDraft(dragState, snappedPoint, event.altKey)
|
||||
|
||||
if (
|
||||
!(
|
||||
@@ -6955,7 +6966,7 @@ export function FloorplanPanel() {
|
||||
|
||||
const wall = wallById.get(dragState.wallId)
|
||||
if (wall) {
|
||||
const nextDraft = buildWallEndpointDragDraft(dragState, dragState.currentPoint)
|
||||
const nextDraft = buildWallEndpointDragDraft(dragState, dragState.currentPoint, altPressed)
|
||||
const hasChanged = !(
|
||||
pointsEqual(nextDraft.start, wall.start) && pointsEqual(nextDraft.end, wall.end)
|
||||
)
|
||||
@@ -7043,6 +7054,7 @@ export function FloorplanPanel() {
|
||||
getSvgPointFromClientPoint,
|
||||
guideById,
|
||||
getPlanPointFromClientPoint,
|
||||
altPressed,
|
||||
shiftPressed,
|
||||
updateNode,
|
||||
wallById,
|
||||
@@ -9919,21 +9931,47 @@ export function FloorplanPanel() {
|
||||
!movingNode &&
|
||||
!curvingWall &&
|
||||
selectedWallCornerMoveActions.map(({ endpoint, x, y }) => (
|
||||
<button
|
||||
aria-label={endpoint === 'start' ? 'Move wall start' : 'Move wall end'}
|
||||
className="pointer-events-auto absolute z-30 flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-muted-foreground shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-foreground"
|
||||
<div
|
||||
className="absolute z-30"
|
||||
key={`selected-wall-corner-move-${endpoint}`}
|
||||
onPointerDown={(event) => handleSelectedWallCornerMovePointerDown(endpoint, event)}
|
||||
style={{
|
||||
left: x,
|
||||
top: y,
|
||||
transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y + 6}px))`,
|
||||
transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y - 4}px))`,
|
||||
}}
|
||||
title={endpoint === 'start' ? 'Move wall start' : 'Move wall end'}
|
||||
type="button"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
aria-label={endpoint === 'start' ? 'Move wall start' : 'Move wall end'}
|
||||
className={cn(
|
||||
'pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors',
|
||||
altPressed
|
||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
onPointerDown={(event) => handleSelectedWallCornerMovePointerDown(endpoint, event)}
|
||||
title={
|
||||
endpoint === 'start'
|
||||
? 'Move wall start (Alt to detach)'
|
||||
: 'Move wall end (Alt to detach)'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
</button>
|
||||
{wallEndpointDraft?.wallId === selectedWallEntry?.wall.id &&
|
||||
wallEndpointDraft.endpoint === endpoint && (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none mt-2 whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors',
|
||||
altPressed
|
||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
|
||||
: 'border-border bg-background/95 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{altPressed ? 'Detaching corner' : 'Alt to detach'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{selectedSlabActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
@@ -87,6 +88,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const previousGridPosRef = useRef<WallPlanPoint | null>(null)
|
||||
const shiftPressedRef = useRef(false)
|
||||
const altPressedRef = useRef(false)
|
||||
const nodeIdRef = useRef(target.wall.id)
|
||||
const originalStartRef = useRef<WallPlanPoint>([...target.wall.start] as WallPlanPoint)
|
||||
const originalEndRef = useRef<WallPlanPoint>([...target.wall.end] as WallPlanPoint)
|
||||
@@ -109,6 +111,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
|
||||
return [point[0], 0, point[1]]
|
||||
})
|
||||
const [altPressed, setAltPressed] = useState(false)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingWallEndpoint(null)
|
||||
@@ -141,20 +144,22 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const applyPreview = (movingPoint: WallPlanPoint) => {
|
||||
const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
|
||||
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
||||
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
||||
previewRef.current = { start: nextStart, end: nextEnd }
|
||||
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: nextStart, end: nextEnd },
|
||||
...getLinkedWallUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
),
|
||||
...(detachLinkedWalls
|
||||
? []
|
||||
: getLinkedWallUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -181,7 +186,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
}
|
||||
previousGridPosRef.current = snappedPoint
|
||||
|
||||
applyPreview(snappedPoint)
|
||||
applyPreview(snappedPoint, event.nativeEvent.altKey)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
@@ -199,13 +204,15 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
useScene.temporal.getState().resume()
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: preview.start, end: preview.end },
|
||||
...getLinkedWallUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
preview.start,
|
||||
preview.end,
|
||||
),
|
||||
...(altPressedRef.current
|
||||
? []
|
||||
: getLinkedWallUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
preview.start,
|
||||
preview.end,
|
||||
)),
|
||||
])
|
||||
useScene.temporal.getState().pause()
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
@@ -231,12 +238,26 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = true
|
||||
}
|
||||
if (event.key === 'Alt') {
|
||||
altPressedRef.current = true
|
||||
setAltPressed(true)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = false
|
||||
}
|
||||
if (event.key === 'Alt') {
|
||||
altPressedRef.current = false
|
||||
setAltPressed(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onWindowBlur = () => {
|
||||
shiftPressedRef.current = false
|
||||
altPressedRef.current = false
|
||||
setAltPressed(false)
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
@@ -244,6 +265,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onWindowBlur)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
@@ -255,12 +277,30 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onWindowBlur)
|
||||
}
|
||||
}, [exitMoveMode, target])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
<Html
|
||||
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
|
||||
style={{ pointerEvents: 'none', touchAction: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div className="translate-y-10">
|
||||
<div
|
||||
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
|
||||
altPressed
|
||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
|
||||
: 'border-border bg-background/95 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{altPressed ? 'Detaching corner' : 'Alt to detach'}
|
||||
</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user