@weasel-js/core 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +9 -5
  3. package/dist/animation/useAnimator.js +20 -13
  4. package/dist/animation/useAnimator.js.map +1 -1
  5. package/dist/{builtins-BW32dOXy.d.ts → builtins-CQcvcLXh.d.ts} +105 -31
  6. package/dist/canvas/DrawCanvas.js +33 -33
  7. package/dist/canvas/DrawCanvas.js.map +1 -1
  8. package/dist/canvas/MinimapCanvas.js +29 -29
  9. package/dist/canvas/MinimapCanvas.js.map +1 -1
  10. package/dist/canvas/NodeShape.js +63 -54
  11. package/dist/canvas/NodeShape.js.map +1 -1
  12. package/dist/canvas/SceneCanvas/features.js +26 -5
  13. package/dist/canvas/SceneCanvas/features.js.map +1 -1
  14. package/dist/canvas/SceneCanvas/useSceneSelectTool.js +3 -2
  15. package/dist/canvas/SceneCanvas/useSceneSelectTool.js.map +1 -1
  16. package/dist/canvas/SceneCanvas.js +526 -529
  17. package/dist/canvas/SceneCanvas.js.map +1 -1
  18. package/dist/canvas/SceneViewCanvas.js +35 -35
  19. package/dist/canvas/SceneViewCanvas.js.map +1 -1
  20. package/dist/canvas/defaultDrawOne.js +6 -1
  21. package/dist/canvas/defaultDrawOne.js.map +1 -1
  22. package/dist/canvas/pickWalk.js.map +1 -1
  23. package/dist/canvas/sceneAdapter.js.map +1 -1
  24. package/dist/canvas/useHitObserver.js +23 -0
  25. package/dist/canvas/useHitObserver.js.map +1 -0
  26. package/dist/canvas/useHostAnchor.js.map +1 -1
  27. package/dist/core/device/useDeviceProfile.js.map +1 -1
  28. package/dist/core/scene/derivedPath.js.map +1 -1
  29. package/dist/core/scene/effectivePose.js.map +1 -1
  30. package/dist/core/scene/kitRegistry.js.map +1 -1
  31. package/dist/core/scene/scene.js +9 -0
  32. package/dist/core/scene/scene.js.map +1 -1
  33. package/dist/core/scene/types.js.map +1 -1
  34. package/dist/core/scene/useScene.js.map +1 -1
  35. package/dist/core/selection/useSelection.js +27 -33
  36. package/dist/core/selection/useSelection.js.map +1 -1
  37. package/dist/core/stableStringify.js +8 -0
  38. package/dist/core/stableStringify.js.map +1 -0
  39. package/dist/core/viewport/fitViewToBounds.js +3 -3
  40. package/dist/core/viewport/fitViewToBounds.js.map +1 -1
  41. package/dist/core/viewport/screenSlop.js +8 -2
  42. package/dist/core/viewport/screenSlop.js.map +1 -1
  43. package/dist/features/minimap/actions.js.map +1 -1
  44. package/dist/features/paths/markerCommands.js +95 -63
  45. package/dist/features/paths/markerCommands.js.map +1 -1
  46. package/dist/features/paths/tessellate/dash.js +52 -0
  47. package/dist/features/paths/tessellate/dash.js.map +1 -0
  48. package/dist/features/paths/tessellate/insets.js +32 -0
  49. package/dist/features/paths/tessellate/insets.js.map +1 -0
  50. package/dist/features/paths/tessellate/metric.js +72 -0
  51. package/dist/features/paths/tessellate/metric.js.map +1 -0
  52. package/dist/features/paths/tessellate/stroke.js +74 -134
  53. package/dist/features/paths/tessellate/stroke.js.map +1 -1
  54. package/dist/features/paths/tessellate/vertexWidths.js +32 -0
  55. package/dist/features/paths/tessellate/vertexWidths.js.map +1 -0
  56. package/dist/features/pointer/PointerContext.js +2 -2
  57. package/dist/features/pointer/PointerContext.js.map +1 -1
  58. package/dist/features/poseRun/usePoseRun.js.map +1 -1
  59. package/dist/features/text/useTextEdit.js +86 -84
  60. package/dist/features/text/useTextEdit.js.map +1 -1
  61. package/dist/index.d.ts +84 -45
  62. package/dist/index.js +269 -268
  63. package/dist/interactions/actions/defaults/align.js.map +1 -1
  64. package/dist/interactions/actions/defaults/anchorEditing.js.map +1 -1
  65. package/dist/interactions/actions/defaults/areaSelect.js.map +1 -1
  66. package/dist/interactions/actions/defaults/booleans.js.map +1 -1
  67. package/dist/interactions/actions/defaults/cancelGesture.js.map +1 -1
  68. package/dist/interactions/actions/defaults/clearSelection.js.map +1 -1
  69. package/dist/interactions/actions/defaults/clipboard.js.map +1 -1
  70. package/dist/interactions/actions/defaults/clone.js.map +1 -1
  71. package/dist/interactions/actions/defaults/createOutlines.js +1 -1
  72. package/dist/interactions/actions/defaults/createOutlines.js.map +1 -1
  73. package/dist/interactions/actions/defaults/createPaintAction.js.map +1 -1
  74. package/dist/interactions/actions/defaults/delete.js.map +1 -1
  75. package/dist/interactions/actions/defaults/distribute.js.map +1 -1
  76. package/dist/interactions/actions/defaults/duplicate.js.map +1 -1
  77. package/dist/interactions/actions/defaults/editAnchors.js.map +1 -1
  78. package/dist/interactions/actions/defaults/enterPathEdit.js.map +1 -1
  79. package/dist/interactions/actions/defaults/enterTextEdit.js.map +1 -1
  80. package/dist/interactions/actions/defaults/escape.js +1 -1
  81. package/dist/interactions/actions/defaults/escape.js.map +1 -1
  82. package/dist/interactions/actions/defaults/exitPathEdit.js.map +1 -1
  83. package/dist/interactions/actions/defaults/flip.js.map +1 -1
  84. package/dist/interactions/actions/defaults/group.js.map +1 -1
  85. package/dist/interactions/actions/defaults/ingest.js.map +1 -1
  86. package/dist/interactions/actions/defaults/insert.js.map +1 -1
  87. package/dist/interactions/actions/defaults/insertPathAnchor.js +1 -1
  88. package/dist/interactions/actions/defaults/insertPathAnchor.js.map +1 -1
  89. package/dist/interactions/actions/defaults/lassoSelect.js.map +1 -1
  90. package/dist/interactions/actions/defaults/move.js.map +1 -1
  91. package/dist/interactions/actions/defaults/nudge.js.map +1 -1
  92. package/dist/interactions/actions/defaults/pinchZoom.js.map +1 -1
  93. package/dist/interactions/actions/defaults/reorder.js.map +1 -1
  94. package/dist/interactions/actions/defaults/requiresSelection.js.map +1 -1
  95. package/dist/interactions/actions/defaults/resize.js.map +1 -1
  96. package/dist/interactions/actions/defaults/rotate.js.map +1 -1
  97. package/dist/interactions/actions/defaults/selectAll.js.map +1 -1
  98. package/dist/interactions/actions/defaults/slice.js.map +1 -1
  99. package/dist/interactions/actions/defaults/toolActivate.js.map +1 -1
  100. package/dist/interactions/actions/defaults/toolResetToDefault.js.map +1 -1
  101. package/dist/interactions/actions/defaults/undoRedo.js +1 -1
  102. package/dist/interactions/actions/defaults/undoRedo.js.map +1 -1
  103. package/dist/interactions/actions/defaults/viewportDragPan.js.map +1 -1
  104. package/dist/interactions/actions/defaults/viewportWheelPan.js.map +1 -1
  105. package/dist/interactions/actions/defaults/viewportZoom.js.map +1 -1
  106. package/dist/interactions/actions/planeInput.js.map +1 -1
  107. package/dist/interactions/gestures/shared/screenTolerance.js.map +1 -1
  108. package/dist/{registerProgram-ateHDGjw.d.ts → registerProgram-BMZRDgrh.d.ts} +4 -0
  109. package/dist/renderer/cache/markerCommandCache.js +46 -42
  110. package/dist/renderer/cache/markerCommandCache.js.map +1 -1
  111. package/dist/renderer/cache/outlineStrokeMeshCache.js +30 -25
  112. package/dist/renderer/cache/outlineStrokeMeshCache.js.map +1 -1
  113. package/dist/renderer/cache/strokeMeshCache.js +42 -31
  114. package/dist/renderer/cache/strokeMeshCache.js.map +1 -1
  115. package/dist/renderer/cullDrawCommands.js +32 -31
  116. package/dist/renderer/cullDrawCommands.js.map +1 -1
  117. package/dist/renderer/draw.js +315 -362
  118. package/dist/renderer/draw.js.map +1 -1
  119. package/dist/renderer/drawBatch.js.map +1 -1
  120. package/dist/renderer/glyphOutlineMesh.js +67 -0
  121. package/dist/renderer/glyphOutlineMesh.js.map +1 -0
  122. package/dist/renderer/math/mat3.js +8 -3
  123. package/dist/renderer/math/mat3.js.map +1 -1
  124. package/dist/renderer/shaders/batchFill.js +3 -2
  125. package/dist/renderer/shaders/batchFill.js.map +1 -1
  126. package/dist/renderer/strokeLengths.js +50 -0
  127. package/dist/renderer/strokeLengths.js.map +1 -0
  128. package/dist/renderer.d.ts +2 -2
  129. package/dist/{test-seams-fYf6m5pu.d.ts → test-seams-CuIxe0xY.d.ts} +1 -1
  130. package/dist/test-seams.d.ts +2 -2
  131. package/dist/tools/builtin/eyedropper/useEyedropperTool.js.map +1 -1
  132. package/dist/tools/builtin/pen/usePenTool.js +1 -1
  133. package/dist/tools/builtin/pen/usePenTool.js.map +1 -1
  134. package/dist/tools/builtin/select/selectionContributions.js +43 -23
  135. package/dist/tools/builtin/select/selectionContributions.js.map +1 -1
  136. package/dist/tools/builtin/select/useSelectTool.js.map +1 -1
  137. package/dist/tools/builtin/slice/useSliceTool.js.map +1 -1
  138. package/dist/version.js +1 -1
  139. package/package.json +17 -16
