Add Alt-detach hints to wall endpoint move controls

This commit is contained in:
sudhir
2026-04-16 12:27:58 +05:30
parent 1cd71630bc
commit a7b97caeb0
3 changed files with 154 additions and 37 deletions
@@ -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>
)
}