@pascal-app/editor 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. package/src/store/use-editor.tsx +125 -10
@@ -0,0 +1,176 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ emitter,
6
+ type GridEvent,
7
+ getClampedWallCurveOffset,
8
+ getMaxWallCurveOffset,
9
+ getWallChordFrame,
10
+ getWallMidpointHandlePoint,
11
+ normalizeWallCurveOffset,
12
+ useScene,
13
+ type WallNode,
14
+ } from '@pascal-app/core'
15
+ import { useViewer } from '@pascal-app/viewer'
16
+ import { useCallback, useEffect, useRef, useState } from 'react'
17
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
18
+ import { sfxEmitter } from '../../../lib/sfx-bus'
19
+ import useEditor from '../../../store/use-editor'
20
+ import { CursorSphere } from '../shared/cursor-sphere'
21
+ import { getWallGridStep, snapScalarToGrid } from './wall-drafting'
22
+
23
+ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
24
+ const activatedAtRef = useRef<number>(Date.now())
25
+ const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
26
+ const previousCurveOffsetRef = useRef<number | null>(null)
27
+ const shiftPressedRef = useRef(false)
28
+ const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
29
+
30
+ const initialHandle = getWallMidpointHandlePoint(node)
31
+ const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
32
+ initialHandle.x,
33
+ 0,
34
+ initialHandle.y,
35
+ ])
36
+
37
+ const exitCurveMode = useCallback(() => {
38
+ useEditor.getState().setCurvingWall(null)
39
+ }, [])
40
+
41
+ useEffect(() => {
42
+ const nodeId = node.id
43
+ const originalCurveOffset = originalCurveOffsetRef.current
44
+ const chord = getWallChordFrame(node)
45
+ const maxCurveOffset = getMaxWallCurveOffset(node)
46
+
47
+ useScene.temporal.getState().pause()
48
+ let wasCommitted = false
49
+
50
+ const applyPreview = (curveOffset: number) => {
51
+ if (previewOffsetRef.current === curveOffset) {
52
+ return
53
+ }
54
+ previewOffsetRef.current = curveOffset
55
+
56
+ const nextNode = {
57
+ ...node,
58
+ curveOffset,
59
+ }
60
+ const handlePoint = getWallMidpointHandlePoint(nextNode)
61
+ setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
62
+ useScene.getState().updateNode(nodeId, { curveOffset })
63
+ useScene.getState().markDirty(nodeId as AnyNodeId)
64
+ }
65
+
66
+ const restoreOriginal = () => {
67
+ if (previewOffsetRef.current === originalCurveOffset) {
68
+ return
69
+ }
70
+ previewOffsetRef.current = originalCurveOffset
71
+ useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
72
+ useScene.getState().markDirty(nodeId as AnyNodeId)
73
+ }
74
+
75
+ const onGridMove = (event: GridEvent) => {
76
+ const snapStep = getWallGridStep()
77
+ const localX = shiftPressedRef.current
78
+ ? event.localPosition[0]
79
+ : snapScalarToGrid(event.localPosition[0], snapStep)
80
+ const localZ = shiftPressedRef.current
81
+ ? event.localPosition[2]
82
+ : snapScalarToGrid(event.localPosition[2], snapStep)
83
+
84
+ const offsetFromMidpoint =
85
+ -(
86
+ (localX - chord.midpoint.x) * chord.normal.x +
87
+ (localZ - chord.midpoint.y) * chord.normal.y
88
+ )
89
+ const snappedOffset = shiftPressedRef.current
90
+ ? offsetFromMidpoint
91
+ : snapScalarToGrid(offsetFromMidpoint, snapStep)
92
+ const nextCurveOffset = normalizeWallCurveOffset(node, Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)))
93
+
94
+ if (
95
+ previousCurveOffsetRef.current !== null &&
96
+ nextCurveOffset !== previousCurveOffsetRef.current
97
+ ) {
98
+ sfxEmitter.emit('sfx:grid-snap')
99
+ }
100
+ previousCurveOffsetRef.current = nextCurveOffset
101
+
102
+ applyPreview(nextCurveOffset)
103
+ }
104
+
105
+ const onGridClick = (event: GridEvent) => {
106
+ if (Date.now() - activatedAtRef.current < 150) {
107
+ event.nativeEvent?.stopPropagation?.()
108
+ return
109
+ }
110
+
111
+ const curveOffset = previewOffsetRef.current
112
+ wasCommitted = true
113
+
114
+ if (curveOffset !== originalCurveOffset) {
115
+ // Restore original baseline while paused so the next resume+update
116
+ // registers as a single tracked change (undo reverts to original).
117
+ useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
118
+ useScene.getState().markDirty(nodeId as AnyNodeId)
119
+
120
+ useScene.temporal.getState().resume()
121
+ useScene.getState().updateNode(nodeId, { curveOffset })
122
+ useScene.getState().markDirty(nodeId as AnyNodeId)
123
+ useScene.temporal.getState().pause()
124
+ }
125
+
126
+ sfxEmitter.emit('sfx:item-place')
127
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
128
+ exitCurveMode()
129
+ event.nativeEvent?.stopPropagation?.()
130
+ }
131
+
132
+ const onCancel = () => {
133
+ restoreOriginal()
134
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
135
+ useScene.temporal.getState().resume()
136
+ markToolCancelConsumed()
137
+ exitCurveMode()
138
+ }
139
+
140
+ const onKeyDown = (event: KeyboardEvent) => {
141
+ if (event.key === 'Shift') {
142
+ shiftPressedRef.current = true
143
+ }
144
+ }
145
+
146
+ const onKeyUp = (event: KeyboardEvent) => {
147
+ if (event.key === 'Shift') {
148
+ shiftPressedRef.current = false
149
+ }
150
+ }
151
+
152
+ emitter.on('grid:move', onGridMove)
153
+ emitter.on('grid:click', onGridClick)
154
+ emitter.on('tool:cancel', onCancel)
155
+ window.addEventListener('keydown', onKeyDown)
156
+ window.addEventListener('keyup', onKeyUp)
157
+
158
+ return () => {
159
+ if (!wasCommitted) {
160
+ restoreOriginal()
161
+ }
162
+ useScene.temporal.getState().resume()
163
+ emitter.off('grid:move', onGridMove)
164
+ emitter.off('grid:click', onGridClick)
165
+ emitter.off('tool:cancel', onCancel)
166
+ window.removeEventListener('keydown', onKeyDown)
167
+ window.removeEventListener('keyup', onKeyUp)
168
+ }
169
+ }, [exitCurveMode, node])
170
+
171
+ return (
172
+ <group>
173
+ <CursorSphere position={cursorLocalPos} showTooltip={false} />
174
+ </group>
175
+ )
176
+ }
@@ -0,0 +1,322 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ emitter,
6
+ type GridEvent,
7
+ pauseSceneHistory,
8
+ resumeSceneHistory,
9
+ useScene,
10
+ type WallNode,
11
+ } from '@pascal-app/core'
12
+ import { Html } from '@react-three/drei'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { useCallback, useEffect, useRef, useState } from 'react'
15
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
16
+ import { sfxEmitter } from '../../../lib/sfx-bus'
17
+ import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor'
18
+ import { CursorSphere } from '../shared/cursor-sphere'
19
+ import {
20
+ isWallLongEnough,
21
+ snapWallDraftPoint,
22
+ type WallPlanPoint,
23
+ } from './wall-drafting'
24
+
25
+ function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
26
+ return a[0] === b[0] && a[1] === b[1]
27
+ }
28
+
29
+ type LinkedWallSnapshot = {
30
+ id: WallNode['id']
31
+ start: WallPlanPoint
32
+ end: WallPlanPoint
33
+ }
34
+
35
+ function getLinkedWallSnapshots(args: {
36
+ wallId: WallNode['id']
37
+ wallParentId: string | null
38
+ originalStart: WallPlanPoint
39
+ originalEnd: WallPlanPoint
40
+ }) {
41
+ const { wallId, wallParentId, originalStart, originalEnd } = args
42
+ const { nodes } = useScene.getState()
43
+ const snapshots: LinkedWallSnapshot[] = []
44
+
45
+ for (const node of Object.values(nodes)) {
46
+ if (!(node?.type === 'wall' && node.id !== wallId)) {
47
+ continue
48
+ }
49
+
50
+ if ((node.parentId ?? null) !== wallParentId) {
51
+ continue
52
+ }
53
+
54
+ if (
55
+ !samePoint(node.start, originalStart) &&
56
+ !samePoint(node.start, originalEnd) &&
57
+ !samePoint(node.end, originalStart) &&
58
+ !samePoint(node.end, originalEnd)
59
+ ) {
60
+ continue
61
+ }
62
+
63
+ snapshots.push({
64
+ id: node.id,
65
+ start: [...node.start] as WallPlanPoint,
66
+ end: [...node.end] as WallPlanPoint,
67
+ })
68
+ }
69
+
70
+ return snapshots
71
+ }
72
+
73
+ function getLinkedWallUpdates(
74
+ linkedWalls: LinkedWallSnapshot[],
75
+ originalStart: WallPlanPoint,
76
+ originalEnd: WallPlanPoint,
77
+ nextStart: WallPlanPoint,
78
+ nextEnd: WallPlanPoint,
79
+ ) {
80
+ return linkedWalls.map((wall) => ({
81
+ id: wall.id,
82
+ start: samePoint(wall.start, originalStart)
83
+ ? nextStart
84
+ : samePoint(wall.start, originalEnd)
85
+ ? nextEnd
86
+ : wall.start,
87
+ end: samePoint(wall.end, originalStart)
88
+ ? nextStart
89
+ : samePoint(wall.end, originalEnd)
90
+ ? nextEnd
91
+ : wall.end,
92
+ }))
93
+ }
94
+
95
+ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => {
96
+ const activatedAtRef = useRef<number>(Date.now())
97
+ const previousGridPosRef = useRef<WallPlanPoint | null>(null)
98
+ const shiftPressedRef = useRef(false)
99
+ const altPressedRef = useRef(false)
100
+ const nodeIdRef = useRef(target.wall.id)
101
+ const originalStartRef = useRef<WallPlanPoint>([...target.wall.start] as WallPlanPoint)
102
+ const originalEndRef = useRef<WallPlanPoint>([...target.wall.end] as WallPlanPoint)
103
+ const fixedPointRef = useRef<WallPlanPoint>(
104
+ target.endpoint === 'start'
105
+ ? ([...target.wall.end] as WallPlanPoint)
106
+ : ([...target.wall.start] as WallPlanPoint),
107
+ )
108
+ const linkedOriginalsRef = useRef(
109
+ getLinkedWallSnapshots({
110
+ wallId: target.wall.id,
111
+ wallParentId: target.wall.parentId ?? null,
112
+ originalStart: target.wall.start,
113
+ originalEnd: target.wall.end,
114
+ }),
115
+ )
116
+ const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
117
+
118
+ const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
119
+ const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
120
+ return [point[0], 0, point[1]]
121
+ })
122
+ const [altPressed, setAltPressed] = useState(false)
123
+
124
+ const exitMoveMode = useCallback(() => {
125
+ useEditor.getState().setMovingWallEndpoint(null)
126
+ }, [])
127
+
128
+ useEffect(() => {
129
+ const nodeId = nodeIdRef.current
130
+ const originalStart = originalStartRef.current
131
+ const originalEnd = originalEndRef.current
132
+ const fixedPoint = fixedPointRef.current
133
+ const levelWalls = Object.values(useScene.getState().nodes).filter(
134
+ (node): node is WallNode =>
135
+ node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
136
+ )
137
+
138
+ pauseSceneHistory(useScene)
139
+ let wasCommitted = false
140
+
141
+ const applyNodePreview = (
142
+ updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>,
143
+ ) => {
144
+ useScene.getState().updateNodes(
145
+ updates.map((entry) => ({
146
+ id: entry.id as AnyNodeId,
147
+ data: { start: entry.start, end: entry.end },
148
+ })),
149
+ )
150
+ for (const entry of updates) {
151
+ useScene.getState().markDirty(entry.id as AnyNodeId)
152
+ }
153
+ }
154
+
155
+ const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
156
+ const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
157
+ const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
158
+ previewRef.current = { start: nextStart, end: nextEnd }
159
+ setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
160
+ applyNodePreview([
161
+ { id: nodeId, start: nextStart, end: nextEnd },
162
+ ...(detachLinkedWalls
163
+ ? []
164
+ : getLinkedWallUpdates(
165
+ linkedOriginalsRef.current,
166
+ originalStart,
167
+ originalEnd,
168
+ nextStart,
169
+ nextEnd,
170
+ )),
171
+ ])
172
+ }
173
+
174
+ const restoreOriginal = () => {
175
+ applyNodePreview([{ id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current])
176
+ }
177
+
178
+ const onGridMove = (event: GridEvent) => {
179
+ const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
180
+ const snappedPoint = snapWallDraftPoint({
181
+ point: planPoint,
182
+ walls: levelWalls,
183
+ start: fixedPoint,
184
+ angleSnap: !shiftPressedRef.current,
185
+ ignoreWallIds: [nodeId],
186
+ })
187
+
188
+ if (
189
+ previousGridPosRef.current &&
190
+ (snappedPoint[0] !== previousGridPosRef.current[0] ||
191
+ snappedPoint[1] !== previousGridPosRef.current[1])
192
+ ) {
193
+ sfxEmitter.emit('sfx:grid-snap')
194
+ }
195
+ previousGridPosRef.current = snappedPoint
196
+
197
+ applyPreview(snappedPoint, event.nativeEvent.altKey)
198
+ }
199
+
200
+ const onGridClick = (event: GridEvent) => {
201
+ if (Date.now() - activatedAtRef.current < 150) {
202
+ event.nativeEvent?.stopPropagation?.()
203
+ return
204
+ }
205
+
206
+ const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
207
+ const hasChanged =
208
+ !samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
209
+
210
+ if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
211
+ wasCommitted = true
212
+
213
+ // Restore original baseline while paused so the next resume+update
214
+ // registers as a single tracked change (undo reverts to original).
215
+ applyNodePreview([
216
+ { id: nodeId, start: originalStart, end: originalEnd },
217
+ ...linkedOriginalsRef.current,
218
+ ])
219
+
220
+ resumeSceneHistory(useScene)
221
+ applyNodePreview([
222
+ { id: nodeId, start: preview.start, end: preview.end },
223
+ ...(altPressedRef.current
224
+ ? []
225
+ : getLinkedWallUpdates(
226
+ linkedOriginalsRef.current,
227
+ originalStart,
228
+ originalEnd,
229
+ preview.start,
230
+ preview.end,
231
+ )),
232
+ ])
233
+ pauseSceneHistory(useScene)
234
+ sfxEmitter.emit('sfx:item-place')
235
+ }
236
+
237
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
238
+ exitMoveMode()
239
+ event.nativeEvent?.stopPropagation?.()
240
+ }
241
+
242
+ const onCancel = () => {
243
+ restoreOriginal()
244
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
245
+ resumeSceneHistory(useScene)
246
+ markToolCancelConsumed()
247
+ exitMoveMode()
248
+ }
249
+
250
+ const onKeyDown = (event: KeyboardEvent) => {
251
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
252
+ return
253
+ }
254
+ if (event.key === 'Shift') {
255
+ shiftPressedRef.current = true
256
+ }
257
+ if (event.key === 'Alt') {
258
+ altPressedRef.current = true
259
+ setAltPressed(true)
260
+ }
261
+ }
262
+
263
+ const onKeyUp = (event: KeyboardEvent) => {
264
+ if (event.key === 'Shift') {
265
+ shiftPressedRef.current = false
266
+ }
267
+ if (event.key === 'Alt') {
268
+ altPressedRef.current = false
269
+ setAltPressed(false)
270
+ }
271
+ }
272
+
273
+ const onWindowBlur = () => {
274
+ shiftPressedRef.current = false
275
+ altPressedRef.current = false
276
+ setAltPressed(false)
277
+ }
278
+
279
+ emitter.on('grid:move', onGridMove)
280
+ emitter.on('grid:click', onGridClick)
281
+ emitter.on('tool:cancel', onCancel)
282
+ window.addEventListener('keydown', onKeyDown)
283
+ window.addEventListener('keyup', onKeyUp)
284
+ window.addEventListener('blur', onWindowBlur)
285
+
286
+ return () => {
287
+ if (!wasCommitted) {
288
+ restoreOriginal()
289
+ }
290
+ resumeSceneHistory(useScene)
291
+ emitter.off('grid:move', onGridMove)
292
+ emitter.off('grid:click', onGridClick)
293
+ emitter.off('tool:cancel', onCancel)
294
+ window.removeEventListener('keydown', onKeyDown)
295
+ window.removeEventListener('keyup', onKeyUp)
296
+ window.removeEventListener('blur', onWindowBlur)
297
+ }
298
+ }, [exitMoveMode, target])
299
+
300
+ return (
301
+ <group>
302
+ <CursorSphere position={cursorLocalPos} showTooltip={false} />
303
+ <Html
304
+ position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
305
+ style={{ pointerEvents: 'none', touchAction: 'none' }}
306
+ zIndexRange={[100, 0]}
307
+ >
308
+ <div className="translate-y-10">
309
+ <div
310
+ className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
311
+ altPressed
312
+ ? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
313
+ : 'border-border bg-background/95 text-muted-foreground'
314
+ }`}
315
+ >
316
+ {altPressed ? 'Detaching corner' : 'Alt to detach'}
317
+ </div>
318
+ </div>
319
+ </Html>
320
+ </group>
321
+ )
322
+ }