diff --git a/frontend/src/editor/components/library.tsx b/frontend/src/editor/components/library.tsx index 96ee9e79..f75b03c7 100644 --- a/frontend/src/editor/components/library.tsx +++ b/frontend/src/editor/components/library.tsx @@ -34,6 +34,7 @@ import { defaultAppearanceId } from "../utils/character-helpers"; import { makeId } from "../utils/utils"; import Sprite from "./sprites/sprite"; import { TapToEditLabel } from "./tap-to-edit-label"; +import { createCharacterSpriteData, createAppearanceData } from "./stage/hooks"; interface LibraryItemProps { character: Character; @@ -78,13 +79,15 @@ const LibraryItem: React.FC = ({ event.dataTransfer.setDragImage(img, offset.dragLeft, offset.dragTop); event.dataTransfer.setData("drag-offset", JSON.stringify(offset)); - if (dragType) { + if (dragType === "sprite") { event.dataTransfer.setData( - dragType, - JSON.stringify({ - characterId: character.id, - appearance: appearance, - }), + "sprite", + createCharacterSpriteData(character.id, appearance), + ); + } else if (dragType === "appearance" && appearance) { + event.dataTransfer.setData( + "appearance", + createAppearanceData(character.id, appearance), ); } }, diff --git a/frontend/src/editor/components/sprites/actor-sprite.tsx b/frontend/src/editor/components/sprites/actor-sprite.tsx index 2e56fbb1..4438aedd 100644 --- a/frontend/src/editor/components/sprites/actor-sprite.tsx +++ b/frontend/src/editor/components/sprites/actor-sprite.tsx @@ -5,6 +5,7 @@ import { renderTransformedImage, transformSwapsDimensions, } from "../../utils/stage-helpers"; +import { createActorSpriteData } from "../stage/hooks"; import VariableOverlay from "../modal-paint/variable-overlay"; import { DEFAULT_APPEARANCE_INFO, SPRITE_TRANSFORM_CSS } from "./sprite"; @@ -99,10 +100,7 @@ const ActorSprite = (props: { ); event.dataTransfer.setData( "sprite", - JSON.stringify({ - dragAnchorActorId: actor.id, - actorIds: dragActorIds, - }), + createActorSpriteData(dragActorIds, actor.id), ); // Create a properly transformed drag image using canvas diff --git a/frontend/src/editor/components/sprites/recording-handle.tsx b/frontend/src/editor/components/sprites/recording-handle.tsx index 911f6f52..91be027a 100644 --- a/frontend/src/editor/components/sprites/recording-handle.tsx +++ b/frontend/src/editor/components/sprites/recording-handle.tsx @@ -1,16 +1,16 @@ import React from "react"; import { Position } from "../../../types"; import { STAGE_CELL_SIZE } from "../../constants/constants"; +import { setHandleDragData, HandleSide } from "../stage/hooks"; -const RecordingHandle = ({ side, position }: { side: string; position: Position }) => { +const RecordingHandle = ({ side, position }: { side: HandleSide; position: Position }) => { const onDragStart = (event: React.DragEvent) => { const img = new Image(); img.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; event.dataTransfer.setDragImage(img, 0, 0); event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData(`handle`, "true"); - event.dataTransfer.setData(`handle:${side}`, "true"); + setHandleDragData(event.dataTransfer, side); }; return ( diff --git a/frontend/src/editor/components/stage/REFACTORING_PLAN.md b/frontend/src/editor/components/stage/REFACTORING_PLAN.md new file mode 100644 index 00000000..408f060b --- /dev/null +++ b/frontend/src/editor/components/stage/REFACTORING_PLAN.md @@ -0,0 +1,805 @@ +# Stage.tsx Refactoring Plan + +## Goal +Refactor the 909-line Stage.tsx into smaller, testable hooks without modifying any existing behavior. + +## Critical Capture Semantics to Preserve + +### The Ref-Based Event Handler Pattern +The current code uses a critical pattern for document event listeners: + +```typescript +const onMouseMove = useRef<(event: MouseEvent) => void>(); +onMouseMove.current = (event: MouseEvent) => { + // This function is reassigned every render to capture latest state + if (selectionRect) { /* uses latest selectionRect */ } +}; + +const onMouseDown = (event: React.MouseEvent) => { + document.addEventListener("mousemove", (e) => onMouseMove.current?.(e)); + // The ref indirection allows the document listener to always call the latest function +}; +``` + +**Why this matters:** Document event listeners are created once in `onMouseDown` but need access to state that changes during the drag (like `selectionRect`). The ref indirection solves this. + +### State Dependencies by Handler + +| Handler | State Read | State Written | Refs Used | +|---------|-----------|---------------|-----------| +| `onMouseDown` | `playback.running`, `selectedToolId` | `selectionRect` | `mouse` | +| `onMouseMove` | `selectionRect`, `selectedToolId`, `stage`, `characters`, `actorSelectionPopover`, `selected` | `selectionRect`, `actorSelectionPopover` | `mouse` | +| `onMouseUp` | `selectionRect`, `selectedToolId`, `stage.actors`, `scale` | `selectionRect` | `mouse` | +| `onMouseUpActor` | `selectedToolId`, `stampToolItem`, `playback.running`, `stage.actors`, `characters`, `selected` | `actorSelectionPopover` | - | +| `onDragOver` | `recordingExtent` | - | `lastFiredExtent` | +| `onDrop` | - | - | - | +| `onDropSprite` | `stage.actors`, `characters`, `recordingExtent` | - | - | +| `onStampAtPosition` | `stampToolItem` | - | - | + +### Tool-Specific Behaviors + +#### TRASH Tool +- On first click with overlapping actors: show popover +- On drag: delete topmost actor (or selected actors if clicked actor is selected) +- Skip processing if popover is open +- Reset to POINTER after use (unless shift held) + +#### STAMP Tool +- If no stampToolItem: clicking actor sets it as stamp source (show popover if overlap) +- If stampToolItem exists: drag creates copies +- Reset to POINTER after use (unless shift held) + +#### PAINT Tool +- Click actor: copy its appearance to paint brush (show popover if overlap) +- Reset to POINTER after use + +#### RECORD Tool +- Click actor: start recording for that actor (show popover if overlap) +- Reset to POINTER after use + +#### POINTER Tool +- Click actor: select it (show popover if overlap) +- Shift+click: toggle in multi-selection +- During playback: record click for game state +- Drag on background: create selection rectangle + +#### IGNORE_SQUARE Tool +- Drag: toggle squares as ignored in recording extent + +#### ADD_CLICK_CONDITION Tool +- Click actor: add click condition for that actor + +--- + +## Phase 1: Characterization Tests + +Before any refactoring, write tests that capture current behavior. These tests will: +1. Verify the exact sequence of Redux actions dispatched +2. Verify state changes (selectionRect, popover) +3. Cover edge cases (shift key, overlapping actors, playback mode) + +### Test File: `stage.test.tsx` + +```typescript +describe('Stage mouse interactions', () => { + describe('POINTER tool', () => { + it('selects actor on click'); + it('shows popover when clicking overlapping actors'); + it('creates selection rectangle when dragging on background'); + it('selects all actors in rectangle on mouse up'); + it('toggles actor in selection with shift+click'); + it('records click during playback mode'); + }); + + describe('TRASH tool', () => { + it('deletes single actor on click'); + it('shows popover when clicking overlapping actors'); + it('deletes topmost actor on drag'); + it('deletes all selected actors if clicked actor is selected'); + it('skips processing when popover is open'); + it('resets to POINTER after delete'); + it('stays on TRASH when shift held'); + }); + + describe('STAMP tool', () => { + it('sets clicked actor as stamp source when no stampToolItem'); + it('shows popover when clicking overlapping actors for source'); + it('creates copies on drag when stampToolItem set'); + it('resets to POINTER after stamping'); + }); + + // ... etc for each tool +}); + +describe('Stage drag and drop', () => { + it('moves actors on drop'); + it('copies actors on alt+drop'); + it('creates new actor when dropping character from library'); + it('prevents drop outside recording extent'); + it('updates recording extent handles on drag'); +}); + +describe('Stage keyboard', () => { + it('records key presses during playback'); + it('deletes selected actors on Delete/Backspace'); + it('selects all actors with Cmd/Ctrl+A'); +}); +``` + +--- + +## Phase 2: Extract Utility Hooks (No Behavior Change) + +### 2.1 `useStageCoordinates` +Pure coordinate transformation - no state, no side effects. + +```typescript +// hooks/useStageCoordinates.ts +export function useStageCoordinates( + stageElRef: React.RefObject, + scale: number +) { + const getPxOffsetForEvent = useCallback( + (event: MouseEvent | React.MouseEvent | React.DragEvent) => { + const stageOffset = stageElRef.current!.getBoundingClientRect(); + return { + left: event.clientX - stageOffset.left, + top: event.clientY - stageOffset.top + }; + }, + [] // stageElRef is stable + ); + + const getPositionForEvent = useCallback( + (event: MouseEvent | React.MouseEvent | React.DragEvent): Position => { + const dragOffset = + "dataTransfer" in event && event.dataTransfer?.getData("drag-offset"); + const halfOffset = { dragTop: STAGE_CELL_SIZE / 2, dragLeft: STAGE_CELL_SIZE / 2 }; + const { dragLeft, dragTop } = dragOffset ? JSON.parse(dragOffset) : halfOffset; + + const px = getPxOffsetForEvent(event); + return { + x: Math.round((px.left - dragLeft) / STAGE_CELL_SIZE / scale), + y: Math.round((px.top - dragTop) / STAGE_CELL_SIZE / scale), + }; + }, + [getPxOffsetForEvent, scale] + ); + + return { getPxOffsetForEvent, getPositionForEvent }; +} +``` + +**Verification:** Functions are pure, return same values for same inputs. + +### 2.2 `useStageZoom` +Manages scale state and window resize listener. + +```typescript +// hooks/useStageZoom.ts +export function useStageZoom( + scrollElRef: React.RefObject, + stageElRef: React.RefObject, + stage: { width: number; height: number; scale?: number | "fit" }, + recordingCentered?: boolean +): number { + const [scale, setScale] = useState( + stage.scale && typeof stage.scale === "number" ? stage.scale : 1 + ); + + useEffect(() => { + const autofit = () => { + const scrollEl = scrollElRef.current; + const stageEl = stageElRef.current; + if (!scrollEl || !stageEl) return; + + if (recordingCentered) { + setScale(1); + } else if (stage.scale === "fit") { + stageEl.style.zoom = "1"; + const fit = Math.min( + scrollEl.clientWidth / (stage.width * STAGE_CELL_SIZE), + scrollEl.clientHeight / (stage.height * STAGE_CELL_SIZE) + ); + const best = STAGE_ZOOM_STEPS.find((z) => z <= fit) || fit; + stageEl.style.zoom = `${best}`; + setScale(best); + } else { + setScale(stage.scale ?? 1); + } + }; + + window.addEventListener("resize", autofit); + autofit(); + return () => window.removeEventListener("resize", autofit); + }, [stage.height, stage.scale, stage.width, recordingCentered, scrollElRef, stageElRef]); + + return scale; +} +``` + +**Verification:** Scale updates on resize, respects stage.scale prop. + +--- + +## Phase 3: Extract State Management Hooks + +### 3.1 `useStageSelection` +Manages selection rectangle state. + +```typescript +// hooks/useStageSelection.ts +export function useStageSelection() { + const [selectionRect, setSelectionRect] = useState(null); + + const startSelection = useCallback((startPx: { left: number; top: number }) => { + setSelectionRect({ start: startPx, end: startPx }); + }, []); + + const updateSelection = useCallback((endPx: { left: number; top: number }) => { + setSelectionRect((prev) => prev ? { ...prev, end: endPx } : null); + }, []); + + const finishSelection = useCallback( + ( + scale: number, + actors: { [id: string]: Actor }, + onSelect: (characterId: string | null, actorIds: string[]) => void + ) => { + if (!selectionRect) return; + + const [minLeft, maxLeft] = [selectionRect.start.left, selectionRect.end.left].sort((a, b) => a - b); + const [minTop, maxTop] = [selectionRect.start.top, selectionRect.end.top].sort((a, b) => a - b); + + const min = { + x: Math.floor(minLeft / STAGE_CELL_SIZE / scale), + y: Math.floor(minTop / STAGE_CELL_SIZE / scale), + }; + const max = { + x: Math.floor(maxLeft / STAGE_CELL_SIZE / scale), + y: Math.floor(maxTop / STAGE_CELL_SIZE / scale), + }; + + const selectedActors = Object.values(actors).filter( + (actor) => + actor.position.x >= min.x && + actor.position.x <= max.x && + actor.position.y >= min.y && + actor.position.y <= max.y + ); + + const characterId = + selectedActors.length && + selectedActors.every((a) => a.characterId === selectedActors[0].characterId) + ? selectedActors[0].characterId + : null; + + onSelect(characterId, selectedActors.map((a) => a.id)); + setSelectionRect(null); + }, + [selectionRect] + ); + + const cancelSelection = useCallback(() => { + setSelectionRect(null); + }, []); + + return { + selectionRect, + startSelection, + updateSelection, + finishSelection, + cancelSelection, + }; +} +``` + +### 3.2 `useStagePopover` +Manages actor selection popover state. + +```typescript +// hooks/useStagePopover.ts +export interface PopoverState { + actors: Actor[]; + position: { x: number; y: number }; + toolId: string; +} + +export function useStagePopover() { + const [popover, setPopover] = useState(null); + + const showPopover = useCallback( + (actors: Actor[], position: { x: number; y: number }, toolId: string) => { + setPopover({ actors, position, toolId }); + }, + [] + ); + + const closePopover = useCallback(() => { + setPopover(null); + }, []); + + return { popover, showPopover, closePopover }; +} +``` + +--- + +## Phase 4: Tool Behavior Strategy Pattern + +### 4.1 `tool-behaviors.ts` + +Define tool behaviors declaratively. This is the key abstraction that simplifies the event handlers. + +```typescript +// tools/tool-behaviors.ts +export interface ToolContext { + // Redux + dispatch: AppDispatch; + + // Props/State + stage: StageType; + world: WorldMinimal; + characters: Characters; + recordingExtent?: RuleExtent; + + // Derived + selected: Actor[]; + selFor: (actorIds: string[]) => ActorSelection; + + // UI State + stampToolItem: UIState["stampToolItem"]; + playback: UIState["playback"]; + + // Popover control (for showing overlap popover) + showPopover: (actors: Actor[], clientPos: { x: number; y: number }) => void; + isPopoverOpen: boolean; +} + +export interface ToolBehavior { + /** + * Called when clicking on an actor. Return true if handled. + */ + onActorClick?: ( + actor: Actor, + event: React.MouseEvent, + ctx: ToolContext + ) => boolean; + + /** + * Called when dragging across a grid position. + * Only called once per position per drag operation. + */ + onDragPosition?: ( + position: Position, + isFirstPosition: boolean, + event: MouseEvent, + ctx: ToolContext + ) => void; + + /** + * If true, shows popover when clicking overlapping actors. + * The selected actor from popover is passed to onActorClick. + */ + showPopoverOnOverlap?: boolean; + + /** + * If true, tool resets to POINTER after use (unless shift held). + */ + resetAfterUse?: boolean; +} +``` + +### 4.2 Implement Each Tool + +```typescript +export const TOOL_BEHAVIORS: Partial> = { + [TOOLS.POINTER]: { + showPopoverOnOverlap: true, + onActorClick: (actor, event, ctx) => { + if (ctx.playback.running) { + ctx.dispatch(recordClickForGameState(ctx.world.id, actor.id)); + return true; + } + + if (event.shiftKey) { + const selectedIds = ctx.selected.map((a) => a.id); + const newIds = selectedIds.includes(actor.id) + ? selectedIds.filter((id) => id !== actor.id) + : [...selectedIds, actor.id]; + ctx.dispatch(select(actor.characterId, ctx.selFor(newIds))); + } else { + ctx.dispatch(select(actor.characterId, ctx.selFor([actor.id]))); + } + return true; + }, + }, + + [TOOLS.TRASH]: { + showPopoverOnOverlap: true, + resetAfterUse: true, + onActorClick: (actor, _event, ctx) => { + const selectedIds = ctx.selected.map((a) => a.id); + if (selectedIds.includes(actor.id)) { + ctx.dispatch(deleteActors(ctx.selFor(selectedIds))); + } else { + ctx.dispatch(deleteActors(ctx.selFor([actor.id]))); + } + return true; + }, + onDragPosition: (position, isFirstPosition, event, ctx) => { + // Skip if popover open + if (ctx.isPopoverOpen) return; + + const overlapping = actorsAtPoint(ctx.stage.actors, ctx.characters, position); + + // On first position with overlap, show popover instead of deleting + if (isFirstPosition && overlapping.length > 1) { + ctx.showPopover(overlapping, { x: event.clientX, y: event.clientY }); + return; + } + + const actor = overlapping[overlapping.length - 1]; + if (!actor) return; + + const selectedIds = ctx.selected.map((a) => a.id); + if (selectedIds.includes(actor.id)) { + ctx.dispatch(deleteActors(ctx.selFor(selectedIds))); + } else { + ctx.dispatch(deleteActors(ctx.selFor([actor.id]))); + } + }, + }, + + [TOOLS.STAMP]: { + showPopoverOnOverlap: true, + resetAfterUse: true, + onActorClick: (actor, _event, ctx) => { + if (!ctx.stampToolItem) { + ctx.dispatch(selectToolItem(ctx.selFor([actor.id]))); + return true; + } + return false; + }, + onDragPosition: (position, _isFirst, _event, ctx) => { + if (!ctx.stampToolItem) return; + + if ("actorIds" in ctx.stampToolItem) { + const ids = { + actorIds: ctx.stampToolItem.actorIds, + dragAnchorActorId: ctx.stampToolItem.actorIds[0], + }; + // Call existing drop logic + onDropActorsAtPosition(ids, position, "stamp-copy", ctx); + } else if ("characterId" in ctx.stampToolItem) { + onDropCharacterAtPosition(ctx.stampToolItem, position, ctx); + } + }, + }, + + [TOOLS.PAINT]: { + showPopoverOnOverlap: true, + resetAfterUse: true, + onActorClick: (actor, _event, ctx) => { + ctx.dispatch(paintCharacterAppearance(actor.characterId, actor.appearance)); + return true; + }, + }, + + [TOOLS.RECORD]: { + showPopoverOnOverlap: true, + resetAfterUse: true, + onActorClick: (actor, _event, ctx) => { + ctx.dispatch(setupRecordingForActor({ characterId: actor.characterId, actor })); + ctx.dispatch(selectToolId(TOOLS.POINTER)); + return true; + }, + }, + + [TOOLS.ADD_CLICK_CONDITION]: { + showPopoverOnOverlap: true, + resetAfterUse: true, + onActorClick: (actor, _event, ctx) => { + ctx.dispatch( + upsertRecordingCondition({ + key: makeId("condition"), + left: { globalId: "click" }, + right: { constant: actor.id }, + comparator: "=", + enabled: true, + }) + ); + ctx.dispatch(selectToolId(TOOLS.POINTER)); + return true; + }, + }, + + [TOOLS.IGNORE_SQUARE]: { + onDragPosition: (position, _isFirst, _event, ctx) => { + ctx.dispatch(toggleSquareIgnored(position)); + }, + }, +}; +``` + +--- + +## Phase 5: Extract Mouse Handler Hook + +### 5.1 `useStageMouseHandlers` + +This is the most complex hook because it needs to: +1. Maintain the ref-based pattern for document listeners +2. Coordinate with selection and popover hooks +3. Delegate to tool behaviors + +```typescript +// hooks/useStageMouseHandlers.ts +export function useStageMouseHandlers( + stageElRef: React.RefObject, + coords: ReturnType, + selection: ReturnType, + popover: ReturnType, + toolContext: ToolContext, + selectedToolId: TOOLS, + stage: StageType, + scale: number +) { + const mouse = useRef<{ isDown: boolean; visited: { [key: string]: true } }>({ + isDown: false, + visited: {}, + }); + + const behavior = TOOL_BEHAVIORS[selectedToolId]; + + // ============================================================ + // CRITICAL: These refs are reassigned every render to capture + // the latest state. Document event listeners call through the + // ref to get current behavior. + // ============================================================ + + const onMouseMoveRef = useRef<(event: MouseEvent) => void>(); + onMouseMoveRef.current = (event: MouseEvent) => { + if (!mouse.current.isDown) return; + + // Selection rectangle mode + if (selection.selectionRect) { + selection.updateSelection(coords.getPxOffsetForEvent(event)); + return; + } + + // Tool drag mode + if (!behavior?.onDragPosition) return; + + const pos = coords.getPositionForEvent(event); + if (pos.x < 0 || pos.x >= stage.width || pos.y < 0 || pos.y >= stage.height) { + return; + } + + const posKey = `${pos.x},${pos.y}`; + if (mouse.current.visited[posKey]) return; + + const isFirstPosition = Object.keys(mouse.current.visited).length === 0; + mouse.current.visited[posKey] = true; + + behavior.onDragPosition(pos, isFirstPosition, event, toolContext); + }; + + const onMouseUpRef = useRef<(event: MouseEvent) => void>(); + onMouseUpRef.current = (event: MouseEvent) => { + // Process final position as a move + onMouseMoveRef.current?.(event); + + mouse.current = { isDown: false, visited: {} }; + + // Finish selection rectangle + if (selection.selectionRect) { + selection.finishSelection(scale, stage.actors, (characterId, actorIds) => { + toolContext.dispatch(select(characterId, toolContext.selFor(actorIds))); + }); + } + + // Auto-reset tool + if (!event.shiftKey && behavior?.resetAfterUse) { + toolContext.dispatch(selectToolId(TOOLS.POINTER)); + } + }; + + const onMouseDown = useCallback( + (event: React.MouseEvent) => { + if (toolContext.playback.running) return; + + // Setup document listeners with cleanup + const onMouseUpAnywhere = (e: MouseEvent) => { + document.removeEventListener("mouseup", onMouseUpAnywhere); + document.removeEventListener("mousemove", onMouseMoveAnywhere); + onMouseUpRef.current?.(e); + }; + const onMouseMoveAnywhere = (e: MouseEvent) => { + onMouseMoveRef.current?.(e); + }; + document.addEventListener("mouseup", onMouseUpAnywhere); + document.addEventListener("mousemove", onMouseMoveAnywhere); + + mouse.current = { isDown: true, visited: {} }; + + // Start selection rectangle for pointer tool on background + const isClickOnBackground = event.target === event.currentTarget; + if (selectedToolId === TOOLS.POINTER && isClickOnBackground) { + selection.startSelection(coords.getPxOffsetForEvent(event)); + } else { + selection.cancelSelection(); + } + }, + [toolContext.playback.running, selectedToolId, selection, coords] + ); + + const onActorMouseUp = useCallback( + (actor: Actor, event: React.MouseEvent) => { + if (!behavior) return; + + // Check for overlapping actors + if (behavior.showPopoverOnOverlap) { + const overlapping = actorsAtPoint( + stage.actors, + toolContext.characters, + actor.position + ); + if (overlapping.length > 1) { + popover.showPopover(overlapping, { x: event.clientX, y: event.clientY }, selectedToolId); + event.preventDefault(); + event.stopPropagation(); + return; + } + } + + // Delegate to tool behavior + const handled = behavior.onActorClick?.(actor, event, toolContext); + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + }, + [behavior, stage.actors, toolContext, popover, selectedToolId] + ); + + return { + onMouseDown, + onActorMouseUp, + }; +} +``` + +--- + +## Phase 6: Integration + +### 6.1 Simplified Stage Component + +```typescript +export const Stage = ({ + stage, + world, + recordingExtent, + recordingCentered, + evaluatedSquares, + readonly, + style, +}: StageProps) => { + const dispatch = useDispatch(); + const scrollElRef = useRef(null); + const stageElRef = useRef(null); + + // Redux state + const characters = useSelector((state: EditorState) => state.characters); + const { selectedActors, selectedToolId, stampToolItem, playback } = useSelector( + (state: EditorState) => ({ + selectedActors: state.ui.selectedActors, + selectedToolId: state.ui.selectedToolId, + stampToolItem: state.ui.stampToolItem, + playback: state.ui.playback, + }) + ); + + // Derived state + const selected = useMemo( + () => + selectedActors?.worldId === world.id && selectedActors?.stageId === stage.id + ? selectedActors.actorIds.map((id) => stage.actors[id]).filter(Boolean) + : [], + [selectedActors, world.id, stage.id, stage.actors] + ); + + const selFor = useCallback( + (actorIds: string[]) => buildActorSelection(world.id, stage.id, actorIds), + [world.id, stage.id] + ); + + // Hooks + const scale = useStageZoom(scrollElRef, stageElRef, stage, recordingCentered); + const coords = useStageCoordinates(stageElRef, scale); + const selection = useStageSelection(); + const popoverHook = useStagePopover(); + + // Build tool context + const toolContext: ToolContext = useMemo( + () => ({ + dispatch, + stage, + world, + characters, + recordingExtent, + selected, + selFor, + stampToolItem, + playback, + showPopover: (actors, pos) => popoverHook.showPopover(actors, pos, selectedToolId), + isPopoverOpen: !!popoverHook.popover, + }), + [dispatch, stage, world, characters, recordingExtent, selected, selFor, stampToolItem, playback, popoverHook, selectedToolId] + ); + + const mouseHandlers = useStageMouseHandlers( + stageElRef, + coords, + selection, + popoverHook, + toolContext, + selectedToolId, + stage, + scale + ); + + const dragDropHandlers = useStageDragDrop(stageElRef, coords, toolContext, recordingExtent); + const keyboardHandlers = useStageKeyboard(stageElRef, stage, world, selected, selFor, playback, dispatch); + + // ... rest of render logic (mostly unchanged) +}; +``` + +--- + +## Verification Checklist + +For each extracted hook, verify: + +- [ ] All state dependencies are correctly captured +- [ ] Ref-based handlers are reassigned every render +- [ ] Document event listeners are properly cleaned up +- [ ] Tool behaviors match exactly (compare action sequences) +- [ ] Edge cases work: shift key, overlapping actors, playback mode +- [ ] No regressions in drag and drop +- [ ] Selection rectangle works correctly +- [ ] Popover shows and selects correctly + +--- + +## File Structure After Refactoring + +``` +frontend/src/editor/components/stage/ +├── stage.tsx # Main component (~200 lines) +├── hooks/ +│ ├── useStageCoordinates.ts # Coordinate transforms +│ ├── useStageZoom.ts # Scale/fit management +│ ├── useStageSelection.ts # Selection rectangle +│ ├── useStagePopover.ts # Actor selection popover +│ ├── useStageMouseHandlers.ts # Mouse event handling +│ ├── useStageDragDrop.ts # Drag and drop +│ └── useStageKeyboard.ts # Keyboard handling +├── tools/ +│ └── tool-behaviors.ts # Tool strategy pattern +├── container.tsx +├── stage-controls.tsx +└── ... (other existing files) +``` + +--- + +## Risk Mitigation + +1. **Write tests first** - Lock in current behavior before any changes +2. **Extract one hook at a time** - Verify after each extraction +3. **Keep original code commented** - Easy to compare and revert +4. **Test each tool individually** - Don't assume if one works, all work +5. **Test keyboard modifiers** - Shift, Alt, Ctrl have specific meanings diff --git a/frontend/src/editor/components/stage/hooks/index.ts b/frontend/src/editor/components/stage/hooks/index.ts new file mode 100644 index 00000000..25ca2268 --- /dev/null +++ b/frontend/src/editor/components/stage/hooks/index.ts @@ -0,0 +1,56 @@ +/** + * Stage Hooks + * + * These hooks extract logic from the main Stage component into + * smaller, testable units. + */ + +export { + useStageCoordinates, + calculatePxOffset, + calculateGridPosition, + parseDragOffset, + isPositionInBounds, + type PxOffset, +} from "./useStageCoordinates"; + +export { + useStageZoom, + calculateFitScale, + STAGE_ZOOM_STEPS, + type StageScaleConfig, +} from "./useStageZoom"; + +export { + useStageSelection, + selectionRectToGridBounds, + findActorsInBounds, + getSelectionCharacterId, + type SelectionRect, +} from "./useStageSelection"; + +export { useStagePopover, type PopoverState } from "./useStagePopover"; + +export { + useStageDragDrop, + // Parsing utilities + parseSpriteDropData, + parseAppearanceDropData, + parseHandleSide, + // Creation utilities + createActorSpriteData, + createCharacterSpriteData, + createAppearanceData, + setHandleDragData, + // Pure calculation functions + calculateNewExtent, + calculateDropOffset, + cloneExistsAtPosition, + // Types + type ActorDropData, + type CharacterDropData, + type AppearanceDropData, + type DropMode, + type HandleSide, + type StageDragDropConfig, +} from "./useStageDragDrop"; diff --git a/frontend/src/editor/components/stage/hooks/useStageCoordinates.test.ts b/frontend/src/editor/components/stage/hooks/useStageCoordinates.test.ts new file mode 100644 index 00000000..d27682f0 --- /dev/null +++ b/frontend/src/editor/components/stage/hooks/useStageCoordinates.test.ts @@ -0,0 +1,176 @@ +import { expect } from "chai"; +import { + calculatePxOffset, + calculateGridPosition, + parseDragOffset, + isPositionInBounds, + PxOffset, +} from "./useStageCoordinates"; + +describe("useStageCoordinates", () => { + describe("calculatePxOffset", () => { + it("should calculate offset from stage origin", () => { + const stageRect = { left: 100, top: 50 } as DOMRect; + const result = calculatePxOffset(150, 75, stageRect); + expect(result).to.deep.equal({ left: 50, top: 25 }); + }); + + it("should handle zero offset (click at stage origin)", () => { + const stageRect = { left: 100, top: 50 } as DOMRect; + const result = calculatePxOffset(100, 50, stageRect); + expect(result).to.deep.equal({ left: 0, top: 0 }); + }); + + it("should handle negative offsets (click above/left of stage)", () => { + const stageRect = { left: 100, top: 50 } as DOMRect; + const result = calculatePxOffset(80, 30, stageRect); + expect(result).to.deep.equal({ left: -20, top: -20 }); + }); + }); + + describe("calculateGridPosition", () => { + // STAGE_CELL_SIZE is 40px + const CELL = 40; + + describe("without drag offset (uses half cell for centering)", () => { + it("should convert pixel offset to grid position at scale 1", () => { + // At (80, 120) pixels, with half-cell offset for rounding + // x = round((80 - 20) / 40 / 1) = round(1.5) = 2 + // y = round((120 - 20) / 40 / 1) = round(2.5) = 3 + // Wait, let me recalculate: + // x = round((80 - 20) / 40 / 1) = round(60 / 40) = round(1.5) = 2 + // y = round((120 - 20) / 40 / 1) = round(100 / 40) = round(2.5) = 3 + const pxOffset: PxOffset = { left: 80, top: 120 }; + const result = calculateGridPosition(pxOffset, 1); + expect(result).to.deep.equal({ x: 2, y: 3 }); + }); + + it("should handle scale factor", () => { + // At scale 0.5, coordinates are effectively doubled + // x = round((80 - 20) / 40 / 0.5) = round(60 / 40 / 0.5) = round(3) = 3 + const pxOffset: PxOffset = { left: 80, top: 80 }; + const result = calculateGridPosition(pxOffset, 0.5); + expect(result).to.deep.equal({ x: 3, y: 3 }); + }); + + it("should round to nearest grid position", () => { + // Position that rounds down: 45px left + // x = round((45 - 20) / 40 / 1) = round(25 / 40) = round(0.625) = 1 + const pxOffset1: PxOffset = { left: 45, top: 20 }; + expect(calculateGridPosition(pxOffset1, 1).x).to.equal(1); + + // Position that rounds down: 35px left + // x = round((35 - 20) / 40 / 1) = round(15 / 40) = round(0.375) = 0 + const pxOffset2: PxOffset = { left: 35, top: 20 }; + expect(calculateGridPosition(pxOffset2, 1).x).to.equal(0); + }); + + it("should handle position at origin", () => { + const pxOffset: PxOffset = { left: 0, top: 0 }; + const result = calculateGridPosition(pxOffset, 1); + // x = round((0 - 20) / 40) = round(-0.5) = -0 in JS + // -0 === 0 is true, so we test equality rather than deep.equal + expect(result.x === 0).to.be.true; + expect(result.y === 0).to.be.true; + }); + }); + + describe("with drag offset", () => { + it("should apply custom drag offset", () => { + const pxOffset: PxOffset = { left: 100, top: 100 }; + const dragOffset = { dragLeft: 10, dragTop: 10 }; + // x = round((100 - 10) / 40 / 1) = round(90 / 40) = round(2.25) = 2 + // y = round((100 - 10) / 40 / 1) = round(90 / 40) = round(2.25) = 2 + const result = calculateGridPosition(pxOffset, 1, dragOffset); + expect(result).to.deep.equal({ x: 2, y: 2 }); + }); + + it("should handle zero drag offset", () => { + const pxOffset: PxOffset = { left: 80, top: 80 }; + const dragOffset = { dragLeft: 0, dragTop: 0 }; + // x = round(80 / 40 / 1) = 2 + const result = calculateGridPosition(pxOffset, 1, dragOffset); + expect(result).to.deep.equal({ x: 2, y: 2 }); + }); + + it("should combine drag offset with scale", () => { + const pxOffset: PxOffset = { left: 100, top: 100 }; + const dragOffset = { dragLeft: 20, dragTop: 20 }; + // At scale 0.5: + // x = round((100 - 20) / 40 / 0.5) = round(80 / 20) = 4 + const result = calculateGridPosition(pxOffset, 0.5, dragOffset); + expect(result).to.deep.equal({ x: 4, y: 4 }); + }); + }); + }); + + describe("parseDragOffset", () => { + it("should return undefined for null dataTransfer", () => { + expect(parseDragOffset(null)).to.be.undefined; + }); + + it("should return undefined when drag-offset data is missing", () => { + const mockDataTransfer = { + getData: () => "", + } as unknown as DataTransfer; + expect(parseDragOffset(mockDataTransfer)).to.be.undefined; + }); + + it("should parse valid drag offset JSON", () => { + const mockDataTransfer = { + getData: (key: string) => + key === "drag-offset" + ? JSON.stringify({ dragLeft: 15, dragTop: 25 }) + : "", + } as unknown as DataTransfer; + expect(parseDragOffset(mockDataTransfer)).to.deep.equal({ + dragLeft: 15, + dragTop: 25, + }); + }); + + it("should return undefined for invalid JSON", () => { + const mockDataTransfer = { + getData: (key: string) => (key === "drag-offset" ? "not json" : ""), + } as unknown as DataTransfer; + expect(parseDragOffset(mockDataTransfer)).to.be.undefined; + }); + }); + + describe("isPositionInBounds", () => { + const width = 10; + const height = 8; + + it("should return true for position inside bounds", () => { + expect(isPositionInBounds({ x: 5, y: 4 }, width, height)).to.be.true; + }); + + it("should return true for position at origin", () => { + expect(isPositionInBounds({ x: 0, y: 0 }, width, height)).to.be.true; + }); + + it("should return true for position at max valid coordinates", () => { + expect(isPositionInBounds({ x: 9, y: 7 }, width, height)).to.be.true; + }); + + it("should return false for position left of bounds", () => { + expect(isPositionInBounds({ x: -1, y: 4 }, width, height)).to.be.false; + }); + + it("should return false for position right of bounds", () => { + expect(isPositionInBounds({ x: 10, y: 4 }, width, height)).to.be.false; + }); + + it("should return false for position above bounds", () => { + expect(isPositionInBounds({ x: 5, y: -1 }, width, height)).to.be.false; + }); + + it("should return false for position below bounds", () => { + expect(isPositionInBounds({ x: 5, y: 8 }, width, height)).to.be.false; + }); + + it("should return false for position at width/height (exclusive bound)", () => { + expect(isPositionInBounds({ x: 10, y: 8 }, width, height)).to.be.false; + }); + }); +}); diff --git a/frontend/src/editor/components/stage/hooks/useStageCoordinates.ts b/frontend/src/editor/components/stage/hooks/useStageCoordinates.ts new file mode 100644 index 00000000..7450662b --- /dev/null +++ b/frontend/src/editor/components/stage/hooks/useStageCoordinates.ts @@ -0,0 +1,194 @@ +import { useCallback } from "react"; +import { Position } from "../../../../types"; +import { STAGE_CELL_SIZE } from "../../../constants/constants"; + +/** + * Pixel offset from stage origin. + * `left` is pixels from left edge, `top` is pixels from top edge. + */ +export interface PxOffset { + left: number; + top: number; +} + +/** + * Calculates the pixel offset of an event relative to the stage element. + * + * This is a pure function extracted for testability. + * + * @param clientX - Event's clientX coordinate + * @param clientY - Event's clientY coordinate + * @param stageRect - Bounding client rect of the stage element + * @returns Pixel offset from stage origin + */ +export function calculatePxOffset( + clientX: number, + clientY: number, + stageRect: DOMRect +): PxOffset { + return { + left: clientX - stageRect.left, + top: clientY - stageRect.top, + }; +} + +/** + * Converts a pixel offset to a grid position, accounting for drag offset and scale. + * + * This is a pure function extracted for testability. + * + * The drag offset is the position within the dragged item where the user grabbed it. + * When no drag offset is provided, we use half a cell size to effectively floor + * instead of round (centering the position calculation). + * + * @param pxOffset - Pixel offset from stage origin + * @param scale - Current zoom scale of the stage + * @param dragOffset - Optional offset from drag data (where user grabbed the item) + * @returns Grid position {x, y} + */ +export function calculateGridPosition( + pxOffset: PxOffset, + scale: number, + dragOffset?: { dragLeft: number; dragTop: number } +): Position { + // When no drag offset is provided, use half cell size. + // This is a way of doing Math.floor instead of Math.round, + // because Math.round(x - 0.5) ≈ Math.floor(x) for positive x + const { dragLeft, dragTop } = dragOffset ?? { + dragLeft: STAGE_CELL_SIZE / 2, + dragTop: STAGE_CELL_SIZE / 2, + }; + + return { + x: Math.round((pxOffset.left - dragLeft) / STAGE_CELL_SIZE / scale), + y: Math.round((pxOffset.top - dragTop) / STAGE_CELL_SIZE / scale), + }; +} + +/** + * Parses drag offset from a dataTransfer object. + * + * @param dataTransfer - The DataTransfer object from a drag event + * @returns Parsed drag offset, or undefined if not present + */ +export function parseDragOffset( + dataTransfer: DataTransfer | null +): { dragLeft: number; dragTop: number } | undefined { + if (!dataTransfer) return undefined; + + const dragOffsetStr = dataTransfer.getData("drag-offset"); + if (!dragOffsetStr) return undefined; + + try { + return JSON.parse(dragOffsetStr); + } catch { + return undefined; + } +} + +/** + * Checks if a position is within the stage bounds. + * + * @param position - Grid position to check + * @param stageWidth - Width of stage in cells + * @param stageHeight - Height of stage in cells + * @returns True if position is within bounds + */ +export function isPositionInBounds( + position: Position, + stageWidth: number, + stageHeight: number +): boolean { + return ( + position.x >= 0 && + position.x < stageWidth && + position.y >= 0 && + position.y < stageHeight + ); +} + +/** + * Hook that provides coordinate transformation utilities for the Stage component. + * + * This hook provides callbacks that transform between: + * - Client coordinates (from mouse/touch events) + * - Pixel offsets (relative to stage element) + * - Grid positions (game coordinates) + * + * ## Closure Semantics + * + * - `stageElRef.current` is read at call time (always gets latest DOM element) + * - `scale` is captured in the closure when useCallback dependencies change + * + * Since this hook is called on every render with the current `scale` value, + * and React recreates callbacks when dependencies change, the callbacks + * always have access to the correct scale. This matches React's standard + * callback patterns. + * + * @param stageElRef - Ref to the stage DOM element + * @param scale - Current zoom scale of the stage + * @returns Object with coordinate transformation functions + */ +export function useStageCoordinates( + stageElRef: React.RefObject, + scale: number +) { + /** + * Gets the pixel offset of a mouse/drag event relative to the stage. + * + * IMPORTANT: This reads from stageElRef.current at call time, not hook execution time. + * The caller must ensure the stage element exists when this is called. + */ + const getPxOffsetForEvent = useCallback( + (event: MouseEvent | React.MouseEvent | React.DragEvent): PxOffset => { + const stageRect = stageElRef.current!.getBoundingClientRect(); + return calculatePxOffset(event.clientX, event.clientY, stageRect); + }, + // Note: stageElRef is intentionally excluded - refs are stable across renders + // and we read from .current at call time + [] + ); + + /** + * Converts a mouse/drag event to a grid position. + * + * For drag events, this will parse and apply the drag offset from dataTransfer. + * For regular mouse events, it centers the position calculation. + * + * IMPORTANT: Uses `scale` captured at hook execution. Since this hook is called + * on every render, scale will always be current. + */ + const getPositionForEvent = useCallback( + (event: MouseEvent | React.MouseEvent | React.DragEvent): Position => { + const pxOffset = getPxOffsetForEvent(event); + + // Extract drag offset from dataTransfer if this is a drag event + const dragOffset = + "dataTransfer" in event + ? parseDragOffset(event.dataTransfer) + : undefined; + + return calculateGridPosition(pxOffset, scale, dragOffset); + }, + [getPxOffsetForEvent, scale] + ); + + /** + * Checks if a position is within the stage bounds. + * + * IMPORTANT: This function does NOT access refs or mutable state. + * It's a pure function wrapped in useCallback for reference stability. + */ + const isInBounds = useCallback( + (position: Position, stageWidth: number, stageHeight: number): boolean => { + return isPositionInBounds(position, stageWidth, stageHeight); + }, + [] + ); + + return { + getPxOffsetForEvent, + getPositionForEvent, + isInBounds, + }; +} diff --git a/frontend/src/editor/components/stage/hooks/useStageDragDrop.test.ts b/frontend/src/editor/components/stage/hooks/useStageDragDrop.test.ts new file mode 100644 index 00000000..3dbc500c --- /dev/null +++ b/frontend/src/editor/components/stage/hooks/useStageDragDrop.test.ts @@ -0,0 +1,629 @@ +import { describe, it } from "mocha"; +import { expect } from "chai"; +import { + parseSpriteDropData, + parseAppearanceDropData, + parseHandleSide, + calculateNewExtent, + calculateDropOffset, + cloneExistsAtPosition, + createActorSpriteData, + createCharacterSpriteData, + createAppearanceData, + setHandleDragData, + ActorDropData, + CharacterDropData, +} from "./useStageDragDrop"; +import { Actor, Characters, RuleExtent } from "../../../../types"; + +// Mock DataTransfer for testing +function createMockDataTransfer(data: Record): DataTransfer { + const storage: Record = { ...data }; + const mock = { + getData: (key: string) => storage[key] || "", + setData: (key: string, value: string) => { + storage[key] = value; + // Update types array when data is set + mock.types = Object.keys(storage); + }, + types: Object.keys(storage), + }; + return mock as unknown as DataTransfer; +} + +describe("useStageDragDrop", () => { + describe("parseSpriteDropData", () => { + it("returns ActorDropData when dragging actors", () => { + const actorData: ActorDropData = { + actorIds: ["actor-1", "actor-2"], + dragAnchorActorId: "actor-1", + }; + const dataTransfer = createMockDataTransfer({ + sprite: JSON.stringify(actorData), + }); + + const result = parseSpriteDropData(dataTransfer); + + expect(result).to.deep.equal(actorData); + expect(result && "actorIds" in result).to.be.true; + }); + + it("returns CharacterDropData when dragging a character", () => { + const characterData: CharacterDropData = { + characterId: "char-1", + appearanceId: "appearance-1", + }; + const dataTransfer = createMockDataTransfer({ + sprite: JSON.stringify(characterData), + }); + + const result = parseSpriteDropData(dataTransfer); + + expect(result).to.deep.equal(characterData); + expect(result && "characterId" in result).to.be.true; + }); + + it("returns undefined when no sprite data", () => { + const dataTransfer = createMockDataTransfer({}); + + const result = parseSpriteDropData(dataTransfer); + + expect(result).to.be.undefined; + }); + + it("returns undefined when sprite data is invalid JSON", () => { + const dataTransfer = createMockDataTransfer({ + sprite: "not valid json", + }); + + const result = parseSpriteDropData(dataTransfer); + + expect(result).to.be.undefined; + }); + + it("returns undefined when sprite data has no recognized fields", () => { + const dataTransfer = createMockDataTransfer({ + sprite: JSON.stringify({ unknownField: "value" }), + }); + + const result = parseSpriteDropData(dataTransfer); + + expect(result).to.be.undefined; + }); + }); + + describe("parseAppearanceDropData", () => { + it("parses valid appearance data", () => { + const appearanceData = { + characterId: "char-1", + appearance: "happy", + }; + const dataTransfer = createMockDataTransfer({ + appearance: JSON.stringify(appearanceData), + }); + + const result = parseAppearanceDropData(dataTransfer); + + expect(result).to.deep.equal(appearanceData); + }); + + it("returns undefined when no appearance data", () => { + const dataTransfer = createMockDataTransfer({}); + + const result = parseAppearanceDropData(dataTransfer); + + expect(result).to.be.undefined; + }); + + it("returns undefined when appearance data is invalid JSON", () => { + const dataTransfer = createMockDataTransfer({ + appearance: "invalid json", + }); + + const result = parseAppearanceDropData(dataTransfer); + + expect(result).to.be.undefined; + }); + }); + + describe("parseHandleSide", () => { + it("extracts left handle", () => { + const types = ["handle", "handle:left"]; + + const result = parseHandleSide(types); + + expect(result).to.equal("left"); + }); + + it("extracts right handle", () => { + const types = ["handle", "handle:right"]; + + const result = parseHandleSide(types); + + expect(result).to.equal("right"); + }); + + it("extracts top handle", () => { + const types = ["handle", "handle:top"]; + + const result = parseHandleSide(types); + + expect(result).to.equal("top"); + }); + + it("extracts bottom handle", () => { + const types = ["handle", "handle:bottom"]; + + const result = parseHandleSide(types); + + expect(result).to.equal("bottom"); + }); + + it("returns undefined when no handle type", () => { + const types = ["sprite", "other"]; + + const result = parseHandleSide(types); + + expect(result).to.be.undefined; + }); + + it("returns undefined for invalid handle side", () => { + const types = ["handle:invalid"]; + + const result = parseHandleSide(types); + + expect(result).to.be.undefined; + }); + }); + + describe("calculateNewExtent", () => { + const baseExtent: RuleExtent = { + xmin: 2, + xmax: 5, + ymin: 2, + ymax: 5, + ignored: [], + }; + const stageWidth = 10; + const stageHeight = 10; + + describe("left handle", () => { + it("moves left edge inward", () => { + const result = calculateNewExtent( + baseExtent, + "left", + { x: 3.5, y: 3 }, + stageWidth, + stageHeight + ); + + expect(result.xmin).to.equal(4); // round(3.5 + 0.25) = 4 + expect(result.xmax).to.equal(baseExtent.xmax); + }); + + it("cannot move past right edge", () => { + const result = calculateNewExtent( + baseExtent, + "left", + { x: 7, y: 3 }, + stageWidth, + stageHeight + ); + + expect(result.xmin).to.equal(baseExtent.xmax); // capped at xmax + }); + + it("cannot go below 0", () => { + const result = calculateNewExtent( + baseExtent, + "left", + { x: -5, y: 3 }, + stageWidth, + stageHeight + ); + + expect(result.xmin).to.equal(0); + }); + }); + + describe("right handle", () => { + it("moves right edge inward", () => { + const result = calculateNewExtent( + baseExtent, + "right", + { x: 4.5, y: 3 }, + stageWidth, + stageHeight + ); + + expect(result.xmax).to.equal(4); // round(4.5 - 1) = round(3.5) = 4 + expect(result.xmin).to.equal(baseExtent.xmin); + }); + + it("cannot move past left edge", () => { + const result = calculateNewExtent( + baseExtent, + "right", + { x: 1, y: 3 }, + stageWidth, + stageHeight + ); + + expect(result.xmax).to.equal(baseExtent.xmin); // capped at xmin + }); + + it("cannot exceed stage width", () => { + const result = calculateNewExtent( + baseExtent, + "right", + { x: 15, y: 3 }, + stageWidth, + stageHeight + ); + + expect(result.xmax).to.equal(stageWidth); + }); + }); + + describe("top handle", () => { + it("moves top edge downward", () => { + const result = calculateNewExtent( + baseExtent, + "top", + { x: 3, y: 3.5 }, + stageWidth, + stageHeight + ); + + expect(result.ymin).to.equal(4); // round(3.5 + 0.25) = 4 + expect(result.ymax).to.equal(baseExtent.ymax); + }); + + it("cannot move past bottom edge", () => { + const result = calculateNewExtent( + baseExtent, + "top", + { x: 3, y: 7 }, + stageWidth, + stageHeight + ); + + expect(result.ymin).to.equal(baseExtent.ymax); + }); + + it("cannot go below 0", () => { + const result = calculateNewExtent( + baseExtent, + "top", + { x: 3, y: -5 }, + stageWidth, + stageHeight + ); + + expect(result.ymin).to.equal(0); + }); + }); + + describe("bottom handle", () => { + it("moves bottom edge upward", () => { + const result = calculateNewExtent( + baseExtent, + "bottom", + { x: 3, y: 4.5 }, + stageWidth, + stageHeight + ); + + expect(result.ymax).to.equal(4); // round(4.5 - 1) = round(3.5) = 4 + expect(result.ymin).to.equal(baseExtent.ymin); + }); + + it("cannot move past top edge", () => { + const result = calculateNewExtent( + baseExtent, + "bottom", + { x: 3, y: 1 }, + stageWidth, + stageHeight + ); + + expect(result.ymax).to.equal(baseExtent.ymin); + }); + + it("cannot exceed stage height", () => { + const result = calculateNewExtent( + baseExtent, + "bottom", + { x: 3, y: 15 }, + stageWidth, + stageHeight + ); + + expect(result.ymax).to.equal(stageHeight); + }); + }); + + it("preserves ignored array", () => { + const extentWithIgnored: RuleExtent = { + ...baseExtent, + ignored: [{ x: 3, y: 3 }], + }; + + const result = calculateNewExtent( + extentWithIgnored, + "left", + { x: 3, y: 3 }, + stageWidth, + stageHeight + ); + + expect(result.ignored).to.deep.equal([{ x: 3, y: 3 }]); + }); + }); + + describe("calculateDropOffset", () => { + it("calculates positive offset when dropping to the right", () => { + const anchorActor = { + id: "actor-1", + position: { x: 2, y: 3 }, + } as Actor; + const targetPosition = { x: 5, y: 3 }; + + const result = calculateDropOffset(anchorActor, targetPosition); + + expect(result.offsetX).to.equal(3); + expect(result.offsetY).to.equal(0); + }); + + it("calculates negative offset when dropping to the left", () => { + const anchorActor = { + id: "actor-1", + position: { x: 5, y: 5 }, + } as Actor; + const targetPosition = { x: 2, y: 3 }; + + const result = calculateDropOffset(anchorActor, targetPosition); + + expect(result.offsetX).to.equal(-3); + expect(result.offsetY).to.equal(-2); + }); + + it("returns zero offset when dropping in same position", () => { + const anchorActor = { + id: "actor-1", + position: { x: 3, y: 3 }, + } as Actor; + const targetPosition = { x: 3, y: 3 }; + + const result = calculateDropOffset(anchorActor, targetPosition); + + expect(result.offsetX).to.equal(0); + expect(result.offsetY).to.equal(0); + }); + }); + + describe("cloneExistsAtPosition", () => { + const characters: Characters = { + "char-1": { + id: "char-1", + name: "Test Character", + spritesheet: { + appearances: { + default: { + grid: [[0]], + }, + }, + }, + rules: [], + } as unknown as Characters[string], + }; + + const stageActors: { [id: string]: Actor } = { + "actor-1": { + id: "actor-1", + characterId: "char-1", + appearance: "default", + position: { x: 3, y: 3 }, + } as Actor, + }; + + it("returns true when identical actor exists at same position", () => { + const newActorPoints = ["3,3"]; + + const result = cloneExistsAtPosition( + newActorPoints, + "char-1", + "default", + stageActors, + characters + ); + + expect(result).to.be.true; + }); + + it("returns false when no actor at position", () => { + const newActorPoints = ["5,5"]; + + const result = cloneExistsAtPosition( + newActorPoints, + "char-1", + "default", + stageActors, + characters + ); + + expect(result).to.be.false; + }); + + it("returns false when actor at position has different appearance", () => { + const newActorPoints = ["3,3"]; + + const result = cloneExistsAtPosition( + newActorPoints, + "char-1", + "different-appearance", + stageActors, + characters + ); + + expect(result).to.be.false; + }); + + it("returns false when actor at position has different character", () => { + const newActorPoints = ["3,3"]; + + const result = cloneExistsAtPosition( + newActorPoints, + "char-2", + "default", + stageActors, + characters + ); + + expect(result).to.be.false; + }); + + it("returns true when any point overlaps", () => { + const newActorPoints = ["2,2", "3,3", "4,4"]; // 3,3 overlaps + + const result = cloneExistsAtPosition( + newActorPoints, + "char-1", + "default", + stageActors, + characters + ); + + expect(result).to.be.true; + }); + + it("returns false with empty stage", () => { + const newActorPoints = ["3,3"]; + + const result = cloneExistsAtPosition( + newActorPoints, + "char-1", + "default", + {}, + characters + ); + + expect(result).to.be.false; + }); + }); + + // ========================================================================== + // Creation Utilities Tests + // ========================================================================== + + describe("createActorSpriteData", () => { + it("creates valid JSON with actorIds and dragAnchorActorId", () => { + const result = createActorSpriteData(["actor-1", "actor-2"], "actor-1"); + const parsed = JSON.parse(result); + + expect(parsed.actorIds).to.deep.equal(["actor-1", "actor-2"]); + expect(parsed.dragAnchorActorId).to.equal("actor-1"); + }); + + it("round-trips through parseSpriteDropData", () => { + const actorIds = ["a1", "a2", "a3"]; + const anchorId = "a1"; + + const jsonData = createActorSpriteData(actorIds, anchorId); + const dataTransfer = createMockDataTransfer({ sprite: jsonData }); + const parsed = parseSpriteDropData(dataTransfer); + + expect(parsed).to.not.be.undefined; + expect("actorIds" in parsed!).to.be.true; + const actorData = parsed as ActorDropData; + expect(actorData.actorIds).to.deep.equal(actorIds); + expect(actorData.dragAnchorActorId).to.equal(anchorId); + }); + }); + + describe("createCharacterSpriteData", () => { + it("creates valid JSON with characterId", () => { + const result = createCharacterSpriteData("char-1"); + const parsed = JSON.parse(result); + + expect(parsed.characterId).to.equal("char-1"); + expect(parsed.appearance).to.be.undefined; + }); + + it("creates valid JSON with characterId and appearance", () => { + const result = createCharacterSpriteData("char-1", "happy"); + const parsed = JSON.parse(result); + + expect(parsed.characterId).to.equal("char-1"); + expect(parsed.appearance).to.equal("happy"); + }); + + it("round-trips through parseSpriteDropData", () => { + const jsonData = createCharacterSpriteData("char-1", "angry"); + const dataTransfer = createMockDataTransfer({ sprite: jsonData }); + const parsed = parseSpriteDropData(dataTransfer); + + expect(parsed).to.not.be.undefined; + expect("characterId" in parsed!).to.be.true; + const charData = parsed as CharacterDropData; + expect(charData.characterId).to.equal("char-1"); + expect(charData.appearance).to.equal("angry"); + }); + }); + + describe("createAppearanceData", () => { + it("creates valid JSON with characterId and appearance", () => { + const result = createAppearanceData("char-1", "happy"); + const parsed = JSON.parse(result); + + expect(parsed.characterId).to.equal("char-1"); + expect(parsed.appearance).to.equal("happy"); + }); + + it("round-trips through parseAppearanceDropData", () => { + const jsonData = createAppearanceData("char-1", "sad"); + const dataTransfer = createMockDataTransfer({ appearance: jsonData }); + const parsed = parseAppearanceDropData(dataTransfer); + + expect(parsed).to.not.be.undefined; + expect(parsed!.characterId).to.equal("char-1"); + expect(parsed!.appearance).to.equal("sad"); + }); + }); + + describe("setHandleDragData", () => { + it("sets handle and handle:side data", () => { + const dataTransfer = createMockDataTransfer({}); + + setHandleDragData(dataTransfer, "left"); + + expect(dataTransfer.getData("handle")).to.equal("true"); + expect(dataTransfer.getData("handle:left")).to.equal("true"); + }); + + it("round-trips through parseHandleSide", () => { + const dataTransfer = createMockDataTransfer({}); + + setHandleDragData(dataTransfer, "bottom"); + const side = parseHandleSide(dataTransfer.types); + + expect(side).to.equal("bottom"); + }); + + it("works for all handle sides", () => { + const sides: Array<"left" | "right" | "top" | "bottom"> = [ + "left", + "right", + "top", + "bottom", + ]; + + for (const side of sides) { + const dataTransfer = createMockDataTransfer({}); + setHandleDragData(dataTransfer, side); + const parsedSide = parseHandleSide(dataTransfer.types); + expect(parsedSide).to.equal(side); + } + }); + }); +}); diff --git a/frontend/src/editor/components/stage/hooks/useStageDragDrop.ts b/frontend/src/editor/components/stage/hooks/useStageDragDrop.ts new file mode 100644 index 00000000..c66ac1e6 --- /dev/null +++ b/frontend/src/editor/components/stage/hooks/useStageDragDrop.ts @@ -0,0 +1,639 @@ +import { useCallback, useRef } from "react"; +import { Actor, Characters, Position, RuleExtent, Stage } from "../../../../types"; +import { STAGE_CELL_SIZE } from "../../../constants/constants"; +import { + actorFilledPoints, + actorFillsPoint, + applyAnchorAdjustment, + pointIsOutside, +} from "../../../utils/stage-helpers"; +import { defaultAppearanceId } from "../../../utils/character-helpers"; +import { + changeActors, + changeActorsIndividually, + createActors, +} from "../../../actions/stage-actions"; +import { setRecordingExtent } from "../../../actions/recording-actions"; +import { PxOffset } from "./useStageCoordinates"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Dispatch = (action: any) => void; + +/** + * Data for moving or stamping existing actors. + */ +export interface ActorDropData { + actorIds: string[]; + dragAnchorActorId: string; +} + +/** + * Data for dropping a new character instance. + * Note: Uses `appearance` (not `appearanceId`) to match the format used by library.tsx + */ +export interface CharacterDropData { + characterId: string; + appearance?: string; +} + +/** + * Data for dropping an appearance change onto an actor. + */ +export interface AppearanceDropData { + characterId: string; + appearance: string; +} + +/** + * Mode for actor drops. + * - "move": Move existing actors to new positions + * - "stamp-copy": Create copies of actors at new positions + */ +export type DropMode = "stamp-copy" | "move"; + +/** + * Handle sides for recording extent handles. + */ +export type HandleSide = "left" | "right" | "top" | "bottom"; + +// ============================================================================= +// Drag Data Creation Utilities +// ============================================================================= +// These functions create drag data in the format expected by the parsing functions. +// Use these when setting dataTransfer data to ensure consistency. + +/** + * Creates sprite drop data for existing actors being dragged. + * + * @param actorIds - IDs of actors being dragged + * @param dragAnchorActorId - ID of the actor used as the drag anchor point + * @returns JSON string to set as "sprite" dataTransfer data + * + * @example + * event.dataTransfer.setData("sprite", createActorSpriteData(["actor1", "actor2"], "actor1")); + */ +export function createActorSpriteData( + actorIds: string[], + dragAnchorActorId: string +): string { + const data: ActorDropData = { actorIds, dragAnchorActorId }; + return JSON.stringify(data); +} + +/** + * Creates sprite drop data for a new character being dragged from the library. + * + * @param characterId - ID of the character to create + * @param appearance - Optional appearance ID (defaults to character's default appearance) + * @returns JSON string to set as "sprite" dataTransfer data + * + * @example + * event.dataTransfer.setData("sprite", createCharacterSpriteData("char1", "happy")); + */ +export function createCharacterSpriteData( + characterId: string, + appearance?: string +): string { + const data: CharacterDropData = { characterId, appearance }; + return JSON.stringify(data); +} + +/** + * Creates appearance drop data for changing an actor's appearance. + * + * @param characterId - ID of the character whose appearance is being applied + * @param appearance - The appearance ID to apply + * @returns JSON string to set as "appearance" dataTransfer data + * + * @example + * event.dataTransfer.setData("appearance", createAppearanceData("char1", "happy")); + */ +export function createAppearanceData( + characterId: string, + appearance: string +): string { + const data: AppearanceDropData = { characterId, appearance }; + return JSON.stringify(data); +} + +/** + * Sets handle drag data on a dataTransfer object. + * + * @param dataTransfer - The dataTransfer object to set data on + * @param side - Which handle is being dragged + * + * @example + * setHandleDragData(event.dataTransfer, "left"); + */ +export function setHandleDragData( + dataTransfer: DataTransfer, + side: HandleSide +): void { + dataTransfer.setData("handle", "true"); + dataTransfer.setData(`handle:${side}`, "true"); +} + +// ============================================================================= +// Drag Data Parsing Utilities +// ============================================================================= + +/** + * Parses sprite drop data from a drag event. + * + * @param dataTransfer - The drag event's dataTransfer object + * @returns ActorDropData if dragging actors, CharacterDropData if dragging a character, or undefined + */ +export function parseSpriteDropData( + dataTransfer: DataTransfer +): ActorDropData | CharacterDropData | undefined { + const spriteData = dataTransfer.getData("sprite"); + if (!spriteData) return undefined; + + try { + const parsed = JSON.parse(spriteData); + if ("actorIds" in parsed) { + return parsed as ActorDropData; + } else if ("characterId" in parsed) { + return parsed as CharacterDropData; + } + return undefined; + } catch { + return undefined; + } +} + +/** + * Parses appearance drop data from a drag event. + * + * @param dataTransfer - The drag event's dataTransfer object + * @returns AppearanceDropData or undefined + */ +export function parseAppearanceDropData( + dataTransfer: DataTransfer +): AppearanceDropData | undefined { + const appearanceData = dataTransfer.getData("appearance"); + if (!appearanceData) return undefined; + + try { + return JSON.parse(appearanceData) as AppearanceDropData; + } catch { + return undefined; + } +} + +/** + * Extracts the handle side from a drag event's types. + * + * @param types - The dataTransfer types array + * @returns The handle side ("left", "right", "top", "bottom") or undefined + */ +export function parseHandleSide( + types: readonly string[] +): "left" | "right" | "top" | "bottom" | undefined { + const handleType = types.find((t) => t.startsWith("handle:")); + if (!handleType) return undefined; + + const side = handleType.split(":").pop(); + if (side === "left" || side === "right" || side === "top" || side === "bottom") { + return side; + } + return undefined; +} + +/** + * Calculates the new extent when dragging a recording handle. + * + * This is a pure function extracted for testability. + * + * @param currentExtent - The current recording extent + * @param side - Which handle is being dragged + * @param position - The current drag position (in cell coordinates) + * @param stageWidth - Width of the stage in cells + * @param stageHeight - Height of the stage in cells + * @returns The new extent, or undefined if no change + */ +export function calculateNewExtent( + currentExtent: RuleExtent, + side: "left" | "right" | "top" | "bottom", + position: { x: number; y: number }, + stageWidth: number, + stageHeight: number +): RuleExtent { + const nextExtent = { ...currentExtent }; + + if (side === "left") { + nextExtent.xmin = Math.min( + nextExtent.xmax, + Math.max(0, Math.round(position.x + 0.25)) + ); + } + if (side === "right") { + nextExtent.xmax = Math.max( + nextExtent.xmin, + Math.min(stageWidth, Math.round(position.x - 1)) + ); + } + if (side === "top") { + nextExtent.ymin = Math.min( + nextExtent.ymax, + Math.max(0, Math.round(position.y + 0.25)) + ); + } + if (side === "bottom") { + nextExtent.ymax = Math.max( + nextExtent.ymin, + Math.min(stageHeight, Math.round(position.y - 1)) + ); + } + + return nextExtent; +} + +/** + * Calculates the position offset between an anchor actor and a target position. + * + * @param anchorActor - The actor used as the drag anchor + * @param targetPosition - The target drop position + * @returns The offset {x, y} to apply to all dragged actors + */ +export function calculateDropOffset( + anchorActor: Actor, + targetPosition: Position +): { offsetX: number; offsetY: number } { + return { + offsetX: targetPosition.x - anchorActor.position.x, + offsetY: targetPosition.y - anchorActor.position.y, + }; +} + +/** + * Checks if an actor clone would overlap with an existing identical actor. + * + * This prevents accidentally stamping duplicate actors. + * + * @param newActorPoints - Array of point strings like "x,y" that the new actor would fill + * @param characterId - The character ID of the actor being cloned + * @param appearance - The appearance of the actor being cloned + * @param stageActors - All actors on the stage + * @param characters - All character definitions + * @returns true if a clone already exists at this position + */ +export function cloneExistsAtPosition( + newActorPoints: string[], + characterId: string, + appearance: string, + stageActors: { [id: string]: Actor }, + characters: Characters +): boolean { + return Object.values(stageActors).some( + (a) => + a.characterId === characterId && + a.appearance === appearance && + actorFilledPoints(a, characters).some((p) => + newActorPoints.includes(`${p.x},${p.y}`) + ) + ); +} + +/** + * Configuration for the useStageDragDrop hook. + */ +export interface StageDragDropConfig { + /** Redux dispatch function */ + dispatch: Dispatch; + /** The current stage */ + stage: Stage; + /** World ID */ + worldId: string; + /** All character definitions */ + characters: Characters; + /** Current recording extent (if any) */ + recordingExtent?: RuleExtent; + /** Function to get grid position from an event */ + getPositionForEvent: (event: React.DragEvent) => Position; + /** Ref to the stage DOM element */ + stageElRef: React.RefObject; +} + +/** + * Hook that manages drag and drop operations on the Stage. + * + * This hook handles: + * - Dropping sprites (actors or characters) onto the stage + * - Dropping appearance changes onto actors + * - Dragging recording extent handles + * - Stamping actors during tool drags + * + * ## Pure Functions + * + * The following functions are exported for testability: + * - `parseSpriteDropData`: Parses sprite data from dataTransfer + * - `parseAppearanceDropData`: Parses appearance data from dataTransfer + * - `parseHandleSide`: Extracts handle side from dataTransfer types + * - `calculateNewExtent`: Calculates new extent when dragging handles + * - `calculateDropOffset`: Calculates offset for actor drops + * - `cloneExistsAtPosition`: Checks for duplicate actors + * + * ## Capture Semantics + * + * - `stageElRef` is a ref, read at call time + * - `stage`, `characters`, `recordingExtent` are captured when callbacks are recreated + * - `getPositionForEvent` is a callback from useStageCoordinates + * + * @param config - Configuration object + * @returns Object with drag/drop handlers and helper functions + */ +export function useStageDragDrop(config: StageDragDropConfig) { + const { + dispatch, + stage, + worldId, + characters, + recordingExtent, + getPositionForEvent, + stageElRef, + } = config; + + // Track last fired extent to avoid redundant dispatches + const lastFiredExtent = useRef(null); + + /** + * Updates the recording extent based on a handle drag. + */ + const onUpdateHandle = useCallback( + (event: React.DragEvent) => { + if (!recordingExtent) return; + + const side = parseHandleSide(event.dataTransfer.types); + if (!side) return; + + const stageOffset = stageElRef.current!.getBoundingClientRect(); + const position = { + x: (event.clientX - stageOffset.left) / STAGE_CELL_SIZE, + y: (event.clientY - stageOffset.top) / STAGE_CELL_SIZE, + }; + + const nextExtent = calculateNewExtent( + recordingExtent, + side, + position, + stage.width, + stage.height + ); + + const str = JSON.stringify(nextExtent); + if (lastFiredExtent.current === str) { + return; + } + lastFiredExtent.current = str; + dispatch(setRecordingExtent(nextExtent)); + }, + // Note: stageElRef intentionally excluded - refs are stable across renders + [dispatch, recordingExtent, stage.width, stage.height] + ); + + /** + * Drops an appearance change onto an actor. + */ + const onDropAppearance = useCallback( + (event: React.DragEvent) => { + const data = parseAppearanceDropData(event.dataTransfer); + if (!data) return; + + const { appearance, characterId } = data; + const position = getPositionForEvent(event); + + if (recordingExtent && pointIsOutside(position, recordingExtent)) { + return; + } + + const actor = Object.values(stage.actors).find( + (a) => + actorFillsPoint(a, characters, position) && + a.characterId === characterId + ); + + if (actor) { + const sel = { + worldId, + stageId: stage.id, + actorIds: [actor.id], + }; + dispatch(changeActors(sel, { appearance })); + } + }, + [ + dispatch, + stage.actors, + stage.id, + worldId, + characters, + recordingExtent, + getPositionForEvent, + ] + ); + + /** + * Drops actors at a position (move or stamp-copy). + */ + const onDropActorsAtPosition = useCallback( + (data: ActorDropData, position: Position, mode: DropMode) => { + if (recordingExtent && pointIsOutside(position, recordingExtent)) { + return; + } + + const anchorActor = stage.actors[data.dragAnchorActorId]; + if (!anchorActor) return; + + const anchorCharacter = characters[anchorActor.characterId]; + applyAnchorAdjustment(position, anchorCharacter, anchorActor); + + const { offsetX, offsetY } = calculateDropOffset(anchorActor, position); + + if (offsetX === 0 && offsetY === 0) { + // Attempting to drop in the same place we started the drag + return; + } + + if (mode === "stamp-copy") { + const creates = data.actorIds + .map((aid) => { + const actor = stage.actors[aid]; + if (!actor) return undefined; + + const character = characters[actor.characterId]; + const clonedActor = { + ...actor, + position: { + x: actor.position.x + offsetX, + y: actor.position.y + offsetY, + }, + }; + const clonedActorPoints = actorFilledPoints( + clonedActor, + characters + ).map((p) => `${p.x},${p.y}`); + + // Don't create if an identical actor already overlaps + if ( + cloneExistsAtPosition( + clonedActorPoints, + actor.characterId, + actor.appearance, + stage.actors, + characters + ) + ) { + return undefined; + } + + return { character, initialValues: clonedActor }; + }) + .filter((c): c is NonNullable => !!c); + + if (creates.length > 0) { + dispatch(createActors(worldId, stage.id, creates)); + } + } else if (mode === "move") { + const upserts = data.actorIds.map((aid) => ({ + id: aid, + values: { + position: { + x: stage.actors[aid].position.x + offsetX, + y: stage.actors[aid].position.y + offsetY, + }, + }, + })); + dispatch(changeActorsIndividually(worldId, stage.id, upserts)); + } + }, + [dispatch, stage.actors, stage.id, worldId, characters, recordingExtent] + ); + + /** + * Drops a new character instance at a position. + */ + const onDropCharacterAtPosition = useCallback( + (data: CharacterDropData, position: Position) => { + if (recordingExtent && pointIsOutside(position, recordingExtent)) { + return; + } + + const character = characters[data.characterId]; + if (!character) return; + + const appearance = + data.appearance ?? defaultAppearanceId(character.spritesheet); + const newActor = { position, appearance } as Actor; + applyAnchorAdjustment(position, character, newActor); + + const newActorPoints = actorFilledPoints(newActor, characters).map( + (p) => `${p.x},${p.y}` + ); + + // Check if an identical actor already exists at this position + const positionContainsCloneAlready = Object.values(stage.actors).find( + (a) => + a.characterId === data.characterId && + actorFilledPoints(a, characters).some((p) => + newActorPoints.includes(`${p.x},${p.y}`) + ) + ); + + if (positionContainsCloneAlready) { + return; + } + + dispatch( + createActors(worldId, stage.id, [{ character, initialValues: newActor }]) + ); + }, + [dispatch, stage.actors, stage.id, worldId, characters, recordingExtent] + ); + + /** + * Handles sprite drops (actors or characters). + */ + const onDropSprite = useCallback( + (event: React.DragEvent) => { + const data = parseSpriteDropData(event.dataTransfer); + if (!data) return; + + const position = getPositionForEvent(event); + + if ("actorIds" in data) { + const mode: DropMode = event.altKey ? "stamp-copy" : "move"; + onDropActorsAtPosition(data, position, mode); + } else if ("characterId" in data) { + onDropCharacterAtPosition(data, position); + } + }, + [getPositionForEvent, onDropActorsAtPosition, onDropCharacterAtPosition] + ); + + /** + * Stamps actors at a position (used by STAMP tool during drag). + */ + const onStampAtPosition = useCallback( + ( + position: Position, + stampItem: + | { actorIds: string[]; worldId: string; stageId: string } + | CharacterDropData + | null + ) => { + if (!stampItem) return; + + if ("actorIds" in stampItem) { + const data: ActorDropData = { + actorIds: stampItem.actorIds, + dragAnchorActorId: stampItem.actorIds[0], + }; + onDropActorsAtPosition(data, position, "stamp-copy"); + } else if ("characterId" in stampItem) { + onDropCharacterAtPosition(stampItem, position); + } + }, + [onDropActorsAtPosition, onDropCharacterAtPosition] + ); + + /** + * Main dragOver handler for the stage. + */ + const onDragOver = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + if (event.dataTransfer.types.includes("handle")) { + onUpdateHandle(event); + } + }, + [onUpdateHandle] + ); + + /** + * Main drop handler for the stage. + */ + const onDrop = useCallback( + (event: React.DragEvent) => { + if (event.dataTransfer.types.includes("sprite")) { + onDropSprite(event); + } + if (event.dataTransfer.types.includes("appearance")) { + onDropAppearance(event); + } + if (event.dataTransfer.types.includes("handle")) { + onUpdateHandle(event); + } + }, + [onDropSprite, onDropAppearance, onUpdateHandle] + ); + + return { + onDragOver, + onDrop, + onStampAtPosition, + // Expose lower-level functions for advanced use cases + onDropActorsAtPosition, + onDropCharacterAtPosition, + }; +} diff --git a/frontend/src/editor/components/stage/hooks/useStagePopover.ts b/frontend/src/editor/components/stage/hooks/useStagePopover.ts new file mode 100644 index 00000000..6d91cb04 --- /dev/null +++ b/frontend/src/editor/components/stage/hooks/useStagePopover.ts @@ -0,0 +1,72 @@ +import { useCallback, useState } from "react"; +import { Actor } from "../../../../types"; + +/** + * State for the actor selection popover. + * + * The popover appears when clicking on overlapping actors, allowing + * the user to choose which actor to interact with. + */ +export interface PopoverState { + /** The overlapping actors to choose from */ + actors: Actor[]; + /** Screen position where popover should appear */ + position: { x: number; y: number }; + /** Which tool triggered this popover (affects what happens on selection) */ + toolId: string; +} + +/** + * Hook that manages the actor selection popover state. + * + * The popover appears when: + * - Clicking on a position with multiple overlapping actors + * - Using tools that need to target a specific actor (paint, stamp, trash, etc.) + * + * IMPORTANT: This hook manages state only. The parent component is responsible for: + * - Detecting when to show the popover (overlapping actors) + * - Handling the selected actor (dispatching appropriate action) + * - Rendering the actual popover UI + * + * @returns Object with popover state and control functions + */ +export function useStagePopover() { + const [popover, setPopover] = useState(null); + + /** + * Shows the popover with the given actors at the specified position. + * + * @param actors - Array of overlapping actors to choose from + * @param position - Screen position (clientX/clientY) where popover should appear + * @param toolId - The tool that triggered this popover + */ + const showPopover = useCallback( + ( + actors: Actor[], + position: { x: number; y: number }, + toolId: string + ) => { + setPopover({ actors, position, toolId }); + }, + [] + ); + + /** + * Closes the popover without making a selection. + */ + const closePopover = useCallback(() => { + setPopover(null); + }, []); + + /** + * Whether the popover is currently visible. + */ + const isOpen = popover !== null; + + return { + popover, + isOpen, + showPopover, + closePopover, + }; +} diff --git a/frontend/src/editor/components/stage/hooks/useStageSelection.test.ts b/frontend/src/editor/components/stage/hooks/useStageSelection.test.ts new file mode 100644 index 00000000..fe3313a1 --- /dev/null +++ b/frontend/src/editor/components/stage/hooks/useStageSelection.test.ts @@ -0,0 +1,189 @@ +import { expect } from "chai"; +import { + selectionRectToGridBounds, + findActorsInBounds, + getSelectionCharacterId, + SelectionRect, +} from "./useStageSelection"; +import { Actor } from "../../../../types"; + +describe("useStageSelection", () => { + // STAGE_CELL_SIZE is 40px + const CELL = 40; + + describe("selectionRectToGridBounds", () => { + it("should convert pixel rectangle to grid bounds at scale 1", () => { + const rect: SelectionRect = { + start: { left: 40, top: 40 }, + end: { left: 120, top: 160 }, + }; + const result = selectionRectToGridBounds(rect, 1); + // min: floor(40/40) = 1, floor(40/40) = 1 + // max: floor(120/40) = 3, floor(160/40) = 4 + expect(result.min).to.deep.equal({ x: 1, y: 1 }); + expect(result.max).to.deep.equal({ x: 3, y: 4 }); + }); + + it("should handle reversed rectangle (dragging up-left)", () => { + const rect: SelectionRect = { + start: { left: 120, top: 160 }, + end: { left: 40, top: 40 }, + }; + const result = selectionRectToGridBounds(rect, 1); + // Same result as dragging down-right + expect(result.min).to.deep.equal({ x: 1, y: 1 }); + expect(result.max).to.deep.equal({ x: 3, y: 4 }); + }); + + it("should handle scale factor", () => { + const rect: SelectionRect = { + start: { left: 20, top: 20 }, + end: { left: 60, top: 60 }, + }; + const result = selectionRectToGridBounds(rect, 0.5); + // At scale 0.5, coordinates are effectively doubled + // min: floor(20/40/0.5) = floor(1) = 1 + // max: floor(60/40/0.5) = floor(3) = 3 + expect(result.min).to.deep.equal({ x: 1, y: 1 }); + expect(result.max).to.deep.equal({ x: 3, y: 3 }); + }); + + it("should handle selection starting at origin", () => { + const rect: SelectionRect = { + start: { left: 0, top: 0 }, + end: { left: 80, top: 80 }, + }; + const result = selectionRectToGridBounds(rect, 1); + expect(result.min).to.deep.equal({ x: 0, y: 0 }); + expect(result.max).to.deep.equal({ x: 2, y: 2 }); + }); + + it("should handle zero-size selection (single click)", () => { + const rect: SelectionRect = { + start: { left: 50, top: 50 }, + end: { left: 50, top: 50 }, + }; + const result = selectionRectToGridBounds(rect, 1); + // Both min and max should be the same cell + expect(result.min).to.deep.equal({ x: 1, y: 1 }); + expect(result.max).to.deep.equal({ x: 1, y: 1 }); + }); + + it("should floor pixel coordinates to grid", () => { + const rect: SelectionRect = { + start: { left: 39, top: 39 }, // Just before cell 1 + end: { left: 81, top: 81 }, // Just into cell 2 + }; + const result = selectionRectToGridBounds(rect, 1); + expect(result.min).to.deep.equal({ x: 0, y: 0 }); + expect(result.max).to.deep.equal({ x: 2, y: 2 }); + }); + }); + + describe("findActorsInBounds", () => { + const createActor = (id: string, x: number, y: number, characterId = "char1"): Actor => ({ + id, + characterId, + position: { x, y }, + appearance: "app1", + variableValues: {}, + }); + + it("should find actors within bounds", () => { + const actors = { + a1: createActor("a1", 2, 2), + a2: createActor("a2", 3, 3), + a3: createActor("a3", 5, 5), // Outside bounds + }; + const result = findActorsInBounds(actors, { x: 1, y: 1 }, { x: 4, y: 4 }); + expect(result).to.have.length(2); + expect(result.map((a) => a.id).sort()).to.deep.equal(["a1", "a2"]); + }); + + it("should include actors on bounds edges", () => { + const actors = { + a1: createActor("a1", 1, 1), // On min edge + a2: createActor("a2", 4, 4), // On max edge + }; + const result = findActorsInBounds(actors, { x: 1, y: 1 }, { x: 4, y: 4 }); + expect(result).to.have.length(2); + }); + + it("should return empty array when no actors in bounds", () => { + const actors = { + a1: createActor("a1", 10, 10), + }; + const result = findActorsInBounds(actors, { x: 1, y: 1 }, { x: 4, y: 4 }); + expect(result).to.have.length(0); + }); + + it("should return empty array for empty actors dict", () => { + const result = findActorsInBounds({}, { x: 1, y: 1 }, { x: 4, y: 4 }); + expect(result).to.have.length(0); + }); + + it("should handle single-cell bounds", () => { + const actors = { + a1: createActor("a1", 2, 2), + a2: createActor("a2", 2, 3), + }; + const result = findActorsInBounds(actors, { x: 2, y: 2 }, { x: 2, y: 2 }); + expect(result).to.have.length(1); + expect(result[0].id).to.equal("a1"); + }); + + it("should find overlapping actors at same position", () => { + const actors = { + a1: createActor("a1", 2, 2), + a2: createActor("a2", 2, 2), // Same position + }; + const result = findActorsInBounds(actors, { x: 2, y: 2 }, { x: 2, y: 2 }); + expect(result).to.have.length(2); + }); + }); + + describe("getSelectionCharacterId", () => { + const createActor = (id: string, characterId: string): Actor => ({ + id, + characterId, + position: { x: 0, y: 0 }, + appearance: "app1", + variableValues: {}, + }); + + it("should return character ID when all actors share same character", () => { + const actors = [ + createActor("a1", "char1"), + createActor("a2", "char1"), + createActor("a3", "char1"), + ]; + expect(getSelectionCharacterId(actors)).to.equal("char1"); + }); + + it("should return null when actors have different characters", () => { + const actors = [ + createActor("a1", "char1"), + createActor("a2", "char2"), + ]; + expect(getSelectionCharacterId(actors)).to.be.null; + }); + + it("should return null for empty array", () => { + expect(getSelectionCharacterId([])).to.be.null; + }); + + it("should return character ID for single actor", () => { + const actors = [createActor("a1", "char1")]; + expect(getSelectionCharacterId(actors)).to.equal("char1"); + }); + + it("should return null when only one actor differs", () => { + const actors = [ + createActor("a1", "char1"), + createActor("a2", "char1"), + createActor("a3", "char2"), // Different + ]; + expect(getSelectionCharacterId(actors)).to.be.null; + }); + }); +}); diff --git a/frontend/src/editor/components/stage/hooks/useStageSelection.ts b/frontend/src/editor/components/stage/hooks/useStageSelection.ts new file mode 100644 index 00000000..95cb6bdf --- /dev/null +++ b/frontend/src/editor/components/stage/hooks/useStageSelection.ts @@ -0,0 +1,193 @@ +import { useCallback, useState } from "react"; +import { Actor, Position } from "../../../../types"; +import { STAGE_CELL_SIZE } from "../../../constants/constants"; + +/** + * Selection rectangle defined by start and end pixel coordinates. + * The start is where the user began dragging, end is current position. + * Note: end may be less than start if user dragged up/left. + */ +export interface SelectionRect { + start: { top: number; left: number }; + end: { top: number; left: number }; +} + +/** + * Converts a selection rectangle to grid bounds. + * + * This is a pure function extracted for testability. + * + * @param rect - Selection rectangle in pixels + * @param scale - Current zoom scale + * @returns Grid bounds { min: Position, max: Position } + */ +export function selectionRectToGridBounds( + rect: SelectionRect, + scale: number +): { min: Position; max: Position } { + // Sort to handle dragging in any direction + const [minLeft, maxLeft] = [rect.start.left, rect.end.left].sort( + (a, b) => a - b + ); + const [minTop, maxTop] = [rect.start.top, rect.end.top].sort((a, b) => a - b); + + return { + min: { + x: Math.floor(minLeft / STAGE_CELL_SIZE / scale), + y: Math.floor(minTop / STAGE_CELL_SIZE / scale), + }, + max: { + x: Math.floor(maxLeft / STAGE_CELL_SIZE / scale), + y: Math.floor(maxTop / STAGE_CELL_SIZE / scale), + }, + }; +} + +/** + * Finds all actors within the given grid bounds. + * + * This is a pure function extracted for testability. + * + * @param actors - Dictionary of actors + * @param min - Minimum grid position (inclusive) + * @param max - Maximum grid position (inclusive) + * @returns Array of actors within bounds + */ +export function findActorsInBounds( + actors: { [id: string]: Actor }, + min: Position, + max: Position +): Actor[] { + return Object.values(actors).filter( + (actor) => + actor.position.x >= min.x && + actor.position.x <= max.x && + actor.position.y >= min.y && + actor.position.y <= max.y + ); +} + +/** + * Determines the character ID to use for selection. + * + * Returns the character ID if all selected actors share the same character, + * otherwise returns null (mixed selection). + * + * @param actors - Array of selected actors + * @returns Character ID if uniform, null otherwise + */ +export function getSelectionCharacterId(actors: Actor[]): string | null { + if (actors.length === 0) return null; + + const firstCharacterId = actors[0].characterId; + const allSameCharacter = actors.every( + (a) => a.characterId === firstCharacterId + ); + + return allSameCharacter ? firstCharacterId : null; +} + +/** + * Hook that manages the selection rectangle state for box selection. + * + * This hook handles: + * - Starting a selection rectangle when user clicks background + * - Updating the rectangle as user drags + * - Finishing selection and determining which actors are selected + * - Canceling selection + * + * IMPORTANT: This hook manages state only. It does NOT: + * - Add event listeners (that's the parent's responsibility) + * - Dispatch Redux actions (the parent passes a callback) + * - Access DOM elements directly + * + * @returns Object with selection state and control functions + */ +export function useStageSelection() { + const [selectionRect, setSelectionRect] = useState( + null + ); + + /** + * Starts a new selection rectangle. + * + * @param startPx - Starting pixel position (typically from getPxOffsetForEvent) + */ + const startSelection = useCallback( + (startPx: { left: number; top: number }) => { + setSelectionRect({ start: startPx, end: startPx }); + }, + [] + ); + + /** + * Updates the selection rectangle's end position. + * + * IMPORTANT: This only updates if a selection is in progress. + * Calling this when selectionRect is null is a no-op. + * + * @param endPx - Current pixel position + */ + const updateSelection = useCallback( + (endPx: { left: number; top: number }) => { + setSelectionRect((prev) => (prev ? { ...prev, end: endPx } : null)); + }, + [] + ); + + /** + * Finishes the selection and calls back with selected actors. + * + * This: + * 1. Converts the rectangle to grid bounds + * 2. Finds actors within bounds + * 3. Determines if selection is uniform (same character) + * 4. Calls the onSelect callback with results + * 5. Clears the selection rectangle + * + * @param scale - Current zoom scale + * @param actors - Dictionary of all actors on stage + * @param onSelect - Callback with (characterId, actorIds) + */ + const finishSelection = useCallback( + ( + scale: number, + actors: { [id: string]: Actor }, + onSelect: (characterId: string | null, actorIds: string[]) => void + ) => { + if (!selectionRect) return; + + const bounds = selectionRectToGridBounds(selectionRect, scale); + const selectedActors = findActorsInBounds(actors, bounds.min, bounds.max); + const characterId = getSelectionCharacterId(selectedActors); + + onSelect( + characterId, + selectedActors.map((a) => a.id) + ); + setSelectionRect(null); + }, + [selectionRect] + ); + + /** + * Cancels any in-progress selection. + */ + const cancelSelection = useCallback(() => { + setSelectionRect(null); + }, []); + + /** + * Returns whether a selection is currently in progress. + */ + const isSelecting = selectionRect !== null; + + return { + selectionRect, + isSelecting, + startSelection, + updateSelection, + finishSelection, + cancelSelection, + }; +} diff --git a/frontend/src/editor/components/stage/hooks/useStageZoom.test.ts b/frontend/src/editor/components/stage/hooks/useStageZoom.test.ts new file mode 100644 index 00000000..1208224b --- /dev/null +++ b/frontend/src/editor/components/stage/hooks/useStageZoom.test.ts @@ -0,0 +1,106 @@ +import { expect } from "chai"; +import { calculateFitScale, STAGE_ZOOM_STEPS } from "./useStageZoom"; + +describe("useStageZoom", () => { + // STAGE_CELL_SIZE is 40px + const CELL = 40; + + describe("STAGE_ZOOM_STEPS", () => { + it("should have 7 zoom steps", () => { + expect(STAGE_ZOOM_STEPS).to.have.length(7); + }); + + it("should be in descending order", () => { + for (let i = 0; i < STAGE_ZOOM_STEPS.length - 1; i++) { + expect(STAGE_ZOOM_STEPS[i]).to.be.greaterThan(STAGE_ZOOM_STEPS[i + 1]); + } + }); + + it("should start at 1 (100% zoom)", () => { + expect(STAGE_ZOOM_STEPS[0]).to.equal(1); + }); + }); + + describe("calculateFitScale", () => { + it("should return 1 when stage fits exactly at 100%", () => { + // 10x10 stage = 400x400 pixels + // Container = 400x400 pixels + const result = calculateFitScale(400, 400, 10, 10); + expect(result).to.equal(1); + }); + + it("should return 1 when stage fits with room to spare", () => { + // 10x10 stage = 400x400 pixels + // Container = 800x800 pixels (fit = 2.0) + // Best zoom step <= 2.0 is 1 + const result = calculateFitScale(800, 800, 10, 10); + expect(result).to.equal(1); + }); + + it("should return smaller zoom when stage is too large", () => { + // 10x10 stage = 400x400 pixels + // Container = 200x200 pixels (fit = 0.5) + // Best zoom step <= 0.5 is 0.5 + const result = calculateFitScale(200, 200, 10, 10); + expect(result).to.equal(0.5); + }); + + it("should handle non-square stages (width-constrained)", () => { + // 20x10 stage = 800x400 pixels + // Container = 400x400 pixels + // Width fit = 400/800 = 0.5, Height fit = 400/400 = 1.0 + // min(0.5, 1.0) = 0.5 + const result = calculateFitScale(400, 400, 20, 10); + expect(result).to.equal(0.5); + }); + + it("should handle non-square stages (height-constrained)", () => { + // 10x20 stage = 400x800 pixels + // Container = 400x400 pixels + // Width fit = 400/400 = 1.0, Height fit = 400/800 = 0.5 + // min(1.0, 0.5) = 0.5 + const result = calculateFitScale(400, 400, 10, 20); + expect(result).to.equal(0.5); + }); + + it("should snap to nearest zoom step", () => { + // Fit = 0.7, should snap down to 0.63 + // 10x10 stage = 400x400 pixels + // Container = 280x280 pixels (fit = 0.7) + const result = calculateFitScale(280, 280, 10, 10); + expect(result).to.equal(0.63); + }); + + it("should return raw fit when smaller than all steps", () => { + // Very small container + // 10x10 stage = 400x400 pixels + // Container = 100x100 pixels (fit = 0.25) + // 0.25 is smaller than all zoom steps, so return 0.25 + const result = calculateFitScale(100, 100, 10, 10); + expect(result).to.equal(0.25); + }); + + it("should use custom zoom steps when provided", () => { + const customSteps = [1, 0.5, 0.25]; + // Fit = 0.7, should snap to 0.5 (largest step <= 0.7) + const result = calculateFitScale(280, 280, 10, 10, customSteps); + expect(result).to.equal(0.5); + }); + + it("should handle edge case of very small stage", () => { + // 1x1 stage = 40x40 pixels + // Container = 400x400 pixels (fit = 10.0) + // Best zoom step <= 10.0 is 1 + const result = calculateFitScale(400, 400, 1, 1); + expect(result).to.equal(1); + }); + + it("should handle edge case of large stage", () => { + // 100x100 stage = 4000x4000 pixels + // Container = 400x400 pixels (fit = 0.1) + // 0.1 is smaller than all zoom steps + const result = calculateFitScale(400, 400, 100, 100); + expect(result).to.equal(0.1); + }); + }); +}); diff --git a/frontend/src/editor/components/stage/hooks/useStageZoom.ts b/frontend/src/editor/components/stage/hooks/useStageZoom.ts new file mode 100644 index 00000000..4975d7df --- /dev/null +++ b/frontend/src/editor/components/stage/hooks/useStageZoom.ts @@ -0,0 +1,120 @@ +import { useEffect, useState } from "react"; +import { STAGE_CELL_SIZE } from "../../../constants/constants"; + +/** + * Available zoom steps for the stage. + * These are the discrete scale values that users can cycle through. + */ +export const STAGE_ZOOM_STEPS = [1, 0.88, 0.75, 0.63, 0.5, 0.42, 0.38]; + +/** + * Calculates the best zoom scale to fit the stage within a container. + * + * This is a pure function extracted for testability. + * + * @param containerWidth - Width of the container in pixels + * @param containerHeight - Height of the container in pixels + * @param stageWidth - Width of the stage in cells + * @param stageHeight - Height of the stage in cells + * @param zoomSteps - Available zoom steps (defaults to STAGE_ZOOM_STEPS) + * @returns The best zoom scale that fits the stage within the container + */ +export function calculateFitScale( + containerWidth: number, + containerHeight: number, + stageWidth: number, + stageHeight: number, + zoomSteps: number[] = STAGE_ZOOM_STEPS +): number { + const fit = Math.min( + containerWidth / (stageWidth * STAGE_CELL_SIZE), + containerHeight / (stageHeight * STAGE_CELL_SIZE) + ); + // Find the largest zoom step that's <= the calculated fit + return zoomSteps.find((z) => z <= fit) ?? fit; +} + +export interface StageScaleConfig { + width: number; + height: number; + scale?: number | "fit"; +} + +/** + * Hook that manages the zoom/scale state for the Stage component. + * + * This hook handles: + * - Initial scale based on stage.scale prop + * - "fit" mode which automatically calculates best scale for container + * - Window resize handling to recalculate fit scale + * - Recording mode which forces scale to 1 + * + * CAPTURE SEMANTICS: + * - `scrollElRef` and `stageElRef` are refs - stable across renders + * - `stage` object properties are captured in the useEffect dependency array + * - `recordingCentered` affects whether we use fit mode + * + * SIDE EFFECTS: + * - Sets stageEl.style.zoom directly (for "fit" mode) + * - Adds/removes window resize listener + * + * @param scrollElRef - Ref to the scrollable container element + * @param stageElRef - Ref to the stage element (where zoom is applied) + * @param stage - Stage configuration with width, height, and scale + * @param recordingCentered - If true, forces scale to 1 (recording preview mode) + * @returns Current scale value + */ +export function useStageZoom( + scrollElRef: React.RefObject, + stageElRef: React.RefObject, + stage: StageScaleConfig, + recordingCentered?: boolean +): number { + const [scale, setScale] = useState(() => { + // Initial scale from props, or 1 as default + if (stage.scale && typeof stage.scale === "number") { + return stage.scale; + } + return 1; + }); + + useEffect(() => { + const autofit = () => { + const scrollEl = scrollElRef.current; + const stageEl = stageElRef.current; + if (!scrollEl || !stageEl) return; + + if (recordingCentered) { + // Recording mode: always use scale 1 + setScale(1); + } else if (stage.scale === "fit") { + // Fit mode: calculate best scale for container + // First reset zoom to get accurate container dimensions + stageEl.style.zoom = "1"; + const best = calculateFitScale( + scrollEl.clientWidth, + scrollEl.clientHeight, + stage.width, + stage.height + ); + // Apply zoom directly to element (this is the existing behavior) + stageEl.style.zoom = `${best}`; + setScale(best); + } else { + // Explicit scale value + setScale(stage.scale ?? 1); + } + }; + + window.addEventListener("resize", autofit); + autofit(); + + return () => window.removeEventListener("resize", autofit); + // Note: scrollElRef and stageElRef are intentionally excluded from deps. + // They are stable refs, and including them would deviate from the original + // stage.tsx implementation. The refs' .current values are read inside + // autofit() at execution time, which is correct. + }, [stage.height, stage.scale, stage.width, recordingCentered]); + + return scale; +} diff --git a/frontend/src/editor/components/stage/stage.tsx b/frontend/src/editor/components/stage/stage.tsx index 240178d7..f4fb3525 100644 --- a/frontend/src/editor/components/stage/stage.tsx +++ b/frontend/src/editor/components/stage/stage.tsx @@ -1,4 +1,4 @@ -import React, { CSSProperties, useEffect, useRef, useState } from "react"; +import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import ActorSprite from "../sprites/actor-sprite"; @@ -9,15 +9,11 @@ import RecordingMaskSprite from "../sprites/recording-mask-sprite"; import { RecordingSquareStatus } from "../sprites/recording-square-status"; import { - setRecordingExtent, setupRecordingForActor, toggleSquareIgnored, upsertRecordingCondition, } from "../../actions/recording-actions"; import { - changeActors, - changeActorsIndividually, - createActors, deleteActors, recordClickForGameState, recordKeyForGameState, @@ -32,12 +28,8 @@ import { import { STAGE_CELL_SIZE, TOOLS } from "../../constants/constants"; import { extentIgnoredPositions } from "../../utils/recording-helpers"; import { - actorFilledPoints, - actorFillsPoint, actorsAtPoint, - applyAnchorAdjustment, buildActorSelection, - pointIsOutside, } from "../../utils/stage-helpers"; import { @@ -51,10 +43,22 @@ import { UIState, WorldMinimal, } from "../../../types"; -import { defaultAppearanceId } from "../../utils/character-helpers"; import { makeId } from "../../utils/utils"; import { keyToCodakoKey } from "../modal-keypicker/keyboard"; +// Import extracted hooks +import { + useStageCoordinates, + useStageZoom, + useStageSelection, + useStagePopover, + useStageDragDrop, + STAGE_ZOOM_STEPS, +} from "./hooks"; + +// Import tool behaviors (ToolContext for type annotation) +import { ToolContext } from "./tools/tool-behaviors"; + interface StageProps { stage: StageType; world: WorldMinimal; @@ -67,11 +71,16 @@ interface StageProps { type Offset = { top: string | number; left: string | number }; type MouseStatus = { isDown: boolean; visited: { [posKey: string]: true } }; -type SelectionRect = { start: { top: number; left: number }; end: { top: number; left: number } }; const DRAGGABLE_TOOLS = [TOOLS.IGNORE_SQUARE, TOOLS.TRASH, TOOLS.STAMP]; -export const STAGE_ZOOM_STEPS = [1, 0.88, 0.75, 0.63, 0.5, 0.42, 0.38]; +// Threshold for detecting stage wrapping. When an actor's position changes by more +// than this many cells in a single frame, we assume it wrapped around the stage edge +// and skip the CSS transition animation (by changing the React key). +const WRAP_DETECTION_THRESHOLD = 6; + +// Re-export for backwards compatibility +export { STAGE_ZOOM_STEPS }; export const Stage = ({ recordingExtent, @@ -83,50 +92,24 @@ export const Stage = ({ style, }: StageProps) => { const [{ top, left }, setOffset] = useState({ top: 0, left: 0 }); - const [scale, setScale] = useState( - stage.scale && typeof stage.scale === "number" ? stage.scale : 1, - ); - - const [selectionRect, setSelectionRect] = useState(null); - const [actorSelectionPopover, setActorSelectionPopover] = useState<{ - actors: Actor[]; - position: { x: number; y: number }; - toolId: string; - } | null>(null); - const lastFiredExtent = useRef(null); const lastActorPositions = useRef<{ [actorId: string]: Position }>({}); const mouse = useRef({ isDown: false, visited: {} }); - const scrollEl = useRef(); - const el = useRef(); + const scrollEl = useRef(null); + const stageEl = useRef(null); - useEffect(() => { - const autofit = () => { - const _scrollEl = scrollEl.current; - const _el = el.current; - if (!_scrollEl || !_el) { - return; - } - if (recordingCentered) { - setScale(1); - } else if (stage.scale === "fit") { - _el.style.zoom = "1"; // this needs to be here for scaling "up" to work - const fit = Math.min( - _scrollEl.clientWidth / (stage.width * STAGE_CELL_SIZE), - _scrollEl.clientHeight / (stage.height * STAGE_CELL_SIZE), - ); - const best = STAGE_ZOOM_STEPS.find((z) => z <= fit) || fit; - _el.style.zoom = `${best}`; - setScale(best); - } else { - setScale(stage.scale ?? 1); - } - }; - window.addEventListener("resize", autofit); - autofit(); - return () => window.removeEventListener("resize", autofit); - }, [stage.height, stage.scale, stage.width, recordingCentered]); + // Use extracted zoom hook + const scale = useStageZoom(scrollEl, stageEl, stage, recordingCentered); + + // Use extracted coordinate hook + const coords = useStageCoordinates(stageEl, scale); + + // Use extracted selection hook + const selection = useStageSelection(); + + // Use extracted popover hook + const popover = useStagePopover(); const dispatch = useDispatch(); const characters = useSelector((state) => state.characters); @@ -141,17 +124,20 @@ export const Stage = ({ })); // Helpers + const selFor = useCallback( + (actorIds: string[]) => buildActorSelection(world.id, stage.id, actorIds), + [world.id, stage.id] + ); - const selFor = (actorIds: string[]) => { - return buildActorSelection(world.id, stage.id, actorIds); - }; - - const selected = - selectedActors && selectedActors?.worldId === world.id && selectedActors?.stageId === stage.id - ? selectedActors.actorIds.map((a) => stage.actors[a]) - : []; + const selected = useMemo( + () => + selectedActors && selectedActors?.worldId === world.id && selectedActors?.stageId === stage.id + ? selectedActors.actorIds.map((a) => stage.actors[a]).filter(Boolean) + : [], + [selectedActors, world.id, stage.id, stage.actors] + ); - const centerOnExtent = () => { + const centerOnExtent = useCallback(() => { if (!recordingExtent) { return { left: 0, top: 0 }; } @@ -162,12 +148,57 @@ export const Stage = ({ left: `calc(-${xCenter * STAGE_CELL_SIZE}px + 50%)`, top: `calc(-${yCenter * STAGE_CELL_SIZE}px + 50%)`, }; - }; + }, [recordingExtent]); + + // Use extracted drag/drop hook + const dragDrop = useStageDragDrop({ + dispatch, + stage, + worldId: world.id, + characters, + recordingExtent, + getPositionForEvent: coords.getPositionForEvent, + stageElRef: stageEl, + }); + + // Build tool context for tool behaviors + const toolContext: ToolContext = useMemo( + () => ({ + dispatch, + stage, + world, + characters, + recordingExtent, + selected, + selFor, + stampToolItem, + playback, + showPopover: (actors, pos) => popover.showPopover(actors, pos, selectedToolId), + isPopoverOpen: popover.isOpen, + }), + [ + dispatch, + stage, + world, + characters, + recordingExtent, + selected, + selFor, + stampToolItem, + playback, + popover, + selectedToolId, + ] + ); + // This effect runs on every render to: + // 1. Keep focus on stage during playback (for keyboard input) + // 2. Recalculate offset when recording extent changes + // We intentionally omit dependencies to run every render, matching the original behavior. // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { - if (playback.running && el.current) { - el.current.focus(); + if (playback.running && stageEl.current) { + stageEl.current.focus(); } let offset: Offset = { top: 0, left: 0 }; @@ -182,7 +213,7 @@ export const Stage = ({ const onBlur = () => { if (playback.running) { - el.current?.focus(); + stageEl.current?.focus(); } }; @@ -212,218 +243,17 @@ export const Stage = ({ dispatch(recordKeyForGameState(world.id, keyToCodakoKey(`${event.key}`))); }; - const onDragOver = (event: React.DragEvent) => { - event.preventDefault(); - if (event.dataTransfer.types.includes("handle")) { - onUpdateHandle(event); - } - }; - - const onDrop = (event: React.DragEvent) => { - if (event.dataTransfer.types.includes("sprite")) { - onDropSprite(event); - } - if (event.dataTransfer.types.includes("appearance")) { - onDropAppearance(event); - } - if (event.dataTransfer.types.includes("handle")) { - onUpdateHandle(event); - } - }; - - const onUpdateHandle = (event: React.DragEvent) => { - const side = event.dataTransfer.types - .find((t) => t.startsWith("handle:"))! - .split(":") - .pop(); - const stageOffset = el.current!.getBoundingClientRect(); - const position = { - x: (event.clientX - stageOffset.left) / STAGE_CELL_SIZE, - y: (event.clientY - stageOffset.top) / STAGE_CELL_SIZE, - }; - - // expand the extent of the recording rule to reflect this new extent - const nextExtent = Object.assign({}, recordingExtent); - if (side === "left") { - nextExtent.xmin = Math.min(nextExtent.xmax, Math.max(0, Math.round(position.x + 0.25))); - } - if (side === "right") { - nextExtent.xmax = Math.max( - nextExtent.xmin, - Math.min(stage.width, Math.round(position.x - 1)), - ); - } - if (side === "top") { - nextExtent.ymin = Math.min(nextExtent.ymax, Math.max(0, Math.round(position.y + 0.25))); - } - if (side === "bottom") { - nextExtent.ymax = Math.max( - nextExtent.ymin, - Math.min(stage.height, Math.round(position.y - 1)), - ); - } - - const str = JSON.stringify(nextExtent); - if (lastFiredExtent.current === str) { - return; - } - lastFiredExtent.current = str; - dispatch(setRecordingExtent(nextExtent)); - }; - - const getPxOffsetForEvent = (event: MouseEvent | React.MouseEvent | React.DragEvent) => { - const stageOffset = el.current!.getBoundingClientRect(); - return { left: event.clientX - stageOffset.left, top: event.clientY - stageOffset.top }; - }; - - const getPositionForEvent = (event: MouseEvent | React.MouseEvent | React.DragEvent) => { - const dragOffset = - "dataTransfer" in event && event.dataTransfer && event.dataTransfer.getData("drag-offset"); - - // subtracting half when no offset is present is a lazy way of doing Math.floor instead of Math.round! - const halfOffset = { dragTop: STAGE_CELL_SIZE / 2, dragLeft: STAGE_CELL_SIZE / 2 }; - const { dragLeft, dragTop } = dragOffset ? JSON.parse(dragOffset) : halfOffset; - - const px = getPxOffsetForEvent(event); - return { - x: Math.round((px.left - dragLeft) / STAGE_CELL_SIZE / scale), - y: Math.round((px.top - dragTop) / STAGE_CELL_SIZE / scale), - }; - }; - - const onDropAppearance = (event: React.DragEvent) => { - const { appearance, characterId } = JSON.parse(event.dataTransfer.getData("appearance")); - const position = getPositionForEvent(event); - if (recordingExtent && pointIsOutside(position, recordingExtent)) { - return; - } - const actor = Object.values(stage.actors).find( - (a) => actorFillsPoint(a, characters, position) && a.characterId === characterId, - ); - if (actor) { - dispatch(changeActors(selFor([actor.id]), { appearance })); - } - }; - - const onDropActorsAtPosition = ( - { actorIds, dragAnchorActorId }: { actorIds: string[]; dragAnchorActorId: string }, - position: Position, - mode: "stamp-copy" | "move", - ) => { - if (recordingExtent && pointIsOutside(position, recordingExtent)) { - return; - } - - const anchorActor = stage.actors[dragAnchorActorId]; - const anchorCharacter = characters[anchorActor.characterId]; - - applyAnchorAdjustment(position, anchorCharacter, anchorActor); - - const offsetX = position.x - anchorActor.position.x; - const offsetY = position.y - anchorActor.position.y; - - if (offsetX === 0 && offsetY === 0) { - // attempting to drop in the same place we started the drag, don't do anything - return; - } - - if (mode === "stamp-copy") { - const creates = actorIds - .map((aid) => { - const actor = stage.actors[aid]; - const character = characters[actor.characterId]; - const clonedActor = Object.assign({}, actor, { - position: { - x: actor.position.x + offsetX, - y: actor.position.y + offsetY, - }, - }); - const clonedActorPoints = actorFilledPoints(clonedActor, characters).map( - (p) => `${p.x},${p.y}`, - ); - - // If there is an exact copy of this actor that overlaps this position already, don't - // drop. It's probably a mistake, and you can override by dropping elsewhere and then - // dragging it to this square. - const positionContainsCloneAlready = Object.values(stage.actors).find( - (a) => - a.characterId === actor.characterId && - a.appearance === actor.appearance && - actorFilledPoints(a, characters).some((p) => - clonedActorPoints.includes(`${p.x},${p.y}`), - ), - ); - if (positionContainsCloneAlready) { - return; - } - return { character, initialValues: clonedActor }; - }) - .filter((c): c is NonNullable => !!c); - - dispatch(createActors(world.id, stage.id, creates)); - } else if (mode === "move") { - const upserts = actorIds.map((aid) => ({ - id: aid, - values: { - position: { - x: stage.actors[aid].position.x + offsetX, - y: stage.actors[aid].position.y + offsetY, - }, - }, - })); - dispatch(changeActorsIndividually(world.id, stage.id, upserts)); - } else { - throw new Error("Invalid mode"); - } - }; - - const onDropCharacterAtPosition = ( - { characterId, appearanceId }: { characterId: string; appearanceId?: string }, - position: Position, - ) => { - if (recordingExtent && pointIsOutside(position, recordingExtent)) { - return; - } - - const character = characters[characterId]; - const appearance = appearanceId ?? defaultAppearanceId(character.spritesheet); - const newActor = { position, appearance } as Actor; - applyAnchorAdjustment(position, character, newActor); - - const newActorPoints = actorFilledPoints(newActor, characters).map((p) => `${p.x},${p.y}`); - - const positionContainsCloneAlready = Object.values(stage.actors).find( - (a) => - a.characterId === characterId && - actorFilledPoints(a, characters).some((p) => newActorPoints.includes(`${p.x},${p.y}`)), - ); - if (positionContainsCloneAlready) { - return; - } - dispatch(createActors(world.id, stage.id, [{ character, initialValues: newActor }])); - }; - - const onDropSprite = (event: React.DragEvent) => { - const ids: { actorIds: string[]; dragAnchorActorId: string } | { characterId: string } = - JSON.parse(event.dataTransfer.getData("sprite")); - const position = getPositionForEvent(event); - if ("actorIds" in ids) { - onDropActorsAtPosition(ids, position, event.altKey ? "stamp-copy" : "move"); - } else if (ids.characterId) { - onDropCharacterAtPosition(ids, position); - } - }; - - const onStampAtPosition = (position: Position) => { - const item = stampToolItem; - if (item && "actorIds" in item && item.actorIds) { - const ids = { actorIds: item.actorIds, dragAnchorActorId: item.actorIds[0] }; - onDropActorsAtPosition(ids, position, "stamp-copy"); - } else if (item && "characterId" in item) { - onDropCharacterAtPosition(item, position); - } - }; - + /** + * Handles mouse up on an actor. + * + * This follows the Integration Contract from tool-behaviors.ts. + * + * Note: While tool-behaviors.ts provides declarative tool behavior definitions, + * we keep the logic inline here during refactoring to minimize risk of behavior + * changes. The tool-behaviors.ts file serves as documentation and as a reference + * for future refactoring that could call behaviors directly. The current inline + * implementation exactly matches the original stage.tsx behavior. + */ const onMouseUpActor = (actor: Actor, event: React.MouseEvent) => { let handled = false; @@ -431,16 +261,13 @@ export const Stage = ({ const showPopoverIfOverlapping = (toolId: string): boolean => { const overlapping = actorsAtPoint(stage.actors, characters, actor.position); if (overlapping.length > 1) { - setActorSelectionPopover({ - actors: overlapping, - position: { x: event.clientX, y: event.clientY }, - toolId, - }); + popover.showPopover(overlapping, { x: event.clientX, y: event.clientY }, toolId); return true; } return false; }; + // Special handling for tools with specific popover behavior switch (selectedToolId) { case TOOLS.PAINT: if (!showPopoverIfOverlapping(TOOLS.PAINT)) { @@ -528,13 +355,14 @@ export const Stage = ({ const isClickOnBackground = event.target === event.currentTarget; if (selectedToolId === TOOLS.POINTER && isClickOnBackground) { - setSelectionRect({ start: getPxOffsetForEvent(event), end: getPxOffsetForEvent(event) }); + selection.startSelection(coords.getPxOffsetForEvent(event)); } else { - setSelectionRect(null); + selection.cancelSelection(); } }; // Note: In this handler, the mouse cursor may be outside the stage + // CRITICAL: This ref is reassigned every render to capture latest state const onMouseMove = useRef<(event: MouseEvent) => void>(); onMouseMove.current = (event: MouseEvent) => { if (!mouse.current.isDown) { @@ -543,12 +371,12 @@ export const Stage = ({ // If we are dragging to select a region, update the region. // Otherwise, process this event as a tool stroke. - if (selectionRect) { - setSelectionRect({ ...selectionRect, end: getPxOffsetForEvent(event) }); + if (selection.isSelecting) { + selection.updateSelection(coords.getPxOffsetForEvent(event)); return; } - const { x, y } = getPositionForEvent(event); + const { x, y } = coords.getPositionForEvent(event); if (!(x >= 0 && x < stage.width && y >= 0 && y < stage.height)) { return; } @@ -562,11 +390,11 @@ export const Stage = ({ dispatch(toggleSquareIgnored({ x, y })); } if (selectedToolId === TOOLS.STAMP) { - onStampAtPosition({ x, y }); + dragDrop.onStampAtPosition({ x, y }, stampToolItem); } if (selectedToolId === TOOLS.TRASH) { // If popover is open, skip - we're waiting for user to pick from the popover - if (actorSelectionPopover) { + if (popover.isOpen) { return; } @@ -575,11 +403,7 @@ export const Stage = ({ // On initial click (not drag), show popover if multiple actors overlap const isFirstClick = Object.keys(mouse.current.visited).length === 1; if (isFirstClick && overlapping.length > 1) { - setActorSelectionPopover({ - actors: overlapping, - position: { x: event.clientX, y: event.clientY }, - toolId: TOOLS.TRASH, - }); + popover.showPopover(overlapping, { x: event.clientX, y: event.clientY }, TOOLS.TRASH); return; } @@ -598,46 +422,22 @@ export const Stage = ({ }; // Note: In this handler, the mouse cursor may be outside the stage + // CRITICAL: This ref is reassigned every render to capture latest state const onMouseUp = useRef<(event: MouseEvent) => void>(); onMouseUp.current = (event: MouseEvent) => { + // Process final position as a drag event (click-as-drag behavior) onMouseMove.current?.(event); mouse.current = { isDown: false, visited: {} }; - if (selectionRect) { - const selectedActors: Actor[] = []; - const [minLeft, maxLeft] = [selectionRect.start.left, selectionRect.end.left].sort( - (a, b) => a - b, - ); - const [minTop, maxTop] = [selectionRect.start.top, selectionRect.end.top].sort( - (a, b) => a - b, - ); - const min = { - x: Math.floor(minLeft / STAGE_CELL_SIZE / scale), - y: Math.floor(minTop / STAGE_CELL_SIZE / scale), - }; - const max = { - x: Math.floor(maxLeft / STAGE_CELL_SIZE / scale), - y: Math.floor(maxTop / STAGE_CELL_SIZE / scale), - }; - for (const actor of Object.values(stage.actors)) { - if ( - actor.position.x >= min.x && - actor.position.x <= max.x && - actor.position.y >= min.y && - actor.position.y <= max.y - ) { - selectedActors.push(actor); - } - } - const characterId = - selectedActors.length && - selectedActors.every((a) => a.characterId === selectedActors[0].characterId) - ? selectedActors[0].characterId - : null; - dispatch(select(characterId, selFor(selectedActors.map((a) => a.id)))); - setSelectionRect(null); + // Handle selection rectangle completion + if (selection.isSelecting) { + selection.finishSelection(scale, stage.actors, (characterId, actorIds) => { + dispatch(select(characterId, selFor(actorIds))); + }); } + + // Reset tool after use (unless shift held) if (!event.shiftKey && !event.defaultPrevented) { if ( TOOLS.TRASH === selectedToolId || @@ -664,9 +464,9 @@ export const Stage = ({ }; const onPopoverSelectActor = (actor: Actor) => { - if (!actorSelectionPopover) return; + if (!popover.popover) return; - const { toolId } = actorSelectionPopover; + const { toolId } = popover.popover; switch (toolId) { case TOOLS.PAINT: @@ -700,16 +500,16 @@ export const Stage = ({ break; } - setActorSelectionPopover(null); + popover.closePopover(); }; const onPopoverDragStart = () => { // Close the popover when drag starts - the drag will continue to the stage - setActorSelectionPopover(null); + popover.closePopover(); }; const onPopoverClose = () => { - setActorSelectionPopover(null); + popover.closePopover(); }; const renderRecordingExtent = () => { @@ -776,7 +576,7 @@ export const Stage = ({ if (!stage) { return ( -
(scrollEl.current = el)} className="stage-scroll-wrap" /> +
); } @@ -802,8 +602,8 @@ export const Stage = ({ y: Number.NaN, }; const didWrap = - Math.abs(lastPosition.x - actor.position.x) > 6 || - Math.abs(lastPosition.y - actor.position.y) > 6; + Math.abs(lastPosition.x - actor.position.x) > WRAP_DETECTION_THRESHOLD || + Math.abs(lastPosition.y - actor.position.y) > WRAP_DETECTION_THRESHOLD; lastActorPositions.current[actor.id] = Object.assign({}, actor.position); const draggable = !readonly && !DRAGGABLE_TOOLS.includes(selectedToolId); @@ -833,13 +633,13 @@ export const Stage = ({ return (
(scrollEl.current = e)} + ref={scrollEl} data-stage-wrap-id={world.id} data-stage-zoom={scale} className={`stage-scroll-wrap tool-supported running-${playback.running}`} >
(el.current = e)} + ref={stageEl} style={ { top, @@ -852,8 +652,8 @@ export const Stage = ({ } as CSSProperties } className="stage" - onDragOver={onDragOver} - onDrop={onDrop} + onDragOver={dragDrop.onDragOver} + onDrop={dragDrop.onDrop} onKeyDown={onKeyDown} onBlur={onBlur} onContextMenu={onRightClickStage} @@ -876,27 +676,27 @@ export const Stage = ({ {recordingExtent ? renderRecordingExtent() : []}
- {selectionRect ? ( + {selection.selectionRect ? (
) : null} - {actorSelectionPopover && ( + {popover.popover && ( void; + +/** + * Context passed to tool behavior handlers. + * + * This provides access to all the state and functions needed to implement + * tool behaviors without the handler needing to know about React hooks. + */ +export interface ToolContext { + // Redux dispatch + dispatch: Dispatch; + + // World/stage data + stage: Stage; + world: WorldMinimal; + characters: Characters; + recordingExtent?: RuleExtent; + + // Current selection + selected: Actor[]; + /** Creates a selection object for the given actor IDs */ + selFor: (actorIds: string[]) => { + worldId: string; + stageId: string; + actorIds: string[]; + }; + + // UI state + stampToolItem: UIState["stampToolItem"]; + playback: UIState["playback"]; + + // Popover control + /** Shows the actor selection popover */ + showPopover: ( + actors: Actor[], + clientPosition: { x: number; y: number } + ) => void; + /** Whether the popover is currently open */ + isPopoverOpen: boolean; +} + +/** + * Defines the behavior of a tool. + */ +export interface ToolBehavior { + /** + * Called when the user clicks (mouse up) on an actor. + * + * Return true if the click was handled, false to let it propagate. + * + * NOTE: This is NOT called during drag operations. For drag behavior, + * use onDragPosition. + * + * @param actor - The actor that was clicked + * @param event - The mouse event + * @param ctx - Tool context with dispatch, state, etc. + * @returns true if the event was handled + */ + onActorClick?: ( + actor: Actor, + event: React.MouseEvent, + ctx: ToolContext + ) => boolean; + + /** + * Called when the user drags across a grid position. + * + * This is called once per position during a drag operation. + * The `isFirstPosition` flag indicates if this is the initial click position. + * + * @param position - The grid position + * @param isFirstPosition - True if this is the first position in the drag + * @param event - The mouse event + * @param ctx - Tool context + */ + onDragPosition?: ( + position: Position, + isFirstPosition: boolean, + event: MouseEvent, + ctx: ToolContext + ) => void; + + /** + * If true, shows the actor selection popover when clicking on + * overlapping actors. The selected actor is passed to onActorClick. + */ + showPopoverOnOverlap?: boolean; + + /** + * If true, the tool resets to POINTER after use (unless shift is held). + */ + resetAfterUse?: boolean; +} + +/** + * Tool behaviors for each tool type. + * + * IMPORTANT: These behaviors must EXACTLY match the original behavior in stage.tsx. + * Any deviation could cause subtle bugs that are hard to track down. + */ +export const TOOL_BEHAVIORS: Partial> = { + /** + * POINTER: Select actors, or record clicks during playback. + * + * Original behavior: + * - During playback: recordClickForGameState + * - With shift: toggle actor in multi-selection + * - Without shift: select single actor + * - Shows popover on overlap + */ + [TOOLS.POINTER]: { + showPopoverOnOverlap: true, + onActorClick: (actor, event, ctx) => { + if (ctx.playback.running) { + ctx.dispatch(recordClickForGameState(ctx.world.id, actor.id)); + return true; + } + + if (event.shiftKey) { + // Toggle in selection + const selectedIds = ctx.selected.map((a) => a.id); + const newIds = selectedIds.includes(actor.id) + ? selectedIds.filter((id) => id !== actor.id) + : [...selectedIds, actor.id]; + ctx.dispatch(select(actor.characterId, ctx.selFor(newIds))); + } else { + // Single select + ctx.dispatch(select(actor.characterId, ctx.selFor([actor.id]))); + } + return true; + }, + }, + + /** + * PAINT: Copy an actor's appearance to the paint brush. + * + * Original behavior: + * - Click actor: set paint brush to that appearance + * - Shows popover on overlap + * - Resets to POINTER after use + */ + [TOOLS.PAINT]: { + showPopoverOnOverlap: true, + resetAfterUse: true, + onActorClick: (actor, _event, ctx) => { + ctx.dispatch( + paintCharacterAppearance(actor.characterId, actor.appearance) + ); + return true; + }, + }, + + /** + * STAMP: Copy actors (like a rubber stamp). + * + * Original behavior: + * - If no stampToolItem: click to set stamp source + * - If stampToolItem exists: drag to stamp copies + * - Shows popover on overlap when selecting source + * - Resets to POINTER after use + * + * NOTE: When stampToolItem exists, the actual stamping (copying actors) + * is handled via onDragPosition. The stamp logic requires access to + * drop helper functions that must be provided via the context. + * + * IMPORTANT: showPopoverOnOverlap only applies when selecting a source + * (i.e., when stampToolItem is null). When stamping, we don't show popovers. + */ + [TOOLS.STAMP]: { + // Only show popover when selecting source, not when stamping + showPopoverOnOverlap: true, + resetAfterUse: true, + onActorClick: (actor, _event, ctx) => { + // Only handle if we don't have a stamp item yet (selecting source) + if (!ctx.stampToolItem) { + ctx.dispatch(selectToolItem(ctx.selFor([actor.id]))); + return true; + } + // If stamp item exists, don't handle click - stamping is drag-based + return false; + }, + /** + * Stamping during drag. + * + * INTEGRATION NOTE: The actual actor copying logic (onStampAtPosition) + * is complex and depends on stage.tsx's drop helpers. The integrating + * component should: + * 1. Check if selectedToolId === TOOLS.STAMP && stampToolItem exists + * 2. Call the appropriate stamp/copy function from stage.tsx + * + * This handler is intentionally minimal - it signals that STAMP has + * drag behavior, but the implementation lives in stage.tsx due to + * dependencies on actor creation logic. + */ + onDragPosition: undefined, // Handled specially in stage.tsx + }, + + /** + * TRASH: Delete actors. + * + * Original behavior: + * - First click with overlap: show popover + * - Click/drag: delete topmost actor + * - If clicked actor is selected: delete all selected + * - Skip if popover is open + * - Resets to POINTER after use + */ + [TOOLS.TRASH]: { + showPopoverOnOverlap: true, + resetAfterUse: true, + onActorClick: (actor, _event, ctx) => { + // Delete the actor (or all selected if clicked actor is selected) + const selectedIds = ctx.selected.map((a) => a.id); + if (selectedIds.includes(actor.id)) { + ctx.dispatch(deleteActors(ctx.selFor(selectedIds))); + } else { + ctx.dispatch(deleteActors(ctx.selFor([actor.id]))); + } + return true; + }, + onDragPosition: (position, isFirstPosition, event, ctx) => { + // Skip if popover is open + if (ctx.isPopoverOpen) return; + + const overlapping = actorsAtPoint( + ctx.stage.actors, + ctx.characters, + position + ); + + // On first position with overlap, show popover instead + if (isFirstPosition && overlapping.length > 1) { + ctx.showPopover(overlapping, { x: event.clientX, y: event.clientY }); + return; + } + + // Delete the topmost actor + const actor = overlapping[overlapping.length - 1]; + if (!actor) return; + + // If clicked actor is in selection, delete all selected + const selectedIds = ctx.selected.map((a) => a.id); + if (selectedIds.includes(actor.id)) { + ctx.dispatch(deleteActors(ctx.selFor(selectedIds))); + } else { + ctx.dispatch(deleteActors(ctx.selFor([actor.id]))); + } + }, + }, + + /** + * RECORD: Start recording a rule for an actor. + * + * Original behavior: + * - Click actor: start recording for that actor's character + * - Shows popover on overlap + * - Resets to POINTER after use + */ + [TOOLS.RECORD]: { + showPopoverOnOverlap: true, + resetAfterUse: true, + onActorClick: (actor, _event, ctx) => { + ctx.dispatch( + setupRecordingForActor({ characterId: actor.characterId, actor }) + ); + ctx.dispatch(selectToolId(TOOLS.POINTER)); + return true; + }, + }, + + /** + * ADD_CLICK_CONDITION: Add a "when clicked" condition for an actor. + * + * Original behavior: + * - Click actor: add condition "click = actorId" + * - Shows popover on overlap + * - Resets to POINTER after use + */ + [TOOLS.ADD_CLICK_CONDITION]: { + showPopoverOnOverlap: true, + resetAfterUse: true, + onActorClick: (actor, _event, ctx) => { + ctx.dispatch( + upsertRecordingCondition({ + key: makeId("condition"), + left: { globalId: "click" }, + right: { constant: actor.id }, + comparator: "=", + enabled: true, + }) + ); + ctx.dispatch(selectToolId(TOOLS.POINTER)); + return true; + }, + }, + + /** + * IGNORE_SQUARE: Mark squares as ignored in the recording extent. + * + * Original behavior: + * - Drag across squares: toggle ignored status + */ + [TOOLS.IGNORE_SQUARE]: { + onDragPosition: (position, _isFirst, _event, ctx) => { + ctx.dispatch(toggleSquareIgnored(position)); + }, + }, +}; + +/** + * Gets the behavior for a tool, or undefined if the tool has no special behavior. + */ +export function getToolBehavior(toolId: TOOLS): ToolBehavior | undefined { + return TOOL_BEHAVIORS[toolId]; +} + +/** + * Checks if a tool should show the popover when clicking overlapping actors. + */ +export function toolShowsPopoverOnOverlap(toolId: TOOLS): boolean { + return TOOL_BEHAVIORS[toolId]?.showPopoverOnOverlap ?? false; +} + +/** + * Checks if a tool should reset to POINTER after use. + */ +export function toolResetsAfterUse(toolId: TOOLS): boolean { + return TOOL_BEHAVIORS[toolId]?.resetAfterUse ?? false; +}