@@ -1 +1 @@
1
- {"version":3,"file":"usePenTool.js","names":["p","PenIcon","freshScratch","resetScratch","anchorCount","s","n","sp","buildPath","all","subpaths","trailing","anchorsToPath","usePenTool","options","scratchRef","useRef","optsRef","useLatest","forceRender","useReducer","x","commitContinuation","useCallback","cont","drawn","edit","added","addedClosed","pathToAnchors","reverseAnchors","set","next","samePath","commit","deps","allClosed","path","pose","id","joinable","hit","scene","commitJoin","current","other","otherSet","tail","joined","joint","out","sameNode","rest","i","label","ops","keptId","node","createInsertOp","createDeleteOp","applyOps","defaultCommitAdapter","pickUpEndpoint","scratch","radius","scale","viewScale","nearestPathAnchor","pathsNear","wx","wy","h","original","picked","reversed","snap","fn","placeAt","y","withinCloseRadius","withinPxRadius","ax","ay","actions","useMemo","p","params","first","join","ActionDisabledReason","origin","ctx","pickedUp","joinHit","linked","applyOutHandle","sm","moveCtx","se","reason","endCtx","defineTool","cursorFor","createElement","anchor","to","target","dx","dy","shift","c","constrainTo45","area","view","e","pxExtent","px","ids","a","b"],"sources":["../../../../src/tools/builtin/pen/usePenTool.ts"],"sourcesContent":["import { useCallback, useMemo, useReducer, useRef, createElement } from 'react';\nimport { useLatest } from '@weasel-js/react';\nimport { defineTool } from '../../overlayBinding';\nimport type { Tool } from '../../overlayBinding';\nimport type { ToolPrefGroup } from '../../prefs';\nimport type { Action } from '@weasel-js/routing';\nimport { ActionDisabledReason } from '@weasel-js/routing';\nimport type { ActionDeps, InvocationCtx } from '@weasel-js/routing';\nimport type { AreaSelectDep, EditAnchorsDep, ViewApi } from 'interactions/actions/depSchema';\nimport { pxExtent, withinPxRadius } from 'core/viewport/pxExtent';\nimport { PenIcon } from '../../../icons';\nimport type { PolygonPath } from '@weasel-js/geom';\nimport { anchorsToPath, pathToAnchors } from 'features/paths/anchors';\nimport { nearestPathAnchor, reverseAnchors, type PathAnchorHit } from 'features/paths/nearestAnchor';\nimport type { Op } from 'core/ops/types';\nimport { createInsertOp } from 'core/ops/create';\nimport { createDeleteOp } from 'core/ops/delete';\nimport type { Scene } from 'core/scene/types';\nimport { defaultCommitAdapter } from 'interactions/actions/defaultCommitAdapter';\nimport { constrainTo45 } from '../../../util/constrainTo45';\nimport { cursorFor } from '@weasel-js/cursor';\n\n/**\n * In-progress pen anchor. A click places a corner, with no handles. A drag\n * sets `outHandle` to the drag point and `inHandle` to its mirror through\n * the anchor, which shapes the segment coming into it.\n */\nexport interface PenAnchor {\n x: number;\n y: number;\n outHandle?: { x: number; y: number };\n inHandle?: { x: number; y: number };\n /** True once Alt is held during the placement drag: from then on the\n * in-handle stays where it is, and an anchor dragged with Alt down from\n * the start gets none. */\n altBroken?: boolean;\n}\n\n/** One in-progress subpath of the pen tool: its anchors so far, and whether\n * the user has closed it. */\nexport interface PenSubpath {\n anchors: PenAnchor[]; // LOCAL PenAnchor — in-progress\n closed: boolean;\n}\n\n/**\n * Mutable scratch shared across pen-tool gestures. The hook keeps a stable\n * reference to a single instance and `initScratch` returns it on every call,\n * so click-by-click state survives gesture boundaries and the preview layer\n * can read the same object.\n *\n * ## The pen draws paths; it does not reshape them\n *\n * It draws new nodes, and extends an existing node's open subpath from one\n * of its ends (see `continuing`). Reshaping an existing path is anchor\n * editing: double-click a path to\n * enter edit mode, then use the `editAnchors` / `insertPathAnchor` /\n * `nudgeAnchors` / `deleteAnchors` / `marqueeAnchors` / `cutPathAtAnchor`\n * Actions.\n *\n * The pen used to carry a second, private implementation of all of that,\n * with its own scratch mode, hit-test override, overlay, and undo\n * plumbing. It was reachable only through a `getPathObj` option whose\n * contract required `pose.kind` to be `'polygon'` or `'rect'` — which no\n * kit-created node has, including the ones the pen itself creates. So it\n * was dead in every consumer while shadowing the live implementation, and\n * the two had drifted. Its geometry now lives in\n * `features/paths/anchorEdits.ts`, driven by those Actions.\n */\nexport interface PenScratch {\n finishedSubpaths: PenSubpath[];\n current: PenSubpath | null;\n cursor: { x: number; y: number } | null;\n draggingHandleAt: number | null;\n closeHintActive: boolean;\n /** Set while the pen is extending an existing node's open subpath rather\n * than drawing a new node. `current` then starts as that subpath (turned\n * around when it was picked up by its first anchor), and the commit\n * writes back into the node. */\n continuing: PenContinuation | null;\n}\n\n/** Which existing subpath the pen picked up, and the node's path as it was. */\nexport interface PenContinuation {\n id: string;\n /** Index of the picked-up subpath within `original`. */\n sub: number;\n /** True when it was picked up by its first anchor, so `current` runs\n * backwards and is turned around again on commit. */\n reversed: boolean;\n /** The node's path in world coords at pick-up. */\n original: PolygonPath;\n}\n\n/** Options for `usePenTool`: how a finished path becomes a pose, where it is\n * inserted, and when a closed subpath commits. */\nexport interface UsePenToolOptions<TPose> {\n /** Wrap a finished PolygonPath in the consumer's pose type. */\n wrapPath: (path: PolygonPath, opts: { closed: boolean }) => TPose;\n /** Insert + select adapter. */\n adapter: {\n addNode: (pose: TPose) => string;\n setSelection: (ids: string[]) => void;\n /** Build, without inserting, the node `addNode` would insert for `pose`,\n * so the pen can put it in a larger op batch — a new path finished on\n * another open path's endpoint is inserted and the other path deleted\n * in one undo step. Without it, that click only places an anchor. */\n makeNode?: (pose: TPose) => { id: string };\n };\n /** Auto-select the new object after commit. Default `true`. */\n autoSelect?: boolean;\n /** When true (default), clicking the first anchor to close a subpath\n * commits immediately — the closed region renders with its fill right\n * away. When false, the closed subpath stays in scratch so the user can\n * start another subpath; commit then fires on Enter / tool-switch /\n * ⌘-click / double-click, producing a compound path. */\n autoCommitOnClose?: boolean;\n /** Screen-px hit radius for \"click first anchor to close\". Default `8`.\n * Measured as a screen-space circle, so it stays round under non-uniform\n * zoom; aligns with `useSelectTool.handleHitRadius`. */\n closeHitRadius?: number;\n /** Screen-px radius within which a placed anchor lands exactly on an\n * existing path's anchor. Default `8`; `0` turns it off. Takes precedence\n * over `snapPoint`, which applies only when no anchor is in range. */\n anchorSnapRadius?: number;\n /** Optional point snapper applied to every world-space coordinate the\n * pen records or previews — anchor positions (corner clicks, smooth-\n * drag base point), the rubber-band cursor, and the outgoing-handle\n * target. Receives raw world coords; returns snapped world coords.\n * Wire to `gridSnapStrategy`-style spacing via the consumer:\n *\n * snapPoint: (p) => ({\n * x: Math.round(p.x / SPACING) * SPACING,\n * y: Math.round(p.y / SPACING) * SPACING,\n * })\n *\n * Pen state (anchor handles, etc.) is computed AFTER snapping so the\n * visible geometry stays grid-aligned. */\n snapPoint?: (p: { x: number; y: number }) => { x: number; y: number };\n}\n\nfunction freshScratch(): PenScratch {\n return {\n finishedSubpaths: [],\n current: null,\n cursor: null,\n draggingHandleAt: null,\n closeHintActive: false,\n continuing: null,\n };\n}\n\nfunction resetScratch(s: PenScratch): void {\n s.finishedSubpaths = [];\n s.current = null;\n s.cursor = null;\n s.draggingHandleAt = null;\n s.closeHintActive = false;\n s.continuing = null;\n}\n\n/** Total anchors across finished subpaths plus the in-progress one. */\nfunction anchorCount(s: PenScratch): number {\n return (s.current ? s.current.anchors.length : 0)\n + s.finishedSubpaths.reduce((n, sp) => n + sp.anchors.length, 0);\n}\n\n/** Build a PolygonPath from the pen's accumulated subpaths, by the same\n * rule as any anchor set: segment A→B is a cubic through A's out-handle and\n * B's in-handle when either exists, else a line. */\nfunction buildPath(\n subpaths: PenSubpath[],\n trailing: PenSubpath | null,\n): PolygonPath {\n const all = [...subpaths];\n if (trailing && trailing.anchors.length > 0) all.push(trailing);\n return anchorsToPath(all.map((sp) => sp.anchors), all.map((sp) => sp.closed));\n}\n\n/**\n * Active-slot Tool: click + drag to build a `PolygonPath` Illustrator-style.\n *\n * State machine: Idle / Drawing / BetweenSubpaths (see design doc).\n * Click places a corner anchor; click-drag places an anchor with an\n * outgoing bezier handle; click-the-first-anchor closes the subpath; Enter\n * open-finishes; Esc discards; tool-switch commits if ≥2 anchors else\n * discards. Shift constrains the placement-drag handle to 0/45/90/135°;\n * Alt during drag breaks the handle mirror for the next segment.\n *\n * Reshaping an existing path is not this tool's job — see {@link PenScratch}.\n */\nexport function usePenTool<TPose>(\n options: UsePenToolOptions<TPose>,\n): Tool<PenScratch> {\n const { wrapPath, adapter, autoSelect = true, autoCommitOnClose = true, closeHitRadius = 8, anchorSnapRadius = 8, snapPoint } = options;\n\n // Persistent scratch: single ref reused across gestures so multi-click\n // state survives the dispatcher's per-gesture initScratch contract.\n const scratchRef = useRef<PenScratch | null>(null);\n if (scratchRef.current === null) scratchRef.current = freshScratch();\n\n // Latest options stashed so handlers see fresh values without rebuilding\n // the Tool record (which would lose scratch identity in the dispatcher).\n const optsRef = useLatest({ wrapPath, adapter, autoSelect, autoCommitOnClose, closeHitRadius, anchorSnapRadius, snapPoint });\n\n // Scratch is a mutable ref (so click-by-click state survives the\n // dispatcher's per-gesture initScratch contract). Mutations alone don't\n // trigger React re-renders, so Canvas never re-paints and the preview\n // layer stays invisible until something else (e.g. commit) bumps host\n // state. Force a render after every scratch mutation so the host's\n // <Canvas layers={{...}}> literal gets a new identity and the paint\n // useEffect fires. Pull the trigger via ref so the memoized Tool record\n // doesn't need to rebuild.\n const [, forceRender] = useReducer((x: number) => x + 1, 0);\n\n // Geometry helpers shared by the actions below. They read `optsRef` so a\n // re-render with new options is visible without rebuilding the actions\n // (which would lose the scratch identity `penPreviewLayer` reads through\n // `Tool.initScratch`).\n\n /** Write the pen's subpaths back into the node it picked up: the first\n * replaces the subpath it continued, any later ones are appended. Skips\n * the write when nothing changed, so an empty pick-up leaves no undo\n * entry. */\n const commitContinuation = useCallback(\n (s: PenScratch, trailing: PenSubpath | null, edit: EditAnchorsDep | undefined): void => {\n const cont = s.continuing!;\n const drawn = [...s.finishedSubpaths, ...(trailing ? [trailing] : [])];\n if (!edit || drawn.length === 0) return;\n const { anchors: added, closed: addedClosed } = pathToAnchors(buildPath(drawn, null));\n if (cont.reversed) added[0] = reverseAnchors(added[0]);\n const set = pathToAnchors(cont.original);\n set.anchors.splice(cont.sub, 1, added[0]);\n set.closed.splice(cont.sub, 1, addedClosed[0]);\n set.anchors.push(...added.slice(1));\n set.closed.push(...addedClosed.slice(1));\n const next: PolygonPath = { ...anchorsToPath(set.anchors, set.closed), fillRule: cont.original.fillRule };\n if (samePath(next, cont.original)) return;\n edit.applyEdit(cont.id, next, 'Continue path');\n if (optsRef.current.autoSelect) optsRef.current.adapter.setSelection([cont.id]);\n },\n [optsRef],\n );\n\n const commit = useCallback((s: PenScratch, deps: ActionDeps): void => {\n const trailing = s.current && s.current.anchors.length > 0 ? s.current : null;\n if (s.continuing) {\n commitContinuation(s, trailing, deps.editAnchors as EditAnchorsDep | undefined);\n resetScratch(s);\n return;\n }\n if (s.finishedSubpaths.length === 0 && !trailing) return;\n const allClosed =\n s.finishedSubpaths.every((sp) => sp.closed) &&\n (trailing === null || trailing.closed);\n const path = buildPath(s.finishedSubpaths, trailing);\n const pose = optsRef.current.wrapPath(path, { closed: allClosed });\n const id = optsRef.current.adapter.addNode(pose);\n if (optsRef.current.autoSelect) optsRef.current.adapter.setSelection([id]);\n resetScratch(s);\n }, [commitContinuation, optsRef]);\n\n /** Whether an anchor about to land on `hit` finishes the path by joining\n * onto another open subpath. Its own continued subpath never counts —\n * reaching that one's far end is a close. Declines when the deps can't\n * commit the join as one op batch. */\n const joinable = useCallback((s: PenScratch, deps: ActionDeps, hit: PathAnchorHit | null): hit is PathAnchorHit => {\n if (!hit || hit.end === null || !s.current || s.current.anchors.length === 0) return false;\n const cont = s.continuing;\n if (cont && hit.id === cont.id && hit.sub === cont.sub) return false;\n const scene = deps.scene as Scene<unknown, string, unknown> | undefined;\n if (!scene && !deps.applyOps) return false;\n if (!(cont ? (deps.editAnchors as EditAnchorsDep).editOps : optsRef.current.adapter.makeNode)) return false;\n return cont?.id === hit.id || scene?.get(hit.id as never) !== undefined;\n }, [optsRef]);\n\n /** Finish the path onto `hit`, the endpoint its last anchor was just placed\n * on: the other subpath's anchors follow on from it, turned around when\n * `hit` is its last anchor, and its node is deleted. The path being drawn\n * keeps its identity — a continued node keeps its id and style, and a new\n * path is minted the way any pen path is. One op batch, so one undo. */\n const commitJoin = useCallback((s: PenScratch, deps: ActionDeps, hit: PathAnchorHit): void => {\n const edit = deps.editAnchors as EditAnchorsDep;\n const scene = deps.scene as Scene<unknown, string, unknown> | undefined;\n const current = s.current!;\n const { anchors: added, closed: addedClosed } = pathToAnchors(buildPath([...s.finishedSubpaths, current], null));\n\n const other = edit.getEditablePath(hit.id) as PolygonPath;\n const otherSet = pathToAnchors(other);\n const tail = hit.end === 'last' ? reverseAnchors(otherSet.anchors[hit.sub]) : otherSet.anchors[hit.sub];\n const joined = added[added.length - 1];\n const joint = joined[joined.length - 1];\n const out = current.anchors[current.anchors.length - 1].outHandle ?? tail[0].outHandle;\n if (out) joint.outHandle = { ...out };\n joined.push(...tail.slice(1));\n\n const cont = s.continuing;\n const sameNode = cont?.id === hit.id;\n const rest = sameNode ? { anchors: [], closed: [] } : {\n anchors: otherSet.anchors.filter((_, i) => i !== hit.sub),\n closed: otherSet.closed.filter((_, i) => i !== hit.sub),\n };\n const label = 'Join paths';\n const ops: Op[] = [];\n let keptId: string;\n if (cont) {\n if (cont.reversed) added[0] = reverseAnchors(added[0]);\n const set = pathToAnchors(cont.original);\n set.anchors.splice(cont.sub, 1, added[0]);\n set.closed.splice(cont.sub, 1, addedClosed[0]);\n set.anchors.push(...added.slice(1), ...rest.anchors);\n set.closed.push(...addedClosed.slice(1), ...rest.closed);\n if (sameNode) {\n set.anchors.splice(hit.sub, 1);\n set.closed.splice(hit.sub, 1);\n }\n const next: PolygonPath = { ...anchorsToPath(set.anchors, set.closed), fillRule: cont.original.fillRule };\n ops.push(...edit.editOps!(cont.id, next, label));\n keptId = cont.id;\n } else {\n const path = anchorsToPath([...added, ...rest.anchors], [...addedClosed, ...rest.closed]);\n const node = optsRef.current.adapter.makeNode!(optsRef.current.wrapPath(path, { closed: false }));\n ops.push(createInsertOp({ node }));\n keptId = node.id;\n }\n if (!sameNode) ops.push(createDeleteOp({ node: scene!.get(hit.id as never) as { id: string } }));\n\n const applyOps = deps.applyOps as ((ops: Op[], label: string) => void) | undefined;\n if (applyOps) applyOps(ops, label);\n else scene!.applyBatch(ops, label, defaultCommitAdapter(scene!));\n if (optsRef.current.autoSelect) optsRef.current.adapter.setSelection([keptId]);\n resetScratch(s);\n }, [optsRef]);\n\n /** From idle, a press on an open subpath's end anchor picks that subpath\n * up: it becomes `current`, turned so the pressed end is last. Returns\n * whether it did. */\n const pickUpEndpoint = useCallback((deps: ActionDeps, wx: number, wy: number): boolean => {\n const scratch = scratchRef.current!;\n if (scratch.current || scratch.finishedSubpaths.length > 0) return false;\n const radius = optsRef.current.closeHitRadius;\n const scale = viewScale(deps);\n const hit = nearestPathAnchor(pathsNear(deps, wx, wy, radius), { x: wx, y: wy }, radius, scale, (h) => h.end !== null);\n if (!hit) return false;\n const original = (deps.editAnchors as EditAnchorsDep).getEditablePath(hit.id) as PolygonPath;\n const picked = pathToAnchors(original).anchors[hit.sub];\n const reversed = hit.end === 'first' && picked.length > 1;\n scratch.current = { anchors: reversed ? reverseAnchors(picked) : picked, closed: false };\n scratch.continuing = { id: hit.id, sub: hit.sub, reversed, original };\n return true;\n }, [optsRef]);\n\n const snap = useCallback((x: number, y: number): { x: number; y: number } => {\n const fn = optsRef.current.snapPoint;\n return fn ? fn({ x, y }) : { x, y };\n }, [optsRef]);\n\n /** Where an anchor pressed at `(x, y)` lands: on an existing path's anchor\n * when one is within `anchorSnapRadius`, else wherever `snapPoint` puts it.\n * `hit` names the anchor it snapped to. */\n const placeAt = useCallback((deps: ActionDeps, x: number, y: number): { x: number; y: number; hit: PathAnchorHit | null } => {\n const radius = optsRef.current.anchorSnapRadius;\n if (radius > 0) {\n const hit = nearestPathAnchor(pathsNear(deps, x, y, radius), { x, y }, radius, viewScale(deps));\n if (hit) return { x: hit.x, y: hit.y, hit };\n }\n return { ...snap(x, y), hit: null };\n }, [snap, optsRef]);\n\n /** Is a world point within the close-hit radius of `(ax, ay)`? Measured as a\n * screen-space circle, so the zone stays round under non-uniform zoom. */\n const withinCloseRadius = useCallback(\n (deps: ActionDeps, ax: number, ay: number, wx: number, wy: number): boolean => {\n return withinPxRadius(ax - wx, ay - wy, optsRef.current.closeHitRadius, viewScale(deps));\n },\n [optsRef],\n );\n\n /**\n * Actions the pen owns, registered by `useToolActions` from inside the\n * ActionsProvider — see `ToolDef.actions`.\n *\n * They close over `scratchRef`, which is why the pen needs no `Selector`\n * exposing tool scratch to the action layer: the state never leaves the\n * hook. `useMemo([])` keeps their identity stable for the same reason\n * `scratchRef` is a ref — the preview layer reads that one object.\n */\n const actions = useMemo<Action[]>(() => {\n const s = () => scratchRef.current!;\n\n return [\n {\n id: 'pen.placeAnchor',\n label: 'Pen — place anchor',\n eligible: { capability: 'creates-paths' },\n requires: ['view', 'areaSelect', 'editAnchors', 'scene', 'applyOps'],\n invoker: {\n timing: 'immediate' as const,\n run: (deps, params) => {\n const p = params as { pressX?: number; pressY?: number } | undefined;\n if (p?.pressX === undefined || p.pressY === undefined) return;\n // The press point, not the release: a click may drift up to the\n // drag threshold, and an anchor should land where you put the\n // pointer down. Snapping happens here rather than at press time\n // so the snapped value is what the geometry records.\n if (pickUpEndpoint(deps, p.pressX, p.pressY)) {\n forceRender();\n return;\n }\n const { x: wx, y: wy, hit } = placeAt(deps, p.pressX, p.pressY);\n const scratch = s();\n\n // Close-on-first-anchor (>= 3 anchors). With `autoCommitOnClose`\n // on (default) this commits right away so the closed region\n // renders with its fill — Illustrator-style. With it off the\n // closed subpath stays in scratch for compound-path builds and\n // commits on Enter / tool switch.\n if (scratch.current && scratch.current.anchors.length >= 3) {\n const first = scratch.current.anchors[0];\n if (withinCloseRadius(deps, first.x, first.y, wx, wy)) {\n scratch.current.closed = true;\n scratch.finishedSubpaths.push(scratch.current);\n scratch.current = null;\n scratch.closeHintActive = false;\n if (optsRef.current.autoCommitOnClose) commit(scratch, deps);\n forceRender();\n return;\n }\n }\n\n const join = joinable(scratch, deps, hit);\n // Otherwise: append a corner anchor, starting a subpath if needed.\n if (!scratch.current) scratch.current = { anchors: [], closed: false };\n scratch.current.anchors.push({ x: wx, y: wy });\n if (join) commitJoin(scratch, deps, hit);\n forceRender();\n },\n },\n },\n\n {\n id: 'pen.finishOpen',\n label: 'Pen — finish open path',\n eligible: { capability: 'creates-paths' },\n requires: ['editAnchors'],\n // Needs an actual path to commit. Declining also lets the gesture\n // fall through: a ⌘-click with fewer than two anchors reaches the\n // plain-click binding and places an anchor instead.\n enabled: () => (anchorCount(s()) >= 2 ? true : ActionDisabledReason.NotApplicable),\n invoker: {\n timing: 'immediate' as const,\n run: (deps, params) => {\n const scratch = s();\n const p = params as { viaDoubleClick?: boolean } | undefined;\n\n // A double click arrives AFTER both of its clicks, so the second\n // one has already appended an anchor. Undo exactly that before\n // committing: pop it, and drop the subpath entirely if it was the\n // only anchor in it (which happens when the FIRST click closed a\n // subpath and the second started a fresh one). The net effect\n // matches the pen's old private 300ms detector — one anchor\n // placed, path finished — while using the dispatcher's single\n // definition of a double click.\n //\n // No position check is needed: the anchor to drop is by\n // construction the one the second click just appended.\n if (p?.viaDoubleClick && scratch.current) {\n scratch.current.anchors.pop();\n if (scratch.current.anchors.length === 0) scratch.current = null;\n }\n\n commit(scratch, deps);\n forceRender();\n },\n },\n },\n\n {\n id: 'pen.dragHandle',\n label: 'Pen — drag out a bezier handle',\n eligible: { capability: 'creates-paths' },\n requires: ['view', 'areaSelect', 'editAnchors', 'scene', 'applyOps'],\n invoker: {\n timing: 'ongoing' as const,\n start: (ctx: InvocationCtx) => {\n const scratch = s();\n // `drag.start` is the pointerdown position — the dispatcher\n // buffers the press and releases it at the drag threshold, so the\n // anchor lands where the user pressed rather than where the\n // threshold happened to be crossed.\n const origin = ctx.drag?.start ?? ctx.world;\n // Dragging from an open path's end picks the path up and pulls\n // that end's own handle rather than placing a new anchor on it.\n const pickedUp = pickUpEndpoint(ctx.deps, origin.x, origin.y);\n // Dragging on another open path's endpoint joins onto it once\n // the handle is set.\n let joinHit: PathAnchorHit | null = null;\n if (!pickedUp) {\n const { x: ax, y: ay, hit } = placeAt(ctx.deps, origin.x, origin.y);\n if (joinable(scratch, ctx.deps, hit)) joinHit = hit;\n if (!scratch.current) scratch.current = { anchors: [], closed: false };\n scratch.current.anchors.push({ x: ax, y: ay });\n }\n scratch.draggingHandleAt = scratch.current!.anchors.length - 1;\n if (ctx.modifiers.alt) {\n scratch.current!.anchors[scratch.draggingHandleAt].altBroken = true;\n }\n // A picked-up endpoint's in-handle belongs to the existing\n // segment into it, which the pen does not reshape.\n const linked = !pickedUp;\n applyOutHandle(scratch, ctx.world, ctx.modifiers.shift, linked, optsRef.current.snapPoint);\n forceRender();\n\n return {\n onMove: (moveCtx: InvocationCtx) => {\n const sm = s();\n if (sm.draggingHandleAt === null) return;\n if (moveCtx.modifiers.alt && sm.current) {\n sm.current.anchors[sm.draggingHandleAt].altBroken = true;\n }\n applyOutHandle(sm, moveCtx.world, moveCtx.modifiers.shift, linked, optsRef.current.snapPoint);\n forceRender();\n },\n onEnd: (endCtx: InvocationCtx, reason: 'commit' | 'cancel') => {\n const se = s();\n if (reason === 'cancel') {\n // `start` appended the anchor (or picked a path up); a\n // cancel (pointercancel is the reachable one — Escape and a\n // tool switch both reset the whole scratch first) has to\n // take it back out.\n if (pickedUp) {\n resetScratch(se);\n } else if (se.current && se.draggingHandleAt === se.current.anchors.length - 1) {\n se.current.anchors.pop();\n if (se.current.anchors.length === 0) se.current = null;\n }\n se.draggingHandleAt = null;\n forceRender();\n return;\n }\n if (se.draggingHandleAt !== null) {\n if (endCtx.modifiers.alt && se.current) {\n se.current.anchors[se.draggingHandleAt].altBroken = true;\n }\n applyOutHandle(se, endCtx.world, endCtx.modifiers.shift, linked, optsRef.current.snapPoint);\n se.draggingHandleAt = null;\n }\n if (joinHit && se.current) commitJoin(se, ctx.deps, joinHit);\n forceRender();\n },\n };\n },\n },\n },\n\n {\n id: 'pen.finish',\n label: 'Pen — finish path',\n eligible: { capability: 'creates-paths' },\n requires: ['editAnchors'],\n enabled: () => (anchorCount(s()) > 0 ? true : ActionDisabledReason.NotApplicable),\n invoker: {\n timing: 'immediate' as const,\n run: (deps) => {\n commit(s(), deps);\n forceRender();\n },\n },\n },\n\n {\n id: 'pen.cancel',\n label: 'Pen — discard path',\n eligible: { capability: 'creates-paths' },\n // Declining with nothing drawn is what keeps Escape's first-match\n // ladder intact: an empty pen passes the key to `escape`, which goes\n // on to clear the selection / return to the default tool.\n enabled: () => {\n const scratch = s();\n return scratch.current !== null || scratch.finishedSubpaths.length > 0\n ? true\n : ActionDisabledReason.NotApplicable;\n },\n invoker: {\n timing: 'immediate' as const,\n run: () => {\n resetScratch(s());\n forceRender();\n },\n },\n },\n ];\n }, [commit, placeAt, withinCloseRadius, pickUpEndpoint, joinable, commitJoin, optsRef]);\n\n return useMemo(() => {\n return defineTool<PenScratch>({\n id: 'pen',\n capabilities: ['creates-paths'],\n hookName: 'usePenTool',\n // Reads the tool's own ref rather than a scratch handed in by a\n // dispatcher — the pen owns this state and nothing else needs it.\n cursor: () =>\n scratchRef.current?.closeHintActive\n ? 'pointer'\n : cursorFor('pen', { fallback: 'crosshair' }),\n presentation: {\n label: 'Pen',\n icon: createElement(PenIcon),\n group: 'draw',\n },\n // Persistent-ref scratch. `penPreviewLayer` reads the in-progress path\n // by calling `Tool.initScratch()` and getting this same object back, so\n // the identity is load-bearing.\n initScratch: () => scratchRef.current!,\n actions,\n\n onDeactivate: () => {\n // Anything still in scratch is by definition incomplete — the user\n // hasn't closed it (close-on-first-anchor), open-finished it\n // (cmd-click), or pressed Enter. Switching tools mid-path should\n // discard, not auto-commit a stub polyline that the user didn't\n // ask for. Mirrors Escape's behavior so \"stop drawing\" is\n // consistent across exits.\n resetScratch(scratchRef.current!);\n forceRender();\n },\n\n bindings: [\n // Plain click places an anchor; ⌘/Ctrl-click open-finishes\n // (Illustrator). Strict modifier matching keeps the two apart, and\n // `mod` resolves to meta on mac / ctrl elsewhere — the tool-route\n // grammar's modifier matcher accepted either key on either platform.\n { spec: { kind: 'click' as const, mods: { shift: 'optional' as const, alt: 'optional' as const } }, actionId: 'pen.placeAnchor' },\n { spec: { kind: 'click' as const, mods: { mod: true, shift: 'optional' as const, alt: 'optional' as const } }, actionId: 'pen.finishOpen' },\n // Double-click on the last anchor also open-finishes. This used to be\n // a private 300ms/radius check inside the click route — a fourth\n // double-click detector in a codebase that had just collapsed three\n // into one (audit 3.3).\n {\n spec: { kind: 'doubleClick' as const, mods: { shift: 'optional' as const, alt: 'optional' as const } },\n actionId: 'pen.finishOpen',\n opts: { params: { viaDoubleClick: true } },\n },\n { spec: { kind: 'drag' as const, mods: { shift: 'optional' as const, alt: 'optional' as const } }, actionId: 'pen.dragHandle' },\n { spec: { kind: 'key' as const, key: 'Enter' }, actionId: 'pen.finish' },\n { spec: { kind: 'key' as const, key: 'Escape' }, actionId: 'pen.cancel' },\n ],\n });\n }, [actions]);\n}\n\nusePenTool.prefs = {\n name: 'Pen',\n description: 'Pen-tool behavior.',\n children: {\n autoCommitOnClose: {\n kind: 'boolean',\n name: 'Auto-commit pen on close',\n description: \"When you click the pen tool's first anchor to close a region, commit immediately so it renders with its fill. Off: keep the path in preview until you press Enter (lets you build a compound path from multiple closed subpaths).\",\n default: true,\n },\n },\n} satisfies ToolPrefGroup;\n\n/** Point the anchor's outgoing handle at `target`, optionally snapped and\n * optionally constrained to 45° steps. When `linked` and not Alt-broken,\n * the in-handle follows as its mirror through the anchor. */\nfunction applyOutHandle<S extends PenScratch>(\n s: S,\n target: { x: number; y: number },\n shift: boolean,\n linked: boolean,\n snap?: (p: { x: number; y: number }) => { x: number; y: number },\n): void {\n if (s.current === null || s.draggingHandleAt === null) return;\n const anchor = s.current.anchors[s.draggingHandleAt];\n const to = snap ? snap(target) : target;\n let dx = to.x - anchor.x;\n let dy = to.y - anchor.y;\n if (shift) {\n const c = constrainTo45(dx, dy);\n dx = c.dx;\n dy = c.dy;\n }\n anchor.outHandle = { x: anchor.x + dx, y: anchor.y + dy };\n if (linked && !anchor.altBroken) anchor.inHandle = { x: anchor.x - dx, y: anchor.y - dy };\n}\n\nfunction viewScale(deps: ActionDeps): { x: number; y: number } {\n return (deps.view as ViewApi | undefined)?.get().scale ?? { x: 1, y: 1 };\n}\n\n/** Existing editable paths, world coords, with ink within `px` screen\n * pixels of `(wx, wy)` — front-to-back, as the area query returns them.\n * Empty when the host publishes no `areaSelect` or `editAnchors` dep. */\nfunction pathsNear(\n deps: ActionDeps,\n wx: number,\n wy: number,\n px: number,\n): Array<{ id: string; path: PolygonPath }> {\n const area = deps.areaSelect as AreaSelectDep | undefined;\n const edit = deps.editAnchors as EditAnchorsDep | undefined;\n if (!area || !edit) return [];\n const view = deps.view as ViewApi | undefined;\n const e = pxExtent(px, viewScale(deps));\n const ids = area.hitTestArea({ x: wx - e.x, y: wy - e.y, width: 2 * e.x, height: 2 * e.y }, view);\n const out: Array<{ id: string; path: PolygonPath }> = [];\n for (const id of ids) {\n const path = edit.getEditablePath(id) as PolygonPath | null;\n if (path?.kind === 'polygon') out.push({ id, path });\n }\n return out;\n}\n\nfunction samePath(a: PolygonPath, b: PolygonPath): boolean {\n if (a.commands.length !== b.commands.length || a.coords.length !== b.coords.length) return false;\n for (let i = 0; i < a.commands.length; i++) if (a.commands[i] !== b.commands[i]) return false;\n for (let i = 0; i < a.coords.length; i++) if (a.coords[i] !== b.coords[i]) return false;\n return true;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"usePenTool.js","names":["p","PenIcon","freshScratch","resetScratch","anchorCount","s","n","sp","buildPath","all","subpaths","trailing","anchorsToPath","usePenTool","options","scratchRef","useRef","optsRef","useLatest","forceRender","useReducer","x","commitContinuation","useCallback","cont","drawn","edit","added","addedClosed","pathToAnchors","reverseAnchors","set","next","samePath","commit","deps","allClosed","path","pose","id","joinable","hit","scene","commitJoin","current","other","otherSet","tail","joined","joint","out","sameNode","rest","i","label","ops","keptId","node","createInsertOp","createDeleteOp","applyOps","defaultCommitAdapter","pickUpEndpoint","scratch","radius","scale","viewScale","nearestPathAnchor","pathsNear","wx","wy","h","original","picked","reversed","snap","fn","placeAt","y","withinCloseRadius","withinPxRadius","ax","ay","actions","useMemo","p","params","first","join","ActionDisabledReason","origin","ctx","pickedUp","joinHit","linked","applyOutHandle","sm","moveCtx","se","reason","endCtx","defineTool","cursorFor","createElement","anchor","to","target","dx","dy","shift","c","constrainTo45","area","view","e","pxExtent","px","ids","a","b"],"sources":["../../../../src/tools/builtin/pen/usePenTool.ts"],"sourcesContent":["import { useCallback, useMemo, useReducer, useRef, createElement } from 'react';\nimport { useLatest } from '@weasel-js/react';\nimport { defineTool } from '../../overlayBinding';\nimport type { Tool } from '../../overlayBinding';\nimport type { ToolPrefGroup } from '../../prefs';\nimport type { Action } from '@weasel-js/routing';\nimport { ActionDisabledReason } from '@weasel-js/routing';\nimport type { ActionDeps, InvocationCtx } from '@weasel-js/routing';\nimport type { EditAnchorsDep } from 'interactions/actions/depSchema';\nimport { pxExtent, withinPxRadius } from 'core/viewport/pxExtent';\nimport { PenIcon } from '../../../icons';\nimport type { PolygonPath } from '@weasel-js/geom';\nimport { anchorsToPath, pathToAnchors } from 'features/paths/anchors';\nimport { nearestPathAnchor, reverseAnchors, type PathAnchorHit } from 'features/paths/nearestAnchor';\nimport type { Op } from 'core/ops/types';\nimport { createInsertOp } from 'core/ops/create';\nimport { createDeleteOp } from 'core/ops/delete';\nimport { defaultCommitAdapter } from 'interactions/actions/defaultCommitAdapter';\nimport { constrainTo45 } from '../../../util/constrainTo45';\nimport { cursorFor } from '@weasel-js/cursor';\n\n/**\n * In-progress pen anchor. A click places a corner, with no handles. A drag\n * sets `outHandle` to the drag point and `inHandle` to its mirror through\n * the anchor, which shapes the segment coming into it.\n */\nexport interface PenAnchor {\n x: number;\n y: number;\n outHandle?: { x: number; y: number };\n inHandle?: { x: number; y: number };\n /** True once Alt is held during the placement drag: from then on the\n * in-handle stays where it is, and an anchor dragged with Alt down from\n * the start gets none. */\n altBroken?: boolean;\n}\n\n/** One in-progress subpath of the pen tool: its anchors so far, and whether\n * the user has closed it. */\nexport interface PenSubpath {\n anchors: PenAnchor[]; // LOCAL PenAnchor — in-progress\n closed: boolean;\n}\n\n/**\n * Mutable scratch shared across pen-tool gestures. The hook keeps a stable\n * reference to a single instance and `initScratch` returns it on every call,\n * so click-by-click state survives gesture boundaries and the preview layer\n * can read the same object.\n *\n * ## The pen draws paths; it does not reshape them\n *\n * It draws new nodes, and extends an existing node's open subpath from one\n * of its ends (see `continuing`). Reshaping an existing path is anchor\n * editing: double-click a path to\n * enter edit mode, then use the `editAnchors` / `insertPathAnchor` /\n * `nudgeAnchors` / `deleteAnchors` / `marqueeAnchors` / `cutPathAtAnchor`\n * Actions.\n *\n * The pen used to carry a second, private implementation of all of that,\n * with its own scratch mode, hit-test override, overlay, and undo\n * plumbing. It was reachable only through a `getPathObj` option whose\n * contract required `pose.kind` to be `'polygon'` or `'rect'` — which no\n * kit-created node has, including the ones the pen itself creates. So it\n * was dead in every consumer while shadowing the live implementation, and\n * the two had drifted. Its geometry now lives in\n * `features/paths/anchorEdits.ts`, driven by those Actions.\n */\nexport interface PenScratch {\n finishedSubpaths: PenSubpath[];\n current: PenSubpath | null;\n cursor: { x: number; y: number } | null;\n draggingHandleAt: number | null;\n closeHintActive: boolean;\n /** Set while the pen is extending an existing node's open subpath rather\n * than drawing a new node. `current` then starts as that subpath (turned\n * around when it was picked up by its first anchor), and the commit\n * writes back into the node. */\n continuing: PenContinuation | null;\n}\n\n/** Which existing subpath the pen picked up, and the node's path as it was. */\nexport interface PenContinuation {\n id: string;\n /** Index of the picked-up subpath within `original`. */\n sub: number;\n /** True when it was picked up by its first anchor, so `current` runs\n * backwards and is turned around again on commit. */\n reversed: boolean;\n /** The node's path in world coords at pick-up. */\n original: PolygonPath;\n}\n\n/** Options for `usePenTool`: how a finished path becomes a pose, where it is\n * inserted, and when a closed subpath commits. */\nexport interface UsePenToolOptions<TPose> {\n /** Wrap a finished PolygonPath in the consumer's pose type. */\n wrapPath: (path: PolygonPath, opts: { closed: boolean }) => TPose;\n /** Insert + select adapter. */\n adapter: {\n addNode: (pose: TPose) => string;\n setSelection: (ids: string[]) => void;\n /** Build, without inserting, the node `addNode` would insert for `pose`,\n * so the pen can put it in a larger op batch — a new path finished on\n * another open path's endpoint is inserted and the other path deleted\n * in one undo step. Without it, that click only places an anchor. */\n makeNode?: (pose: TPose) => { id: string };\n };\n /** Auto-select the new object after commit. Default `true`. */\n autoSelect?: boolean;\n /** When true (default), clicking the first anchor to close a subpath\n * commits immediately — the closed region renders with its fill right\n * away. When false, the closed subpath stays in scratch so the user can\n * start another subpath; commit then fires on Enter / tool-switch /\n * ⌘-click / double-click, producing a compound path. */\n autoCommitOnClose?: boolean;\n /** Screen-px hit radius for \"click first anchor to close\". Default `8`.\n * Measured as a screen-space circle, so it stays round under non-uniform\n * zoom; aligns with `useSelectTool.handleHitRadius`. */\n closeHitRadius?: number;\n /** Screen-px radius within which a placed anchor lands exactly on an\n * existing path's anchor. Default `8`; `0` turns it off. Takes precedence\n * over `snapPoint`, which applies only when no anchor is in range. */\n anchorSnapRadius?: number;\n /** Optional point snapper applied to every world-space coordinate the\n * pen records or previews — anchor positions (corner clicks, smooth-\n * drag base point), the rubber-band cursor, and the outgoing-handle\n * target. Receives raw world coords; returns snapped world coords.\n * Wire to `gridSnapStrategy`-style spacing via the consumer:\n *\n * snapPoint: (p) => ({\n * x: Math.round(p.x / SPACING) * SPACING,\n * y: Math.round(p.y / SPACING) * SPACING,\n * })\n *\n * Pen state (anchor handles, etc.) is computed AFTER snapping so the\n * visible geometry stays grid-aligned. */\n snapPoint?: (p: { x: number; y: number }) => { x: number; y: number };\n}\n\nfunction freshScratch(): PenScratch {\n return {\n finishedSubpaths: [],\n current: null,\n cursor: null,\n draggingHandleAt: null,\n closeHintActive: false,\n continuing: null,\n };\n}\n\nfunction resetScratch(s: PenScratch): void {\n s.finishedSubpaths = [];\n s.current = null;\n s.cursor = null;\n s.draggingHandleAt = null;\n s.closeHintActive = false;\n s.continuing = null;\n}\n\n/** Total anchors across finished subpaths plus the in-progress one. */\nfunction anchorCount(s: PenScratch): number {\n return (s.current ? s.current.anchors.length : 0)\n + s.finishedSubpaths.reduce((n, sp) => n + sp.anchors.length, 0);\n}\n\n/** Build a PolygonPath from the pen's accumulated subpaths, by the same\n * rule as any anchor set: segment A→B is a cubic through A's out-handle and\n * B's in-handle when either exists, else a line. */\nfunction buildPath(\n subpaths: PenSubpath[],\n trailing: PenSubpath | null,\n): PolygonPath {\n const all = [...subpaths];\n if (trailing && trailing.anchors.length > 0) all.push(trailing);\n return anchorsToPath(all.map((sp) => sp.anchors), all.map((sp) => sp.closed));\n}\n\n/**\n * Active-slot Tool: click + drag to build a `PolygonPath` Illustrator-style.\n *\n * State machine: Idle / Drawing / BetweenSubpaths (see design doc).\n * Click places a corner anchor; click-drag places an anchor with an\n * outgoing bezier handle; click-the-first-anchor closes the subpath; Enter\n * open-finishes; Esc discards; tool-switch commits if ≥2 anchors else\n * discards. Shift constrains the placement-drag handle to 0/45/90/135°;\n * Alt during drag breaks the handle mirror for the next segment.\n *\n * Reshaping an existing path is not this tool's job — see {@link PenScratch}.\n */\nexport function usePenTool<TPose>(\n options: UsePenToolOptions<TPose>,\n): Tool<PenScratch> {\n const { wrapPath, adapter, autoSelect = true, autoCommitOnClose = true, closeHitRadius = 8, anchorSnapRadius = 8, snapPoint } = options;\n\n // Persistent scratch: single ref reused across gestures so multi-click\n // state survives the dispatcher's per-gesture initScratch contract.\n const scratchRef = useRef<PenScratch | null>(null);\n if (scratchRef.current === null) scratchRef.current = freshScratch();\n\n // Latest options stashed so handlers see fresh values without rebuilding\n // the Tool record (which would lose scratch identity in the dispatcher).\n const optsRef = useLatest({ wrapPath, adapter, autoSelect, autoCommitOnClose, closeHitRadius, anchorSnapRadius, snapPoint });\n\n // Scratch is a mutable ref (so click-by-click state survives the\n // dispatcher's per-gesture initScratch contract). Mutations alone don't\n // trigger React re-renders, so Canvas never re-paints and the preview\n // layer stays invisible until something else (e.g. commit) bumps host\n // state. Force a render after every scratch mutation so the host's\n // <Canvas layers={{...}}> literal gets a new identity and the paint\n // useEffect fires. Pull the trigger via ref so the memoized Tool record\n // doesn't need to rebuild.\n const [, forceRender] = useReducer((x: number) => x + 1, 0);\n\n // Geometry helpers shared by the actions below. They read `optsRef` so a\n // re-render with new options is visible without rebuilding the actions\n // (which would lose the scratch identity `penPreviewLayer` reads through\n // `Tool.initScratch`).\n\n /** Write the pen's subpaths back into the node it picked up: the first\n * replaces the subpath it continued, any later ones are appended. Skips\n * the write when nothing changed, so an empty pick-up leaves no undo\n * entry. */\n const commitContinuation = useCallback(\n (s: PenScratch, trailing: PenSubpath | null, edit: EditAnchorsDep | undefined): void => {\n const cont = s.continuing!;\n const drawn = [...s.finishedSubpaths, ...(trailing ? [trailing] : [])];\n if (!edit || drawn.length === 0) return;\n const { anchors: added, closed: addedClosed } = pathToAnchors(buildPath(drawn, null));\n if (cont.reversed) added[0] = reverseAnchors(added[0]);\n const set = pathToAnchors(cont.original);\n set.anchors.splice(cont.sub, 1, added[0]);\n set.closed.splice(cont.sub, 1, addedClosed[0]);\n set.anchors.push(...added.slice(1));\n set.closed.push(...addedClosed.slice(1));\n const next: PolygonPath = { ...anchorsToPath(set.anchors, set.closed), fillRule: cont.original.fillRule };\n if (samePath(next, cont.original)) return;\n edit.applyEdit(cont.id, next, 'Continue path');\n if (optsRef.current.autoSelect) optsRef.current.adapter.setSelection([cont.id]);\n },\n [optsRef],\n );\n\n const commit = useCallback((s: PenScratch, deps: ActionDeps): void => {\n const trailing = s.current && s.current.anchors.length > 0 ? s.current : null;\n if (s.continuing) {\n commitContinuation(s, trailing, deps.editAnchors);\n resetScratch(s);\n return;\n }\n if (s.finishedSubpaths.length === 0 && !trailing) return;\n const allClosed =\n s.finishedSubpaths.every((sp) => sp.closed) &&\n (trailing === null || trailing.closed);\n const path = buildPath(s.finishedSubpaths, trailing);\n const pose = optsRef.current.wrapPath(path, { closed: allClosed });\n const id = optsRef.current.adapter.addNode(pose);\n if (optsRef.current.autoSelect) optsRef.current.adapter.setSelection([id]);\n resetScratch(s);\n }, [commitContinuation, optsRef]);\n\n /** Whether an anchor about to land on `hit` finishes the path by joining\n * onto another open subpath. Its own continued subpath never counts —\n * reaching that one's far end is a close. Declines when the deps can't\n * commit the join as one op batch. */\n const joinable = useCallback((s: PenScratch, deps: ActionDeps, hit: PathAnchorHit | null): hit is PathAnchorHit => {\n if (!hit || hit.end === null || !s.current || s.current.anchors.length === 0) return false;\n const cont = s.continuing;\n if (cont && hit.id === cont.id && hit.sub === cont.sub) return false;\n const scene = deps.scene;\n if (!scene && !deps.applyOps) return false;\n if (!(cont ? deps.editAnchors?.editOps : optsRef.current.adapter.makeNode)) return false;\n return cont?.id === hit.id || scene?.get(hit.id as never) !== undefined;\n }, [optsRef]);\n\n /** Finish the path onto `hit`, the endpoint its last anchor was just placed\n * on: the other subpath's anchors follow on from it, turned around when\n * `hit` is its last anchor, and its node is deleted. The path being drawn\n * keeps its identity — a continued node keeps its id and style, and a new\n * path is minted the way any pen path is. One op batch, so one undo. */\n const commitJoin = useCallback((s: PenScratch, deps: ActionDeps, hit: PathAnchorHit): void => {\n const edit = deps.editAnchors!;\n const scene = deps.scene;\n const current = s.current!;\n const { anchors: added, closed: addedClosed } = pathToAnchors(buildPath([...s.finishedSubpaths, current], null));\n\n const other = edit.getEditablePath(hit.id) as PolygonPath;\n const otherSet = pathToAnchors(other);\n const tail = hit.end === 'last' ? reverseAnchors(otherSet.anchors[hit.sub]) : otherSet.anchors[hit.sub];\n const joined = added[added.length - 1];\n const joint = joined[joined.length - 1];\n const out = current.anchors[current.anchors.length - 1].outHandle ?? tail[0].outHandle;\n if (out) joint.outHandle = { ...out };\n joined.push(...tail.slice(1));\n\n const cont = s.continuing;\n const sameNode = cont?.id === hit.id;\n const rest = sameNode ? { anchors: [], closed: [] } : {\n anchors: otherSet.anchors.filter((_, i) => i !== hit.sub),\n closed: otherSet.closed.filter((_, i) => i !== hit.sub),\n };\n const label = 'Join paths';\n const ops: Op[] = [];\n let keptId: string;\n if (cont) {\n if (cont.reversed) added[0] = reverseAnchors(added[0]);\n const set = pathToAnchors(cont.original);\n set.anchors.splice(cont.sub, 1, added[0]);\n set.closed.splice(cont.sub, 1, addedClosed[0]);\n set.anchors.push(...added.slice(1), ...rest.anchors);\n set.closed.push(...addedClosed.slice(1), ...rest.closed);\n if (sameNode) {\n set.anchors.splice(hit.sub, 1);\n set.closed.splice(hit.sub, 1);\n }\n const next: PolygonPath = { ...anchorsToPath(set.anchors, set.closed), fillRule: cont.original.fillRule };\n ops.push(...edit.editOps!(cont.id, next, label));\n keptId = cont.id;\n } else {\n const path = anchorsToPath([...added, ...rest.anchors], [...addedClosed, ...rest.closed]);\n const node = optsRef.current.adapter.makeNode!(optsRef.current.wrapPath(path, { closed: false }));\n ops.push(createInsertOp({ node }));\n keptId = node.id;\n }\n if (!sameNode) ops.push(createDeleteOp({ node: scene!.get(hit.id as never) as { id: string } }));\n\n const applyOps = deps.applyOps;\n if (applyOps) applyOps(ops, label);\n else scene!.applyBatch(ops, label, defaultCommitAdapter(scene!));\n if (optsRef.current.autoSelect) optsRef.current.adapter.setSelection([keptId]);\n resetScratch(s);\n }, [optsRef]);\n\n /** From idle, a press on an open subpath's end anchor picks that subpath\n * up: it becomes `current`, turned so the pressed end is last. Returns\n * whether it did. */\n const pickUpEndpoint = useCallback((deps: ActionDeps, wx: number, wy: number): boolean => {\n const scratch = scratchRef.current!;\n if (scratch.current || scratch.finishedSubpaths.length > 0) return false;\n const radius = optsRef.current.closeHitRadius;\n const scale = viewScale(deps);\n const hit = nearestPathAnchor(pathsNear(deps, wx, wy, radius), { x: wx, y: wy }, radius, scale, (h) => h.end !== null);\n if (!hit) return false;\n const original = deps.editAnchors!.getEditablePath(hit.id) as PolygonPath;\n const picked = pathToAnchors(original).anchors[hit.sub];\n const reversed = hit.end === 'first' && picked.length > 1;\n scratch.current = { anchors: reversed ? reverseAnchors(picked) : picked, closed: false };\n scratch.continuing = { id: hit.id, sub: hit.sub, reversed, original };\n return true;\n }, [optsRef]);\n\n const snap = useCallback((x: number, y: number): { x: number; y: number } => {\n const fn = optsRef.current.snapPoint;\n return fn ? fn({ x, y }) : { x, y };\n }, [optsRef]);\n\n /** Where an anchor pressed at `(x, y)` lands: on an existing path's anchor\n * when one is within `anchorSnapRadius`, else wherever `snapPoint` puts it.\n * `hit` names the anchor it snapped to. */\n const placeAt = useCallback((deps: ActionDeps, x: number, y: number): { x: number; y: number; hit: PathAnchorHit | null } => {\n const radius = optsRef.current.anchorSnapRadius;\n if (radius > 0) {\n const hit = nearestPathAnchor(pathsNear(deps, x, y, radius), { x, y }, radius, viewScale(deps));\n if (hit) return { x: hit.x, y: hit.y, hit };\n }\n return { ...snap(x, y), hit: null };\n }, [snap, optsRef]);\n\n /** Is a world point within the close-hit radius of `(ax, ay)`? Measured as a\n * screen-space circle, so the zone stays round under non-uniform zoom. */\n const withinCloseRadius = useCallback(\n (deps: ActionDeps, ax: number, ay: number, wx: number, wy: number): boolean => {\n return withinPxRadius(ax - wx, ay - wy, optsRef.current.closeHitRadius, viewScale(deps));\n },\n [optsRef],\n );\n\n /**\n * Actions the pen owns, registered by `useToolActions` from inside the\n * ActionsProvider — see `ToolDef.actions`.\n *\n * They close over `scratchRef`, which is why the pen needs no `Selector`\n * exposing tool scratch to the action layer: the state never leaves the\n * hook. `useMemo([])` keeps their identity stable for the same reason\n * `scratchRef` is a ref — the preview layer reads that one object.\n */\n const actions = useMemo<Action[]>(() => {\n const s = () => scratchRef.current!;\n\n return [\n {\n id: 'pen.placeAnchor',\n label: 'Pen — place anchor',\n eligible: { capability: 'creates-paths' },\n requires: ['view', 'areaSelect', 'editAnchors', 'scene', 'applyOps'],\n invoker: {\n timing: 'immediate' as const,\n run: (deps, params) => {\n const p = params as { pressX?: number; pressY?: number } | undefined;\n if (p?.pressX === undefined || p.pressY === undefined) return;\n // The press point, not the release: a click may drift up to the\n // drag threshold, and an anchor should land where you put the\n // pointer down. Snapping happens here rather than at press time\n // so the snapped value is what the geometry records.\n if (pickUpEndpoint(deps, p.pressX, p.pressY)) {\n forceRender();\n return;\n }\n const { x: wx, y: wy, hit } = placeAt(deps, p.pressX, p.pressY);\n const scratch = s();\n\n // Close-on-first-anchor (>= 3 anchors). With `autoCommitOnClose`\n // on (default) this commits right away so the closed region\n // renders with its fill — Illustrator-style. With it off the\n // closed subpath stays in scratch for compound-path builds and\n // commits on Enter / tool switch.\n if (scratch.current && scratch.current.anchors.length >= 3) {\n const first = scratch.current.anchors[0];\n if (withinCloseRadius(deps, first.x, first.y, wx, wy)) {\n scratch.current.closed = true;\n scratch.finishedSubpaths.push(scratch.current);\n scratch.current = null;\n scratch.closeHintActive = false;\n if (optsRef.current.autoCommitOnClose) commit(scratch, deps);\n forceRender();\n return;\n }\n }\n\n const join = joinable(scratch, deps, hit);\n // Otherwise: append a corner anchor, starting a subpath if needed.\n if (!scratch.current) scratch.current = { anchors: [], closed: false };\n scratch.current.anchors.push({ x: wx, y: wy });\n if (join) commitJoin(scratch, deps, hit);\n forceRender();\n },\n },\n },\n\n {\n id: 'pen.finishOpen',\n label: 'Pen — finish open path',\n eligible: { capability: 'creates-paths' },\n requires: ['editAnchors'],\n // Needs an actual path to commit. Declining also lets the gesture\n // fall through: a ⌘-click with fewer than two anchors reaches the\n // plain-click binding and places an anchor instead.\n enabled: () => (anchorCount(s()) >= 2 ? true : ActionDisabledReason.NotApplicable),\n invoker: {\n timing: 'immediate' as const,\n run: (deps, params) => {\n const scratch = s();\n const p = params as { viaDoubleClick?: boolean } | undefined;\n\n // A double click arrives AFTER both of its clicks, so the second\n // one has already appended an anchor. Undo exactly that before\n // committing: pop it, and drop the subpath entirely if it was the\n // only anchor in it (which happens when the FIRST click closed a\n // subpath and the second started a fresh one). The net effect\n // matches the pen's old private 300ms detector — one anchor\n // placed, path finished — while using the dispatcher's single\n // definition of a double click.\n //\n // No position check is needed: the anchor to drop is by\n // construction the one the second click just appended.\n if (p?.viaDoubleClick && scratch.current) {\n scratch.current.anchors.pop();\n if (scratch.current.anchors.length === 0) scratch.current = null;\n }\n\n commit(scratch, deps);\n forceRender();\n },\n },\n },\n\n {\n id: 'pen.dragHandle',\n label: 'Pen — drag out a bezier handle',\n eligible: { capability: 'creates-paths' },\n requires: ['view', 'areaSelect', 'editAnchors', 'scene', 'applyOps'],\n invoker: {\n timing: 'ongoing' as const,\n start: (ctx: InvocationCtx) => {\n const scratch = s();\n // `drag.start` is the pointerdown position — the dispatcher\n // buffers the press and releases it at the drag threshold, so the\n // anchor lands where the user pressed rather than where the\n // threshold happened to be crossed.\n const origin = ctx.drag?.start ?? ctx.world;\n // Dragging from an open path's end picks the path up and pulls\n // that end's own handle rather than placing a new anchor on it.\n const pickedUp = pickUpEndpoint(ctx.deps, origin.x, origin.y);\n // Dragging on another open path's endpoint joins onto it once\n // the handle is set.\n let joinHit: PathAnchorHit | null = null;\n if (!pickedUp) {\n const { x: ax, y: ay, hit } = placeAt(ctx.deps, origin.x, origin.y);\n if (joinable(scratch, ctx.deps, hit)) joinHit = hit;\n if (!scratch.current) scratch.current = { anchors: [], closed: false };\n scratch.current.anchors.push({ x: ax, y: ay });\n }\n scratch.draggingHandleAt = scratch.current!.anchors.length - 1;\n if (ctx.modifiers.alt) {\n scratch.current!.anchors[scratch.draggingHandleAt].altBroken = true;\n }\n // A picked-up endpoint's in-handle belongs to the existing\n // segment into it, which the pen does not reshape.\n const linked = !pickedUp;\n applyOutHandle(scratch, ctx.world, ctx.modifiers.shift, linked, optsRef.current.snapPoint);\n forceRender();\n\n return {\n onMove: (moveCtx: InvocationCtx) => {\n const sm = s();\n if (sm.draggingHandleAt === null) return;\n if (moveCtx.modifiers.alt && sm.current) {\n sm.current.anchors[sm.draggingHandleAt].altBroken = true;\n }\n applyOutHandle(sm, moveCtx.world, moveCtx.modifiers.shift, linked, optsRef.current.snapPoint);\n forceRender();\n },\n onEnd: (endCtx: InvocationCtx, reason: 'commit' | 'cancel') => {\n const se = s();\n if (reason === 'cancel') {\n // `start` appended the anchor (or picked a path up); a\n // cancel (pointercancel is the reachable one — Escape and a\n // tool switch both reset the whole scratch first) has to\n // take it back out.\n if (pickedUp) {\n resetScratch(se);\n } else if (se.current && se.draggingHandleAt === se.current.anchors.length - 1) {\n se.current.anchors.pop();\n if (se.current.anchors.length === 0) se.current = null;\n }\n se.draggingHandleAt = null;\n forceRender();\n return;\n }\n if (se.draggingHandleAt !== null) {\n if (endCtx.modifiers.alt && se.current) {\n se.current.anchors[se.draggingHandleAt].altBroken = true;\n }\n applyOutHandle(se, endCtx.world, endCtx.modifiers.shift, linked, optsRef.current.snapPoint);\n se.draggingHandleAt = null;\n }\n if (joinHit && se.current) commitJoin(se, ctx.deps, joinHit);\n forceRender();\n },\n };\n },\n },\n },\n\n {\n id: 'pen.finish',\n label: 'Pen — finish path',\n eligible: { capability: 'creates-paths' },\n requires: ['editAnchors'],\n enabled: () => (anchorCount(s()) > 0 ? true : ActionDisabledReason.NotApplicable),\n invoker: {\n timing: 'immediate' as const,\n run: (deps) => {\n commit(s(), deps);\n forceRender();\n },\n },\n },\n\n {\n id: 'pen.cancel',\n label: 'Pen — discard path',\n eligible: { capability: 'creates-paths' },\n // Declining with nothing drawn is what keeps Escape's first-match\n // ladder intact: an empty pen passes the key to `escape`, which goes\n // on to clear the selection / return to the default tool.\n enabled: () => {\n const scratch = s();\n return scratch.current !== null || scratch.finishedSubpaths.length > 0\n ? true\n : ActionDisabledReason.NotApplicable;\n },\n invoker: {\n timing: 'immediate' as const,\n run: () => {\n resetScratch(s());\n forceRender();\n },\n },\n },\n ];\n }, [commit, placeAt, withinCloseRadius, pickUpEndpoint, joinable, commitJoin, optsRef]);\n\n return useMemo(() => {\n return defineTool<PenScratch>({\n id: 'pen',\n capabilities: ['creates-paths'],\n hookName: 'usePenTool',\n // Reads the tool's own ref rather than a scratch handed in by a\n // dispatcher — the pen owns this state and nothing else needs it.\n cursor: () =>\n scratchRef.current?.closeHintActive\n ? 'pointer'\n : cursorFor('pen', { fallback: 'crosshair' }),\n presentation: {\n label: 'Pen',\n icon: createElement(PenIcon),\n group: 'draw',\n },\n // Persistent-ref scratch. `penPreviewLayer` reads the in-progress path\n // by calling `Tool.initScratch()` and getting this same object back, so\n // the identity is load-bearing.\n initScratch: () => scratchRef.current!,\n actions,\n\n onDeactivate: () => {\n // Anything still in scratch is by definition incomplete — the user\n // hasn't closed it (close-on-first-anchor), open-finished it\n // (cmd-click), or pressed Enter. Switching tools mid-path should\n // discard, not auto-commit a stub polyline that the user didn't\n // ask for. Mirrors Escape's behavior so \"stop drawing\" is\n // consistent across exits.\n resetScratch(scratchRef.current!);\n forceRender();\n },\n\n bindings: [\n // Plain click places an anchor; ⌘/Ctrl-click open-finishes\n // (Illustrator). Strict modifier matching keeps the two apart, and\n // `mod` resolves to meta on mac / ctrl elsewhere — the tool-route\n // grammar's modifier matcher accepted either key on either platform.\n { spec: { kind: 'click' as const, mods: { shift: 'optional' as const, alt: 'optional' as const } }, actionId: 'pen.placeAnchor' },\n { spec: { kind: 'click' as const, mods: { mod: true, shift: 'optional' as const, alt: 'optional' as const } }, actionId: 'pen.finishOpen' },\n // Double-click on the last anchor also open-finishes. This used to be\n // a private 300ms/radius check inside the click route — a fourth\n // double-click detector in a codebase that had just collapsed three\n // into one (audit 3.3).\n {\n spec: { kind: 'doubleClick' as const, mods: { shift: 'optional' as const, alt: 'optional' as const } },\n actionId: 'pen.finishOpen',\n opts: { params: { viaDoubleClick: true } },\n },\n { spec: { kind: 'drag' as const, mods: { shift: 'optional' as const, alt: 'optional' as const } }, actionId: 'pen.dragHandle' },\n { spec: { kind: 'key' as const, key: 'Enter' }, actionId: 'pen.finish' },\n { spec: { kind: 'key' as const, key: 'Escape' }, actionId: 'pen.cancel' },\n ],\n });\n }, [actions]);\n}\n\nusePenTool.prefs = {\n name: 'Pen',\n description: 'Pen-tool behavior.',\n children: {\n autoCommitOnClose: {\n kind: 'boolean',\n name: 'Auto-commit pen on close',\n description: \"When you click the pen tool's first anchor to close a region, commit immediately so it renders with its fill. Off: keep the path in preview until you press Enter (lets you build a compound path from multiple closed subpaths).\",\n default: true,\n },\n },\n} satisfies ToolPrefGroup;\n\n/** Point the anchor's outgoing handle at `target`, optionally snapped and\n * optionally constrained to 45° steps. When `linked` and not Alt-broken,\n * the in-handle follows as its mirror through the anchor. */\nfunction applyOutHandle<S extends PenScratch>(\n s: S,\n target: { x: number; y: number },\n shift: boolean,\n linked: boolean,\n snap?: (p: { x: number; y: number }) => { x: number; y: number },\n): void {\n if (s.current === null || s.draggingHandleAt === null) return;\n const anchor = s.current.anchors[s.draggingHandleAt];\n const to = snap ? snap(target) : target;\n let dx = to.x - anchor.x;\n let dy = to.y - anchor.y;\n if (shift) {\n const c = constrainTo45(dx, dy);\n dx = c.dx;\n dy = c.dy;\n }\n anchor.outHandle = { x: anchor.x + dx, y: anchor.y + dy };\n if (linked && !anchor.altBroken) anchor.inHandle = { x: anchor.x - dx, y: anchor.y - dy };\n}\n\nfunction viewScale(deps: ActionDeps): { x: number; y: number } {\n return deps.view?.get().scale ?? { x: 1, y: 1 };\n}\n\n/** Existing editable paths, world coords, with ink within `px` screen\n * pixels of `(wx, wy)` — front-to-back, as the area query returns them.\n * Empty when the host publishes no `areaSelect` or `editAnchors` dep. */\nfunction pathsNear(\n deps: ActionDeps,\n wx: number,\n wy: number,\n px: number,\n): Array<{ id: string; path: PolygonPath }> {\n const area = deps.areaSelect;\n const edit = deps.editAnchors;\n if (!area || !edit) return [];\n const view = deps.view;\n const e = pxExtent(px, viewScale(deps));\n const ids = area.hitTestArea({ x: wx - e.x, y: wy - e.y, width: 2 * e.x, height: 2 * e.y }, view);\n const out: Array<{ id: string; path: PolygonPath }> = [];\n for (const id of ids) {\n const path = edit.getEditablePath(id) as PolygonPath | null;\n if (path?.kind === 'polygon') out.push({ id, path });\n }\n return out;\n}\n\nfunction samePath(a: PolygonPath, b: PolygonPath): boolean {\n if (a.commands.length !== b.commands.length || a.coords.length !== b.coords.length) return false;\n for (let i = 0; i < a.commands.length; i++) if (a.commands[i] !== b.commands[i]) return false;\n for (let i = 0; i < a.coords.length; i++) if (a.coords[i] !== b.coords[i]) return false;\n return true;\n}\n"],"mappings":"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"}
@@ -1,8 +1,8 @@
1
1
  import { gestureLifecycleParams as e } from "../../../interactions/actions/gestureLifecycle.js";
2
2
  import { isAnchorOrControl as t, isResizeHandle as n, isRotateHandle as r } from "@weasel-js/routing";
3
3
  //#region src/tools/builtin/select/selectionContributions.ts
4
- var i = "selection.move", a = "selection.transform";
5
- function o(n = {}) {
4
+ var i = "selection.move", a = "selection.transform", o = "selection.resize", s = "selection.rotate";
5
+ function c(n = {}) {
6
6
  let r = (() => {
7
7
  let t = n.move ?? {}, r = e({
8
8
  ...t,
@@ -50,60 +50,80 @@ function o(n = {}) {
50
50
  }
51
51
  ];
52
52
  }
53
- function s(t = {}) {
54
- let i = t.rotate ?? {}, a = e({
55
- ...i,
56
- label: i.rotateLabel
57
- });
58
- i.pivot && (a.pivot = i.pivot);
59
- let o = {
60
- ...Object.keys(a).length > 0 ? { params: a } : {},
61
- ...i.behaviors?.length ? { behaviors: i.behaviors } : {}
62
- };
53
+ function l() {
63
54
  return [{
64
55
  spec: {
65
56
  kind: "drag",
66
57
  target: { kindOf: n }
67
58
  },
68
59
  actionId: "resize"
69
- }, {
60
+ }];
61
+ }
62
+ function u(t = {}) {
63
+ let n = t.rotate ?? {}, i = e({
64
+ ...n,
65
+ label: n.rotateLabel
66
+ });
67
+ n.pivot && (i.pivot = n.pivot);
68
+ let a = {
69
+ ...Object.keys(i).length > 0 ? { params: i } : {},
70
+ ...n.behaviors?.length ? { behaviors: n.behaviors } : {}
71
+ };
72
+ return [{
70
73
  spec: {
71
74
  kind: "drag",
72
75
  target: { kindOf: r }
73
76
  },
74
77
  actionId: "rotate",
75
- ...Object.keys(o).length > 0 ? { opts: o } : {}
78
+ ...Object.keys(a).length > 0 ? { opts: a } : {}
76
79
  }];
77
80
  }
78
- function c(e = {}) {
81
+ function d(e = {}) {
82
+ return [...l(), ...u(e)];
83
+ }
84
+ function f(e = {}) {
79
85
  return {
80
86
  id: i,
81
87
  eligibility: { always: !0 },
82
- bindings: o(e)
88
+ bindings: c(e)
83
89
  };
84
90
  }
85
- function l(e = {}) {
91
+ function p(e = {}) {
86
92
  return {
87
93
  id: a,
88
94
  eligibility: { always: !0 },
89
- bindings: s(e)
95
+ bindings: d(e)
96
+ };
97
+ }
98
+ function m() {
99
+ return {
100
+ id: o,
101
+ eligibility: { always: !0 },
102
+ bindings: l()
103
+ };
104
+ }
105
+ function h(e = {}) {
106
+ return {
107
+ id: s,
108
+ eligibility: { always: !0 },
109
+ bindings: u(e)
90
110
  };
91
111
  }
92
- var u = "selection.areaSelect", d = (e, t) => e == null && t === "empty";
93
- function f() {
112
+ var g = "selection.areaSelect", _ = (e, t) => e == null && t === "empty";
113
+ function v() {
94
114
  return {
95
- id: u,
115
+ id: g,
96
116
  eligibility: { always: !0 },
97
117
  bindings: [{
98
118
  spec: {
99
119
  kind: "drag",
100
- target: { kindOf: d }
120
+ target: { kindOf: _ }
101
121
  },
102
122
  actionId: "areaSelect"
103
123
  }]
104
124
  };
105
125
  }
106
126
  //#endregion
107
- export { u as AREA_SELECT_ID, i as SELECTION_MOVE_ID, a as SELECTION_TRANSFORM_ID, f as areaSelectContribution, d as onEmptyCanvas, o as selectionMoveBindings, c as selectionMoveContribution, s as selectionTransformBindings, l as selectionTransformContribution };
127
+ export { g as AREA_SELECT_ID, i as SELECTION_MOVE_ID, o as SELECTION_RESIZE_ID, s as SELECTION_ROTATE_ID, a as SELECTION_TRANSFORM_ID, v as areaSelectContribution, _ as onEmptyCanvas, c as selectionMoveBindings, f as selectionMoveContribution, l as selectionResizeBindings, m as selectionResizeContribution, u as selectionRotateBindings, h as selectionRotateContribution, d as selectionTransformBindings, p as selectionTransformContribution };
108
128
 
109
129
  //# sourceMappingURL=selectionContributions.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"selectionContributions.js","names":["SELECTION_MOVE_ID","SELECTION_TRANSFORM_ID","selectionMoveBindings","moveOpts","move","options","params","gestureLifecycleParams","withParams","behaviors","body","isAnchorOrControl","afford","selectionTransformBindings","rotate","opts","isResizeHandle","isRotateHandle","selectionMoveContribution","selectionTransformContribution","AREA_SELECT_ID","onEmptyCanvas","hit","areaSelectContribution"],"sources":["../../../../src/tools/builtin/select/selectionContributions.ts"],"sourcesContent":["/**\n * The ambient bindings that act on the selection: moving it, cloning it, and\n * resizing or rotating it from its handles. They belong to the selection, not\n * to the select tool, so they run under any tool that does not claim the\n * gesture itself. `<SceneCanvas features>` installs them as the `move` and\n * `transform` presets.\n */\nimport type { BindingOpts, GestureBinding } from '@weasel-js/routing';\nimport { isResizeHandle, isRotateHandle, isAnchorOrControl } from '@weasel-js/routing';\nimport type { Contribution } from '../../overlayBinding';\nimport type { UseMoveOptions } from '../../../interactions/actions/move/options';\nimport type { UseRotateOptions } from '../../../interactions/actions/rotate/options';\nimport { gestureLifecycleParams } from '../../../interactions/actions/gestureLifecycle';\n\n/** Options for {@link selectionMoveContribution}. */\nexport interface SelectionMoveOptions<TPose = unknown> {\n /** Move-action options: `behaviors` become the move bindings'\n * `opts.behaviors`, everything else their `opts.params`. */\n move?: UseMoveOptions<TPose>;\n /** Reparent-on-drop behavior for drag-to-move. `'off'` (default) keeps\n * translate-only commits. `'top'` lands the moved nodes at the top of the\n * container under the drop point; `'above'` lands them immediately above the\n * hit sibling. Requires the `nodeAtPoint` dep (`<SceneCanvas>` sources it). */\n reparentOnDrop?: 'off' | 'top' | 'above';\n}\n\n/** Id of the entry {@link selectionMoveContribution} builds. */\nexport const SELECTION_MOVE_ID = 'selection.move';\n/** Id of the entry {@link selectionTransformContribution} builds. */\nexport const SELECTION_TRANSFORM_ID = 'selection.transform';\n\n/** Drag a body to move the selection; Alt-drag to clone it. */\nexport function selectionMoveBindings<TPose>(\n options: SelectionMoveOptions<TPose> = {},\n): GestureBinding[] {\n // Shared move-binding opts (params + behaviors). Applied to\n // both the selected-body and unselected-body move bindings so a\n // first-touch drag and a re-drag commit identically.\n const moveOpts: { opts?: BindingOpts } = (() => {\n const move = options.move ?? {};\n const params: Record<string, unknown> = gestureLifecycleParams({ ...move, label: move.moveLabel });\n if (options.reparentOnDrop && options.reparentOnDrop !== 'off') params.reparentOnDrop = options.reparentOnDrop;\n if (move.dragThresholdPx !== undefined) params.dragThresholdPx = move.dragThresholdPx;\n if (move.expandIds) params.expandIds = move.expandIds;\n const withParams = Object.keys(params).length > 0 ? { params } : undefined;\n const behaviors = options.move?.behaviors?.length\n ? { behaviors: options.move.behaviors as BindingOpts['behaviors'] }\n : undefined;\n return withParams || behaviors\n ? { opts: { ...withParams, ...behaviors } satisfies BindingOpts }\n : {};\n })();\n\n return [\n // Alt-drag on a body → clone (Illustrator convention).\n // Listed BEFORE bare move so the strict-modifier dispatcher\n // picks this when Alt is held; bare move matches no-mod drags.\n // Both selected and unselected bodies are valid clone targets — the\n // select tool's pointerDown classifier calls `selection.applyClick(top, mods)`\n // on an unselected hit before the drag fires, so cloneAction's\n // `start` sees the hit node in `selection.get()` either way.\n { spec: { kind: 'drag' as const, target: 'selected-body' as const, mods: { alt: true } }, actionId: 'clone' },\n { spec: { kind: 'drag' as const, target: 'unselected-body' as const, mods: { alt: true } }, actionId: 'clone' },\n {\n // Body-drag → move, BUT defer when the pointerdown hit a path\n // anchor / control affordance. Anchors lie on the curve so the\n // body classifier still reports 'selected-body'; without this\n // opt-out, move's binding beats editAnchors's on every anchor drag.\n //\n // `isAnchorOrControl` is the SAME predicate `editAnchorsAction`\n // matches on. That is load-bearing: the two must agree on what\n // counts as an anchor hit or move steals the drag — which is\n // exactly the bug this opt-out exists to prevent.\n spec: {\n kind: 'drag' as const,\n target: {\n kindOf: (afford: unknown, body?: string): boolean =>\n body === 'selected-body' && !isAnchorOrControl(afford),\n },\n },\n actionId: 'move',\n ...moveOpts,\n },\n // Body-drag on a NOT-yet-selected node → also move. The select tool's\n // pointerDown classifier selects the hit node before the drag fires (same\n // contract clone relies on), so by the time moveAction.start() runs the\n // node is in `selection.get()`. Without this binding an unselected-body\n // drag falls through to whatever ambient drag binding comes next — once\n // the `rotate` catch-all, which made the first drag rotate and later ones\n // move. Unselected nodes never carry anchor affordances (those gate on\n // selection), so the plain string-form target is sufficient here.\n {\n spec: { kind: 'drag' as const, target: 'unselected-body' as const },\n actionId: 'move',\n ...moveOpts,\n },\n ];\n}\n\n/** Options for {@link selectionTransformContribution}. Resize takes its\n * options through the `resizePolicy` dep (`useResizePolicy`), not here. */\nexport interface SelectionTransformOptions<TPose = unknown> {\n /** Rotate-action options: `behaviors` become the rotate binding's\n * `opts.behaviors`, everything else its `opts.params`. */\n rotate?: UseRotateOptions<TPose>;\n}\n\n/** Drag a resize handle to resize the selection, the rotation handle to rotate it. */\nexport function selectionTransformBindings<TPose>(\n options: SelectionTransformOptions<TPose> = {},\n): GestureBinding[] {\n const rotate = options.rotate ?? {};\n const params: Record<string, unknown> = gestureLifecycleParams({ ...rotate, label: rotate.rotateLabel });\n if (rotate.pivot) params.pivot = rotate.pivot;\n const opts: BindingOpts = {\n ...(Object.keys(params).length > 0 ? { params } : {}),\n ...(rotate.behaviors?.length ? { behaviors: rotate.behaviors as BindingOpts['behaviors'] } : {}),\n };\n return [\n { spec: { kind: 'drag' as const, target: { kindOf: isResizeHandle } }, actionId: 'resize' },\n {\n spec: { kind: 'drag' as const, target: { kindOf: isRotateHandle } },\n actionId: 'rotate',\n ...(Object.keys(opts).length > 0 ? { opts } : {}),\n },\n ];\n}\n\n/** An always-live entry carrying {@link selectionMoveBindings}. Add it to a\n * canvas's ambient list; `<SceneCanvas features={['move']}>` does. */\nexport function selectionMoveContribution<TPose>(\n options: SelectionMoveOptions<TPose> = {},\n): Contribution {\n return {\n id: SELECTION_MOVE_ID,\n eligibility: { always: true },\n bindings: selectionMoveBindings(options),\n };\n}\n\n/** An always-live entry carrying {@link selectionTransformBindings}. Add it\n * to a canvas's ambient list; `<SceneCanvas features={['transform']}>` does. */\nexport function selectionTransformContribution<TPose>(\n options: SelectionTransformOptions<TPose> = {},\n): Contribution {\n return {\n id: SELECTION_TRANSFORM_ID,\n eligibility: { always: true },\n bindings: selectionTransformBindings(options),\n };\n}\n\n/** Id of the entry {@link areaSelectContribution} builds. */\nexport const AREA_SELECT_ID = 'selection.areaSelect';\n\n/** A press on empty canvas: no hit, and not on chrome over it — the rotation\n * handle and the outer half of a resize handle sit off the body. */\nexport const onEmptyCanvas = (hit: unknown, body?: string): boolean => hit == null && body === 'empty';\n\n/** An always-live entry that marquee-selects on a drag across empty canvas,\n * under any tool that does not claim the drag itself. No preset installs it:\n * the select tool already marquees, and a canvas that wants it under every\n * tool adds this to its ambient list. */\nexport function areaSelectContribution(): Contribution {\n return {\n id: AREA_SELECT_ID,\n eligibility: { always: true },\n bindings: [{ spec: { kind: 'drag', target: { kindOf: onEmptyCanvas } }, actionId: 'areaSelect' }],\n };\n}\n"],"mappings":";;;AA2BA,IAAaA,IAAoB,kBAEpBC,IAAyB;AAGtC,SAAgBC,EACd,IAAuC,CAAC,GACtB;CAIlB,IAAMC,WAA0C;EAC9C,IAAMC,IAAOC,EAAQ,QAAQ,CAAC,GACxBC,IAAkCC,EAAuB;GAAE,GAAGH;GAAM,OAAOA,EAAK;EAAU,CAAC;EAGjG,AAFIC,EAAQ,kBAAkBA,EAAQ,mBAAmB,UAAO,EAAO,iBAAiBA,EAAQ,iBAC5FD,EAAK,oBAAoB,KAAA,MAAW,EAAO,kBAAkBA,EAAK,kBAClEA,EAAK,cAAW,EAAO,YAAYA,EAAK;EAC5C,IAAMI,IAAa,OAAO,KAAKF,CAAM,CAAC,CAAC,SAAS,IAAI,EAAE,UAAO,IAAI,KAAA,GAC3DG,IAAYJ,EAAQ,MAAM,WAAW,SACvC,EAAE,WAAWA,EAAQ,KAAK,UAAsC,IAChE,KAAA;EACJ,OAAOG,KAAcC,IACjB,EAAE,MAAM;GAAE,GAAGD;GAAY,GAAGC;EAAU,EAAwB,IAC9D,CAAC;CACP,EAAA,CAAG;CAEH,OAAO;EAQL;GAAE,MAAM;IAAE,MAAM;IAAiB,QAAQ;IAA0B,MAAM,EAAE,KAAK,GAAK;GAAE;GAAG,UAAU;EAAQ;EAC5G;GAAE,MAAM;IAAE,MAAM;IAAiB,QAAQ;IAA4B,MAAM,EAAE,KAAK,GAAK;GAAE;GAAG,UAAU;EAAQ;EAC9G;GAUE,MAAM;IACJ,MAAM;IACN,QAAQ,EACN,SAAS,GAAiB,MACxBC,MAAS,mBAAmB,CAACC,EAAkBC,CAAM,EACzD;GACF;GACA,UAAU;GACV,GAAGT;EACL;EASA;GACE,MAAM;IAAE,MAAM;IAAiB,QAAQ;GAA2B;GAClE,UAAU;GACV,GAAGA;EACL;CACF;AACF;AAWA,SAAgBU,EACd,IAA4C,CAAC,GAC3B;CAClB,IAAMC,IAAST,EAAQ,UAAU,CAAC,GAC5BC,IAAkCC,EAAuB;EAAE,GAAGO;EAAQ,OAAOA,EAAO;CAAY,CAAC;CACvG,AAAIA,EAAO,UAAO,EAAO,QAAQA,EAAO;CACxC,IAAMC,IAAoB;EACxB,GAAI,OAAO,KAAKT,CAAM,CAAC,CAAC,SAAS,IAAI,EAAE,UAAO,IAAI,CAAC;EACnD,GAAIQ,EAAO,WAAW,SAAS,EAAE,WAAWA,EAAO,UAAsC,IAAI,CAAC;CAChG;CACA,OAAO,CACL;EAAE,MAAM;GAAE,MAAM;GAAiB,QAAQ,EAAE,QAAQE,EAAe;EAAE;EAAG,UAAU;CAAS,GAC1F;EACE,MAAM;GAAE,MAAM;GAAiB,QAAQ,EAAE,QAAQC,EAAe;EAAE;EAClE,UAAU;EACV,GAAI,OAAO,KAAKF,CAAI,CAAC,CAAC,SAAS,IAAI,EAAE,QAAK,IAAI,CAAC;CACjD,CACF;AACF;AAIA,SAAgBG,EACd,IAAuC,CAAC,GAC1B;CACd,OAAO;EACL,IAAIlB;EACJ,aAAa,EAAE,QAAQ,GAAK;EAC5B,UAAUE,EAAsBG,CAAO;CACzC;AACF;AAIA,SAAgBc,EACd,IAA4C,CAAC,GAC/B;CACd,OAAO;EACL,IAAIlB;EACJ,aAAa,EAAE,QAAQ,GAAK;EAC5B,UAAUY,EAA2BR,CAAO;CAC9C;AACF;AAGA,IAAae,IAAiB,wBAIjBC,KAAiB,GAAc,MAA2BC,KAAO,QAAQZ,MAAS;AAM/F,SAAgBa,IAAuC;CACrD,OAAO;EACL,IAAIH;EACJ,aAAa,EAAE,QAAQ,GAAK;EAC5B,UAAU,CAAC;GAAE,MAAM;IAAE,MAAM;IAAQ,QAAQ,EAAE,QAAQC,EAAc;GAAE;GAAG,UAAU;EAAa,CAAC;CAClG;AACF"}
1
+ {"version":3,"file":"selectionContributions.js","names":["SELECTION_MOVE_ID","SELECTION_TRANSFORM_ID","SELECTION_RESIZE_ID","SELECTION_ROTATE_ID","selectionMoveBindings","moveOpts","move","options","params","gestureLifecycleParams","withParams","behaviors","body","isAnchorOrControl","afford","selectionResizeBindings","isResizeHandle","selectionRotateBindings","rotate","opts","isRotateHandle","selectionTransformBindings","selectionMoveContribution","selectionTransformContribution","selectionResizeContribution","selectionRotateContribution","AREA_SELECT_ID","onEmptyCanvas","hit","areaSelectContribution"],"sources":["../../../../src/tools/builtin/select/selectionContributions.ts"],"sourcesContent":["/**\n * The ambient bindings that act on the selection: moving it, cloning it, and\n * resizing or rotating it from its handles. They belong to the selection, not\n * to the select tool, so they run under any tool that does not claim the\n * gesture itself. `<SceneCanvas features>` installs them as the `move`,\n * `resize` and `rotate` presets.\n */\nimport type { BindingOpts, GestureBinding } from '@weasel-js/routing';\nimport { isResizeHandle, isRotateHandle, isAnchorOrControl } from '@weasel-js/routing';\nimport type { Contribution } from '../../overlayBinding';\nimport type { UseMoveOptions } from '../../../interactions/actions/move/options';\nimport type { UseRotateOptions } from '../../../interactions/actions/rotate/options';\nimport { gestureLifecycleParams } from '../../../interactions/actions/gestureLifecycle';\n\n/** Options for {@link selectionMoveContribution}. */\nexport interface SelectionMoveOptions<TPose = unknown> {\n /** Move-action options: `behaviors` become the move bindings'\n * `opts.behaviors`, everything else their `opts.params`. */\n move?: UseMoveOptions<TPose>;\n /** Reparent-on-drop behavior for drag-to-move. `'off'` (default) keeps\n * translate-only commits. `'top'` lands the moved nodes at the top of the\n * container under the drop point; `'above'` lands them immediately above the\n * hit sibling. Requires the `nodeAtPoint` dep (`<SceneCanvas>` sources it). */\n reparentOnDrop?: 'off' | 'top' | 'above';\n}\n\n/** Id of the entry {@link selectionMoveContribution} builds. */\nexport const SELECTION_MOVE_ID = 'selection.move';\n/** Id of the entry {@link selectionTransformContribution} builds. */\nexport const SELECTION_TRANSFORM_ID = 'selection.transform';\n/** Id of the entry {@link selectionResizeContribution} builds. */\nexport const SELECTION_RESIZE_ID = 'selection.resize';\n/** Id of the entry {@link selectionRotateContribution} builds. */\nexport const SELECTION_ROTATE_ID = 'selection.rotate';\n\n/** Drag a body to move the selection; Alt-drag to clone it. */\nexport function selectionMoveBindings<TPose>(\n options: SelectionMoveOptions<TPose> = {},\n): GestureBinding[] {\n // Shared move-binding opts (params + behaviors). Applied to\n // both the selected-body and unselected-body move bindings so a\n // first-touch drag and a re-drag commit identically.\n const moveOpts: { opts?: BindingOpts } = (() => {\n const move = options.move ?? {};\n const params: Record<string, unknown> = gestureLifecycleParams({ ...move, label: move.moveLabel });\n if (options.reparentOnDrop && options.reparentOnDrop !== 'off') params.reparentOnDrop = options.reparentOnDrop;\n if (move.dragThresholdPx !== undefined) params.dragThresholdPx = move.dragThresholdPx;\n if (move.expandIds) params.expandIds = move.expandIds;\n const withParams = Object.keys(params).length > 0 ? { params } : undefined;\n const behaviors = options.move?.behaviors?.length\n ? { behaviors: options.move.behaviors as BindingOpts['behaviors'] }\n : undefined;\n return withParams || behaviors\n ? { opts: { ...withParams, ...behaviors } satisfies BindingOpts }\n : {};\n })();\n\n return [\n // Alt-drag on a body → clone (Illustrator convention).\n // Listed BEFORE bare move so the strict-modifier dispatcher\n // picks this when Alt is held; bare move matches no-mod drags.\n // Both selected and unselected bodies are valid clone targets — the\n // select tool's pointerDown classifier calls `selection.applyClick(top, mods)`\n // on an unselected hit before the drag fires, so cloneAction's\n // `start` sees the hit node in `selection.get()` either way.\n { spec: { kind: 'drag' as const, target: 'selected-body' as const, mods: { alt: true } }, actionId: 'clone' },\n { spec: { kind: 'drag' as const, target: 'unselected-body' as const, mods: { alt: true } }, actionId: 'clone' },\n {\n // Body-drag → move, BUT defer when the pointerdown hit a path\n // anchor / control affordance. Anchors lie on the curve so the\n // body classifier still reports 'selected-body'; without this\n // opt-out, move's binding beats editAnchors's on every anchor drag.\n //\n // `isAnchorOrControl` is the SAME predicate `editAnchorsAction`\n // matches on. That is load-bearing: the two must agree on what\n // counts as an anchor hit or move steals the drag — which is\n // exactly the bug this opt-out exists to prevent.\n spec: {\n kind: 'drag' as const,\n target: {\n kindOf: (afford: unknown, body?: string): boolean =>\n body === 'selected-body' && !isAnchorOrControl(afford),\n },\n },\n actionId: 'move',\n ...moveOpts,\n },\n // Body-drag on a NOT-yet-selected node → also move. The select tool's\n // pointerDown classifier selects the hit node before the drag fires (same\n // contract clone relies on), so by the time moveAction.start() runs the\n // node is in `selection.get()`. Without this binding an unselected-body\n // drag falls through to whatever ambient drag binding comes next — once\n // the `rotate` catch-all, which made the first drag rotate and later ones\n // move. Unselected nodes never carry anchor affordances (those gate on\n // selection), so the plain string-form target is sufficient here.\n {\n spec: { kind: 'drag' as const, target: 'unselected-body' as const },\n actionId: 'move',\n ...moveOpts,\n },\n ];\n}\n\n/** Options for {@link selectionRotateContribution}. */\nexport interface SelectionRotateOptions<TPose = unknown> {\n /** Rotate-action options: `behaviors` become the rotate binding's\n * `opts.behaviors`, everything else its `opts.params`. */\n rotate?: UseRotateOptions<TPose>;\n}\n\n/** Options for {@link selectionTransformContribution}. Resize takes its\n * options through the `resizePolicy` dep (`useResizePolicy`), not here. */\nexport type SelectionTransformOptions<TPose = unknown> = SelectionRotateOptions<TPose>;\n\n/** Drag a resize handle to resize the selection. Options come through the\n * `resizePolicy` dep (`useResizePolicy`). */\nexport function selectionResizeBindings(): GestureBinding[] {\n return [{ spec: { kind: 'drag' as const, target: { kindOf: isResizeHandle } }, actionId: 'resize' }];\n}\n\n/** Drag the rotation handle to rotate the selection. */\nexport function selectionRotateBindings<TPose>(\n options: SelectionRotateOptions<TPose> = {},\n): GestureBinding[] {\n const rotate = options.rotate ?? {};\n const params: Record<string, unknown> = gestureLifecycleParams({ ...rotate, label: rotate.rotateLabel });\n if (rotate.pivot) params.pivot = rotate.pivot;\n const opts: BindingOpts = {\n ...(Object.keys(params).length > 0 ? { params } : {}),\n ...(rotate.behaviors?.length ? { behaviors: rotate.behaviors as BindingOpts['behaviors'] } : {}),\n };\n return [{\n spec: { kind: 'drag' as const, target: { kindOf: isRotateHandle } },\n actionId: 'rotate',\n ...(Object.keys(opts).length > 0 ? { opts } : {}),\n }];\n}\n\n/** Drag a resize handle to resize the selection, the rotation handle to rotate it. */\nexport function selectionTransformBindings<TPose>(\n options: SelectionTransformOptions<TPose> = {},\n): GestureBinding[] {\n return [...selectionResizeBindings(), ...selectionRotateBindings(options)];\n}\n\n/** An always-live entry carrying {@link selectionMoveBindings}. Add it to a\n * canvas's ambient list; `<SceneCanvas features={['move']}>` does. */\nexport function selectionMoveContribution<TPose>(\n options: SelectionMoveOptions<TPose> = {},\n): Contribution {\n return {\n id: SELECTION_MOVE_ID,\n eligibility: { always: true },\n bindings: selectionMoveBindings(options),\n };\n}\n\n/** An always-live entry carrying {@link selectionTransformBindings}: resize\n * and rotate in one entry, what `<SceneCanvas features={['transform']}>`\n * turns on. */\nexport function selectionTransformContribution<TPose>(\n options: SelectionTransformOptions<TPose> = {},\n): Contribution {\n return {\n id: SELECTION_TRANSFORM_ID,\n eligibility: { always: true },\n bindings: selectionTransformBindings(options),\n };\n}\n\n/** An always-live entry carrying {@link selectionResizeBindings}. Add it to a\n * canvas's ambient list; `<SceneCanvas features={['resize']}>` does. */\nexport function selectionResizeContribution(): Contribution {\n return {\n id: SELECTION_RESIZE_ID,\n eligibility: { always: true },\n bindings: selectionResizeBindings(),\n };\n}\n\n/** An always-live entry carrying {@link selectionRotateBindings}. Add it to a\n * canvas's ambient list; `<SceneCanvas features={['rotate']}>` does. */\nexport function selectionRotateContribution<TPose>(\n options: SelectionRotateOptions<TPose> = {},\n): Contribution {\n return {\n id: SELECTION_ROTATE_ID,\n eligibility: { always: true },\n bindings: selectionRotateBindings(options),\n };\n}\n\n/** Id of the entry {@link areaSelectContribution} builds. */\nexport const AREA_SELECT_ID = 'selection.areaSelect';\n\n/** A press on empty canvas: no hit, and not on chrome over it — the rotation\n * handle and the outer half of a resize handle sit off the body. */\nexport const onEmptyCanvas = (hit: unknown, body?: string): boolean => hit == null && body === 'empty';\n\n/** An always-live entry that marquee-selects on a drag across empty canvas,\n * under any tool that does not claim the drag itself. No preset installs it:\n * the select tool already marquees, and a canvas that wants it under every\n * tool adds this to its ambient list. */\nexport function areaSelectContribution(): Contribution {\n return {\n id: AREA_SELECT_ID,\n eligibility: { always: true },\n bindings: [{ spec: { kind: 'drag', target: { kindOf: onEmptyCanvas } }, actionId: 'areaSelect' }],\n };\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"useSelectTool.js","names":["SelectIcon","useSelectTool","d","options","AUTO_POSE_DESCRIPTOR","preciseLeaves","tolerance","pickEveryFn","pointQuery","covers","slop","t","px","b","py","shapeCoversPoint","node","pose","poseDescriptorForNode","own","ownClipOf","pathContainsPoint","clip","p","toPlane","m","meanScale","camera","planeOf","scenePlaneOf","adapter","pickWalk","adapterPickSource","worldX","worldY","pickEveryRef","useLatest","pressRef","useRef","optionsRef","adapterRef","pickAction","useMemo","params","selection","deps","mods","opts","sel","top","ids","pickTopMostHit","hitAlreadySelected","isExtend","extendLocked","deferClick","collapseDeferredAction","ActionDisabledReason","deferred","defineTool","createElement","hit","onEmptyCanvas","body"],"sources":["../../../../src/tools/builtin/select/useSelectTool.ts"],"sourcesContent":["import { useMemo, useRef, createElement } from 'react';\nimport { useLatest } from '@weasel-js/react';\nimport { SelectIcon } from '../../../icons';\nimport { pathContainsPoint } from '@weasel-js/geom';\nimport { shapeCoversPoint } from 'canvas/NodeShape';\nimport { pickWalk, adapterPickSource, ownClipOf, scenePlaneOf, type PickQuery } from 'canvas/pickWalk';\nimport { toPlane } from 'core/viewport/parallax';\nimport { meanScale } from 'core/viewport/meanScale';\nimport type { View } from 'core/viewport/view';\nimport type { LayerEnumerableAdapter } from 'core/adapters/types';\nimport type { Node } from 'core/scene/types';\nimport type { MoveAdapter } from 'core/adapters/types';\nimport type { AreaSelectAdapter } from 'core/adapters/types';\nimport type { NodeId } from 'core/scene/types';\nimport { defineTool } from '../../overlayBinding';\nimport type { Action } from '@weasel-js/routing';\nimport { ActionDisabledReason } from '@weasel-js/routing';\nimport type { SelectionApi } from 'core/selection/useSelection';\nimport type { Tool } from '../../overlayBinding';\nimport { pickTopMostHit } from '../pickTopMostHit';\nimport { onEmptyCanvas } from './selectionContributions';\nimport { poseDescriptorForNode, type PoseDescriptor } from 'core/geometry/poseDescriptor';\nimport { AUTO_POSE_DESCRIPTOR } from 'interactions/actions/resize/autoPoseDescriptor';\nimport { MULTI_RESIZE_TARGET_ID, type Bounds } from '../shared/selectionTarget';\nexport type { Bounds };\nexport { MULTI_RESIZE_TARGET_ID };\n\n/** Options for `useSelectTool`: how a point resolves to nodes, how a click\n * chooses among them, and the appearance of the marquee. */\nexport interface UseSelectToolOptions<TPose> {\n /** Return ids of all objects whose painted body covers (worldX, worldY).\n * Order doesn't matter — the tool collapses parent/child overlap via\n * `pickTopMostHit`. When omitted, defaults to an AABB-vs-point scan over\n * `adapter.getNodes()` using the pose descriptor. Override for tighter\n * shapes (path / polygon hit-tests). */\n pickEvery?: (worldX: number, worldY: number) => string[];\n /** Optional alt-aware selection-update hit returning the single id the\n * click should act on. */\n pickBest?: (\n worldX: number,\n worldY: number,\n alt: boolean,\n selection: readonly string[],\n ) => string | null;\n /** How to read poses for the default `pickEvery`. Default\n * `AUTO_POSE_DESCRIPTOR`. */\n poseDescriptor?: PoseDescriptor<TPose>;\n /**\n * How the default `pickEvery` decides a **leaf** node covers the pointer.\n * Ignored when `pickEvery` is supplied.\n *\n * - `'aabb'` (default) — the pose rect. Cheap, and the historical behavior:\n * a click anywhere in the bounding box selects the node.\n * - `'silhouette'` — the pose rect as a pre-test, then the ink the painter\n * lays down: `findShapeSilhouette` filled or not per the painter's `ink`,\n * plus its outline widened by the stroke half-width and `pickTolerance`.\n * A click inside the bounding box but outside the drawn shape misses —\n * the concave notch of a star, the corner outside an ellipse, the blank\n * half of a text box — while a click on the thin outline of an unfilled\n * shape hits.\n *\n * Container nodes already consult their silhouette unconditionally (it is\n * how their clip is derived); this extends the same test to leaves. Not the\n * default because it changes what a click selects for every existing\n * consumer, and a painter with no `silhouette` is unaffected either way —\n * it falls back to the AABB.\n */\n leafPicking?: 'aabb' | 'silhouette';\n /**\n * Grab slop around a shape's outline, in **world** units. Default 0.\n *\n * Only consulted under `leafPicking: 'silhouette'`. World units because\n * this hook has no view; `<SceneCanvas>` takes a screen-pixel figure\n * (`geometry.pickTolerancePx`) and divides by scale on its own pick path.\n * Without some slop a hairline outline is an unhittable target.\n */\n pickTolerance?: number;\n /** Painted alpha per node in the view this tool picks for. A node painted\n * at alpha 0 is not under the pointer. Ignored when `pickEvery` is\n * supplied. */\n alphaOf?: (id: string) => number;\n /** Whether a node's `layer` reaches the screen in the view this tool picks\n * for. Ignored when `pickEvery` is supplied. */\n layerIsPainted?: (layer: string) => boolean;\n /** The camera of the view this tool picks for. With it, a node on an\n * adapter layer carrying `parallax` (`getLayers`) is picked where that\n * camera's plane paints it; without it, every layer is taken to move with\n * the camera. Ignored when `pickEvery` is supplied. */\n getView?: () => View | null;\n /**\n * When this returns true, a Shift/Meta extend-click must NOT change the\n * node selection.\n *\n * `<SceneCanvas>` wires it to \"a path is in anchor-edit mode\", where\n * Shift-click means \"add this anchor to the anchor selection\". Without\n * the lock the same click also toggles the edited node out of the node\n * selection, and the `editAnchors` dep treats that as the edit target\n * disappearing — so multi-selecting anchors silently exited edit mode.\n *\n * Only extend-clicks are locked. A plain click still re-selects, so\n * clicking a different node exits edit mode as usual.\n */\n extendClickLocked?: () => boolean;\n}\n\n/** Intersection of the move + area-select adapter interfaces.\n * Resize and rotate run through `resizeAction` / `rotateAction`. */\nexport type SelectAdapter<TNode extends { id: string }, TPose> =\n MoveAdapter<TNode, TPose>\n & AreaSelectAdapter\n & LayerEnumerableAdapter;\n\n/**\n * What the press classified, carried from `select.pick` (pointerDown) to\n * `select.collapseDeferred` (click) and to the tool's cursor.\n *\n * This is tool-local bookkeeping between two dispatches of the same gesture,\n * so it lives in a ref the tool owns rather than in dispatcher state. It used\n * to be the tool's `SelectScratch`, held by the tool-routing dispatcher, and\n * carried an `ids` list that nothing ever read — `moveAction` computes its own\n * ids from the live selection.\n *\n * @internal\n */\ninterface PressClassification {\n /**\n * Set when the press hit an already-selected node inside a multi-selection\n * with no extend modifier.\n *\n * The selection is deliberately NOT collapsed on the press: the user may be\n * starting a drag of the whole set. If the gesture turns out to be a click,\n * `select.collapseDeferred` collapses to this id on release.\n */\n deferredClickId: string | null;\n}\n\n/** Active-slot Tool:\n * - `select.pick` classifies the press (pointerDown) — it runs the tool's own\n * pickBest/pickEvery so click semantics (selection replace, extend,\n * deferred collapse) live in one place.\n * - an empty drag marquees (`areaSelect`) and a click on empty clears the\n * selection (`clearSelection`); the marquee renders via the dispatcher\n * overlay layer, driven by areaSelectAction's `overlay()`.\n * - moving and transforming the selection are not the tool's: they are the\n * ambient bindings in `selectionContributions.ts`.\n */\nexport function useSelectTool<TNode extends { id: string }, TPose>(\n adapter: SelectAdapter<TNode, TPose>,\n options: UseSelectToolOptions<TPose>,\n): Tool<null> {\n\n // pickEvery / boundsOf defaults — for any adapter the kit can derive both\n // from `adapter.getNodes()` + `adapter.getPose(id)` + the descriptor.\n const d = (options.poseDescriptor ?? AUTO_POSE_DESCRIPTOR) as PoseDescriptor<TPose>;\n const preciseLeaves = options.leafPicking === 'silhouette';\n // World units here, not screen: this hook has no view. `<SceneCanvas>`\n // converts from screen px on its own pick path; a bare `useSelectTool`\n // consumer that zooms should pass `pickTolerance` from its own scale.\n const tolerance = options.pickTolerance ?? 0;\n const pickEveryFn = options.pickEvery ?? ((worldX: number, worldY: number): string[] => {\n /** A point query at (px, py) with `t` world units of slop. AABB first\n * (cheap), then the painter's ink when asked for and available. A painter\n * with no `silhouette` — or one that returns null, as `kit:text` does for\n * an empty node — keeps the AABB answer.\n *\n * The AABB pre-filter is grown by the slop for the same reason\n * `<SceneCanvas>`'s is: a shape's outline, and the slop around it, reach\n * outside the shape's own bounds, and an un-grown pre-filter would\n * discard those hits before the refinement could claim them. */\n const pointQuery = (px: number, py: number, t: number): PickQuery<TPose> => {\n const covers = (node: unknown, pose: TPose, b: Bounds): boolean => {\n const slop = preciseLeaves ? t : 0;\n if (!(px >= b.x - slop && px <= b.x + b.width + slop\n && py >= b.y - slop && py <= b.y + b.height + slop)) {\n return false;\n }\n if (!preciseLeaves) return true;\n return shapeCoversPoint(\n node as Node<unknown, string, TPose>, pose, px, py, { tolerance: t },\n );\n };\n return {\n hits: (node, pose) => {\n const b = poseDescriptorForNode(d, node).getBounds(pose);\n if (node.kind !== 'container') return covers(node, pose, b);\n // A container's own clip *is* its hit shape — it is picked where it\n // paints, and it paints only inside its clip. Ancestor clips are the\n // walk's business; this one is not.\n const inAabb = px >= b.x && px <= b.x + b.width\n && py >= b.y && py <= b.y + b.height;\n if (!inAabb) return false;\n const own = ownClipOf(node, pose);\n return own === null || pathContainsPoint(own, px, py);\n },\n clipAdmits: (clip) => pathContainsPoint(clip, px, py),\n inPlane: (m) => {\n const p = toPlane(m, { x: px, y: py });\n return pointQuery(p.x, p.y, t * meanScale(m.scale));\n },\n };\n };\n\n const camera = options.getView?.() ?? null;\n const planeOf = camera ? scenePlaneOf(adapter.getLayers?.(), camera) : null;\n return pickWalk<TPose>(adapterPickSource(adapter as never, {\n ...(options.alphaOf ? { alphaOf: options.alphaOf } : {}),\n ...(options.layerIsPainted ? { layerIsPainted: options.layerIsPainted } : {}),\n ...(planeOf ? { planeOf } : {}),\n }), pointQuery(worldX, worldY, tolerance));\n });\n\n const pickEveryRef = useLatest(pickEveryFn);\n\n // What the last press classified. Written by `select.pick`, read by\n // `select.collapseDeferred` and the cursor. See `PressClassification`.\n const pressRef = useRef<PressClassification>({ deferredClickId: null });\n\n // Options in a ref so the actions — built once — always see the live\n // callbacks without rebuilding the Tool record.\n const optionsRef = useLatest(options);\n const adapterRef = useLatest(adapter);\n\n /**\n * `select.pick` — classifies the press and updates the node selection.\n *\n * Bound to `pointerDown` rather than `click` on purpose: pressing an\n * unselected node must highlight it while the button is still down, and any\n * drag that follows has to start from the updated selection (both\n * `moveAction` and `cloneAction` read `selection.get()` in their `start`).\n *\n * Eligibility replaces the `selectionAllowed` option the audit added as an\n * interim per-route patch: this action carries the same\n * `creates-selection` capability rule the `clearSelection` binding declares,\n * so a mode that forbids selection now gates both halves of the tool by one\n * mechanism instead of by which dispatcher the route happened to live on.\n */\n const pickAction = useMemo<Action>(() => ({\n id: 'select.pick',\n label: 'Select — pick under pointer',\n eligible: { capability: 'creates-selection' },\n requires: ['selection'],\n invoker: {\n timing: 'immediate' as const,\n run: (deps, params) => {\n const p = params as {\n worldX?: number; worldY?: number;\n affordance?: unknown;\n mods?: { alt: boolean; ctrl: boolean; meta: boolean; shift: boolean };\n } | undefined;\n const selection = deps.selection as SelectionApi | undefined;\n if (!selection || p?.worldX === undefined || p.worldY === undefined) return;\n const mods = p.mods ?? { alt: false, ctrl: false, meta: false, shift: false };\n const opts = optionsRef.current;\n\n const sel = selection.get();\n const top = opts.pickBest\n ? opts.pickBest(p.worldX, p.worldY, mods.alt, sel)\n : (() => {\n const ids = pickEveryRef.current(p.worldX, p.worldY);\n if (ids.length === 0) return null;\n return pickTopMostHit(ids, adapterRef.current) ?? ids[0];\n })();\n\n if (top === null) {\n pressRef.current = { deferredClickId: null };\n return;\n }\n\n const hitAlreadySelected = sel.includes(top as NodeId);\n const isExtend = mods.shift || mods.meta;\n // While a path is in anchor-edit mode, an extend-click belongs to the\n // anchor selection, not the node selection. Letting it through would\n // toggle the edited node out of the node selection, which the\n // `editAnchors` dep reads as \"the edit target is gone\" — so\n // shift-clicking a second anchor silently tore down edit mode.\n // Plain clicks still fall through: clicking a different node exits\n // edit mode, which is what you'd expect.\n //\n // This stays an option rather than becoming `Action.enabled` because\n // it only applies to *extend* presses, and `enabled` is evaluated\n // without the event's modifiers.\n const extendLocked = isExtend && opts.extendClickLocked?.() === true;\n const deferClick = hitAlreadySelected && sel.length > 1 && !isExtend;\n if (!deferClick && !extendLocked) {\n selection.applyClick(top as NodeId, mods);\n }\n pressRef.current = { deferredClickId: deferClick ? top : null };\n },\n },\n }), [optionsRef, pickEveryRef, adapterRef]);\n\n /**\n * `select.collapseDeferred` — the release half of the deferred multi-click.\n *\n * Pressing an already-selected node inside a multi-selection leaves the\n * selection alone so the press can start a drag of the whole set. If the\n * gesture ends without a drag, this collapses to the clicked node.\n */\n const collapseDeferredAction = useMemo<Action>(() => ({\n id: 'select.collapseDeferred',\n label: 'Select — collapse deferred click',\n eligible: { capability: 'creates-selection' },\n requires: ['selection'],\n // Declining when there is nothing deferred is what keeps this binding\n // from swallowing every click. It is bound with no target (any click can\n // in principle be the release of a deferred press) at ACTIVE scope, so\n // without the gate it would outrank ambient click bindings like\n // `selectAnchor` and silently break anchor selection. Same fall-through\n // contract `clearSelection.enabled` relies on.\n enabled: () =>\n pressRef.current.deferredClickId !== null\n ? true\n : ActionDisabledReason.NotApplicable,\n invoker: {\n timing: 'immediate' as const,\n run: (deps, params) => {\n const deferred = pressRef.current.deferredClickId;\n pressRef.current = { deferredClickId: null };\n if (deferred === null) return;\n const selection = deps.selection as SelectionApi | undefined;\n if (!selection) return;\n const p = params as {\n mods?: { alt: boolean; ctrl: boolean; meta: boolean; shift: boolean };\n } | undefined;\n selection.applyClick(\n deferred as NodeId,\n p?.mods ?? { alt: false, ctrl: false, meta: false, shift: false },\n );\n },\n },\n }), []);\n\n return useMemo(\n () => {\n const base = defineTool<null>({\n id: 'select',\n capabilities: ['creates-selection'],\n hookName: 'useSelectTool',\n // A plain string: the mid-gesture cursors this used to compute from\n // gesture scratch now live on the actions that own those gestures\n // (`moveAction.activeCursor`, `areaSelectAction.activeCursor`), so\n // they apply to any consumer binding them, not just this tool.\n cursor: 'default',\n presentation: {\n label: 'Select',\n icon: createElement(SelectIcon),\n group: 'select',\n },\n actions: [pickAction, collapseDeferredAction],\n });\n\n return {\n ...base,\n // Declarative bindings for the gesture dispatcher. The select tool\n // only chooses: press → classify + select, release → collapse a\n // deferred multi-click, empty drag → marquee, click on empty → clear.\n // Moving, cloning, resizing and rotating the selection are ambient\n // bindings owned by the selection (`selectionContributions.ts`), so\n // they run under any tool that leaves the gesture unclaimed.\n bindings: [\n // Every press that landed on the scene rather than on chrome. This\n // is the former `initial.pointerDown['*']` route: it classifies the\n // press and applies the selection change, and it does not consume\n // the gesture — the same press goes on to open a drag or synthesize\n // a click.\n //\n // The no-affordance target is load-bearing twice over. A press on a\n // resize handle, a rotate ring, a path anchor, or a registered\n // layer's own chrome is not a body pick, and re-picking underneath\n // it would change the selection the user was about to act on. And\n // because it is expressed in the SPEC rather than as an early\n // return in the action, the press falls through to whatever owns\n // that chrome instead of being consumed here — which is how an\n // ambient binding (`@weasel-js/hud`) gets to see it at all. The old\n // pipeline got both properties by construction: affordance hits\n // bypassed the active tool entirely.\n //\n // Modifiers are all `'optional'` because they are *data* here\n // (forwarded to `SelectionApi.applyClick`, which resolves them\n // against the host's configured extend key) rather than a route.\n {\n spec: {\n kind: 'pointerDown' as const,\n target: { kindOf: (hit: unknown) => hit == null },\n mods: { shift: 'optional' as const, alt: 'optional' as const, meta: 'optional' as const, ctrl: 'optional' as const },\n },\n actionId: 'select.pick',\n },\n // Release half of the deferred multi-click. Same `'optional'`\n // modifiers, same reason.\n {\n spec: {\n kind: 'click' as const,\n target: { kindOf: (hit: unknown) => hit == null },\n mods: { shift: 'optional' as const, alt: 'optional' as const, meta: 'optional' as const, ctrl: 'optional' as const },\n },\n actionId: 'select.collapseDeferred',\n },\n // Empty drag → marquee. `onEmptyCanvas` rather than a bare `'empty'`\n // target: the ambient bindings that own the handles rank below this\n // active one, so `'empty'` took drags that start on them.\n {\n spec: { kind: 'drag' as const, target: { kindOf: onEmptyCanvas } },\n actionId: 'areaSelect',\n },\n // resize/rotate bind only drag (no click), so a click on either handle\n // (body genuinely empty) would hit target:'empty' and wrongly clear selection.\n {\n spec: {\n kind: 'click' as const,\n target: { kindOf: (hit: unknown, body?: string) => hit == null && body === 'empty' },\n mods: {},\n },\n actionId: 'clearSelection',\n },\n ],\n };\n },\n [pickAction, collapseDeferredAction],\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"useSelectTool.js","names":["SelectIcon","useSelectTool","d","options","AUTO_POSE_DESCRIPTOR","preciseLeaves","tolerance","pickEveryFn","pointQuery","covers","slop","t","px","b","py","shapeCoversPoint","node","pose","poseDescriptorForNode","own","ownClipOf","pathContainsPoint","clip","p","toPlane","m","meanScale","camera","planeOf","scenePlaneOf","adapter","pickWalk","adapterPickSource","worldX","worldY","pickEveryRef","useLatest","pressRef","useRef","optionsRef","adapterRef","pickAction","useMemo","params","selection","deps","mods","opts","sel","top","ids","pickTopMostHit","hitAlreadySelected","isExtend","extendLocked","deferClick","collapseDeferredAction","ActionDisabledReason","deferred","defineTool","createElement","hit","onEmptyCanvas","body"],"sources":["../../../../src/tools/builtin/select/useSelectTool.ts"],"sourcesContent":["import { useMemo, useRef, createElement } from 'react';\nimport { useLatest } from '@weasel-js/react';\nimport { SelectIcon } from '../../../icons';\nimport { pathContainsPoint } from '@weasel-js/geom';\nimport { shapeCoversPoint } from 'canvas/NodeShape';\nimport { pickWalk, adapterPickSource, ownClipOf, scenePlaneOf, type PickQuery } from 'canvas/pickWalk';\nimport { toPlane } from 'core/viewport/parallax';\nimport { meanScale } from 'core/viewport/meanScale';\nimport type { View } from 'core/viewport/view';\nimport type { LayerEnumerableAdapter } from 'core/adapters/types';\nimport type { Node } from 'core/scene/types';\nimport type { MoveAdapter } from 'core/adapters/types';\nimport type { AreaSelectAdapter } from 'core/adapters/types';\nimport type { NodeId } from 'core/scene/types';\nimport { defineTool } from '../../overlayBinding';\nimport type { Action } from '@weasel-js/routing';\nimport { ActionDisabledReason } from '@weasel-js/routing';\nimport type { Tool } from '../../overlayBinding';\nimport { pickTopMostHit } from '../pickTopMostHit';\nimport { onEmptyCanvas } from './selectionContributions';\nimport { poseDescriptorForNode, type PoseDescriptor } from 'core/geometry/poseDescriptor';\nimport { AUTO_POSE_DESCRIPTOR } from 'interactions/actions/resize/autoPoseDescriptor';\nimport { MULTI_RESIZE_TARGET_ID, type Bounds } from '../shared/selectionTarget';\nexport type { Bounds };\nexport { MULTI_RESIZE_TARGET_ID };\n\n/** Options for `useSelectTool`: how a point resolves to nodes, how a click\n * chooses among them, and the appearance of the marquee. */\nexport interface UseSelectToolOptions<TPose> {\n /** Return ids of all objects whose painted body covers (worldX, worldY).\n * Order doesn't matter — the tool collapses parent/child overlap via\n * `pickTopMostHit`. When omitted, defaults to an AABB-vs-point scan over\n * `adapter.getNodes()` using the pose descriptor. Override for tighter\n * shapes (path / polygon hit-tests). */\n pickEvery?: (worldX: number, worldY: number) => string[];\n /** Optional alt-aware selection-update hit returning the single id the\n * click should act on. */\n pickBest?: (\n worldX: number,\n worldY: number,\n alt: boolean,\n selection: readonly string[],\n ) => string | null;\n /** How to read poses for the default `pickEvery`. Default\n * `AUTO_POSE_DESCRIPTOR`. */\n poseDescriptor?: PoseDescriptor<TPose>;\n /**\n * How the default `pickEvery` decides a **leaf** node covers the pointer.\n * Ignored when `pickEvery` is supplied.\n *\n * - `'aabb'` (default) — the pose rect. Cheap, and the historical behavior:\n * a click anywhere in the bounding box selects the node.\n * - `'silhouette'` — the pose rect as a pre-test, then the ink the painter\n * lays down: `findShapeSilhouette` filled or not per the painter's `ink`,\n * plus its outline widened by the stroke half-width and `pickTolerance`.\n * A click inside the bounding box but outside the drawn shape misses —\n * the concave notch of a star, the corner outside an ellipse, the blank\n * half of a text box — while a click on the thin outline of an unfilled\n * shape hits.\n *\n * Container nodes already consult their silhouette unconditionally (it is\n * how their clip is derived); this extends the same test to leaves. Not the\n * default because it changes what a click selects for every existing\n * consumer, and a painter with no `silhouette` is unaffected either way —\n * it falls back to the AABB.\n */\n leafPicking?: 'aabb' | 'silhouette';\n /**\n * Grab slop around a shape's outline, in **world** units. Default 0.\n *\n * Only consulted under `leafPicking: 'silhouette'`. World units because\n * this hook has no view; `<SceneCanvas>` takes a screen-pixel figure\n * (`geometry.pickTolerancePx`) and divides by scale on its own pick path.\n * Without some slop a hairline outline is an unhittable target.\n */\n pickTolerance?: number;\n /** Painted alpha per node in the view this tool picks for. A node painted\n * at alpha 0 is not under the pointer. Ignored when `pickEvery` is\n * supplied. */\n alphaOf?: (id: string) => number;\n /** Whether a node's `layer` reaches the screen in the view this tool picks\n * for. Ignored when `pickEvery` is supplied. */\n layerIsPainted?: (layer: string) => boolean;\n /** The camera of the view this tool picks for. With it, a node on an\n * adapter layer carrying `parallax` (`getLayers`) is picked where that\n * camera's plane paints it; without it, every layer is taken to move with\n * the camera. Ignored when `pickEvery` is supplied. */\n getView?: () => View | null;\n /**\n * When this returns true, a Shift/Meta extend-click must NOT change the\n * node selection.\n *\n * `<SceneCanvas>` wires it to \"a path is in anchor-edit mode\", where\n * Shift-click means \"add this anchor to the anchor selection\". Without\n * the lock the same click also toggles the edited node out of the node\n * selection, and the `editAnchors` dep treats that as the edit target\n * disappearing — so multi-selecting anchors silently exited edit mode.\n *\n * Only extend-clicks are locked. A plain click still re-selects, so\n * clicking a different node exits edit mode as usual.\n */\n extendClickLocked?: () => boolean;\n}\n\n/** Intersection of the move + area-select adapter interfaces.\n * Resize and rotate run through `resizeAction` / `rotateAction`. */\nexport type SelectAdapter<TNode extends { id: string }, TPose> =\n MoveAdapter<TNode, TPose>\n & AreaSelectAdapter\n & LayerEnumerableAdapter;\n\n/**\n * What the press classified, carried from `select.pick` (pointerDown) to\n * `select.collapseDeferred` (click) and to the tool's cursor.\n *\n * This is tool-local bookkeeping between two dispatches of the same gesture,\n * so it lives in a ref the tool owns rather than in dispatcher state. It used\n * to be the tool's `SelectScratch`, held by the tool-routing dispatcher, and\n * carried an `ids` list that nothing ever read — `moveAction` computes its own\n * ids from the live selection.\n *\n * @internal\n */\ninterface PressClassification {\n /**\n * Set when the press hit an already-selected node inside a multi-selection\n * with no extend modifier.\n *\n * The selection is deliberately NOT collapsed on the press: the user may be\n * starting a drag of the whole set. If the gesture turns out to be a click,\n * `select.collapseDeferred` collapses to this id on release.\n */\n deferredClickId: string | null;\n}\n\n/** Active-slot Tool:\n * - `select.pick` classifies the press (pointerDown) — it runs the tool's own\n * pickBest/pickEvery so click semantics (selection replace, extend,\n * deferred collapse) live in one place.\n * - an empty drag marquees (`areaSelect`) and a click on empty clears the\n * selection (`clearSelection`); the marquee renders via the dispatcher\n * overlay layer, driven by areaSelectAction's `overlay()`.\n * - moving and transforming the selection are not the tool's: they are the\n * ambient bindings in `selectionContributions.ts`.\n */\nexport function useSelectTool<TNode extends { id: string }, TPose>(\n adapter: SelectAdapter<TNode, TPose>,\n options: UseSelectToolOptions<TPose>,\n): Tool<null> {\n\n // pickEvery / boundsOf defaults — for any adapter the kit can derive both\n // from `adapter.getNodes()` + `adapter.getPose(id)` + the descriptor.\n const d = (options.poseDescriptor ?? AUTO_POSE_DESCRIPTOR) as PoseDescriptor<TPose>;\n const preciseLeaves = options.leafPicking === 'silhouette';\n // World units here, not screen: this hook has no view. `<SceneCanvas>`\n // converts from screen px on its own pick path; a bare `useSelectTool`\n // consumer that zooms should pass `pickTolerance` from its own scale.\n const tolerance = options.pickTolerance ?? 0;\n const pickEveryFn = options.pickEvery ?? ((worldX: number, worldY: number): string[] => {\n /** A point query at (px, py) with `t` world units of slop. AABB first\n * (cheap), then the painter's ink when asked for and available. A painter\n * with no `silhouette` — or one that returns null, as `kit:text` does for\n * an empty node — keeps the AABB answer.\n *\n * The AABB pre-filter is grown by the slop for the same reason\n * `<SceneCanvas>`'s is: a shape's outline, and the slop around it, reach\n * outside the shape's own bounds, and an un-grown pre-filter would\n * discard those hits before the refinement could claim them. */\n const pointQuery = (px: number, py: number, t: number): PickQuery<TPose> => {\n const covers = (node: unknown, pose: TPose, b: Bounds): boolean => {\n const slop = preciseLeaves ? t : 0;\n if (!(px >= b.x - slop && px <= b.x + b.width + slop\n && py >= b.y - slop && py <= b.y + b.height + slop)) {\n return false;\n }\n if (!preciseLeaves) return true;\n return shapeCoversPoint(\n node as Node<unknown, string, TPose>, pose, px, py, { tolerance: t },\n );\n };\n return {\n hits: (node, pose) => {\n const b = poseDescriptorForNode(d, node).getBounds(pose);\n if (node.kind !== 'container') return covers(node, pose, b);\n // A container's own clip *is* its hit shape — it is picked where it\n // paints, and it paints only inside its clip. Ancestor clips are the\n // walk's business; this one is not.\n const inAabb = px >= b.x && px <= b.x + b.width\n && py >= b.y && py <= b.y + b.height;\n if (!inAabb) return false;\n const own = ownClipOf(node, pose);\n return own === null || pathContainsPoint(own, px, py);\n },\n clipAdmits: (clip) => pathContainsPoint(clip, px, py),\n inPlane: (m) => {\n const p = toPlane(m, { x: px, y: py });\n return pointQuery(p.x, p.y, t * meanScale(m.scale));\n },\n };\n };\n\n const camera = options.getView?.() ?? null;\n const planeOf = camera ? scenePlaneOf(adapter.getLayers?.(), camera) : null;\n return pickWalk<TPose>(adapterPickSource(adapter as never, {\n ...(options.alphaOf ? { alphaOf: options.alphaOf } : {}),\n ...(options.layerIsPainted ? { layerIsPainted: options.layerIsPainted } : {}),\n ...(planeOf ? { planeOf } : {}),\n }), pointQuery(worldX, worldY, tolerance));\n });\n\n const pickEveryRef = useLatest(pickEveryFn);\n\n // What the last press classified. Written by `select.pick`, read by\n // `select.collapseDeferred` and the cursor. See `PressClassification`.\n const pressRef = useRef<PressClassification>({ deferredClickId: null });\n\n // Options in a ref so the actions — built once — always see the live\n // callbacks without rebuilding the Tool record.\n const optionsRef = useLatest(options);\n const adapterRef = useLatest(adapter);\n\n /**\n * `select.pick` — classifies the press and updates the node selection.\n *\n * Bound to `pointerDown` rather than `click` on purpose: pressing an\n * unselected node must highlight it while the button is still down, and any\n * drag that follows has to start from the updated selection (both\n * `moveAction` and `cloneAction` read `selection.get()` in their `start`).\n *\n * Eligibility replaces the `selectionAllowed` option the audit added as an\n * interim per-route patch: this action carries the same\n * `creates-selection` capability rule the `clearSelection` binding declares,\n * so a mode that forbids selection now gates both halves of the tool by one\n * mechanism instead of by which dispatcher the route happened to live on.\n */\n const pickAction = useMemo<Action>(() => ({\n id: 'select.pick',\n label: 'Select — pick under pointer',\n eligible: { capability: 'creates-selection' },\n requires: ['selection'],\n invoker: {\n timing: 'immediate' as const,\n run: (deps, params) => {\n const p = params as {\n worldX?: number; worldY?: number;\n affordance?: unknown;\n mods?: { alt: boolean; ctrl: boolean; meta: boolean; shift: boolean };\n } | undefined;\n const selection = deps.selection;\n if (!selection || p?.worldX === undefined || p.worldY === undefined) return;\n const mods = p.mods ?? { alt: false, ctrl: false, meta: false, shift: false };\n const opts = optionsRef.current;\n\n const sel = selection.get();\n const top = opts.pickBest\n ? opts.pickBest(p.worldX, p.worldY, mods.alt, sel)\n : (() => {\n const ids = pickEveryRef.current(p.worldX, p.worldY);\n if (ids.length === 0) return null;\n return pickTopMostHit(ids, adapterRef.current) ?? ids[0];\n })();\n\n if (top === null) {\n pressRef.current = { deferredClickId: null };\n return;\n }\n\n const hitAlreadySelected = sel.includes(top as NodeId);\n const isExtend = mods.shift || mods.meta;\n // While a path is in anchor-edit mode, an extend-click belongs to the\n // anchor selection, not the node selection. Letting it through would\n // toggle the edited node out of the node selection, which the\n // `editAnchors` dep reads as \"the edit target is gone\" — so\n // shift-clicking a second anchor silently tore down edit mode.\n // Plain clicks still fall through: clicking a different node exits\n // edit mode, which is what you'd expect.\n //\n // This stays an option rather than becoming `Action.enabled` because\n // it only applies to *extend* presses, and `enabled` is evaluated\n // without the event's modifiers.\n const extendLocked = isExtend && opts.extendClickLocked?.() === true;\n const deferClick = hitAlreadySelected && sel.length > 1 && !isExtend;\n if (!deferClick && !extendLocked) {\n selection.applyClick(top as NodeId, mods);\n }\n pressRef.current = { deferredClickId: deferClick ? top : null };\n },\n },\n }), [optionsRef, pickEveryRef, adapterRef]);\n\n /**\n * `select.collapseDeferred` — the release half of the deferred multi-click.\n *\n * Pressing an already-selected node inside a multi-selection leaves the\n * selection alone so the press can start a drag of the whole set. If the\n * gesture ends without a drag, this collapses to the clicked node.\n */\n const collapseDeferredAction = useMemo<Action>(() => ({\n id: 'select.collapseDeferred',\n label: 'Select — collapse deferred click',\n eligible: { capability: 'creates-selection' },\n requires: ['selection'],\n // Declining when there is nothing deferred is what keeps this binding\n // from swallowing every click. It is bound with no target (any click can\n // in principle be the release of a deferred press) at ACTIVE scope, so\n // without the gate it would outrank ambient click bindings like\n // `selectAnchor` and silently break anchor selection. Same fall-through\n // contract `clearSelection.enabled` relies on.\n enabled: () =>\n pressRef.current.deferredClickId !== null\n ? true\n : ActionDisabledReason.NotApplicable,\n invoker: {\n timing: 'immediate' as const,\n run: (deps, params) => {\n const deferred = pressRef.current.deferredClickId;\n pressRef.current = { deferredClickId: null };\n if (deferred === null) return;\n const selection = deps.selection;\n if (!selection) return;\n const p = params as {\n mods?: { alt: boolean; ctrl: boolean; meta: boolean; shift: boolean };\n } | undefined;\n selection.applyClick(\n deferred as NodeId,\n p?.mods ?? { alt: false, ctrl: false, meta: false, shift: false },\n );\n },\n },\n }), []);\n\n return useMemo(\n () => {\n const base = defineTool<null>({\n id: 'select',\n capabilities: ['creates-selection'],\n hookName: 'useSelectTool',\n // A plain string: the mid-gesture cursors this used to compute from\n // gesture scratch now live on the actions that own those gestures\n // (`moveAction.activeCursor`, `areaSelectAction.activeCursor`), so\n // they apply to any consumer binding them, not just this tool.\n cursor: 'default',\n presentation: {\n label: 'Select',\n icon: createElement(SelectIcon),\n group: 'select',\n },\n actions: [pickAction, collapseDeferredAction],\n });\n\n return {\n ...base,\n // Declarative bindings for the gesture dispatcher. The select tool\n // only chooses: press → classify + select, release → collapse a\n // deferred multi-click, empty drag → marquee, click on empty → clear.\n // Moving, cloning, resizing and rotating the selection are ambient\n // bindings owned by the selection (`selectionContributions.ts`), so\n // they run under any tool that leaves the gesture unclaimed.\n bindings: [\n // Every press that landed on the scene rather than on chrome. This\n // is the former `initial.pointerDown['*']` route: it classifies the\n // press and applies the selection change, and it does not consume\n // the gesture — the same press goes on to open a drag or synthesize\n // a click.\n //\n // The no-affordance target is load-bearing twice over. A press on a\n // resize handle, a rotate ring, a path anchor, or a registered\n // layer's own chrome is not a body pick, and re-picking underneath\n // it would change the selection the user was about to act on. And\n // because it is expressed in the SPEC rather than as an early\n // return in the action, the press falls through to whatever owns\n // that chrome instead of being consumed here — which is how an\n // ambient binding (`@weasel-js/hud`) gets to see it at all. The old\n // pipeline got both properties by construction: affordance hits\n // bypassed the active tool entirely.\n //\n // Modifiers are all `'optional'` because they are *data* here\n // (forwarded to `SelectionApi.applyClick`, which resolves them\n // against the host's configured extend key) rather than a route.\n {\n spec: {\n kind: 'pointerDown' as const,\n target: { kindOf: (hit: unknown) => hit == null },\n mods: { shift: 'optional' as const, alt: 'optional' as const, meta: 'optional' as const, ctrl: 'optional' as const },\n },\n actionId: 'select.pick',\n },\n // Release half of the deferred multi-click. Same `'optional'`\n // modifiers, same reason.\n {\n spec: {\n kind: 'click' as const,\n target: { kindOf: (hit: unknown) => hit == null },\n mods: { shift: 'optional' as const, alt: 'optional' as const, meta: 'optional' as const, ctrl: 'optional' as const },\n },\n actionId: 'select.collapseDeferred',\n },\n // Empty drag → marquee. `onEmptyCanvas` rather than a bare `'empty'`\n // target: the ambient bindings that own the handles rank below this\n // active one, so `'empty'` took drags that start on them.\n {\n spec: { kind: 'drag' as const, target: { kindOf: onEmptyCanvas } },\n actionId: 'areaSelect',\n },\n // resize/rotate bind only drag (no click), so a click on either handle\n // (body genuinely empty) would hit target:'empty' and wrongly clear selection.\n {\n spec: {\n kind: 'click' as const,\n target: { kindOf: (hit: unknown, body?: string) => hit == null && body === 'empty' },\n mods: {},\n },\n actionId: 'clearSelection',\n },\n ],\n };\n },\n [pickAction, collapseDeferredAction],\n );\n}\n"],"mappings":"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"}