fix(editor): door/window move — fix 2D+3D FPS collapse + finish modifier migration
The 3D MoveDoor/MoveWindow tools wrote useScene every frame during a move (freeFollowAt + applyPreview alternating): the wall:move (R3F) / grid:move (DOM) de-dup compared event.timeStamp across two event systems with different clocks, so it never matched and the floor free-follow ran during on-wall slides too, ping-ponging the host and churning the nodes ref → framerate collapse in both 2D and 3D. Replace it with a single-clock wall-ownership window (performance.now, ~4 frames): the floor follow stands down while a wall/roof hit is fresh. On-wall slides now write no scene per frame (mesh + useLiveTransforms only). Lower the live wall-cutout throttle 120→60ms now that the per-frame churn is gone. Also completes the door/window modifier-model migration (#10): Shift=cycle / Alt=force-place, fully mode-driven snap, snapProfile:'item'; exclude ground-line candidates from along-wall opening alignment; emit the move SFX once per snapped step. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8a57105eec
commit
76096ffe72
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
isCurvedWall,
|
||||
@@ -18,6 +17,8 @@ import {
|
||||
consumePlacementDragRelease,
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
stripPlacementMetadataFlags,
|
||||
@@ -40,7 +41,10 @@ import {
|
||||
resolveRoofWallOpeningTarget,
|
||||
} from '../shared/roof-wall-opening-placement'
|
||||
import { resolveOpeningPlacement } from '../shared/wall-attach-target'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import {
|
||||
collectWallOpeningAlignmentCandidates,
|
||||
resolveWallSlideAlignment,
|
||||
} from '../shared/wall-opening-alignment'
|
||||
import { WindowFloorProjection } from './floor-projection'
|
||||
import WindowPreview from './preview'
|
||||
import {
|
||||
@@ -149,29 +153,45 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
let currentHostId: string | null = movingWindowNode.parentId
|
||||
let committed = false
|
||||
// Off-wall free-follow: over empty floor the window is parented to the
|
||||
// level and tracks the cursor like an item. `freeFollowing` marks that
|
||||
// state; `lastMeshEventTime` defers the floor handler whenever a wall/roof
|
||||
// mesh event owns the same pointermove — that's the only thing that snaps.
|
||||
// level and tracks the cursor like an item. `freeFollowing` marks that state.
|
||||
let freeFollowing = false
|
||||
let lastMeshEventTime = -1
|
||||
// Last open-floor cursor point (level-local X/Z), so an R-flip while free-
|
||||
// following can re-run the ghost at the same spot with the new facing.
|
||||
let lastFloorPoint: [number, number] | null = null
|
||||
// Live Shift state (force-place) — lets the preview tint re-evaluate when
|
||||
// Shift is pressed/released with the pointer stationary (see `MoveDoorTool`).
|
||||
let shiftHeld = false
|
||||
// Movement SFX: ONE soft `sfx:grid-snap` click per grid step — identical
|
||||
// whether free-following over floor or sliding along a wall (the user's
|
||||
// ask). Always keyed on the RAW cursor (continuous ~0.1m cadence), never the
|
||||
// snapped along-wall value. Guards: `lastStepKey` (cell change) +
|
||||
// `lastTickFrame` (one tick per DOM pointermove). No separate snap cue — a
|
||||
// distinct floor→wall sound was the "double" the user heard. See `MoveDoorTool`.
|
||||
const STEP_M = 0.1
|
||||
// The floor free-follow (`grid:move`, a DOM event) and the wall/roof snap
|
||||
// (`wall:move`/`roof:move`, R3F mesh events) are INDEPENDENT event streams
|
||||
// with different clocks, so the old `event.timeStamp` de-dup never matched —
|
||||
// the free-follow ran during on-wall slides too, and both wrote the scene
|
||||
// node every frame (a per-frame `nodes` churn that tanked 2D + 3D framerate).
|
||||
// Instead, stamp one monotonic clock whenever a wall/roof hit owns the
|
||||
// pointer; the floor handler stands down while that stamp is fresh. `wall:move`
|
||||
// fires every frame on-wall, so the stamp stays fresh across the pointermove
|
||||
// interval and the free-follow only re-engages once the cursor is off any wall.
|
||||
let wallOwnedPointerAt = Number.NEGATIVE_INFINITY
|
||||
// ~4 frames: comfortably longer than the pointermove interval (so a fast
|
||||
// on-wall slide never lets the floor follow slip through) yet short enough
|
||||
// that leaving a wall re-engages the free-follow without a perceptible stick.
|
||||
const WALL_OWNS_POINTER_MS = 64
|
||||
const markWallOwnedPointer = () => {
|
||||
wallOwnedPointerAt = performance.now()
|
||||
}
|
||||
const wallOwnsPointer = () => performance.now() - wallOwnedPointerAt < WALL_OWNS_POINTER_MS
|
||||
// Live Alt state (force-place) — lets the preview tint re-evaluate when
|
||||
// Alt is pressed/released with the pointer stationary (see `MoveDoorTool`).
|
||||
let altHeld = false
|
||||
// Movement SFX: ONE soft `sfx:grid-snap` click each time the window's PLACED
|
||||
// position crosses a step. Keyed on the SNAPPED value (passed by the caller),
|
||||
// quantized by the live grid step in grid mode, else a gentle fixed cadence —
|
||||
// so grid mode ticks once per cell (not on every micro mouse-move while the
|
||||
// window sits in a cell) while lines/off still tick as the window moves.
|
||||
// Guards: `lastStepKey` (cell change) + `lastTickFrame` (one per pointermove).
|
||||
const FREE_STEP_M = 0.1
|
||||
let lastStepKey: string | null = null
|
||||
let lastTickFrame = -1
|
||||
const tickGridStep = (frame: number, ...coords: number[]) => {
|
||||
if (frame === lastTickFrame) return
|
||||
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
|
||||
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : FREE_STEP_M
|
||||
const key = coords.map((c) => Math.round(c / step)).join(',')
|
||||
if (key === lastStepKey) return
|
||||
lastStepKey = key
|
||||
lastTickFrame = frame
|
||||
@@ -208,7 +228,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const now = globalThis.performance?.now?.() ?? Date.now()
|
||||
const last = lastHostDirtyAt.get(hostId) ?? 0
|
||||
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
|
||||
if (now - last > 120) {
|
||||
if (now - last > 60) {
|
||||
lastHostDirtyAt.set(hostId, now)
|
||||
markHostDirty(hostId)
|
||||
}
|
||||
@@ -243,10 +263,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
setGhostPose(null)
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every OTHER alignable object (the
|
||||
// moving window is excluded so it never aligns to itself). Along-wall only;
|
||||
// the floor-plane guides don't cover sill height.
|
||||
const alignmentCandidates = collectAlignmentAnchors(
|
||||
// Alignment candidates — only OTHER things on a wall (sibling openings +
|
||||
// wall-mounted items), never ground objects, so the along-wall guides don't
|
||||
// line up with furniture on the floor. The moving window is excluded.
|
||||
const alignmentCandidates = collectWallOpeningAlignmentCandidates(
|
||||
useScene.getState().nodes,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
@@ -281,28 +301,23 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const rawLocalX = event.localPosition[0]
|
||||
const rawLocalY = event.localPosition[1]
|
||||
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
dragAnchor = {
|
||||
wallId: event.node.id,
|
||||
rawX: rawLocalX,
|
||||
rawY: rawLocalY,
|
||||
startX: event.node.id === original.parentId ? original.position[0] : rawLocalX,
|
||||
startY:
|
||||
event.node.id === original.parentId
|
||||
? original.position[1]
|
||||
: bypassSnap
|
||||
? rawLocalY
|
||||
: snapToHalf(rawLocalY),
|
||||
event.node.id === original.parentId ? original.position[1] : snapToHalf(rawLocalY),
|
||||
}
|
||||
}
|
||||
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
|
||||
const targetRawLocalY = dragAnchor.startY + (rawLocalY - dragAnchor.rawY)
|
||||
// Vertical sill alignment (snap + guide): a sibling's sill/centre/top wins
|
||||
// over the 0.5m grid when within threshold; Shift bypasses both.
|
||||
const bypassY = event.nativeEvent?.shiftKey === true
|
||||
const sillSnapped = bypassY
|
||||
? null
|
||||
: resolveSillSnap({
|
||||
// Vertical sill alignment (snap + guide) is the magnetic ("lines")
|
||||
// component for Y: a sibling's sill/centre/top wins over the grid when
|
||||
// within threshold, so it runs only when magnetic snap is on; otherwise
|
||||
// the mode-aware `snapToHalf` decides Y.
|
||||
const sillSnapped = isMagneticSnapActive()
|
||||
? resolveSillSnap({
|
||||
wall: event.node,
|
||||
movingId: movingWindowNode.id,
|
||||
localX: targetLocalX,
|
||||
@@ -311,16 +326,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
height: movingWindowNode.height,
|
||||
nodes: useScene.getState().nodes,
|
||||
})
|
||||
const targetLocalY = bypassY ? targetRawLocalY : (sillSnapped ?? snapToHalf(targetRawLocalY))
|
||||
: null
|
||||
const targetLocalY = sillSnapped ?? snapToHalf(targetRawLocalY)
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: targetLocalX,
|
||||
width: movingWindowNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
// Alt still hard-disables alignment (no guides). Shift = free-place:
|
||||
// land at the raw cursor but keep showing the along-wall guides.
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
freePlace: event.nativeEvent?.shiftKey === true,
|
||||
// Along-wall alignment follows the magnetic ("lines") mode; the grid
|
||||
// component lives in `snapToHalf` (itself mode-aware).
|
||||
bypass: !isMagneticSnapActive(),
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
@@ -352,10 +367,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
||||
// Same click as the off-wall ghost: one grid-snap tick per grid step,
|
||||
// keyed on the RAW cursor along-wall position (not the snapped clampedX).
|
||||
// One grid-snap tick per real ALONG-WALL step, keyed on the snapped
|
||||
// `clampedX` only — NOT the sill `clampedY`, which tracks the cursor's
|
||||
// vertical position on the wall face and so re-keys on every micro
|
||||
// mouse-move even when the window stays in the same along-wall cell.
|
||||
// Per-frame guard collapses duplicate wall events on the same pointermove.
|
||||
tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0])
|
||||
tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.clampedX)
|
||||
// Keep the REAL node hidden and show a tinted ghost in the wall opening —
|
||||
// green when placeable, red when it collides — matching the free-follow
|
||||
// ghost so validity reads at a glance (see MoveDoorTool). The node position
|
||||
@@ -388,7 +405,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
markHostDirtyThrottled(target.wallId)
|
||||
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
const placement = resolveOpeningPlacement({ collides: !target.valid, forcePlace: shiftHeld })
|
||||
const placement = resolveOpeningPlacement({ collides: !target.valid, forcePlace: altHeld })
|
||||
// Ghost world yaw must equal the committed wall-CHILD's world yaw
|
||||
// (-wallAngle + itemRotation); `cursorRotation` is π off here. See
|
||||
// `MoveDoorTool.applyPreview`.
|
||||
@@ -424,12 +441,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
// Valid wall hit owns the pointer for the next few frames; the floor
|
||||
// free-follow stands down until the cursor genuinely leaves the wall.
|
||||
markWallOwnedPointer()
|
||||
freeFollowing = false
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
@@ -438,7 +457,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
onWallLeave()
|
||||
return
|
||||
@@ -458,6 +476,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
// Valid wall hit owns the pointer for the next few frames; the floor
|
||||
// free-follow stands down until the cursor genuinely leaves the wall.
|
||||
markWallOwnedPointer()
|
||||
freeFollowing = false
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
@@ -548,11 +569,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||
// Shift force-places: commit even when the window overlaps another opening.
|
||||
// The preview keeps its red invalid tint as a warning; Shift just lifts the
|
||||
// commit block. Read shift from THIS event so it's never stale at commit.
|
||||
// Alt force-places: commit even when the window overlaps another opening.
|
||||
// The preview keeps its red invalid tint as a warning; Alt just lifts the
|
||||
// commit block. Read alt from THIS event so it's never stale at commit.
|
||||
if (!target) return
|
||||
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
|
||||
if (!target.valid && event.nativeEvent?.altKey !== true) return
|
||||
commitToWall(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -585,13 +606,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
// Free-follow: over open floor there's no wall to host the window, so hide
|
||||
// the real (pale, near-invisible-on-grid) node and float a red translucent
|
||||
// ghost at the cursor — same treatment the raw `WindowTool` build path uses.
|
||||
const freeFollowAt = (localX: number, localZ: number, frame: number) => {
|
||||
const freeFollowAt = (localX: number, localZ: number) => {
|
||||
freeFollowing = true
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
// Click per grid cell as the ghost slides over open floor (X+Z) — the
|
||||
// same `tickGridStep` the on-wall slide uses, so both feel identical.
|
||||
tickGridStep(frame, localX, localZ)
|
||||
// No snap SFX here: the free-follow fires off-wall (an invalid red ghost,
|
||||
// not a placeable position) AND interleaves with the on-wall slide on the
|
||||
// same pointer move (R3F `wall:move` and DOM `grid:move` carry different
|
||||
// timestamps, so the de-dupe guard can't merge them). Emitting here was the
|
||||
// source of the constant click while sliding a window along a wall — the
|
||||
// on-wall `applyPreview` already ticks once per along-wall cell.
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
const levelId = getLevelId()
|
||||
@@ -633,14 +657,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (committed) return
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
// A wall/roof mesh handler owns this exact pointermove (shared DOM
|
||||
// timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise
|
||||
// the cursor is over open floor — free-follow it. No proximity magnet:
|
||||
// snapping engages only when the cursor ray actually hovers a wall.
|
||||
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
|
||||
// A wall/roof handler owns the pointer right now — the cursor ray is on a
|
||||
// wall/roof that snaps, so skip the floor follow (see `wallOwnsPointer`).
|
||||
if (wallOwnsPointer()) return
|
||||
const [x, , z] = event.localPosition
|
||||
lastFloorPoint = [x, z]
|
||||
freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1)
|
||||
freeFollowAt(x, z)
|
||||
}
|
||||
|
||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||
@@ -657,7 +679,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
ignoreId: movingWindowNode.id,
|
||||
vertical: {
|
||||
kind: 'free',
|
||||
snap: event.nativeEvent?.shiftKey === true ? undefined : snapToHalf,
|
||||
// `snapToHalf` is mode-aware (raw cursor when grid snap is off).
|
||||
snap: snapToHalf,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -667,12 +690,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const onRoofHover = (event: RoofEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
const target = resolveRoofMoveTarget(event)
|
||||
if (!target) {
|
||||
onRoofLeave()
|
||||
return
|
||||
}
|
||||
// Valid roof hit owns the pointer for the next few frames; the floor
|
||||
// free-follow stands down until the cursor genuinely leaves the roof.
|
||||
markWallOwnedPointer()
|
||||
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
||||
freeFollowing = false
|
||||
dragAnchor = null
|
||||
@@ -710,9 +735,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = resolveRoofMoveTarget(event)
|
||||
// Shift force-places over a colliding roof-face target too (see onWallClick).
|
||||
// Alt force-places over a colliding roof-face target too (see onWallClick).
|
||||
if (!target) return
|
||||
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
|
||||
if (!target.valid && event.nativeEvent?.altKey !== true) return
|
||||
committed = true
|
||||
const segmentId = target.segment.id
|
||||
|
||||
@@ -819,10 +844,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
// Free-following over open floor can't commit (no wall). A wall hover
|
||||
// target commits via commitToWall; a roof face via onRoofClick. Shift
|
||||
// target commits via commitToWall; a roof face via onRoofClick. Alt
|
||||
// force-places over a colliding wall target (tint stays red as a warning);
|
||||
// read shift from this pointerup so it's current at commit.
|
||||
if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) {
|
||||
// read alt from this pointerup so it's current at commit.
|
||||
if (lastTarget && !freeFollowing && (lastTarget.valid || event.altKey)) {
|
||||
commitToWall(lastTarget)
|
||||
return
|
||||
}
|
||||
@@ -859,7 +884,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
} else if (lastFloorPoint) {
|
||||
// Free-following: re-run at the same spot so the floating ghost rebuilds
|
||||
// with the flipped side.
|
||||
freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1)
|
||||
freeFollowAt(lastFloorPoint[0], lastFloorPoint[1])
|
||||
} else {
|
||||
// No preview yet (R before the first pointermove): flip the hidden node
|
||||
// so the first preview/commit already reflects the chosen side.
|
||||
@@ -870,13 +895,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
}
|
||||
|
||||
// Shift toggles force-place — re-run the on-wall preview so the tint flips
|
||||
// green↔red live (pointer stationary). Commit gates still read shift fresh.
|
||||
const onShiftToggle = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Shift') return
|
||||
// Alt toggles force-place — re-run the on-wall preview so the tint flips
|
||||
// green↔red live (pointer stationary). Commit gates still read alt fresh.
|
||||
const onAltToggle = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Alt') return
|
||||
const held = e.type === 'keydown'
|
||||
if (held === shiftHeld) return
|
||||
shiftHeld = held
|
||||
if (held === altHeld) return
|
||||
altHeld = held
|
||||
if (!committed && lastTarget) applyPreview(lastTarget)
|
||||
}
|
||||
|
||||
@@ -892,8 +917,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keydown', onShiftToggle)
|
||||
window.addEventListener('keyup', onShiftToggle)
|
||||
window.addEventListener('keydown', onAltToggle)
|
||||
window.addEventListener('keyup', onAltToggle)
|
||||
|
||||
return () => {
|
||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
||||
@@ -941,8 +966,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keydown', onShiftToggle)
|
||||
window.removeEventListener('keyup', onShiftToggle)
|
||||
window.removeEventListener('keydown', onAltToggle)
|
||||
window.removeEventListener('keyup', onAltToggle)
|
||||
}
|
||||
}, [movingWindowNode, exitMoveMode])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user