@1agh/maude 0.28.1 → 0.30.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.
- package/README.md +3 -3
- package/{plugins/design/dev-server → apps/studio}/ai-banner.tsx +1 -2
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/{plugins/design/dev-server → apps/studio}/annotations-context-toolbar.tsx +681 -151
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +4537 -0
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/{plugins/design/dev-server → apps/studio}/api.ts +616 -70
- package/{plugins/design/dev-server → apps/studio}/artboard-activity-overlay.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_motion-sample-playwright.mjs +129 -0
- package/{plugins/design/dev-server → apps/studio}/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/{plugins/design/dev-server → apps/studio}/bin/canvas-edit.sh +1 -1
- package/{plugins/design/dev-server → apps/studio}/bin/draw-proof.sh +37 -2
- package/{plugins/design/dev-server → apps/studio}/bin/preflight.sh +1 -1
- package/apps/studio/bin/read-annotations.mjs +573 -0
- package/apps/studio/bin/read-annotations.sh +11 -0
- package/{plugins/design/dev-server → apps/studio}/bin/runtime-health.sh +1 -1
- package/{plugins/design/dev-server → apps/studio}/bin/screenshot.sh +62 -2
- package/{plugins/design/dev-server → apps/studio}/bin/server-up.sh +9 -3
- package/apps/studio/bin/to-lottie-verify.html +68 -0
- package/apps/studio/bin/to-lottie.sh +128 -0
- package/{plugins/design/dev-server → apps/studio}/build.ts +6 -4
- package/{plugins/design/dev-server → apps/studio}/canvas-arrowheads.ts +79 -10
- package/{plugins/design/dev-server → apps/studio}/canvas-comment-mount.tsx +1 -1
- package/apps/studio/canvas-create.ts +104 -0
- package/{plugins/design/dev-server → apps/studio}/canvas-cursors.ts +48 -12
- package/{plugins/design/dev-server → apps/studio}/canvas-edit.ts +257 -7
- package/{plugins/design/dev-server → apps/studio}/canvas-icons.tsx +165 -1
- package/{plugins/design/dev-server → apps/studio}/canvas-lib-inline.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/canvas-lib-resolver.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/canvas-lib.tsx +31 -12
- package/{plugins/design/dev-server → apps/studio}/canvas-shell.tsx +360 -48
- package/apps/studio/client/app.jsx +6742 -0
- package/apps/studio/client/canvas-url.js +81 -0
- package/{plugins/design/dev-server → apps/studio}/client/comments-overlay.css +41 -27
- package/apps/studio/client/index.html +16 -0
- package/apps/studio/client/styles/1-tokens-maude.css +296 -0
- package/apps/studio/client/styles/3-shell-maude.css +1161 -0
- package/{plugins/design/dev-server → apps/studio}/client/styles/3-shell.css +129 -0
- package/apps/studio/client/styles/4-components-maude.css +74 -0
- package/{plugins/design/dev-server → apps/studio}/client/styles/4-components.css +396 -34
- package/apps/studio/client/styles/5-maude-overrides.css +137 -0
- package/{plugins/design/dev-server → apps/studio}/client/styles/_index.css +9 -0
- package/{plugins/design/dev-server → apps/studio}/client/styles.css +1 -1
- package/apps/studio/client/tour/overlay.jsx +279 -0
- package/apps/studio/client/tour/usage-tour.js +48 -0
- package/apps/studio/client/whats-new-seen.js +52 -0
- package/apps/studio/client/whats-new.jsx +250 -0
- package/{plugins/design/dev-server → apps/studio}/commands/annotation-strokes-command.ts +2 -2
- package/{plugins/design/dev-server → apps/studio}/commands/equal-spacing-command.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/commands/move-artboards-command.ts +2 -2
- package/{plugins/design/dev-server → apps/studio}/comments-overlay.tsx +32 -11
- package/{plugins/design/dev-server → apps/studio}/context-menu.tsx +5 -4
- package/{plugins/design/dev-server → apps/studio}/contextual-toolbar.tsx +18 -7
- package/{plugins/design/dev-server → apps/studio}/cursors-overlay.tsx +21 -21
- package/apps/studio/dist/client.bundle.js +25545 -0
- package/apps/studio/dist/comment-mount.js +2046 -0
- package/apps/studio/dist/styles.css +8018 -0
- package/apps/studio/dom-selection.ts +342 -0
- package/apps/studio/draw/animate.ts +333 -0
- package/{plugins/design/dev-server → apps/studio}/draw/brush.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/draw/composition.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/draw/geometry.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/draw/index.ts +4 -1
- package/{plugins/design/dev-server → apps/studio}/draw/layout.ts +1 -1
- package/apps/studio/draw/morph.ts +204 -0
- package/{plugins/design/dev-server → apps/studio}/draw/optimize.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/draw/palette.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/draw/primitives.ts +1 -1
- package/apps/studio/draw/serialize-animate.ts +324 -0
- package/{plugins/design/dev-server → apps/studio}/draw/serialize.ts +9 -3
- package/apps/studio/draw/test/animate.test.ts +172 -0
- package/apps/studio/draw/test/morph.test.ts +107 -0
- package/apps/studio/draw/test/serialize-animate.test.ts +142 -0
- package/{plugins/design/dev-server → apps/studio}/equal-spacing-detector.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/equal-spacing-handles.tsx +7 -29
- package/{plugins/design/dev-server → apps/studio}/export-dialog.tsx +16 -4
- package/{plugins/design/dev-server → apps/studio}/history.ts +20 -3
- package/{plugins/design/dev-server → apps/studio}/http.ts +226 -6
- package/{plugins/design/dev-server → apps/studio}/input-router.tsx +15 -1
- package/{plugins/design/dev-server → apps/studio}/inspect.ts +50 -4
- package/{plugins/design/dev-server → apps/studio}/marquee-overlay.tsx +5 -2
- package/{plugins/design/dev-server → apps/studio}/participants-chrome.tsx +2 -8
- package/{plugins/design/dev-server → apps/studio}/paths.ts +15 -11
- package/{plugins/design/dev-server → apps/studio}/server.ts +23 -2
- package/{plugins/design/dev-server → apps/studio}/sync/agent.ts +231 -57
- package/{plugins/design/dev-server → apps/studio}/sync/codec.ts +45 -0
- package/apps/studio/sync/cold-start.ts +158 -0
- package/{plugins/design/dev-server → apps/studio}/sync/connection-state.ts +58 -2
- package/{plugins/design/dev-server → apps/studio}/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +321 -0
- package/{plugins/design/dev-server → apps/studio}/sync/projection.ts +22 -3
- package/{plugins/design/dev-server → apps/studio}/sync/status.ts +15 -4
- package/{plugins/design/dev-server → apps/studio}/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/{plugins/design/dev-server → apps/studio}/test/annotations-layer.test.ts +63 -0
- package/{plugins/design/dev-server → apps/studio}/test/annotations-roundtrip.test.ts +97 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/annotations-text-format.test.ts +396 -0
- package/apps/studio/test/asset-api.test.ts +149 -0
- package/apps/studio/test/canvas-create-api.test.ts +361 -0
- package/{plugins/design/dev-server → apps/studio}/test/canvas-cursors.test.ts +2 -0
- package/apps/studio/test/canvas-edit.test.ts +319 -0
- package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-resolver.test.ts +1 -1
- package/apps/studio/test/canvas-media-drop.test.ts +158 -0
- package/{plugins/design/dev-server → apps/studio}/test/canvas-origin-gate.test.ts +25 -1
- package/apps/studio/test/canvas-url.test.ts +99 -0
- package/{plugins/design/dev-server → apps/studio}/test/collab-bridge.test.ts +0 -2
- package/{plugins/design/dev-server → apps/studio}/test/collab-room.test.ts +2 -7
- package/{plugins/design/dev-server → apps/studio}/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +52 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/index-data-canvas-ds.test.ts +97 -0
- package/apps/studio/test/motion-sample.test.ts +35 -0
- package/{plugins/design/dev-server → apps/studio}/test/perf-harness.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/test/phase-3.6-smoke.test.ts +3 -1
- package/apps/studio/test/read-annotations.test.ts +409 -0
- package/{plugins/design/dev-server → apps/studio}/test/sanitize-annotation-svg.test.ts +108 -2
- package/{plugins/design/dev-server → apps/studio}/test/shared-doc-convergence.test.ts +1 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +369 -0
- package/apps/studio/test/sync-agent.test.ts +575 -0
- package/{plugins/design/dev-server → apps/studio}/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-start.test.ts +244 -0
- package/{plugins/design/dev-server → apps/studio}/test/sync-connection-state.test.ts +68 -0
- package/{plugins/design/dev-server → apps/studio}/test/sync-hardening.test.ts +0 -8
- package/{plugins/design/dev-server → apps/studio}/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/{plugins/design/dev-server → apps/studio}/test/sync-runtime.test.ts +360 -5
- package/{plugins/design/dev-server → apps/studio}/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +56 -0
- package/apps/studio/test/use-annotation-resize.test.ts +402 -0
- package/{plugins/design/dev-server → apps/studio}/test/use-artboard-drag.test.ts +0 -1
- package/{plugins/design/dev-server → apps/studio}/test/use-collab.test.ts +0 -0
- package/{plugins/design/dev-server → apps/studio}/test/use-tool-mode.test.tsx +8 -2
- package/apps/studio/test/whats-new.test.ts +101 -0
- package/apps/studio/text-imports.d.ts +9 -0
- package/{plugins/design/dev-server → apps/studio}/tool-palette.tsx +15 -70
- package/{plugins/design/dev-server → apps/studio}/undo-hud.tsx +1 -2
- package/{plugins/design/dev-server → apps/studio}/undo-stack.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/use-agent-presence.tsx +5 -3
- package/apps/studio/use-annotation-resize.tsx +912 -0
- package/{plugins/design/dev-server → apps/studio}/use-annotation-selection.tsx +17 -5
- package/{plugins/design/dev-server → apps/studio}/use-annotations-visibility.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/use-artboard-drag.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/use-canvas-activity.tsx +3 -3
- package/apps/studio/use-canvas-media-drop.tsx +343 -0
- package/{plugins/design/dev-server → apps/studio}/use-collab.tsx +21 -16
- package/{plugins/design/dev-server → apps/studio}/use-cursor-modifiers.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/use-keyboard-discipline.tsx +5 -2
- package/{plugins/design/dev-server → apps/studio}/use-selection-set.tsx +18 -1
- package/{plugins/design/dev-server → apps/studio}/use-snap-guides.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/use-tool-mode.tsx +8 -2
- package/{plugins/design/dev-server → apps/studio}/use-undo-stack.tsx +1 -1
- package/apps/studio/whats-new.json +264 -0
- package/apps/studio/whats-new.schema.json +86 -0
- package/apps/studio/whats-new.ts +87 -0
- package/cli/bin/maude.mjs +10 -0
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +16 -7
- package/cli/commands/design.test.mjs +40 -0
- package/cli/commands/help.mjs +8 -2
- package/cli/commands/hub.mjs +3 -3
- package/cli/commands/hub.test.mjs +1 -1
- package/cli/commands/scenario-report.mjs +2 -9
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/flow-design-integration.test.mjs +1 -1
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/plugin-cli-reachability.test.mjs +8 -7
- package/cli/lib/preflight.mjs +1 -1
- package/package.json +15 -15
- package/plugins/design/templates/_shell.html +28 -4
- package/plugins/design/templates/brief-board.tsx.template +61 -0
- package/plugins/design/templates/canvas.tsx.template +1 -1
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/plugins/flow/.claude-plugin/config.schema.json +16 -0
- package/plugins/design/dev-server/annotations-layer.tsx +0 -3402
- package/plugins/design/dev-server/client/app.jsx +0 -2985
- package/plugins/design/dev-server/client/index.html +0 -15
- package/plugins/design/dev-server/dist/client.bundle.js +0 -20692
- package/plugins/design/dev-server/dist/comment-mount.js +0 -1924
- package/plugins/design/dev-server/dist/styles.css +0 -2710
- package/plugins/design/dev-server/dom-selection.ts +0 -156
- package/plugins/design/dev-server/sync/migrate-seed.ts +0 -163
- package/plugins/design/dev-server/test/canvas-edit.test.ts +0 -139
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +0 -159
- package/plugins/design/dev-server/test/sync-agent.test.ts +0 -305
- package/plugins/design/dev-server/test/use-annotation-resize.test.ts +0 -200
- package/plugins/design/dev-server/use-annotation-resize.tsx +0 -449
- /package/{plugins/design/dev-server → apps/studio}/activity.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_enumerate-artboards-playwright.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_html-playwright.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_pdf-playwright.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_pptx-playwright.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_pw-launch.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_screenshot-playwright.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_svg-optimize.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_svg-playwright.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/asset-sweep.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/bootstrap-check.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/check-runtime-bundles.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/draw-build.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/handoff.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/prep.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/scenario-report.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/slug.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/smoke.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/svg-optimize.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/visual-sanity.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/boot-self-heal.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/canvas-build.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/canvas-header.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/canvas-meta.schema.json +0 -0
- /package/{plugins/design/dev-server → apps/studio}/canvas-pipeline.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/client/hmr.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/client/iframe-lazy.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/client/styles/0-reset.css +0 -0
- /package/{plugins/design/dev-server → apps/studio}/client/styles/1-tokens.css +0 -0
- /package/{plugins/design/dev-server → apps/studio}/client/styles/2-layout.css +0 -0
- /package/{plugins/design/dev-server → apps/studio}/client/styles/5-utilities.css +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/ai-activity.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/awareness-bridge.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/git-lifecycle.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/index.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/persistence.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/protocol.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/registry.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/room.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/config.schema.json +0 -0
- /package/{plugins/design/dev-server → apps/studio}/context.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/.min-sizes.json +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/lib0_decoding.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/lib0_encoding.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/motion.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/motion_react.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/pixi-js.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react-dom.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react-dom_client.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react_jsx-dev-runtime.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react_jsx-runtime.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/y-protocols_awareness.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/y-protocols_sync.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/yjs.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/brush.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/composition.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/geometry.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/gradient.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/layout.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/optimize.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/palette.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/primitives.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/serialize.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/examples/README.md +0 -0
- /package/{plugins/design/dev-server → apps/studio}/examples/perf-100-artboards.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/_browser-bundles.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/canva-handoff-prompt.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/canva.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/html.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/index.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/pdf.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/png.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/pptx.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/scope.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/svg.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/zip.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/fs-watch.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/handoff.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/hmr-broadcast.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/locator.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/mem.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/runtime-bundle.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/server.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/atomic-write.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/echo-guard.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/fs-mirror.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/hubs-config.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/materialize.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/origins.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/untrusted.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/_helpers.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/active-state.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/ai-activity.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/annotation-strokes-command.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/annotations-api.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/annotations-draw-modifiers.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/artboard-activity-overlay.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/binary-smoke.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/boot-self-heal.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/bundle-smoke.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-build.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-header.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-hmr-runtime.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-inline.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-motion.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-meta-api.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-pipeline.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-route.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/collab-annotations-bridge.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/collab-awareness-bridge.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/collab-loopback.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/collab-protocol.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/collab-reseed-guard.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/collab-stress.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/comment-mount.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/comments-api.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/compile-entry.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/context-resolve-tokens.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/equal-spacing-detector.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/canva.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/endpoint.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/history.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/html.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/pdf.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/png.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/pptx-deck.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/pptx.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/pw-launch.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/scope.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/svg.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/zip.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/fixtures/phase-20-annotations.svg +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/fixtures/phase-21-annotations.svg +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/fs-watch.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/git-lifecycle.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/handoff-static-frames.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/handoff.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/history-rollback.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/hmr-broadcast.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/hmr-classify.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/input-router.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/locator.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/marquee-overlay.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/move-artboards-command.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/participants-chrome.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/paths.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/runtime-bundle-error-mapping.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/runtime-bundle.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/server-lifecycle.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-foundation.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-projection.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/snap-distance-pill.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-atomic-write.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-echo-guard.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-fs-mirror.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-meta-codec.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-untrusted.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/system-endpoint.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/tool-palette.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/undo-stack.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-agent-presence.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-annotation-selection.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-canvas-activity.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-cursor-modifiers.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-keyboard-discipline.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-selection-set.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-snap-guides.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-undo-stack.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/ws-handshake.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/tsconfig.json +0 -0
- /package/{plugins/design/dev-server → apps/studio}/ws.ts +0 -0
|
@@ -0,0 +1,4537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations-layer.tsx — FigJam-style annotation overlay
|
|
3
|
+
* @scope apps/studio/annotations-layer.tsx
|
|
4
|
+
* @purpose Portal-rendered draw layer. Strokes live in world coords and
|
|
5
|
+
* render INSIDE `.dc-world` via `createPortal`, so CSS `zoom` +
|
|
6
|
+
* `translate` on the world move them in lockstep with artboards
|
|
7
|
+
* with zero frame lag. A separate transparent input overlay
|
|
8
|
+
* (also portal-mounted inside the host) captures pointerdown
|
|
9
|
+
* only for draw / erase tools; viewport gestures (space-pan,
|
|
10
|
+
* middle-mouse, wheel/pinch) bypass us and reach
|
|
11
|
+
* `useViewportController` directly.
|
|
12
|
+
*
|
|
13
|
+
* Schema (back-compatible with Phase 5):
|
|
14
|
+
* - pen → <path data-tool="pen" d="M.. L..">
|
|
15
|
+
* - rect → <rect data-tool="rect" x= y= width= height= [fill=]>
|
|
16
|
+
* - ellipse → <ellipse data-tool="ellipse" cx= cy= rx= ry= [fill=]> NEW
|
|
17
|
+
* - arrow → <g data-tool="arrow"><line/><polyline/></g>
|
|
18
|
+
* - text → <text data-tool="text" data-anchor-id= x= y= fill= …> NEW
|
|
19
|
+
*
|
|
20
|
+
* Persists to `<designRoot>/<slug>.annotations.svg` via PUT /_api/annotations
|
|
21
|
+
* on commit, debounced 200 ms.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
type CSSProperties,
|
|
26
|
+
createContext,
|
|
27
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
28
|
+
type PointerEvent as ReactPointerEvent,
|
|
29
|
+
useCallback,
|
|
30
|
+
useContext,
|
|
31
|
+
useEffect,
|
|
32
|
+
useMemo,
|
|
33
|
+
useRef,
|
|
34
|
+
useState,
|
|
35
|
+
} from 'react';
|
|
36
|
+
import { createPortal } from 'react-dom';
|
|
37
|
+
import {
|
|
38
|
+
type AlignEdge,
|
|
39
|
+
alignStrokes,
|
|
40
|
+
type DistributeAxis,
|
|
41
|
+
distributeStrokes,
|
|
42
|
+
} from './annotations-align.ts';
|
|
43
|
+
import {
|
|
44
|
+
anchorPoint,
|
|
45
|
+
BIND_THRESHOLD_PX,
|
|
46
|
+
bindCandidate,
|
|
47
|
+
isBindable,
|
|
48
|
+
recomputeBoundArrows,
|
|
49
|
+
} from './annotations-bindings.ts';
|
|
50
|
+
import { AnnotationContextToolbar } from './annotations-context-toolbar.tsx';
|
|
51
|
+
import {
|
|
52
|
+
duplicateStrokes,
|
|
53
|
+
expandIdsToGroups,
|
|
54
|
+
groupStrokes,
|
|
55
|
+
normalizeGroups,
|
|
56
|
+
outermostGroupOf,
|
|
57
|
+
reorderStrokes,
|
|
58
|
+
ungroupStrokes,
|
|
59
|
+
type ZOrderOp,
|
|
60
|
+
} from './annotations-groups.ts';
|
|
61
|
+
import {
|
|
62
|
+
type AnchorHost,
|
|
63
|
+
type ArrowStroke,
|
|
64
|
+
applyDrawModifiers,
|
|
65
|
+
clampLinkTitle,
|
|
66
|
+
DEFAULT_FONT_SIZE,
|
|
67
|
+
DEFAULT_HIGHLIGHTER_COLOR,
|
|
68
|
+
DEFAULT_HIGHLIGHTER_WIDTH,
|
|
69
|
+
DEFAULT_SECTION_COLOR,
|
|
70
|
+
DEFAULT_STICKY_COLOR,
|
|
71
|
+
type DrawMods,
|
|
72
|
+
defaultFillFor,
|
|
73
|
+
type EditorFmt,
|
|
74
|
+
FILL_PALETTE,
|
|
75
|
+
fmtEqual,
|
|
76
|
+
HALO_PAD_PX,
|
|
77
|
+
HIGHLIGHTER_PALETTE,
|
|
78
|
+
HIGHLIGHTER_WIDTHS,
|
|
79
|
+
IMAGE_MAX_DROP_SIDE,
|
|
80
|
+
IMAGE_MIN_SIZE,
|
|
81
|
+
type ImageStroke,
|
|
82
|
+
isStrokeMeaningful,
|
|
83
|
+
LINK_CARD_FILL,
|
|
84
|
+
LINK_CARD_STROKE,
|
|
85
|
+
LINK_DEFAULT_H,
|
|
86
|
+
LINK_DEFAULT_W,
|
|
87
|
+
LINK_DOMAIN_FILL,
|
|
88
|
+
LINK_GLYPH_D1,
|
|
89
|
+
LINK_GLYPH_D2,
|
|
90
|
+
LINK_GLYPH_STROKE,
|
|
91
|
+
LINK_TITLE_FILL,
|
|
92
|
+
type ListType,
|
|
93
|
+
linkCardLayout,
|
|
94
|
+
listPrefixedBody,
|
|
95
|
+
listPrefixedLine,
|
|
96
|
+
normalizeBox,
|
|
97
|
+
normalizeRect,
|
|
98
|
+
normalizeSticky,
|
|
99
|
+
normFmt,
|
|
100
|
+
penPathD,
|
|
101
|
+
polygonPoints,
|
|
102
|
+
resolveDefaultInk,
|
|
103
|
+
rid,
|
|
104
|
+
SECTION_CORNER_RADIUS,
|
|
105
|
+
SECTION_DEFAULT_H,
|
|
106
|
+
SECTION_DEFAULT_W,
|
|
107
|
+
SECTION_LABEL_FONT,
|
|
108
|
+
SECTION_LABEL_H,
|
|
109
|
+
SECTION_MIN_SIZE,
|
|
110
|
+
type SectionStroke,
|
|
111
|
+
SHAPE_DEFAULT_SIZE,
|
|
112
|
+
STICKY_CORNER_RADIUS,
|
|
113
|
+
STICKY_DEFAULT_H,
|
|
114
|
+
STICKY_DEFAULT_W,
|
|
115
|
+
STICKY_MIN_SIZE,
|
|
116
|
+
STICKY_PALETTE,
|
|
117
|
+
STROKE_PALETTE,
|
|
118
|
+
STROKE_WIDTH_THICK,
|
|
119
|
+
STROKE_WIDTH_THIN,
|
|
120
|
+
type StickyStroke,
|
|
121
|
+
type Stroke,
|
|
122
|
+
splitTextLines,
|
|
123
|
+
stickyCornerPath,
|
|
124
|
+
stripEditorMarkers,
|
|
125
|
+
strokeBBox,
|
|
126
|
+
strokeCenter,
|
|
127
|
+
strokeHitTest,
|
|
128
|
+
strokeRotation,
|
|
129
|
+
strokesShallowEqual,
|
|
130
|
+
strokesToSvg,
|
|
131
|
+
svgToStrokes,
|
|
132
|
+
TEXT_LINE_HEIGHT,
|
|
133
|
+
type TextAlign,
|
|
134
|
+
type TextStroke,
|
|
135
|
+
type Thickness,
|
|
136
|
+
textDecoCss,
|
|
137
|
+
textLineDy,
|
|
138
|
+
translateOne,
|
|
139
|
+
type WorldPoint,
|
|
140
|
+
} from './annotations-model.ts';
|
|
141
|
+
import {
|
|
142
|
+
computeSnap,
|
|
143
|
+
GRID_PITCH_PX,
|
|
144
|
+
SNAP_THRESHOLD_PX,
|
|
145
|
+
type SnapGuide,
|
|
146
|
+
} from './annotations-snap.ts';
|
|
147
|
+
import { arrowPrimitives, type SvgPrimitive } from './canvas-arrowheads.ts';
|
|
148
|
+
import { IconLineThick, IconLineThin } from './canvas-icons.tsx';
|
|
149
|
+
import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
|
|
150
|
+
import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
|
|
151
|
+
import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
|
|
152
|
+
import { crossedDragThreshold, type Tool } from './input-router.tsx';
|
|
153
|
+
import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
|
|
154
|
+
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
155
|
+
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
156
|
+
import {
|
|
157
|
+
isHttpUrl,
|
|
158
|
+
linkDomain,
|
|
159
|
+
prettifyUrl,
|
|
160
|
+
showCanvasToast,
|
|
161
|
+
uploadAsset,
|
|
162
|
+
useCanvasMediaDrop,
|
|
163
|
+
} from './use-canvas-media-drop.tsx';
|
|
164
|
+
import { useCollab } from './use-collab.tsx';
|
|
165
|
+
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
166
|
+
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
167
|
+
import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
168
|
+
|
|
169
|
+
// FigJam v3 — the pure data model (Stroke types, palettes, serialize/parse,
|
|
170
|
+
// geometry) lives in annotations-model.ts: React-free, importable headlessly
|
|
171
|
+
// by bun tests and the `maude design annotate` write verb. The layer
|
|
172
|
+
// re-exports the whole model so every existing
|
|
173
|
+
// `from './annotations-layer.tsx'` import keeps working unchanged.
|
|
174
|
+
export * from './annotations-model.ts';
|
|
175
|
+
|
|
176
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
// Types
|
|
178
|
+
|
|
179
|
+
// Phase 24 — arrow style enums are OWNED by canvas-arrowheads.ts (so that
|
|
180
|
+
// module imports nothing back from here — no cycle, see DDR-067) and re-exported
|
|
181
|
+
// here for back-compat (context-toolbar etc. import them from this module).
|
|
182
|
+
export type { ArrowHead, ArrowLineType } from './canvas-arrowheads.ts';
|
|
183
|
+
|
|
184
|
+
/** Phase 24 — cursor-following ghost placeholder descriptor (pure chrome). */
|
|
185
|
+
type GhostDescriptor =
|
|
186
|
+
| { kind: 'text'; x: number; y: number; color: string }
|
|
187
|
+
| { kind: 'sticky'; x: number; y: number; color: string }
|
|
188
|
+
| { kind: 'shape'; x: number; y: number; shapeKind: ShapeKind; color: string };
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Phase 21 — what the inline editor is currently bound to. `anchored` edits
|
|
192
|
+
* the text hosted by a rect/ellipse; `sticky` edits a card body; `standalone`
|
|
193
|
+
* re-edits a free text node; `pending` is a not-yet-born text caret (no stroke
|
|
194
|
+
* exists until real text is committed).
|
|
195
|
+
*/
|
|
196
|
+
type EditingTarget =
|
|
197
|
+
| { kind: 'anchored'; anchorId: string; host: AnchorHost }
|
|
198
|
+
| { kind: 'sticky'; sticky: StickyStroke }
|
|
199
|
+
| { kind: 'standalone'; text: TextStroke }
|
|
200
|
+
/** FigJam v3 — renaming a section's label chip. */
|
|
201
|
+
| { kind: 'section'; section: SectionStroke }
|
|
202
|
+
| { kind: 'pending'; x: number; y: number }
|
|
203
|
+
| null;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The canvas-shell CHROME theme — `data-maude-theme` on `<html>`, default
|
|
207
|
+
* 'dark'. This (NOT the DS `data-theme`, which is deliberately separate and
|
|
208
|
+
* themes only artboard palettes — canvas-shell.tsx) is what flips the canvas
|
|
209
|
+
* BACKGROUND the annotation ink sits on, so the theme-aware default ink follows
|
|
210
|
+
* it. Re-resolves on `data-maude-theme` mutation (the dark/light toggle posts it
|
|
211
|
+
* into the iframe after mount, so we re-read once on mount too).
|
|
212
|
+
*/
|
|
213
|
+
function readChromeTheme(): 'light' | 'dark' {
|
|
214
|
+
if (typeof document === 'undefined') return 'dark';
|
|
215
|
+
return document.documentElement.dataset.maudeTheme === 'light' ? 'light' : 'dark';
|
|
216
|
+
}
|
|
217
|
+
function useCanvasChromeTheme(): 'light' | 'dark' {
|
|
218
|
+
const [theme, setTheme] = useState<'light' | 'dark'>(readChromeTheme);
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (typeof document === 'undefined') return;
|
|
221
|
+
const sync = () => setTheme(readChromeTheme());
|
|
222
|
+
sync(); // catch a value stamped between the initializer and this effect
|
|
223
|
+
const obs = new MutationObserver(sync);
|
|
224
|
+
obs.observe(document.documentElement, {
|
|
225
|
+
attributes: true,
|
|
226
|
+
attributeFilter: ['data-maude-theme'],
|
|
227
|
+
});
|
|
228
|
+
return () => obs.disconnect();
|
|
229
|
+
}, []);
|
|
230
|
+
return theme;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Shared inline-formatting state for the three text editors (sticky / anchored /
|
|
235
|
+
* standalone) — the unification surface (item 4d). Cmd/Ctrl + B / I / U toggle
|
|
236
|
+
* bold / italic / underline WHILE editing (preventing the browser's native
|
|
237
|
+
* execCommand, which would inject markup the model can't read), preview live via
|
|
238
|
+
* `style`, and commit on the stroke via `fmtRef`. `strike` rides along unchanged
|
|
239
|
+
* (toolbar-only — no universal shortcut). One hook = identical behaviour across
|
|
240
|
+
* all three editors.
|
|
241
|
+
*/
|
|
242
|
+
function useEditorFormat(initial: EditorFmt): {
|
|
243
|
+
fmtRef: { current: EditorFmt };
|
|
244
|
+
style: CSSProperties;
|
|
245
|
+
onFormatKey: (e: ReactKeyboardEvent) => boolean;
|
|
246
|
+
} {
|
|
247
|
+
const [bold, setBold] = useState(!!initial.bold);
|
|
248
|
+
const [italic, setItalic] = useState(!!initial.italic);
|
|
249
|
+
const [underline, setUnderline] = useState(!!initial.underline);
|
|
250
|
+
const [strike, setStrike] = useState(!!initial.strike);
|
|
251
|
+
// FigJam v3 — edit-mode toolbar extensions: size + alignment preview live in
|
|
252
|
+
// the editor and commit with the text (normFmt carries them through).
|
|
253
|
+
const [fontSize, setFontSize] = useState<number | undefined>(initial.fontSize);
|
|
254
|
+
const [align, setAlign] = useState<TextAlign | undefined>(initial.align);
|
|
255
|
+
const fmtRef = useRef<EditorFmt>({ bold, italic, underline, strike, fontSize, align });
|
|
256
|
+
fmtRef.current = { bold, italic, underline, strike, fontSize, align };
|
|
257
|
+
const style: CSSProperties = {
|
|
258
|
+
fontWeight: bold ? 700 : undefined,
|
|
259
|
+
fontStyle: italic ? 'italic' : undefined,
|
|
260
|
+
textDecoration: textDecoCss(strike, underline),
|
|
261
|
+
...(fontSize != null && fontSize !== initial.fontSize ? { fontSize: `${fontSize}px` } : {}),
|
|
262
|
+
...(align && align !== initial.align ? { textAlign: align } : {}),
|
|
263
|
+
};
|
|
264
|
+
// FigJam v3 — the edit-mode context toolbar drives the editor through this
|
|
265
|
+
// event (mutating the STROKE mid-edit would re-render the contentEditable
|
|
266
|
+
// and clobber typed text). The editor echoes its state back so the toolbar's
|
|
267
|
+
// pressed-states track live.
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
if (typeof document === 'undefined') return;
|
|
270
|
+
const onFmt = (e: Event) => {
|
|
271
|
+
const d = (e as CustomEvent<{ key?: string; value?: unknown }>).detail;
|
|
272
|
+
if (!d?.key) return;
|
|
273
|
+
if (d.key === 'bold') setBold((v) => !v);
|
|
274
|
+
else if (d.key === 'italic') setItalic((v) => !v);
|
|
275
|
+
else if (d.key === 'underline') setUnderline((v) => !v);
|
|
276
|
+
else if (d.key === 'strike') setStrike((v) => !v);
|
|
277
|
+
else if (d.key === 'fontSize' && typeof d.value === 'number') setFontSize(d.value);
|
|
278
|
+
else if (d.key === 'align' && typeof d.value === 'string') setAlign(d.value as TextAlign);
|
|
279
|
+
};
|
|
280
|
+
document.addEventListener('maude:editor-format', onFmt);
|
|
281
|
+
return () => document.removeEventListener('maude:editor-format', onFmt);
|
|
282
|
+
}, []);
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (typeof document === 'undefined') return;
|
|
285
|
+
const broadcast = () => {
|
|
286
|
+
document.dispatchEvent(
|
|
287
|
+
new CustomEvent('maude:editor-format-state', {
|
|
288
|
+
detail: { bold, italic, underline, strike, fontSize, align },
|
|
289
|
+
})
|
|
290
|
+
);
|
|
291
|
+
};
|
|
292
|
+
broadcast();
|
|
293
|
+
// The toolbar may mount AFTER the editor's first broadcast — it asks.
|
|
294
|
+
document.addEventListener('maude:editor-format-request', broadcast);
|
|
295
|
+
return () => document.removeEventListener('maude:editor-format-request', broadcast);
|
|
296
|
+
}, [bold, italic, underline, strike, fontSize, align]);
|
|
297
|
+
const onFormatKey = useCallback((e: ReactKeyboardEvent): boolean => {
|
|
298
|
+
if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false;
|
|
299
|
+
const k = e.key.toLowerCase();
|
|
300
|
+
if (k === 'b') {
|
|
301
|
+
e.preventDefault();
|
|
302
|
+
setBold((v) => !v);
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
if (k === 'i') {
|
|
306
|
+
e.preventDefault();
|
|
307
|
+
setItalic((v) => !v);
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
if (k === 'u') {
|
|
311
|
+
e.preventDefault();
|
|
312
|
+
setUnderline((v) => !v);
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}, []);
|
|
317
|
+
return { fmtRef, style, onFormatKey };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
|
|
321
|
+
// Re-exported so the existing test import (`from '../annotations-layer.tsx'`)
|
|
322
|
+
// and the byte-identical canary keep working.
|
|
323
|
+
export { arrowHeadPoints } from './canvas-arrowheads.ts';
|
|
324
|
+
|
|
325
|
+
function isEditable(t: EventTarget | null): boolean {
|
|
326
|
+
if (!t || !(t as HTMLElement).tagName) return false;
|
|
327
|
+
const el = t as HTMLElement;
|
|
328
|
+
const tag = el.tagName;
|
|
329
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
|
330
|
+
if (el.isContentEditable) return true;
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function deriveFile(): string | undefined {
|
|
335
|
+
if (typeof window === 'undefined') return undefined;
|
|
336
|
+
try {
|
|
337
|
+
const p = window.location.pathname;
|
|
338
|
+
if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
|
|
339
|
+
const qs = new URLSearchParams(window.location.search);
|
|
340
|
+
const canvas = qs.get('canvas') ?? '';
|
|
341
|
+
const designRel = (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
|
|
342
|
+
return `${designRel}/${canvas}`;
|
|
343
|
+
}
|
|
344
|
+
return decodeURIComponent(p).replace(/^\//, '');
|
|
345
|
+
} catch {
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Phase 23 — the served designRoot prefix for the current canvas document. An
|
|
352
|
+
* image stroke persists its href as a RELATIVE `assets/<sha8>.<ext>` path (the
|
|
353
|
+
* shape the sanitizer allowlists), but a bare relative href resolves against
|
|
354
|
+
* `/_canvas-shell.html` → `/assets/…`, which the canvas-origin gate 403s. The
|
|
355
|
+
* real served path is `/<designRel>/assets/…`, so the render must prefix it.
|
|
356
|
+
*/
|
|
357
|
+
function canvasDesignRel(): string {
|
|
358
|
+
if (typeof window === 'undefined') return '.design';
|
|
359
|
+
try {
|
|
360
|
+
const p = window.location.pathname;
|
|
361
|
+
if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
|
|
362
|
+
const qs = new URLSearchParams(window.location.search);
|
|
363
|
+
return (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
|
|
364
|
+
}
|
|
365
|
+
// Direct `/<designRel>/<group>/<canvas>.tsx` route → the first path segment.
|
|
366
|
+
const seg = decodeURIComponent(p).replace(/^\/+/, '').split('/')[0];
|
|
367
|
+
return seg || '.design';
|
|
368
|
+
} catch {
|
|
369
|
+
return '.design';
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Resolve an image stroke href for the browser. The persisted `assets/<name>`
|
|
375
|
+
* form is rewritten to the served `/<designRel>/assets/<name>` path; an
|
|
376
|
+
* optimistic `blob:`/`data:` preview href (pre-upload) is used verbatim.
|
|
377
|
+
*/
|
|
378
|
+
function resolveAssetHref(href: string): string {
|
|
379
|
+
return /^assets\//.test(href) ? `/${canvasDesignRel()}/${href}` : href;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
383
|
+
// Styles
|
|
384
|
+
|
|
385
|
+
const ANNOT_CSS = `
|
|
386
|
+
.dc-annot-chrome {
|
|
387
|
+
/* Stacks directly above the centered tool toolbar (which is bottom:16px,
|
|
388
|
+
32px tall → top edge ~ bottom:48px). 8 px gap → chrome at bottom:60px.
|
|
389
|
+
Phase 21 — dark "marker tray" matching the FigJam selection bar. */
|
|
390
|
+
position: absolute;
|
|
391
|
+
left: 50%;
|
|
392
|
+
bottom: 64px;
|
|
393
|
+
transform: translateX(-50%);
|
|
394
|
+
display: flex;
|
|
395
|
+
align-items: center;
|
|
396
|
+
gap: 2px;
|
|
397
|
+
background: #26262b;
|
|
398
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
399
|
+
border-radius: 12px;
|
|
400
|
+
padding: 5px 8px;
|
|
401
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
402
|
+
font-size: 11px;
|
|
403
|
+
color: rgba(255,255,255,0.85);
|
|
404
|
+
z-index: 6;
|
|
405
|
+
box-shadow: 0 8px 28px rgba(0,0,0,0.34), 0 2px 6px rgba(0,0,0,0.22);
|
|
406
|
+
user-select: none;
|
|
407
|
+
}
|
|
408
|
+
.dc-annot-chrome .dc-annot-swatches { display: flex; align-items: center; gap: 1px; }
|
|
409
|
+
.dc-annot-chrome .dc-annot-sw {
|
|
410
|
+
width: 20px;
|
|
411
|
+
height: 20px;
|
|
412
|
+
border-radius: 50%;
|
|
413
|
+
border: 1px solid rgba(255,255,255,0.16);
|
|
414
|
+
cursor: pointer;
|
|
415
|
+
padding: 0;
|
|
416
|
+
appearance: none;
|
|
417
|
+
transition: transform 80ms ease;
|
|
418
|
+
}
|
|
419
|
+
.dc-annot-chrome .dc-annot-sw:hover { transform: scale(1.1); }
|
|
420
|
+
.dc-annot-chrome .dc-annot-sw[aria-pressed="true"] {
|
|
421
|
+
box-shadow: 0 0 0 2px #26262b, 0 0 0 3px rgba(255,255,255,0.92);
|
|
422
|
+
border-color: transparent;
|
|
423
|
+
}
|
|
424
|
+
.dc-annot-chrome .dc-annot-sw:focus-visible {
|
|
425
|
+
outline: 2px solid #ffffff;
|
|
426
|
+
outline-offset: 1px;
|
|
427
|
+
}
|
|
428
|
+
.dc-annot-chrome .dc-annot-sep {
|
|
429
|
+
width: 1px;
|
|
430
|
+
height: 16px;
|
|
431
|
+
align-self: center;
|
|
432
|
+
background: rgba(255,255,255,0.09);
|
|
433
|
+
margin: 0 4px;
|
|
434
|
+
}
|
|
435
|
+
.dc-annot-chrome .dc-annot-fill {
|
|
436
|
+
width: 20px;
|
|
437
|
+
height: 20px;
|
|
438
|
+
border-radius: 50%;
|
|
439
|
+
border: 1px solid rgba(255,255,255,0.16);
|
|
440
|
+
cursor: pointer;
|
|
441
|
+
padding: 0;
|
|
442
|
+
appearance: none;
|
|
443
|
+
position: relative;
|
|
444
|
+
transition: transform 80ms ease;
|
|
445
|
+
}
|
|
446
|
+
.dc-annot-chrome .dc-annot-fill:hover { transform: scale(1.1); }
|
|
447
|
+
.dc-annot-chrome .dc-annot-fill:focus-visible {
|
|
448
|
+
outline: 2px solid #ffffff;
|
|
449
|
+
outline-offset: 1px;
|
|
450
|
+
}
|
|
451
|
+
.dc-annot-chrome .dc-annot-fill--none {
|
|
452
|
+
background: #3a3a40;
|
|
453
|
+
}
|
|
454
|
+
.dc-annot-chrome .dc-annot-fill--none::after {
|
|
455
|
+
content: "";
|
|
456
|
+
position: absolute; inset: 4px;
|
|
457
|
+
border-radius: 50%;
|
|
458
|
+
background:
|
|
459
|
+
linear-gradient(135deg, transparent 44%, rgba(255,255,255,0.55) 44%, rgba(255,255,255,0.55) 56%, transparent 56%);
|
|
460
|
+
}
|
|
461
|
+
.dc-annot-chrome .dc-annot-fill[aria-pressed="true"] {
|
|
462
|
+
box-shadow: 0 0 0 2px #26262b, 0 0 0 3px rgba(255,255,255,0.92);
|
|
463
|
+
border-color: transparent;
|
|
464
|
+
}
|
|
465
|
+
/* Phase 21 — icon buttons (light glyph on dark, white-tint active). */
|
|
466
|
+
.dc-annot-chrome .dc-annot-ibtn {
|
|
467
|
+
appearance: none;
|
|
468
|
+
background: transparent;
|
|
469
|
+
border: 0;
|
|
470
|
+
border-radius: 7px;
|
|
471
|
+
width: 26px;
|
|
472
|
+
height: 26px;
|
|
473
|
+
display: inline-flex;
|
|
474
|
+
align-items: center;
|
|
475
|
+
justify-content: center;
|
|
476
|
+
color: rgba(255,255,255,0.78);
|
|
477
|
+
cursor: pointer;
|
|
478
|
+
padding: 0;
|
|
479
|
+
transition: background-color 80ms linear, color 80ms linear;
|
|
480
|
+
}
|
|
481
|
+
.dc-annot-chrome .dc-annot-ibtn:hover {
|
|
482
|
+
background: rgba(255,255,255,0.1);
|
|
483
|
+
color: #ffffff;
|
|
484
|
+
}
|
|
485
|
+
.dc-annot-chrome .dc-annot-ibtn[aria-pressed="true"] {
|
|
486
|
+
background: rgba(255,255,255,0.18);
|
|
487
|
+
color: #ffffff;
|
|
488
|
+
}
|
|
489
|
+
.dc-annot-chrome .dc-annot-ibtn:focus-visible {
|
|
490
|
+
outline: 2px solid #ffffff;
|
|
491
|
+
outline-offset: -2px;
|
|
492
|
+
}
|
|
493
|
+
@media (prefers-reduced-motion: reduce) {
|
|
494
|
+
.dc-annot-chrome .dc-annot-ibtn, .dc-annot-chrome .dc-annot-sw, .dc-annot-chrome .dc-annot-fill { transition: none; }
|
|
495
|
+
}
|
|
496
|
+
.dc-annot-input {
|
|
497
|
+
position: absolute;
|
|
498
|
+
inset: 0;
|
|
499
|
+
z-index: 4;
|
|
500
|
+
}
|
|
501
|
+
.dc-annot-svg {
|
|
502
|
+
position: absolute;
|
|
503
|
+
left: 0;
|
|
504
|
+
top: 0;
|
|
505
|
+
/*
|
|
506
|
+
* .dc-world has no intrinsic dimensions — its children render via absolute
|
|
507
|
+
* positioning. An SVG inside with width:100%/height:100% resolves to 0 px
|
|
508
|
+
* and Chrome clips children even under overflow:visible. We hardcode a
|
|
509
|
+
* very large width/height instead so the SVG viewport easily covers any
|
|
510
|
+
* world-coord stroke. vector-effect="non-scaling-stroke" on every stroke
|
|
511
|
+
* keeps thickness px-constant under CSS zoom; overflow:visible covers the
|
|
512
|
+
* rare edge case of a stroke straying outside this 200k box.
|
|
513
|
+
*/
|
|
514
|
+
width: 200000px;
|
|
515
|
+
height: 200000px;
|
|
516
|
+
overflow: visible;
|
|
517
|
+
pointer-events: none;
|
|
518
|
+
}
|
|
519
|
+
/* Drag-select marquee — rendered while user is dragging to select strokes. */
|
|
520
|
+
.dc-annot-marquee {
|
|
521
|
+
pointer-events: none;
|
|
522
|
+
fill: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
|
|
523
|
+
stroke: var(--maude-hud-accent, #d63b1f);
|
|
524
|
+
stroke-width: 1;
|
|
525
|
+
stroke-dasharray: 4 3;
|
|
526
|
+
}
|
|
527
|
+
/* Phase 24 — sticky-note body. Word-wrapped multi-line text inside the card's
|
|
528
|
+
foreignObject. Text sits TOP-LEFT (FigJam parity); the editor contentEditable
|
|
529
|
+
mirrors the same box metrics so the read-edit swap doesn't shift the text.
|
|
530
|
+
text-align is overridden inline per-sticky when align is not left. */
|
|
531
|
+
.dc-sticky-body {
|
|
532
|
+
width: 100%;
|
|
533
|
+
height: 100%;
|
|
534
|
+
box-sizing: border-box;
|
|
535
|
+
padding: 14px 16px;
|
|
536
|
+
display: flex;
|
|
537
|
+
align-items: flex-start;
|
|
538
|
+
justify-content: flex-start;
|
|
539
|
+
text-align: left;
|
|
540
|
+
color: #2a2a28;
|
|
541
|
+
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
542
|
+
line-height: 1.35;
|
|
543
|
+
white-space: pre-wrap;
|
|
544
|
+
overflow-wrap: anywhere;
|
|
545
|
+
overflow: hidden;
|
|
546
|
+
}
|
|
547
|
+
/* Phase 24 — while editing an annotation's text (a text label OR a sticky body,
|
|
548
|
+
both carry the dc-annot-editor class), force the I-beam. The important flag
|
|
549
|
+
plus the class selector beat use-tool-mode's blanket star-cursor rule (you
|
|
550
|
+
usually open the editor from MOVE mode, whose move glyph would otherwise sit
|
|
551
|
+
over the text you're typing into). The element's inline text cursor can't win
|
|
552
|
+
that fight on its own — a non-important inline style loses to !important.
|
|
553
|
+
See DDR-067. (No backticks in this comment: the whole block is a JS template
|
|
554
|
+
literal, so a backtick here would terminate the string.) */
|
|
555
|
+
.dc-annot-editor, .dc-annot-editor * { cursor: text !important; }
|
|
556
|
+
/* FigJam v3 — connection dots on a selected bindable shape. The important flag
|
|
557
|
+
beats use-tool-mode's blanket star-cursor rule (same fight as the editor +
|
|
558
|
+
resize handles — DDR-067). */
|
|
559
|
+
.dc-annot-conn-dot { cursor: crosshair !important; }
|
|
560
|
+
`.trim();
|
|
561
|
+
|
|
562
|
+
function ensureAnnotStyles(): void {
|
|
563
|
+
if (typeof document === 'undefined') return;
|
|
564
|
+
if (document.getElementById('dc-annot-css')) return;
|
|
565
|
+
const s = document.createElement('style');
|
|
566
|
+
s.id = 'dc-annot-css';
|
|
567
|
+
s.textContent = ANNOT_CSS;
|
|
568
|
+
document.head.appendChild(s);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
572
|
+
// Strokes store — lifted out of the layer so the contextual toolbar (Phase 5.1
|
|
573
|
+
// Task 8) can mutate strokes without prop-drilling.
|
|
574
|
+
|
|
575
|
+
export interface StrokesStoreValue {
|
|
576
|
+
strokes: Stroke[];
|
|
577
|
+
setStrokes: (next: Stroke[]) => void;
|
|
578
|
+
updateStroke: (id: string, patch: Partial<Stroke>) => void;
|
|
579
|
+
deleteStrokes: (ids: string[]) => void;
|
|
580
|
+
translateStrokes: (ids: string[], dx: number, dy: number) => void;
|
|
581
|
+
/**
|
|
582
|
+
* FigJam v3 — bulk mutation in ONE undo record (the per-stroke
|
|
583
|
+
* `updateStroke` loop the context toolbar used pre-v3 pushed N records for
|
|
584
|
+
* an N-stroke selection). `fn` returns the patch for a stroke or null to
|
|
585
|
+
* leave it untouched.
|
|
586
|
+
*/
|
|
587
|
+
applyToStrokes: (
|
|
588
|
+
ids: readonly string[],
|
|
589
|
+
fn: (s: Stroke) => Partial<Stroke> | null,
|
|
590
|
+
label?: string
|
|
591
|
+
) => void;
|
|
592
|
+
/** Group the (expanded) selection; returns the member ids to select, or null. */
|
|
593
|
+
groupSelection: (ids: readonly string[]) => string[] | null;
|
|
594
|
+
/** Dissolve the outermost group of every selected stroke. */
|
|
595
|
+
ungroupSelection: (ids: readonly string[]) => void;
|
|
596
|
+
/** Cmd+D / paste — clone with fresh ids; returns the clone ids to select. */
|
|
597
|
+
duplicateSelection: (ids: readonly string[], dx: number, dy: number) => string[];
|
|
598
|
+
/** Z-order — `]` `[` `Cmd+]` `Cmd+[`; group units move contiguously. */
|
|
599
|
+
reorderSelection: (ids: readonly string[], op: ZOrderOp) => void;
|
|
600
|
+
alignSelection: (ids: readonly string[], edge: AlignEdge) => void;
|
|
601
|
+
distributeSelection: (ids: readonly string[], axis: DistributeAxis) => void;
|
|
602
|
+
/**
|
|
603
|
+
* Wave H — transient gesture preview: applies the patch to local React
|
|
604
|
+
* state ONLY (no undo record, no persistence), exactly like the move-drag's
|
|
605
|
+
* per-tick path. Close the gesture with `commitGesture` on pointerup so the
|
|
606
|
+
* whole drag lands as ONE undo record (undo used to walk every resize px).
|
|
607
|
+
*/
|
|
608
|
+
previewStroke: (id: string, patch: Partial<Stroke>) => void;
|
|
609
|
+
/** Wave H — single undo record from a preview gesture's start snapshot. */
|
|
610
|
+
commitGesture: (before: readonly Stroke[], label?: string) => void;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const StrokesStoreContext = createContext<StrokesStoreValue | null>(null);
|
|
614
|
+
|
|
615
|
+
export function useStrokesStore(): StrokesStoreValue | null {
|
|
616
|
+
return useContext(StrokesStoreContext);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
620
|
+
// FigJam v3 — one-time contextual hints (first-use discoverability). Behaviour-
|
|
621
|
+
// triggered micro-toasts, never a modal tour: each key fires at most once per
|
|
622
|
+
// browser profile (localStorage bitmap), reusing the existing canvas toast.
|
|
623
|
+
|
|
624
|
+
// Chrome elements never deselect. Includes the per-shape context toolbar,
|
|
625
|
+
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
626
|
+
// right-click menu. Clicks on these route to their own handlers.
|
|
627
|
+
const CHROME_SELECTOR =
|
|
628
|
+
'.dc-annot-conn-dot, .dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle, .dc-annot-rotate-zone, .dc-annot-editor';
|
|
629
|
+
|
|
630
|
+
const HINTS_KEY = 'maude-annot-hints-v1';
|
|
631
|
+
|
|
632
|
+
function showOnceHint(key: string, msg: string): void {
|
|
633
|
+
if (typeof window === 'undefined') return;
|
|
634
|
+
try {
|
|
635
|
+
const seen = JSON.parse(window.localStorage.getItem(HINTS_KEY) || '{}') as Record<
|
|
636
|
+
string,
|
|
637
|
+
number
|
|
638
|
+
>;
|
|
639
|
+
if (seen[key]) return;
|
|
640
|
+
seen[key] = 1;
|
|
641
|
+
window.localStorage.setItem(HINTS_KEY, JSON.stringify(seen));
|
|
642
|
+
} catch {
|
|
643
|
+
return; // storage blocked — skip rather than re-toast forever
|
|
644
|
+
}
|
|
645
|
+
showCanvasToast(msg);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Annotations visibility now lives in use-annotations-visibility.tsx so the
|
|
649
|
+
// ToolPalette (a sibling under CanvasRouter, not a descendant of this layer)
|
|
650
|
+
// can read the same state. Re-exported here for back-compat.
|
|
651
|
+
export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
652
|
+
|
|
653
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
654
|
+
// Component
|
|
655
|
+
|
|
656
|
+
export function AnnotationsLayer() {
|
|
657
|
+
ensureAnnotStyles();
|
|
658
|
+
const { tool, setTool, sticky, tools, shapeKind } = useToolMode();
|
|
659
|
+
const theme = useCanvasChromeTheme();
|
|
660
|
+
const controller = useViewportControllerContext();
|
|
661
|
+
const vp = controller?.viewport ?? null;
|
|
662
|
+
const worldRef = useWorldRefContext();
|
|
663
|
+
const annotSel = useAnnotationSelectionOptional();
|
|
664
|
+
const elementSel = useSelectionSetOptional();
|
|
665
|
+
|
|
666
|
+
const [strokes, setStrokesState] = useState<Stroke[]>([]);
|
|
667
|
+
const [drawing, setDrawing] = useState<Stroke | null>(null);
|
|
668
|
+
// Theme-aware live default ink (items 3/5/6). Initialized from the current
|
|
669
|
+
// theme; tracked-untouched until the user picks a swatch, after which it
|
|
670
|
+
// sticks (colorTouchedRef). The themed-default effect lives below.
|
|
671
|
+
const [color, setColorState] = useState<string>(() => resolveDefaultInk(theme));
|
|
672
|
+
const colorTouchedRef = useRef(false);
|
|
673
|
+
const setColor = useCallback((c: string) => {
|
|
674
|
+
colorTouchedRef.current = true;
|
|
675
|
+
setColorState(c);
|
|
676
|
+
}, []);
|
|
677
|
+
// While the user hasn't picked an ink swatch yet, follow the theme — so a
|
|
678
|
+
// dark canvas arms a light-reading default and a light canvas a dark one.
|
|
679
|
+
useEffect(() => {
|
|
680
|
+
if (!colorTouchedRef.current) setColorState(resolveDefaultInk(theme));
|
|
681
|
+
}, [theme]);
|
|
682
|
+
// Shape default fill (item 2). A freshly-armed Shape tool gets a fill (not
|
|
683
|
+
// outline-only); the index-paired tint of the active ink. Untouched until the
|
|
684
|
+
// user picks a fill swatch (incl. "No fill"), after which it sticks.
|
|
685
|
+
const [fill, setFillState] = useState<string | null>(null);
|
|
686
|
+
const fillTouchedRef = useRef(false);
|
|
687
|
+
const setFill = useCallback((f: string | null) => {
|
|
688
|
+
fillTouchedRef.current = true;
|
|
689
|
+
setFillState(f);
|
|
690
|
+
}, []);
|
|
691
|
+
// While the Shape tool is armed and the fill is untouched, follow the ink
|
|
692
|
+
// (and theme) so a square/circle/diamond lands with a paired-tint wash. Once
|
|
693
|
+
// the user picks a fill swatch (or "No fill"), this stops.
|
|
694
|
+
useEffect(() => {
|
|
695
|
+
if (tool === 'shape' && !fillTouchedRef.current) setFillState(defaultFillFor(color, theme));
|
|
696
|
+
}, [tool, color, theme]);
|
|
697
|
+
const [thickness, setThickness] = useState<Thickness>(STROKE_WIDTH_THIN);
|
|
698
|
+
// Phase 21 — draw-time paper tint for the sticky tool (recolor-after via the
|
|
699
|
+
// context toolbar). Separate from `color` (ink for pen/rect/text/arrow).
|
|
700
|
+
const [stickyColor, setStickyColor] = useState<string>(DEFAULT_STICKY_COLOR);
|
|
701
|
+
// Annotation polish (item 8) — draw-time highlighter marker hue + nib width.
|
|
702
|
+
const [highlighterColor, setHighlighterColor] = useState<string>(DEFAULT_HIGHLIGHTER_COLOR);
|
|
703
|
+
const [highlighterWidth, setHighlighterWidth] = useState<number>(DEFAULT_HIGHLIGHTER_WIDTH);
|
|
704
|
+
// Phase 21 — a standalone-text caret waiting for its first keystroke. No
|
|
705
|
+
// stroke exists yet (mirrors anchored text: the stroke is born on commit,
|
|
706
|
+
// so an abandoned empty caret leaves nothing behind / no undo record).
|
|
707
|
+
const [pendingText, setPendingText] = useState<{ x: number; y: number } | null>(null);
|
|
708
|
+
// Phase 24 — ghost placeholder: world coords the cursor is hovering while a
|
|
709
|
+
// shape/sticky/text tool is armed and nothing is being drawn yet. Pure chrome
|
|
710
|
+
// (low-opacity, pointer-events:none) — never selectable, hit-tested, or saved.
|
|
711
|
+
const [ghost, setGhost] = useState<{ x: number; y: number } | null>(null);
|
|
712
|
+
// FigJam v3 — smart-guide lines painted while a drag is snapping, and the
|
|
713
|
+
// id of the host a dragged arrow endpoint would bind to (accent halo).
|
|
714
|
+
const [snapGuides, setSnapGuides] = useState<SnapGuide[] | null>(null);
|
|
715
|
+
const [bindHintId, setBindHintId] = useState<string | null>(null);
|
|
716
|
+
// Cmd/Ctrl held — suppresses binding at arrow draw-end (FigJam: ⌘ keeps the
|
|
717
|
+
// endpoint free). Tracked here because endStroke (pointerup) carries no
|
|
718
|
+
// modifier state of its own.
|
|
719
|
+
const cmdHeldRef = useRef(false);
|
|
720
|
+
const vpRef = useRef(vp);
|
|
721
|
+
vpRef.current = vp;
|
|
722
|
+
const visibilityCtx = useAnnotationsVisibility();
|
|
723
|
+
const visible = visibilityCtx?.visible ?? true;
|
|
724
|
+
const setVisible = useCallback(
|
|
725
|
+
(next: boolean | ((cur: boolean) => boolean)) => {
|
|
726
|
+
if (!visibilityCtx) return;
|
|
727
|
+
const v =
|
|
728
|
+
typeof next === 'function'
|
|
729
|
+
? (next as (cur: boolean) => boolean)(visibilityCtx.visible)
|
|
730
|
+
: next;
|
|
731
|
+
visibilityCtx.setVisible(v);
|
|
732
|
+
},
|
|
733
|
+
[visibilityCtx]
|
|
734
|
+
);
|
|
735
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
736
|
+
|
|
737
|
+
const fileRef = useRef<string | undefined>(undefined);
|
|
738
|
+
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
739
|
+
const drawingRef = useRef<Stroke | null>(null);
|
|
740
|
+
drawingRef.current = drawing;
|
|
741
|
+
// Phase 24 — pointer-down anchor + last cursor (world coords) for the active
|
|
742
|
+
// draw, so the resize modifiers (Shift 1:1 / Alt from-center) can re-constrain
|
|
743
|
+
// the draft on a bare keydown/keyup, not just on pointer-move.
|
|
744
|
+
const drawAnchorRef = useRef<{ x: number; y: number } | null>(null);
|
|
745
|
+
const lastDrawPointRef = useRef<{ x: number; y: number } | null>(null);
|
|
746
|
+
/**
|
|
747
|
+
* Phase 20 — latest strokes mirror so command builders can read the
|
|
748
|
+
* pre-mutation snapshot synchronously (React state isn't refreshed
|
|
749
|
+
* between rapid taps in the same tick).
|
|
750
|
+
*/
|
|
751
|
+
const strokesRef = useRef<Stroke[]>(strokes);
|
|
752
|
+
strokesRef.current = strokes;
|
|
753
|
+
|
|
754
|
+
const isDraw =
|
|
755
|
+
tool === 'pen' ||
|
|
756
|
+
tool === 'highlighter' ||
|
|
757
|
+
tool === 'shape' ||
|
|
758
|
+
tool === 'arrow' ||
|
|
759
|
+
tool === 'sticky' ||
|
|
760
|
+
tool === 'section' ||
|
|
761
|
+
tool === 'text';
|
|
762
|
+
const isErase = tool === 'eraser';
|
|
763
|
+
const isActive = isDraw || isErase;
|
|
764
|
+
// T20 / Phase 24 — every shape primitive carries stroke weight (FigJam ships
|
|
765
|
+
// thickness on all of them). The annotation toolbar reads supportsThickness
|
|
766
|
+
// to decide whether to render the Thin / Thick chips.
|
|
767
|
+
const supportsThickness = tool === 'pen' || tool === 'arrow' || tool === 'shape';
|
|
768
|
+
const supportsFill = tool === 'shape';
|
|
769
|
+
// Phase 24 — tools that show a cursor-following ghost placeholder.
|
|
770
|
+
const ghostCapable = tool === 'shape' || tool === 'sticky' || tool === 'text';
|
|
771
|
+
|
|
772
|
+
// Clear the ghost when the active tool stops being ghost-capable (or
|
|
773
|
+
// visibility toggles) so a stale ghost never lingers after a tool change.
|
|
774
|
+
useEffect(() => {
|
|
775
|
+
if (!ghostCapable || !visible) setGhost(null);
|
|
776
|
+
}, [ghostCapable, visible]);
|
|
777
|
+
|
|
778
|
+
// Load existing annotations on mount.
|
|
779
|
+
// Phase 8 Task 5 — seed lastAppliedSvgRef so the first Y.Map observe (when
|
|
780
|
+
// collab connects shortly after this fetch lands) doesn't re-apply the
|
|
781
|
+
// same content we just hydrated from REST.
|
|
782
|
+
const lastAppliedSvgRef = useRef<string>('');
|
|
783
|
+
useEffect(() => {
|
|
784
|
+
const file = deriveFile();
|
|
785
|
+
fileRef.current = file;
|
|
786
|
+
if (!file) return;
|
|
787
|
+
let cancelled = false;
|
|
788
|
+
void fetch(`/_api/annotations?file=${encodeURIComponent(file)}`, {
|
|
789
|
+
headers: { Accept: 'image/svg+xml' },
|
|
790
|
+
})
|
|
791
|
+
.then((r) => (r.ok ? r.text() : ''))
|
|
792
|
+
.then((text) => {
|
|
793
|
+
if (cancelled) return;
|
|
794
|
+
const loaded = svgToStrokes(text);
|
|
795
|
+
if (loaded.length) {
|
|
796
|
+
setStrokesState(loaded);
|
|
797
|
+
lastAppliedSvgRef.current = text;
|
|
798
|
+
}
|
|
799
|
+
})
|
|
800
|
+
.catch(() => {
|
|
801
|
+
/* network blip — start with an empty annotation set */
|
|
802
|
+
});
|
|
803
|
+
return () => {
|
|
804
|
+
cancelled = true;
|
|
805
|
+
};
|
|
806
|
+
}, []);
|
|
807
|
+
|
|
808
|
+
// Phase 8 Task 5 — observe the Y.Map.annotations for live updates from
|
|
809
|
+
// other tabs. Bail when the incoming SVG STRING is identical to the one
|
|
810
|
+
// we last applied (covers the local echo round-trip without missing real
|
|
811
|
+
// foreign changes). The prior length+first/last-id check was wrong: a
|
|
812
|
+
// resize / move keeps the same id list, so all three predicates matched
|
|
813
|
+
// even though geometry changed — foreign edits silently disappeared.
|
|
814
|
+
const collab = useCollab();
|
|
815
|
+
useEffect(() => {
|
|
816
|
+
if (!collab) return;
|
|
817
|
+
const map = collab.doc.getMap<string>('annotations');
|
|
818
|
+
const apply = () => {
|
|
819
|
+
const svg = map.get('svg');
|
|
820
|
+
if (typeof svg !== 'string' || !svg) return;
|
|
821
|
+
if (svg === lastAppliedSvgRef.current) return;
|
|
822
|
+
lastAppliedSvgRef.current = svg;
|
|
823
|
+
setStrokesState(svgToStrokes(svg));
|
|
824
|
+
};
|
|
825
|
+
apply();
|
|
826
|
+
map.observe(apply);
|
|
827
|
+
return () => {
|
|
828
|
+
try {
|
|
829
|
+
map.unobserve(apply);
|
|
830
|
+
} catch {
|
|
831
|
+
/* doc destroyed before unmount */
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
}, [collab]);
|
|
835
|
+
|
|
836
|
+
const undoStack = useUndoStackOptional();
|
|
837
|
+
const undoSinks = useUndoSinks();
|
|
838
|
+
const undoStackRef = useRef(undoStack);
|
|
839
|
+
undoStackRef.current = undoStack;
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Apply a `Stroke[]` snapshot: update local React state AND fire-and-forget
|
|
843
|
+
* PUT to the server. Used as the `putFn` injected into the
|
|
844
|
+
* `AnnotationStrokesCommand` — both the initial push AND every undo/redo
|
|
845
|
+
* replay route through here, so the iframe's `strokes` state always
|
|
846
|
+
* tracks the server. (Without the setStrokesState here, Cmd+Z would
|
|
847
|
+
* silently PUT the prior SVG but the canvas would keep painting the
|
|
848
|
+
* post-edit strokes until the user reloaded.)
|
|
849
|
+
*
|
|
850
|
+
* The 200 ms scheduled-save debounce (legacy path) is cleared the moment
|
|
851
|
+
* we push a command, so the server only sees one PUT per edit instead
|
|
852
|
+
* of two-step racing.
|
|
853
|
+
*/
|
|
854
|
+
const putStrokes = useCallback((next: readonly Stroke[]) => {
|
|
855
|
+
setStrokesState(next as Stroke[]);
|
|
856
|
+
const file = fileRef.current;
|
|
857
|
+
if (!file) return Promise.resolve();
|
|
858
|
+
const svg = strokesToSvg(next);
|
|
859
|
+
// Phase 8 Task 5 — record the SVG we just authored locally so the
|
|
860
|
+
// server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
|
|
861
|
+
// Y.Map.observe) doesn't trigger a redundant setStrokesState.
|
|
862
|
+
lastAppliedSvgRef.current = svg;
|
|
863
|
+
return fetch('/_api/annotations', {
|
|
864
|
+
method: 'PUT',
|
|
865
|
+
headers: { 'Content-Type': 'application/json' },
|
|
866
|
+
body: JSON.stringify({ file, svg }),
|
|
867
|
+
})
|
|
868
|
+
.then(() => undefined)
|
|
869
|
+
.catch(() => {
|
|
870
|
+
/* swallow — user sees uncommitted state until the next stroke */
|
|
871
|
+
});
|
|
872
|
+
}, []);
|
|
873
|
+
|
|
874
|
+
// Register the strokes put sink with the undo provider so the rebuilt
|
|
875
|
+
// AnnotationStrokesCommand (after a canvas switch + return) routes through
|
|
876
|
+
// THIS iframe's React state, not the gone iframe's stale closures.
|
|
877
|
+
useEffect(() => {
|
|
878
|
+
undoSinks.setSink('strokesPutFn', putStrokes);
|
|
879
|
+
return () => undoSinks.setSink('strokesPutFn', undefined);
|
|
880
|
+
}, [undoSinks, putStrokes]);
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Single entry point for every stroke mutation. Builds an undo record
|
|
884
|
+
* and pushes onto the stack — `push()` rebuilds the command via the
|
|
885
|
+
* registered `strokesPutFn` sink and calls `cmd.do() = putStrokes(next)`,
|
|
886
|
+
* which both updates local state and PUTs. Cancels any pending debounced
|
|
887
|
+
* save first — DDR-050 gotcha: a queued auto-save flushing AFTER our PUT
|
|
888
|
+
* would race the stack into a stale state.
|
|
889
|
+
*/
|
|
890
|
+
const commitStrokes = useCallback(
|
|
891
|
+
(prev: readonly Stroke[], next: readonly Stroke[], label?: string) => {
|
|
892
|
+
if (saveTimerRef.current) {
|
|
893
|
+
clearTimeout(saveTimerRef.current);
|
|
894
|
+
saveTimerRef.current = null;
|
|
895
|
+
}
|
|
896
|
+
const record = buildAnnotationStrokesRecord({
|
|
897
|
+
before: prev,
|
|
898
|
+
after: next,
|
|
899
|
+
...(label ? { label } : {}),
|
|
900
|
+
});
|
|
901
|
+
void undoStackRef.current.push(record);
|
|
902
|
+
},
|
|
903
|
+
[]
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
const setStrokes = useCallback(
|
|
907
|
+
(next: Stroke[]) => {
|
|
908
|
+
const prev = strokesRef.current;
|
|
909
|
+
commitStrokes(prev, next);
|
|
910
|
+
},
|
|
911
|
+
[commitStrokes]
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
const strokesStore = useMemo<StrokesStoreValue>(() => {
|
|
915
|
+
const updateStroke = (id: string, patch: Partial<Stroke>): void => {
|
|
916
|
+
const prev = strokesRef.current;
|
|
917
|
+
// FigJam v3 — bound arrow endpoints re-derive from their host after ANY
|
|
918
|
+
// geometry patch (resize ticks included), so connectors track live.
|
|
919
|
+
const next = recomputeBoundArrows(
|
|
920
|
+
prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
|
|
921
|
+
);
|
|
922
|
+
commitStrokes(prev, next);
|
|
923
|
+
};
|
|
924
|
+
const deleteStrokes = (ids: string[]): void => {
|
|
925
|
+
const set = new Set(ids);
|
|
926
|
+
const prev = strokesRef.current;
|
|
927
|
+
const filtered = prev.filter(
|
|
928
|
+
(s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
|
|
929
|
+
);
|
|
930
|
+
if (filtered.length === prev.length) return;
|
|
931
|
+
// FigJam v3 — deleting a bind host strips the bind (endpoint frozen,
|
|
932
|
+
// arrow survives); singleton/empty groups dissolve (tldraw lifecycle).
|
|
933
|
+
commitStrokes(prev, recomputeBoundArrows(normalizeGroups(filtered)));
|
|
934
|
+
};
|
|
935
|
+
const translateStrokes = (ids: string[], dx: number, dy: number): void => {
|
|
936
|
+
const set = new Set(ids);
|
|
937
|
+
const prev = strokesRef.current;
|
|
938
|
+
const next = recomputeBoundArrows(
|
|
939
|
+
prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s))
|
|
940
|
+
);
|
|
941
|
+
commitStrokes(prev, next, `move ${ids.length} stroke${ids.length === 1 ? '' : 's'}`);
|
|
942
|
+
};
|
|
943
|
+
const applyToStrokes = (
|
|
944
|
+
ids: readonly string[],
|
|
945
|
+
fn: (s: Stroke) => Partial<Stroke> | null,
|
|
946
|
+
label?: string
|
|
947
|
+
): void => {
|
|
948
|
+
const set = new Set(ids);
|
|
949
|
+
const prev = strokesRef.current;
|
|
950
|
+
let touched = 0;
|
|
951
|
+
const next = recomputeBoundArrows(
|
|
952
|
+
prev.map((s) => {
|
|
953
|
+
if (!set.has(s.id)) return s;
|
|
954
|
+
const patch = fn(s);
|
|
955
|
+
if (!patch) return s;
|
|
956
|
+
touched++;
|
|
957
|
+
return { ...s, ...patch } as Stroke;
|
|
958
|
+
})
|
|
959
|
+
);
|
|
960
|
+
if (touched === 0) return;
|
|
961
|
+
commitStrokes(prev, next, label ?? `edit ${touched} stroke${touched === 1 ? '' : 's'}`);
|
|
962
|
+
};
|
|
963
|
+
const groupSelection = (ids: readonly string[]): string[] | null => {
|
|
964
|
+
const prev = strokesRef.current;
|
|
965
|
+
const res = groupStrokes(prev, ids);
|
|
966
|
+
if (!res) return null;
|
|
967
|
+
commitStrokes(prev, res.strokes, `group ${res.memberIds.length} strokes`);
|
|
968
|
+
return res.memberIds;
|
|
969
|
+
};
|
|
970
|
+
const ungroupSelection = (ids: readonly string[]): void => {
|
|
971
|
+
const prev = strokesRef.current;
|
|
972
|
+
const next = ungroupStrokes(prev, ids);
|
|
973
|
+
if (strokesShallowEqual(prev, next)) return;
|
|
974
|
+
commitStrokes(prev, next, 'ungroup');
|
|
975
|
+
};
|
|
976
|
+
const duplicateSelection = (ids: readonly string[], dx: number, dy: number): string[] => {
|
|
977
|
+
const prev = strokesRef.current;
|
|
978
|
+
const res = duplicateStrokes(prev, ids, dx, dy);
|
|
979
|
+
if (res.strokes.length === prev.length) return [];
|
|
980
|
+
const added = res.strokes.length - prev.length;
|
|
981
|
+
commitStrokes(prev, res.strokes, `duplicate ${added} stroke${added === 1 ? '' : 's'}`);
|
|
982
|
+
return res.newIds;
|
|
983
|
+
};
|
|
984
|
+
const reorderSelection = (ids: readonly string[], op: ZOrderOp): void => {
|
|
985
|
+
const prev = strokesRef.current;
|
|
986
|
+
const next = reorderStrokes(prev, ids, op);
|
|
987
|
+
if (strokesShallowEqual(prev, next)) return;
|
|
988
|
+
commitStrokes(
|
|
989
|
+
prev,
|
|
990
|
+
next,
|
|
991
|
+
op === 'front' || op === 'forward' ? 'bring forward' : 'send backward'
|
|
992
|
+
);
|
|
993
|
+
};
|
|
994
|
+
const alignSelection = (ids: readonly string[], edge: AlignEdge): void => {
|
|
995
|
+
const prev = strokesRef.current;
|
|
996
|
+
const next = recomputeBoundArrows(alignStrokes(prev, ids, edge));
|
|
997
|
+
if (strokesShallowEqual(prev, next)) return;
|
|
998
|
+
commitStrokes(prev, next, `align ${edge}`);
|
|
999
|
+
};
|
|
1000
|
+
const distributeSelection = (ids: readonly string[], axis: DistributeAxis): void => {
|
|
1001
|
+
const prev = strokesRef.current;
|
|
1002
|
+
const next = recomputeBoundArrows(distributeStrokes(prev, ids, axis));
|
|
1003
|
+
if (strokesShallowEqual(prev, next)) return;
|
|
1004
|
+
commitStrokes(prev, next, 'distribute');
|
|
1005
|
+
};
|
|
1006
|
+
// Wave H — transient per-tick path for handle drags (resize / rotate /
|
|
1007
|
+
// endpoint re-anchor). Local React state only — no undo push, no PUT —
|
|
1008
|
+
// mirroring the move-drag's onMove. `commitGesture` closes it as ONE
|
|
1009
|
+
// record (no-op when the gesture ended where it started).
|
|
1010
|
+
const previewStroke = (id: string, patch: Partial<Stroke>): void => {
|
|
1011
|
+
setStrokesState(
|
|
1012
|
+
recomputeBoundArrows(
|
|
1013
|
+
strokesRef.current.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
|
|
1014
|
+
)
|
|
1015
|
+
);
|
|
1016
|
+
};
|
|
1017
|
+
const commitGesture = (before: readonly Stroke[], label?: string): void => {
|
|
1018
|
+
const cur = strokesRef.current;
|
|
1019
|
+
if (strokesShallowEqual(before, cur)) return;
|
|
1020
|
+
commitStrokes(before, cur, label);
|
|
1021
|
+
};
|
|
1022
|
+
return {
|
|
1023
|
+
strokes,
|
|
1024
|
+
setStrokes,
|
|
1025
|
+
updateStroke,
|
|
1026
|
+
deleteStrokes,
|
|
1027
|
+
translateStrokes,
|
|
1028
|
+
applyToStrokes,
|
|
1029
|
+
groupSelection,
|
|
1030
|
+
ungroupSelection,
|
|
1031
|
+
duplicateSelection,
|
|
1032
|
+
reorderSelection,
|
|
1033
|
+
alignSelection,
|
|
1034
|
+
distributeSelection,
|
|
1035
|
+
previewStroke,
|
|
1036
|
+
commitGesture,
|
|
1037
|
+
};
|
|
1038
|
+
}, [strokes, setStrokes, commitStrokes]);
|
|
1039
|
+
|
|
1040
|
+
// Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
|
|
1041
|
+
// dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
|
|
1042
|
+
// (those providers are above us); we own visibility + annotation-select-all
|
|
1043
|
+
// because they read this layer's local state.
|
|
1044
|
+
useEffect(() => {
|
|
1045
|
+
if (typeof window === 'undefined') return;
|
|
1046
|
+
const onMessage = (e: MessageEvent) => {
|
|
1047
|
+
const m = e.data as { dgn?: string; visible?: boolean } | null;
|
|
1048
|
+
if (!m || typeof m !== 'object' || !m.dgn) return;
|
|
1049
|
+
if (m.dgn === 'view-annotations') {
|
|
1050
|
+
if (typeof m.visible === 'boolean') setVisible(m.visible);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
if (m.dgn === 'annotation-select-all') {
|
|
1054
|
+
if (annotSel) annotSel.replace(strokes.map((s) => s.id));
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
window.addEventListener('message', onMessage);
|
|
1058
|
+
return () => window.removeEventListener('message', onMessage);
|
|
1059
|
+
}, [annotSel, strokes, setVisible]);
|
|
1060
|
+
|
|
1061
|
+
// Document-level toggle: Shift+P (presentation). Annotation-shortcut help is
|
|
1062
|
+
// owned by the dev-server menubar (Help button); we no longer ship an
|
|
1063
|
+
// in-canvas help dialog from this layer.
|
|
1064
|
+
useEffect(() => {
|
|
1065
|
+
if (typeof document === 'undefined') return;
|
|
1066
|
+
const onKey = (e: KeyboardEvent) => {
|
|
1067
|
+
if (isEditable(e.target)) return;
|
|
1068
|
+
if (e.key === 'P' && e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
1069
|
+
e.preventDefault();
|
|
1070
|
+
setVisible((v) => !v);
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
document.addEventListener('keydown', onKey, true);
|
|
1074
|
+
return () => document.removeEventListener('keydown', onKey, true);
|
|
1075
|
+
}, [setVisible]);
|
|
1076
|
+
|
|
1077
|
+
const screenToWorld = useCallback(
|
|
1078
|
+
(cx: number, cy: number): [number, number] => {
|
|
1079
|
+
const v = vp ?? { x: 0, y: 0, zoom: 1 };
|
|
1080
|
+
const z = v.zoom || 1;
|
|
1081
|
+
return [(cx - v.x) / z, (cy - v.y) / z];
|
|
1082
|
+
},
|
|
1083
|
+
[vp]
|
|
1084
|
+
);
|
|
1085
|
+
|
|
1086
|
+
// ── Phase 23 — media intake (drag-drop / paste / file-picker / link input) ──
|
|
1087
|
+
// Image: optimistic blob: preview stroke (LOCAL only — never persisted) →
|
|
1088
|
+
// POST /_api/asset → swap href to assets/… and commit (one undo record) →
|
|
1089
|
+
// revoke the blob URL. On failure the optimistic stroke is removed + a toast.
|
|
1090
|
+
const createImageFromFile = useCallback(
|
|
1091
|
+
(file: File, world: [number, number]) => {
|
|
1092
|
+
if (typeof window === 'undefined' || !file.type.startsWith('image/')) return;
|
|
1093
|
+
const blobUrl = URL.createObjectURL(file);
|
|
1094
|
+
const probe = new Image();
|
|
1095
|
+
probe.onload = () => {
|
|
1096
|
+
const natW = probe.naturalWidth || IMAGE_MAX_DROP_SIDE;
|
|
1097
|
+
const natH = probe.naturalHeight || Math.round(IMAGE_MAX_DROP_SIDE * 0.66);
|
|
1098
|
+
const longest = Math.max(natW, natH) || 1;
|
|
1099
|
+
const scale = longest > IMAGE_MAX_DROP_SIDE ? IMAGE_MAX_DROP_SIDE / longest : 1;
|
|
1100
|
+
const w = Math.max(IMAGE_MIN_SIZE, Math.round(natW * scale));
|
|
1101
|
+
const h = Math.max(IMAGE_MIN_SIZE, Math.round(natH * scale));
|
|
1102
|
+
const id = rid();
|
|
1103
|
+
const optimistic: ImageStroke = {
|
|
1104
|
+
id,
|
|
1105
|
+
tool: 'image',
|
|
1106
|
+
x: world[0] - w / 2,
|
|
1107
|
+
y: world[1] - h / 2,
|
|
1108
|
+
w,
|
|
1109
|
+
h,
|
|
1110
|
+
href: blobUrl,
|
|
1111
|
+
};
|
|
1112
|
+
// Local-only insert — the blob: href must NOT reach the server (it's
|
|
1113
|
+
// ephemeral + would be stripped by the sanitizer); we commit only the
|
|
1114
|
+
// assets/… form once the upload lands.
|
|
1115
|
+
setStrokesState([...strokesRef.current, optimistic]);
|
|
1116
|
+
void uploadAsset(file).then((res) => {
|
|
1117
|
+
const cur = strokesRef.current;
|
|
1118
|
+
if ('path' in res) {
|
|
1119
|
+
const after = cur.map((s) => (s.id === id ? ({ ...s, href: res.path } as Stroke) : s));
|
|
1120
|
+
const beforeForUndo = cur.filter((s) => s.id !== id);
|
|
1121
|
+
commitStrokes(beforeForUndo, after, 'add image');
|
|
1122
|
+
annotSel?.replace([id]);
|
|
1123
|
+
} else {
|
|
1124
|
+
setStrokesState(cur.filter((s) => s.id !== id));
|
|
1125
|
+
showCanvasToast(`Image upload failed — ${res.error}`);
|
|
1126
|
+
}
|
|
1127
|
+
URL.revokeObjectURL(blobUrl);
|
|
1128
|
+
});
|
|
1129
|
+
};
|
|
1130
|
+
probe.onerror = () => {
|
|
1131
|
+
URL.revokeObjectURL(blobUrl);
|
|
1132
|
+
showCanvasToast('Could not read that image file');
|
|
1133
|
+
};
|
|
1134
|
+
probe.src = blobUrl;
|
|
1135
|
+
},
|
|
1136
|
+
[commitStrokes, annotSel]
|
|
1137
|
+
);
|
|
1138
|
+
|
|
1139
|
+
// Link: client-only preview chip — no upload, no server fetch. Only http(s)
|
|
1140
|
+
// URLs are accepted (the hook already gates, re-checked here defensively).
|
|
1141
|
+
const createLink = useCallback(
|
|
1142
|
+
(url: string, title: string, world: [number, number]) => {
|
|
1143
|
+
if (!isHttpUrl(url)) return;
|
|
1144
|
+
const w = LINK_DEFAULT_W;
|
|
1145
|
+
const h = LINK_DEFAULT_H;
|
|
1146
|
+
const id = rid();
|
|
1147
|
+
const link: LinkStroke = {
|
|
1148
|
+
id,
|
|
1149
|
+
tool: 'link',
|
|
1150
|
+
x: world[0] - w / 2,
|
|
1151
|
+
y: world[1] - h / 2,
|
|
1152
|
+
w,
|
|
1153
|
+
h,
|
|
1154
|
+
url,
|
|
1155
|
+
title: (title || prettifyUrl(url)).slice(0, 300),
|
|
1156
|
+
domain: linkDomain(url),
|
|
1157
|
+
};
|
|
1158
|
+
const before = strokesRef.current;
|
|
1159
|
+
commitStrokes(before, [...before, link], 'add link');
|
|
1160
|
+
annotSel?.replace([id]);
|
|
1161
|
+
},
|
|
1162
|
+
[commitStrokes, annotSel]
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
const mediaCallbacks = useMemo(
|
|
1166
|
+
() => ({ onImage: createImageFromFile, onLink: createLink }),
|
|
1167
|
+
[createImageFromFile, createLink]
|
|
1168
|
+
);
|
|
1169
|
+
// Media intake is paste/drop only (per product steer — no toolbar buttons):
|
|
1170
|
+
// drop an image / URL or Cmd+V a clipboard image / link straight onto the
|
|
1171
|
+
// canvas. The hook owns the dragover/drop/paste wiring; the create callbacks
|
|
1172
|
+
// hold the commit/undo sink + screenToWorld.
|
|
1173
|
+
useCanvasMediaDrop({ enabled: visible, screenToWorld, callbacks: mediaCallbacks });
|
|
1174
|
+
|
|
1175
|
+
const eraseAt = useCallback(
|
|
1176
|
+
(wx: number, wy: number) => {
|
|
1177
|
+
const zoom = vp?.zoom || 1;
|
|
1178
|
+
const tol = 8 / zoom;
|
|
1179
|
+
const prev = strokesRef.current;
|
|
1180
|
+
for (let i = prev.length - 1; i >= 0; i--) {
|
|
1181
|
+
const candidate = prev[i];
|
|
1182
|
+
if (candidate && strokeHitTest(candidate, wx, wy, tol)) {
|
|
1183
|
+
const removedId = candidate.id;
|
|
1184
|
+
const next = prev
|
|
1185
|
+
.slice(0, i)
|
|
1186
|
+
.concat(prev.slice(i + 1))
|
|
1187
|
+
.filter((s) => !(s.tool === 'text' && s.anchorId === removedId));
|
|
1188
|
+
commitStrokes(prev, next, 'erase 1 stroke');
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
[vp, commitStrokes]
|
|
1194
|
+
);
|
|
1195
|
+
|
|
1196
|
+
const beginStroke = useCallback(
|
|
1197
|
+
(e: ReactPointerEvent<HTMLDivElement>, spaceHeld: boolean) => {
|
|
1198
|
+
if (!isActive || !visible) return false;
|
|
1199
|
+
if (e.button !== 0) return false;
|
|
1200
|
+
if (spaceHeld) return false;
|
|
1201
|
+
if (e.metaKey || e.ctrlKey) return false;
|
|
1202
|
+
// We do NOT stopPropagation — viewport-controller listens on the host
|
|
1203
|
+
// ancestor and never claims a bare-left/no-space pointerdown anyway.
|
|
1204
|
+
e.preventDefault();
|
|
1205
|
+
setGhost(null); // a draw is starting — the ghost placeholder is done
|
|
1206
|
+
try {
|
|
1207
|
+
(e.target as Element & { setPointerCapture?: (id: number) => void }).setPointerCapture?.(
|
|
1208
|
+
e.pointerId
|
|
1209
|
+
);
|
|
1210
|
+
} catch {
|
|
1211
|
+
/* some browsers reject capture on synthetic events */
|
|
1212
|
+
}
|
|
1213
|
+
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1214
|
+
drawAnchorRef.current = { x: wx, y: wy };
|
|
1215
|
+
lastDrawPointRef.current = { x: wx, y: wy };
|
|
1216
|
+
if (isErase) {
|
|
1217
|
+
eraseAt(wx, wy);
|
|
1218
|
+
return true;
|
|
1219
|
+
}
|
|
1220
|
+
const id = rid();
|
|
1221
|
+
const width: number = supportsThickness ? thickness : STROKE_WIDTH_THIN;
|
|
1222
|
+
const activeFill = supportsFill ? fill : null;
|
|
1223
|
+
if (tool === 'pen') {
|
|
1224
|
+
setDrawing({ id, tool: 'pen', color, width, points: [[wx, wy]] });
|
|
1225
|
+
} else if (tool === 'highlighter') {
|
|
1226
|
+
// Highlighter (item 8) — a pen stroke with a wide width, a translucent
|
|
1227
|
+
// marker hue, and the highlighter flag (renders multiply at draw time).
|
|
1228
|
+
setDrawing({
|
|
1229
|
+
id,
|
|
1230
|
+
tool: 'pen',
|
|
1231
|
+
color: highlighterColor,
|
|
1232
|
+
width: highlighterWidth,
|
|
1233
|
+
points: [[wx, wy]],
|
|
1234
|
+
highlighter: true,
|
|
1235
|
+
});
|
|
1236
|
+
} else if (tool === 'shape') {
|
|
1237
|
+
// Phase 24 — the single Shape tool maps its kind onto a stroke type:
|
|
1238
|
+
// circle → ellipse; square/rounded → rect (cornerRadius 0 / 8);
|
|
1239
|
+
// diamond/triangle/triangle-down → polygon.
|
|
1240
|
+
if (shapeKind === 'circle') {
|
|
1241
|
+
setDrawing({
|
|
1242
|
+
id,
|
|
1243
|
+
tool: 'ellipse',
|
|
1244
|
+
color,
|
|
1245
|
+
width,
|
|
1246
|
+
cx: wx,
|
|
1247
|
+
cy: wy,
|
|
1248
|
+
rx: 0,
|
|
1249
|
+
ry: 0,
|
|
1250
|
+
fill: activeFill,
|
|
1251
|
+
});
|
|
1252
|
+
} else if (shapeKind === 'square' || shapeKind === 'rounded') {
|
|
1253
|
+
setDrawing({
|
|
1254
|
+
id,
|
|
1255
|
+
tool: 'rect',
|
|
1256
|
+
color,
|
|
1257
|
+
width,
|
|
1258
|
+
x: wx,
|
|
1259
|
+
y: wy,
|
|
1260
|
+
w: 0,
|
|
1261
|
+
h: 0,
|
|
1262
|
+
fill: activeFill,
|
|
1263
|
+
cornerRadius: shapeKind === 'rounded' ? 8 : 0,
|
|
1264
|
+
});
|
|
1265
|
+
} else {
|
|
1266
|
+
setDrawing({
|
|
1267
|
+
id,
|
|
1268
|
+
tool: 'polygon',
|
|
1269
|
+
shape: shapeKind,
|
|
1270
|
+
color,
|
|
1271
|
+
width,
|
|
1272
|
+
x: wx,
|
|
1273
|
+
y: wy,
|
|
1274
|
+
w: 0,
|
|
1275
|
+
h: 0,
|
|
1276
|
+
fill: activeFill,
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
} else if (tool === 'arrow') {
|
|
1280
|
+
setDrawing({
|
|
1281
|
+
id,
|
|
1282
|
+
tool: 'arrow',
|
|
1283
|
+
color,
|
|
1284
|
+
width,
|
|
1285
|
+
x1: wx,
|
|
1286
|
+
y1: wy,
|
|
1287
|
+
x2: wx,
|
|
1288
|
+
y2: wy,
|
|
1289
|
+
});
|
|
1290
|
+
} else if (tool === 'sticky') {
|
|
1291
|
+
// Phase 21 — drag-create a paper card. Default size if the user just
|
|
1292
|
+
// taps (no drag) is applied in endStroke.
|
|
1293
|
+
setDrawing({
|
|
1294
|
+
id,
|
|
1295
|
+
tool: 'sticky',
|
|
1296
|
+
color: stickyColor,
|
|
1297
|
+
x: wx,
|
|
1298
|
+
y: wy,
|
|
1299
|
+
w: 0,
|
|
1300
|
+
h: 0,
|
|
1301
|
+
text: '',
|
|
1302
|
+
fontSize: DEFAULT_FONT_SIZE,
|
|
1303
|
+
cornerRadius: STICKY_CORNER_RADIUS,
|
|
1304
|
+
});
|
|
1305
|
+
} else if (tool === 'section') {
|
|
1306
|
+
// FigJam v3 — drag-create a labelled container; a bare tap drops the
|
|
1307
|
+
// default-sized region (endStroke applies the default).
|
|
1308
|
+
setDrawing({
|
|
1309
|
+
id,
|
|
1310
|
+
tool: 'section',
|
|
1311
|
+
x: wx,
|
|
1312
|
+
y: wy,
|
|
1313
|
+
w: 0,
|
|
1314
|
+
h: 0,
|
|
1315
|
+
label: 'Section',
|
|
1316
|
+
color: DEFAULT_SECTION_COLOR,
|
|
1317
|
+
});
|
|
1318
|
+
} else if (tool === 'text') {
|
|
1319
|
+
// Phase 21 — single click drops an editable caret at the click point.
|
|
1320
|
+
// No stroke is created until the user commits real text (mirrors the
|
|
1321
|
+
// anchored double-click flow), so an empty caret leaves nothing behind.
|
|
1322
|
+
setPendingText({ x: wx, y: wy });
|
|
1323
|
+
if (annotSel) annotSel.clear();
|
|
1324
|
+
const stickyOnText = sticky.locked && sticky.tool === 'text';
|
|
1325
|
+
if (!stickyOnText) setTool('move');
|
|
1326
|
+
return true;
|
|
1327
|
+
}
|
|
1328
|
+
return true;
|
|
1329
|
+
},
|
|
1330
|
+
[
|
|
1331
|
+
tool,
|
|
1332
|
+
shapeKind,
|
|
1333
|
+
color,
|
|
1334
|
+
fill,
|
|
1335
|
+
thickness,
|
|
1336
|
+
stickyColor,
|
|
1337
|
+
highlighterColor,
|
|
1338
|
+
highlighterWidth,
|
|
1339
|
+
supportsThickness,
|
|
1340
|
+
supportsFill,
|
|
1341
|
+
isActive,
|
|
1342
|
+
isErase,
|
|
1343
|
+
visible,
|
|
1344
|
+
screenToWorld,
|
|
1345
|
+
eraseAt,
|
|
1346
|
+
annotSel,
|
|
1347
|
+
sticky,
|
|
1348
|
+
setTool,
|
|
1349
|
+
]
|
|
1350
|
+
);
|
|
1351
|
+
|
|
1352
|
+
const moveStroke = useCallback(
|
|
1353
|
+
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1354
|
+
if (!isActive || !visible) return;
|
|
1355
|
+
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1356
|
+
cmdHeldRef.current = e.metaKey || e.ctrlKey;
|
|
1357
|
+
if (isErase) {
|
|
1358
|
+
if ((e.buttons & 1) === 0) return;
|
|
1359
|
+
eraseAt(wx, wy);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
// Phase 24 — ghost placeholder. While nothing is being drawn, track the
|
|
1363
|
+
// cursor so a translucent preview can follow it; an active draw hides it.
|
|
1364
|
+
if (drawingRef.current == null) {
|
|
1365
|
+
if (ghostCapable) setGhost({ x: wx, y: wy });
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
setGhost(null);
|
|
1369
|
+
lastDrawPointRef.current = { x: wx, y: wy };
|
|
1370
|
+
const anchor = drawAnchorRef.current;
|
|
1371
|
+
const mods: DrawMods = { shift: e.shiftKey, alt: e.altKey };
|
|
1372
|
+
// FigJam v3 — while dragging an arrow, halo the host the dragged end
|
|
1373
|
+
// would magnetically attach to (⌘ suppresses binding entirely).
|
|
1374
|
+
if (drawingRef.current.tool === 'arrow') {
|
|
1375
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
1376
|
+
const cand = cmdHeldRef.current
|
|
1377
|
+
? null
|
|
1378
|
+
: bindCandidate(wx, wy, strokesRef.current, BIND_THRESHOLD_PX / zoom);
|
|
1379
|
+
setBindHintId(cand?.hostId ?? null);
|
|
1380
|
+
}
|
|
1381
|
+
setDrawing((cur) => {
|
|
1382
|
+
if (!cur) return cur;
|
|
1383
|
+
if (cur.tool === 'pen') {
|
|
1384
|
+
const last = cur.points[cur.points.length - 1] as WorldPoint | undefined;
|
|
1385
|
+
if (last && Math.hypot(wx - last[0], wy - last[1]) < 1) return cur;
|
|
1386
|
+
return { ...cur, points: [...cur.points, [wx, wy] as WorldPoint] };
|
|
1387
|
+
}
|
|
1388
|
+
// Phase 24 — Shift (1:1) / Alt (from-center) apply to every box + arrow
|
|
1389
|
+
// shape, mirroring the resize handles. The anchor is the pointer-down
|
|
1390
|
+
// point; without it (shouldn't happen mid-draw) fall back to no change.
|
|
1391
|
+
if (!anchor) return cur;
|
|
1392
|
+
return applyDrawModifiers(cur, anchor, wx, wy, mods);
|
|
1393
|
+
});
|
|
1394
|
+
},
|
|
1395
|
+
[isActive, isErase, visible, screenToWorld, eraseAt, ghostCapable]
|
|
1396
|
+
);
|
|
1397
|
+
|
|
1398
|
+
const endStroke = useCallback(() => {
|
|
1399
|
+
if (!isActive || !visible) return;
|
|
1400
|
+
if (isErase) return;
|
|
1401
|
+
const cur = drawingRef.current;
|
|
1402
|
+
if (!cur) return;
|
|
1403
|
+
let final: Stroke | null = cur;
|
|
1404
|
+
// Phase 24 — a bare tap (both axes below the drag threshold) drops a
|
|
1405
|
+
// default-sized shape at the tap point so "click to place" works like
|
|
1406
|
+
// FigJam; a real drag sizes it. A thin/degenerate drag still gets discarded
|
|
1407
|
+
// by isStrokeMeaningful below.
|
|
1408
|
+
const isTap = (w: number, h: number) => Math.abs(w) < 4 && Math.abs(h) < 4;
|
|
1409
|
+
if (cur.tool === 'rect') {
|
|
1410
|
+
const norm = normalizeRect(cur);
|
|
1411
|
+
final = isTap(norm.w, norm.h)
|
|
1412
|
+
? { ...norm, x: cur.x, y: cur.y, w: SHAPE_DEFAULT_SIZE, h: SHAPE_DEFAULT_SIZE }
|
|
1413
|
+
: norm;
|
|
1414
|
+
} else if (cur.tool === 'polygon') {
|
|
1415
|
+
const norm = normalizeBox(cur);
|
|
1416
|
+
final = isTap(norm.w, norm.h)
|
|
1417
|
+
? { ...norm, x: cur.x, y: cur.y, w: SHAPE_DEFAULT_SIZE, h: SHAPE_DEFAULT_SIZE }
|
|
1418
|
+
: norm;
|
|
1419
|
+
} else if (cur.tool === 'ellipse') {
|
|
1420
|
+
final =
|
|
1421
|
+
cur.rx < 2 && cur.ry < 2
|
|
1422
|
+
? { ...cur, rx: SHAPE_DEFAULT_SIZE / 2, ry: SHAPE_DEFAULT_SIZE / 2 }
|
|
1423
|
+
: cur;
|
|
1424
|
+
} else if (cur.tool === 'sticky') {
|
|
1425
|
+
const norm = normalizeSticky(cur);
|
|
1426
|
+
// A bare tap (or a drag too small to be a usable card) drops a
|
|
1427
|
+
// default-sized note at the tap point — FigJam parity.
|
|
1428
|
+
final =
|
|
1429
|
+
Math.abs(norm.w) < STICKY_MIN_SIZE || Math.abs(norm.h) < STICKY_MIN_SIZE
|
|
1430
|
+
? { ...norm, w: STICKY_DEFAULT_W, h: STICKY_DEFAULT_H }
|
|
1431
|
+
: norm;
|
|
1432
|
+
} else if (cur.tool === 'section') {
|
|
1433
|
+
const norm = normalizeBox(cur);
|
|
1434
|
+
final =
|
|
1435
|
+
Math.abs(norm.w) < SECTION_MIN_SIZE || Math.abs(norm.h) < SECTION_MIN_SIZE
|
|
1436
|
+
? { ...norm, w: SECTION_DEFAULT_W, h: SECTION_DEFAULT_H }
|
|
1437
|
+
: norm;
|
|
1438
|
+
}
|
|
1439
|
+
if (final && !isStrokeMeaningful(final)) final = null;
|
|
1440
|
+
// FigJam v3 — magnetic connector binding at draw-end. Each arrow endpoint
|
|
1441
|
+
// within the zoom-scaled threshold of a bindable host attaches to its
|
|
1442
|
+
// nearest side/center magnet and snaps onto it; the bind persists and the
|
|
1443
|
+
// endpoint re-derives from the host from then on. ⌘ held = stay free.
|
|
1444
|
+
if (final && final.tool === 'arrow' && !cmdHeldRef.current) {
|
|
1445
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
1446
|
+
const threshold = BIND_THRESHOLD_PX / zoom;
|
|
1447
|
+
const others = strokesRef.current;
|
|
1448
|
+
const sb = bindCandidate(final.x1, final.y1, others, threshold);
|
|
1449
|
+
const eb = bindCandidate(final.x2, final.y2, others, threshold);
|
|
1450
|
+
if (sb) {
|
|
1451
|
+
const host = others.find((s) => s.id === sb.hostId);
|
|
1452
|
+
const pt = host ? anchorPoint(host, sb.nx, sb.ny) : null;
|
|
1453
|
+
if (pt) final = { ...final, startBind: sb, x1: pt[0], y1: pt[1] };
|
|
1454
|
+
}
|
|
1455
|
+
// A zero-length self-loop (both ends on the same magnet) is useless —
|
|
1456
|
+
// keep the end free when it would collapse onto the start bind.
|
|
1457
|
+
const sameMagnet = sb && eb && sb.hostId === eb.hostId && sb.nx === eb.nx && sb.ny === eb.ny;
|
|
1458
|
+
if (eb && !sameMagnet) {
|
|
1459
|
+
const host = others.find((s) => s.id === eb.hostId);
|
|
1460
|
+
const pt = host ? anchorPoint(host, eb.nx, eb.ny) : null;
|
|
1461
|
+
if (pt) final = { ...final, endBind: eb, x2: pt[0], y2: pt[1] };
|
|
1462
|
+
}
|
|
1463
|
+
if ((final as ArrowStroke).startBind || (final as ArrowStroke).endBind) {
|
|
1464
|
+
showOnceHint(
|
|
1465
|
+
'bind',
|
|
1466
|
+
'Arrow attached — it follows the shape now. Drag an endpoint to re-anchor, hold ⌘ to keep it free.'
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
setBindHintId(null);
|
|
1471
|
+
if (final) {
|
|
1472
|
+
const committed = final;
|
|
1473
|
+
const prev = strokesRef.current;
|
|
1474
|
+
// FigJam — sections are CONTAINERS: they slot in at the BACK of the
|
|
1475
|
+
// z-order so content placed on them keeps rendering above.
|
|
1476
|
+
const next = committed.tool === 'section' ? [committed, ...prev] : [...prev, committed];
|
|
1477
|
+
commitStrokes(prev, next, `draw ${committed.tool}`);
|
|
1478
|
+
// T18 — auto-select the freshly drawn shape so the user can immediately
|
|
1479
|
+
// see + adjust it. annotSel is optional (some test harnesses mount
|
|
1480
|
+
// AnnotationsLayer without the provider), so guard the call.
|
|
1481
|
+
if (annotSel) annotSel.replace(committed.id);
|
|
1482
|
+
// Phase 21 — a fresh sticky opens in edit mode (FigJam parity: drop a
|
|
1483
|
+
// note, type immediately). Only meaningful deviation from rect/ellipse.
|
|
1484
|
+
if (committed.tool === 'sticky') {
|
|
1485
|
+
setEditingId(committed.id);
|
|
1486
|
+
showOnceHint('chain', '⌘Enter commits and creates the next sticky beside it.');
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
// T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
|
|
1490
|
+
// mode is locked on this tool. Sticky lets the user draw many shapes in a
|
|
1491
|
+
// row (canonical pattern: tldraw double-click to lock). Eraser stays
|
|
1492
|
+
// armed by default — that tool is destructive, not constructive.
|
|
1493
|
+
// Map a highlighter pen (a 'pen' stroke with the flag) back to the
|
|
1494
|
+
// 'highlighter' tool id so its sticky-lock check matches the active tool.
|
|
1495
|
+
const toolJustUsed = cur.tool === 'pen' && cur.highlighter ? 'highlighter' : cur.tool;
|
|
1496
|
+
if (toolJustUsed !== 'eraser') {
|
|
1497
|
+
const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
|
|
1498
|
+
if (!stickyOnThis) setTool('move');
|
|
1499
|
+
}
|
|
1500
|
+
drawAnchorRef.current = null;
|
|
1501
|
+
lastDrawPointRef.current = null;
|
|
1502
|
+
setDrawing(null);
|
|
1503
|
+
}, [isActive, isErase, visible, commitStrokes, annotSel, setTool, sticky]);
|
|
1504
|
+
|
|
1505
|
+
// T21 — abort a mid-stroke draw without committing. Dispatched by the
|
|
1506
|
+
// canvas-shell Esc handler (`maude:cancel-stroke`). Safe to call when
|
|
1507
|
+
// nothing is being drawn — the early-return on drawingRef keeps it
|
|
1508
|
+
// a no-op.
|
|
1509
|
+
const cancelStroke = useCallback(() => {
|
|
1510
|
+
if (!drawingRef.current) return;
|
|
1511
|
+
drawAnchorRef.current = null;
|
|
1512
|
+
lastDrawPointRef.current = null;
|
|
1513
|
+
setDrawing(null);
|
|
1514
|
+
}, []);
|
|
1515
|
+
|
|
1516
|
+
useEffect(() => {
|
|
1517
|
+
if (typeof document === 'undefined') return;
|
|
1518
|
+
const onCancel = () => cancelStroke();
|
|
1519
|
+
document.addEventListener('maude:cancel-stroke', onCancel);
|
|
1520
|
+
return () => document.removeEventListener('maude:cancel-stroke', onCancel);
|
|
1521
|
+
}, [cancelStroke]);
|
|
1522
|
+
|
|
1523
|
+
// Phase 24 — holding/releasing Shift or Alt mid-draw re-constrains the draft
|
|
1524
|
+
// at the last cursor position (FigJam: the modifier engages while held, no
|
|
1525
|
+
// pointer-move needed). Pen / text carry no box, so they're skipped.
|
|
1526
|
+
useEffect(() => {
|
|
1527
|
+
if (typeof document === 'undefined') return;
|
|
1528
|
+
const onKey = (e: KeyboardEvent) => {
|
|
1529
|
+
const cur = drawingRef.current;
|
|
1530
|
+
const anchor = drawAnchorRef.current;
|
|
1531
|
+
const p = lastDrawPointRef.current;
|
|
1532
|
+
if (!cur || !anchor || !p) return;
|
|
1533
|
+
if (e.key !== 'Shift' && e.key !== 'Alt') return;
|
|
1534
|
+
if (cur.tool === 'pen' || cur.tool === 'text') return;
|
|
1535
|
+
e.preventDefault();
|
|
1536
|
+
const mods: DrawMods = { shift: e.shiftKey, alt: e.altKey };
|
|
1537
|
+
setDrawing((c) => (c ? applyDrawModifiers(c, anchor, p.x, p.y, mods) : c));
|
|
1538
|
+
};
|
|
1539
|
+
document.addEventListener('keydown', onKey, true);
|
|
1540
|
+
document.addEventListener('keyup', onKey, true);
|
|
1541
|
+
return () => {
|
|
1542
|
+
document.removeEventListener('keydown', onKey, true);
|
|
1543
|
+
document.removeEventListener('keyup', onKey, true);
|
|
1544
|
+
};
|
|
1545
|
+
}, []);
|
|
1546
|
+
|
|
1547
|
+
const renderStrokes = useMemo(
|
|
1548
|
+
() => (drawing ? [...strokes, drawing] : strokes),
|
|
1549
|
+
[strokes, drawing]
|
|
1550
|
+
);
|
|
1551
|
+
|
|
1552
|
+
// Phase 24 — the ghost descriptor handed to the SVG layer. Suppressed while a
|
|
1553
|
+
// draw is in progress (the real preview takes over) so the two never overlap.
|
|
1554
|
+
const ghostPreview = useMemo<GhostDescriptor | null>(() => {
|
|
1555
|
+
if (!ghost || !ghostCapable || drawing) return null;
|
|
1556
|
+
if (tool === 'text') return { kind: 'text', x: ghost.x, y: ghost.y, color };
|
|
1557
|
+
if (tool === 'sticky') return { kind: 'sticky', x: ghost.x, y: ghost.y, color: stickyColor };
|
|
1558
|
+
return { kind: 'shape', x: ghost.x, y: ghost.y, shapeKind, color };
|
|
1559
|
+
}, [ghost, ghostCapable, drawing, tool, shapeKind, color, stickyColor]);
|
|
1560
|
+
|
|
1561
|
+
const anchorsById = useMemo(() => {
|
|
1562
|
+
const map = new Map<string, AnchorHost>();
|
|
1563
|
+
for (const s of strokes) {
|
|
1564
|
+
if (s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon') map.set(s.id, s);
|
|
1565
|
+
}
|
|
1566
|
+
return map;
|
|
1567
|
+
}, [strokes]);
|
|
1568
|
+
|
|
1569
|
+
const strokesById = useMemo(() => {
|
|
1570
|
+
const map = new Map<string, Stroke>();
|
|
1571
|
+
for (const s of strokes) map.set(s.id, s);
|
|
1572
|
+
return map;
|
|
1573
|
+
}, [strokes]);
|
|
1574
|
+
|
|
1575
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1576
|
+
// Move-tool selection + drag (Phase 5.1 Tasks 6 + 7). Single doc-level
|
|
1577
|
+
// capture pointerdown listener:
|
|
1578
|
+
// - target is a stroke → select (replace, or add with Shift)
|
|
1579
|
+
// - bare click on empty world → clear annotation selection
|
|
1580
|
+
// - Cmd / Cmd+Shift falls through to element-selection (we bail).
|
|
1581
|
+
// Once a stroke is selected, clicking inside its bbox starts a drag.
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Annotation drag state. Snapshot of strokes captured at pointerdown so the
|
|
1585
|
+
* whole gesture (pointermove × N → pointerup) commits as ONE undo record at
|
|
1586
|
+
* release time. Without the snapshot, each pointermove tick became its own
|
|
1587
|
+
* `translateStrokes` call and each call became its own undo record — Cmd+Z
|
|
1588
|
+
* had to be pressed dozens of times to walk back a single drag.
|
|
1589
|
+
*/
|
|
1590
|
+
const dragStateRef = useRef<{
|
|
1591
|
+
pointerId: number;
|
|
1592
|
+
startWX: number;
|
|
1593
|
+
startWY: number;
|
|
1594
|
+
movedIds: string[];
|
|
1595
|
+
snapshot: Stroke[];
|
|
1596
|
+
/** FigJam v3 — pre-Alt-duplicate baseline. Differs from `snapshot` only
|
|
1597
|
+
* during an Alt+drag duplicate; the undo record spans undoBase → final
|
|
1598
|
+
* so clone + move land as ONE step. */
|
|
1599
|
+
undoBase: Stroke[];
|
|
1600
|
+
altDup: boolean;
|
|
1601
|
+
} | null>(null);
|
|
1602
|
+
|
|
1603
|
+
// Drag-select marquee state. World-coord rectangle (anchor + cursor); the
|
|
1604
|
+
// cursor end animates with pointermove. `null` = no marquee active.
|
|
1605
|
+
const [marquee, setMarquee] = useState<{
|
|
1606
|
+
ax: number;
|
|
1607
|
+
ay: number;
|
|
1608
|
+
bx: number;
|
|
1609
|
+
by: number;
|
|
1610
|
+
} | null>(null);
|
|
1611
|
+
|
|
1612
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: commitStrokes/strokesStore are stable refs from parent context; manual dep list reflects effect-trigger semantics, not internal callbacks
|
|
1613
|
+
useEffect(() => {
|
|
1614
|
+
if (typeof document === 'undefined') return;
|
|
1615
|
+
if (tool !== 'move') return;
|
|
1616
|
+
if (!annotSel) return;
|
|
1617
|
+
|
|
1618
|
+
const findStrokeId = (el: Element | null): string | null => {
|
|
1619
|
+
const node = el?.closest?.('[data-id][data-tool]') ?? null;
|
|
1620
|
+
const id = node?.getAttribute('data-id') ?? null;
|
|
1621
|
+
const t = node?.getAttribute('data-tool') ?? null;
|
|
1622
|
+
if (
|
|
1623
|
+
id &&
|
|
1624
|
+
t &&
|
|
1625
|
+
(t === 'pen' ||
|
|
1626
|
+
t === 'rect' ||
|
|
1627
|
+
t === 'ellipse' ||
|
|
1628
|
+
t === 'polygon' ||
|
|
1629
|
+
t === 'arrow' ||
|
|
1630
|
+
t === 'text' ||
|
|
1631
|
+
t === 'sticky' ||
|
|
1632
|
+
t === 'image' ||
|
|
1633
|
+
t === 'link' ||
|
|
1634
|
+
t === 'section')
|
|
1635
|
+
) {
|
|
1636
|
+
return id;
|
|
1637
|
+
}
|
|
1638
|
+
return null;
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
const onDown = (e: PointerEvent) => {
|
|
1642
|
+
if (e.button !== 0) return;
|
|
1643
|
+
if (e.metaKey || e.ctrlKey) return; // escape hatch into element-selection
|
|
1644
|
+
const target = e.target as Element | null;
|
|
1645
|
+
if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
|
|
1646
|
+
const strokeId = findStrokeId(target);
|
|
1647
|
+
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1648
|
+
const startClientX = e.clientX;
|
|
1649
|
+
const startClientY = e.clientY;
|
|
1650
|
+
|
|
1651
|
+
// Stroke hit OR multi-selection hull hit — start a group drag. Clicking a
|
|
1652
|
+
// selected stroke, OR anywhere inside the current selection's bounding box
|
|
1653
|
+
// (FigJam parity), moves the whole group. Without the hull case, grabbing
|
|
1654
|
+
// the empty space BETWEEN selected strokes fell through to the marquee
|
|
1655
|
+
// branch below and DESELECTED them.
|
|
1656
|
+
let ids: string[] | null = null;
|
|
1657
|
+
if (strokeId) {
|
|
1658
|
+
elementSel?.clear();
|
|
1659
|
+
// FigJam v3 — clicking a group member acts on the WHOLE outermost
|
|
1660
|
+
// group. Double-click deep-selects the member (see the dblclick
|
|
1661
|
+
// handler); an already-selected stroke keeps the current selection, so
|
|
1662
|
+
// a deep-selected member drags alone without re-expanding.
|
|
1663
|
+
const members = expandIdsToGroups([strokeId], strokesStoreRef.current.strokes);
|
|
1664
|
+
if (e.shiftKey) {
|
|
1665
|
+
annotSel.add(members);
|
|
1666
|
+
const merged = new Set([...annotSel.selectedIds, ...members]);
|
|
1667
|
+
ids = [...merged];
|
|
1668
|
+
} else if (annotSel.contains(strokeId)) {
|
|
1669
|
+
ids = annotSel.selectedIds;
|
|
1670
|
+
} else {
|
|
1671
|
+
annotSel.replace(members);
|
|
1672
|
+
ids = members;
|
|
1673
|
+
}
|
|
1674
|
+
} else if (!e.shiftKey && annotSel.selectedIds.length > 0) {
|
|
1675
|
+
// Hull hit-test — union bbox of the currently-selected strokes.
|
|
1676
|
+
let hx1 = Infinity;
|
|
1677
|
+
let hy1 = Infinity;
|
|
1678
|
+
let hx2 = -Infinity;
|
|
1679
|
+
let hy2 = -Infinity;
|
|
1680
|
+
for (const s of strokesStoreRef.current.strokes) {
|
|
1681
|
+
if (!annotSel.contains(s.id)) continue;
|
|
1682
|
+
const bb = strokeBBox(s);
|
|
1683
|
+
if (!bb) continue;
|
|
1684
|
+
hx1 = Math.min(hx1, bb.x);
|
|
1685
|
+
hy1 = Math.min(hy1, bb.y);
|
|
1686
|
+
hx2 = Math.max(hx2, bb.x + bb.w);
|
|
1687
|
+
hy2 = Math.max(hy2, bb.y + bb.h);
|
|
1688
|
+
}
|
|
1689
|
+
if (hx2 >= hx1 && wx >= hx1 && wx <= hx2 && wy >= hy1 && wy <= hy2) {
|
|
1690
|
+
ids = annotSel.selectedIds;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (ids?.length) {
|
|
1694
|
+
e.preventDefault();
|
|
1695
|
+
e.stopImmediatePropagation();
|
|
1696
|
+
// Capture a snapshot of all strokes at drag start. Every pointermove
|
|
1697
|
+
// re-translates FROM the snapshot using the cumulative cursor delta
|
|
1698
|
+
// (NOT a delta-from-last-frame mutation), so dragging back to origin
|
|
1699
|
+
// restores positions exactly. Optimistic state-only updates during
|
|
1700
|
+
// the move; ONE undo record + ONE server PUT fires on pointerup.
|
|
1701
|
+
const undoBase = strokesRef.current.slice();
|
|
1702
|
+
const preAltIds = ids;
|
|
1703
|
+
let dragSnapshot = undoBase;
|
|
1704
|
+
let altDup = false;
|
|
1705
|
+
// FigJam v3 — Alt+drag duplicates: clone the (expanded) selection up
|
|
1706
|
+
// front and drag the CLONES; a zero-movement release reverts so a bare
|
|
1707
|
+
// Alt+click can't silently mint copies. undoBase stays pre-clone, so
|
|
1708
|
+
// clone + move commit as one record.
|
|
1709
|
+
if (e.altKey) {
|
|
1710
|
+
const res = duplicateStrokes(undoBase, ids, 0, 0);
|
|
1711
|
+
if (res.newIds.length) {
|
|
1712
|
+
altDup = true;
|
|
1713
|
+
dragSnapshot = res.strokes;
|
|
1714
|
+
setStrokesState(res.strokes);
|
|
1715
|
+
annotSel.replace(res.newIds);
|
|
1716
|
+
ids = res.newIds;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
dragStateRef.current = {
|
|
1720
|
+
pointerId: e.pointerId,
|
|
1721
|
+
startWX: wx,
|
|
1722
|
+
startWY: wy,
|
|
1723
|
+
movedIds: ids,
|
|
1724
|
+
snapshot: dragSnapshot,
|
|
1725
|
+
undoBase,
|
|
1726
|
+
altDup,
|
|
1727
|
+
};
|
|
1728
|
+
const movedSet = new Set(ids);
|
|
1729
|
+
// FigJam v3 — dragging a SECTION carries everything sitting on it
|
|
1730
|
+
// (bbox-center containment, captured at gesture start).
|
|
1731
|
+
for (const s of dragSnapshot) {
|
|
1732
|
+
if (s.tool !== 'section' || !movedSet.has(s.id)) continue;
|
|
1733
|
+
const sx = Math.min(s.x, s.x + s.w);
|
|
1734
|
+
const sy = Math.min(s.y, s.y + s.h);
|
|
1735
|
+
const sx2 = sx + Math.abs(s.w);
|
|
1736
|
+
const sy2 = sy + Math.abs(s.h);
|
|
1737
|
+
for (const t of dragSnapshot) {
|
|
1738
|
+
if (movedSet.has(t.id) || t.tool === 'section') continue;
|
|
1739
|
+
const bb = strokeBBox(t);
|
|
1740
|
+
if (!bb) continue;
|
|
1741
|
+
const ccx = bb.x + bb.w / 2;
|
|
1742
|
+
const ccy = bb.y + bb.h / 2;
|
|
1743
|
+
if (ccx >= sx && ccx <= sx2 && ccy >= sy && ccy <= sy2) movedSet.add(t.id);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
// FigJam v3 — snap setup, computed ONCE per gesture: candidates are
|
|
1747
|
+
// the bboxes of every non-moved stroke plus the artboard rects (in
|
|
1748
|
+
// world coords); the moving hull is the union bbox of the dragged
|
|
1749
|
+
// strokes at drag start.
|
|
1750
|
+
const candidates: Array<{ x: number; y: number; w: number; h: number }> = [];
|
|
1751
|
+
for (const s of dragSnapshot) {
|
|
1752
|
+
if (movedSet.has(s.id)) continue;
|
|
1753
|
+
const bb = strokeBBox(s);
|
|
1754
|
+
if (bb && bb.w > 0 && bb.h > 0) candidates.push(bb);
|
|
1755
|
+
}
|
|
1756
|
+
for (const screenEl of Array.from(document.querySelectorAll('[data-dc-screen]'))) {
|
|
1757
|
+
const r = screenEl.getBoundingClientRect();
|
|
1758
|
+
if (r.width <= 0 || r.height <= 0) continue;
|
|
1759
|
+
const [ax, ay] = screenToWorld(r.left, r.top);
|
|
1760
|
+
const [bx, by] = screenToWorld(r.right, r.bottom);
|
|
1761
|
+
candidates.push({ x: ax, y: ay, w: bx - ax, h: by - ay });
|
|
1762
|
+
}
|
|
1763
|
+
let hull: { x: number; y: number; w: number; h: number } | null = null;
|
|
1764
|
+
for (const s of dragSnapshot) {
|
|
1765
|
+
if (!movedSet.has(s.id)) continue;
|
|
1766
|
+
const bb = strokeBBox(s);
|
|
1767
|
+
if (!bb) continue;
|
|
1768
|
+
if (!hull) {
|
|
1769
|
+
hull = { ...bb };
|
|
1770
|
+
} else {
|
|
1771
|
+
const hx = Math.min(hull.x, bb.x);
|
|
1772
|
+
const hy = Math.min(hull.y, bb.y);
|
|
1773
|
+
hull = {
|
|
1774
|
+
x: hx,
|
|
1775
|
+
y: hy,
|
|
1776
|
+
w: Math.max(hull.x + hull.w, bb.x + bb.w) - hx,
|
|
1777
|
+
h: Math.max(hull.y + hull.h, bb.y + bb.h) - hy,
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
1782
|
+
const onMove = (mv: PointerEvent) => {
|
|
1783
|
+
const st = dragStateRef.current;
|
|
1784
|
+
if (!st || mv.pointerId !== st.pointerId) return;
|
|
1785
|
+
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
1786
|
+
let dx = cwx - st.startWX;
|
|
1787
|
+
let dy = cwy - st.startWY;
|
|
1788
|
+
// FigJam v3 — edge/center snapping; ⌘ suppresses (Figma convention).
|
|
1789
|
+
// Axes with no smart-guide match fall back to the 24px dot grid
|
|
1790
|
+
// (GRID_PITCH_PX = the DS --canvas-grid pitch).
|
|
1791
|
+
if (hull && !(mv.metaKey || mv.ctrlKey)) {
|
|
1792
|
+
const snap = computeSnap(
|
|
1793
|
+
{ x: hull.x + dx, y: hull.y + dy, w: hull.w, h: hull.h },
|
|
1794
|
+
candidates,
|
|
1795
|
+
SNAP_THRESHOLD_PX / zoom,
|
|
1796
|
+
{ grid: GRID_PITCH_PX }
|
|
1797
|
+
);
|
|
1798
|
+
dx += snap.dx;
|
|
1799
|
+
dy += snap.dy;
|
|
1800
|
+
setSnapGuides(snap.guides.length ? snap.guides : null);
|
|
1801
|
+
} else {
|
|
1802
|
+
setSnapGuides(null);
|
|
1803
|
+
}
|
|
1804
|
+
// Drag-back-to-origin: restore exact references so the pointerup
|
|
1805
|
+
// shallow-equality check skips committing a no-op record. Bound
|
|
1806
|
+
// arrows re-derive from their hosts so connectors track live.
|
|
1807
|
+
const next =
|
|
1808
|
+
dx === 0 && dy === 0
|
|
1809
|
+
? st.snapshot
|
|
1810
|
+
: recomputeBoundArrows(
|
|
1811
|
+
st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s))
|
|
1812
|
+
);
|
|
1813
|
+
// Local React state only. No commitStrokes — no PUT, no undo push.
|
|
1814
|
+
setStrokesState(next);
|
|
1815
|
+
};
|
|
1816
|
+
const onUp = (up: PointerEvent) => {
|
|
1817
|
+
const st = dragStateRef.current;
|
|
1818
|
+
if (!st || up.pointerId !== st.pointerId) return;
|
|
1819
|
+
dragStateRef.current = null;
|
|
1820
|
+
setSnapGuides(null);
|
|
1821
|
+
document.removeEventListener('pointermove', onMove, true);
|
|
1822
|
+
document.removeEventListener('pointerup', onUp, true);
|
|
1823
|
+
document.removeEventListener('pointercancel', onUp, true);
|
|
1824
|
+
// Commit the gesture as ONE record. Skip on zero-movement
|
|
1825
|
+
// (click without drag past threshold or drag back to origin).
|
|
1826
|
+
const final = strokesRef.current;
|
|
1827
|
+
if (strokesShallowEqual(st.snapshot, final)) {
|
|
1828
|
+
if (st.altDup) {
|
|
1829
|
+
// Alt+click without a drag — revert the eager clones.
|
|
1830
|
+
setStrokesState(st.undoBase);
|
|
1831
|
+
annotSel.replace(preAltIds);
|
|
1832
|
+
}
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
commitStrokes(
|
|
1836
|
+
st.undoBase,
|
|
1837
|
+
final,
|
|
1838
|
+
`${st.altDup ? 'duplicate' : 'move'} ${st.movedIds.length} stroke${
|
|
1839
|
+
st.movedIds.length === 1 ? '' : 's'
|
|
1840
|
+
}`
|
|
1841
|
+
);
|
|
1842
|
+
};
|
|
1843
|
+
document.addEventListener('pointermove', onMove, true);
|
|
1844
|
+
document.addEventListener('pointerup', onUp, true);
|
|
1845
|
+
document.addEventListener('pointercancel', onUp, true);
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
// Not a stroke / hull-group drag. When pointerdown lands inside an
|
|
1850
|
+
// artboard the gesture belongs to artboard-drag / element-marquee — not
|
|
1851
|
+
// the annotation marquee (post-Wave-3 grievance G5). Checked AFTER the
|
|
1852
|
+
// group-drag decision so a multi-selection hull-drag still wins even when
|
|
1853
|
+
// the strokes sit over an artboard. FigJam v3 exception: SHIFT+drag is
|
|
1854
|
+
// the additive annotation marquee, so it rubber-bands annotations
|
|
1855
|
+
// sitting ON an artboard instead of falling through to artboard-drag.
|
|
1856
|
+
if (!strokeId && !e.shiftKey && target?.closest?.('[data-dc-screen]')) return;
|
|
1857
|
+
|
|
1858
|
+
// Empty world — start a drag-select gesture. A bare click without
|
|
1859
|
+
// moving clears annotation selection (post-Wave-3 feedback: click-to-
|
|
1860
|
+
// deselect is back; Esc also still works).
|
|
1861
|
+
const addToSelection = e.shiftKey;
|
|
1862
|
+
let moved = false;
|
|
1863
|
+
const onMove = (mv: PointerEvent) => {
|
|
1864
|
+
if (!moved && !crossedDragThreshold(startClientX, startClientY, mv.clientX, mv.clientY)) {
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
moved = true;
|
|
1868
|
+
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
1869
|
+
setMarquee({ ax: wx, ay: wy, bx: cwx, by: cwy });
|
|
1870
|
+
};
|
|
1871
|
+
const onUp = (_up: PointerEvent) => {
|
|
1872
|
+
document.removeEventListener('pointermove', onMove, true);
|
|
1873
|
+
document.removeEventListener('pointerup', onUp, true);
|
|
1874
|
+
document.removeEventListener('pointercancel', onUp, true);
|
|
1875
|
+
if (!moved) {
|
|
1876
|
+
// Click without movement on empty world → clear annotation
|
|
1877
|
+
// selection (post-Wave-3 user feedback). Shift-click preserves
|
|
1878
|
+
// existing selection for additive-mode workflows.
|
|
1879
|
+
if (!addToSelection) annotSel.clear();
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
const final = marqueeRef.current;
|
|
1883
|
+
setMarquee(null);
|
|
1884
|
+
if (!final) return;
|
|
1885
|
+
const xMin = Math.min(final.ax, final.bx);
|
|
1886
|
+
const xMax = Math.max(final.ax, final.bx);
|
|
1887
|
+
const yMin = Math.min(final.ay, final.by);
|
|
1888
|
+
const yMax = Math.max(final.ay, final.by);
|
|
1889
|
+
const hits: string[] = [];
|
|
1890
|
+
for (const s of strokesStoreRef.current.strokes) {
|
|
1891
|
+
// Anchored text inherits its host's bbox (selected with the host);
|
|
1892
|
+
// standalone text (Phase 21) has its own synthetic bbox and IS
|
|
1893
|
+
// marquee-selectable.
|
|
1894
|
+
if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') continue;
|
|
1895
|
+
const bb = strokeBBox(s);
|
|
1896
|
+
if (!bb) continue;
|
|
1897
|
+
if (bb.x + bb.w >= xMin && bb.x <= xMax && bb.y + bb.h >= yMin && bb.y <= yMax) {
|
|
1898
|
+
hits.push(s.id);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
// Marquee that captured no strokes — preserve existing selection.
|
|
1902
|
+
if (hits.length === 0) return;
|
|
1903
|
+
// FigJam v3 — a marquee touching any group member selects the whole
|
|
1904
|
+
// group (tldraw: the brush resolves to the outermost ancestor).
|
|
1905
|
+
const expanded = expandIdsToGroups(hits, strokesStoreRef.current.strokes);
|
|
1906
|
+
if (addToSelection) annotSel.add(expanded);
|
|
1907
|
+
else annotSel.replace(expanded);
|
|
1908
|
+
};
|
|
1909
|
+
document.addEventListener('pointermove', onMove, true);
|
|
1910
|
+
document.addEventListener('pointerup', onUp, true);
|
|
1911
|
+
document.addEventListener('pointercancel', onUp, true);
|
|
1912
|
+
};
|
|
1913
|
+
|
|
1914
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
1915
|
+
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
1916
|
+
// commitStrokes is included defensively (it is a stable useCallback([]) ref,
|
|
1917
|
+
// so this never re-binds the listener) to remove the latent stale-closure
|
|
1918
|
+
// trap flagged in the Phase 24 frontend review.
|
|
1919
|
+
}, [tool, annotSel, elementSel, screenToWorld, strokesStore, commitStrokes]);
|
|
1920
|
+
|
|
1921
|
+
// Latest marquee + strokes refs for the doc-level pointerup callback
|
|
1922
|
+
// (avoids re-binding the listener on every state tick).
|
|
1923
|
+
const marqueeRef = useRef(marquee);
|
|
1924
|
+
marqueeRef.current = marquee;
|
|
1925
|
+
const strokesStoreRef = useRef(strokesStore);
|
|
1926
|
+
strokesStoreRef.current = strokesStore;
|
|
1927
|
+
|
|
1928
|
+
// Double-click enters text-edit mode: rect/ellipse (anchored text), sticky
|
|
1929
|
+
// (its own body), or a standalone text node (re-edit in place). Anchored text
|
|
1930
|
+
// nodes are edited via their host, so a data-anchor-id text node is skipped.
|
|
1931
|
+
useEffect(() => {
|
|
1932
|
+
if (typeof document === 'undefined') return;
|
|
1933
|
+
if (tool !== 'move') return;
|
|
1934
|
+
const onDbl = (e: MouseEvent) => {
|
|
1935
|
+
const target = e.target as Element | null;
|
|
1936
|
+
const node = target?.closest?.('[data-id][data-tool]');
|
|
1937
|
+
if (!node) return;
|
|
1938
|
+
const id = node.getAttribute('data-id');
|
|
1939
|
+
const t = node.getAttribute('data-tool');
|
|
1940
|
+
if (!id) return;
|
|
1941
|
+
// FigJam v3 — double-click DEEP-SELECTS a group member (a single click
|
|
1942
|
+
// selects the whole outermost group; Esc clears back out). The editor
|
|
1943
|
+
// still opens below for text-bearing types — FigJam's enter-group-then-
|
|
1944
|
+
// edit flow in one gesture.
|
|
1945
|
+
const stroke = strokesRef.current.find((s) => s.id === id);
|
|
1946
|
+
if (stroke && outermostGroupOf(stroke) && annotSel) {
|
|
1947
|
+
e.preventDefault();
|
|
1948
|
+
annotSel.replace(id);
|
|
1949
|
+
}
|
|
1950
|
+
if (t === 'rect' || t === 'ellipse' || t === 'polygon' || t === 'sticky' || t === 'section') {
|
|
1951
|
+
e.preventDefault();
|
|
1952
|
+
setEditingId(id);
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
if (t === 'text' && !node.getAttribute('data-anchor-id')) {
|
|
1956
|
+
e.preventDefault();
|
|
1957
|
+
setEditingId(id);
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1960
|
+
document.addEventListener('dblclick', onDbl, true);
|
|
1961
|
+
return () => document.removeEventListener('dblclick', onDbl, true);
|
|
1962
|
+
}, [tool, annotSel]);
|
|
1963
|
+
|
|
1964
|
+
const commitText = useCallback(
|
|
1965
|
+
(anchorId: string, text: string, fmt?: EditorFmt) => {
|
|
1966
|
+
const trimmed = text.trim();
|
|
1967
|
+
const prev = strokesRef.current;
|
|
1968
|
+
const existing = prev.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
|
|
1969
|
+
| TextStroke
|
|
1970
|
+
| undefined;
|
|
1971
|
+
let next: Stroke[];
|
|
1972
|
+
let label = 'edit text';
|
|
1973
|
+
if (trimmed.length === 0) {
|
|
1974
|
+
if (!existing) return; // nothing to do
|
|
1975
|
+
next = prev.filter((s) => s.id !== existing.id);
|
|
1976
|
+
label = 'delete text';
|
|
1977
|
+
} else if (existing) {
|
|
1978
|
+
if (existing.text === trimmed && fmtEqual(existing, fmt)) return; // identity edit
|
|
1979
|
+
next = prev.map((s) =>
|
|
1980
|
+
s.id === existing.id ? { ...existing, text: trimmed, ...normFmt(fmt) } : s
|
|
1981
|
+
);
|
|
1982
|
+
} else {
|
|
1983
|
+
next = [
|
|
1984
|
+
...prev,
|
|
1985
|
+
{
|
|
1986
|
+
id: rid(),
|
|
1987
|
+
tool: 'text',
|
|
1988
|
+
color: '#1a1a1a',
|
|
1989
|
+
fontSize: DEFAULT_FONT_SIZE,
|
|
1990
|
+
text: trimmed,
|
|
1991
|
+
anchorId,
|
|
1992
|
+
...normFmt(fmt),
|
|
1993
|
+
} as TextStroke,
|
|
1994
|
+
];
|
|
1995
|
+
label = 'add text';
|
|
1996
|
+
}
|
|
1997
|
+
commitStrokes(prev, next, label);
|
|
1998
|
+
},
|
|
1999
|
+
[commitStrokes]
|
|
2000
|
+
);
|
|
2001
|
+
|
|
2002
|
+
// Phase 21 — sticky body edit. Sticky text is freeform (newlines preserved,
|
|
2003
|
+
// no trim) and the card persists even when blank, so this only updates text.
|
|
2004
|
+
const commitStickyText = useCallback(
|
|
2005
|
+
(id: string, text: string, fmt?: EditorFmt) => {
|
|
2006
|
+
const prev = strokesRef.current;
|
|
2007
|
+
const existing = prev.find((s) => s.id === id && s.tool === 'sticky') as
|
|
2008
|
+
| StickyStroke
|
|
2009
|
+
| undefined;
|
|
2010
|
+
if (!existing || (existing.text === text && fmtEqual(existing, fmt))) return;
|
|
2011
|
+
const next = prev.map((s) => (s.id === id ? { ...existing, text, ...normFmt(fmt) } : s));
|
|
2012
|
+
commitStrokes(prev, next, 'edit sticky');
|
|
2013
|
+
},
|
|
2014
|
+
[commitStrokes]
|
|
2015
|
+
);
|
|
2016
|
+
|
|
2017
|
+
// Phase 21 — re-edit an EXISTING standalone text node. Empty text deletes it
|
|
2018
|
+
// (same rule as anchored text).
|
|
2019
|
+
const commitStandaloneText = useCallback(
|
|
2020
|
+
(id: string, text: string, fmt?: EditorFmt) => {
|
|
2021
|
+
const trimmed = text.trim();
|
|
2022
|
+
const prev = strokesRef.current;
|
|
2023
|
+
const existing = prev.find((s) => s.id === id && s.tool === 'text') as TextStroke | undefined;
|
|
2024
|
+
if (!existing) return;
|
|
2025
|
+
if (trimmed.length === 0) {
|
|
2026
|
+
commitStrokes(
|
|
2027
|
+
prev,
|
|
2028
|
+
prev.filter((s) => s.id !== id),
|
|
2029
|
+
'delete text'
|
|
2030
|
+
);
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
if (existing.text === trimmed && fmtEqual(existing, fmt)) return;
|
|
2034
|
+
commitStrokes(
|
|
2035
|
+
prev,
|
|
2036
|
+
prev.map((s) => (s.id === id ? { ...existing, text: trimmed, ...normFmt(fmt) } : s)),
|
|
2037
|
+
'edit text'
|
|
2038
|
+
);
|
|
2039
|
+
},
|
|
2040
|
+
[commitStrokes]
|
|
2041
|
+
);
|
|
2042
|
+
|
|
2043
|
+
// Phase 21 — born-on-commit standalone text (from the text-tool caret). An
|
|
2044
|
+
// empty caret persists nothing — ONE undo record only when real text lands.
|
|
2045
|
+
const createStandaloneText = useCallback(
|
|
2046
|
+
(x: number, y: number, text: string, fmt?: EditorFmt) => {
|
|
2047
|
+
const trimmed = text.trim();
|
|
2048
|
+
if (trimmed.length === 0) return;
|
|
2049
|
+
const prev = strokesRef.current;
|
|
2050
|
+
const id = rid();
|
|
2051
|
+
const next: Stroke[] = [
|
|
2052
|
+
...prev,
|
|
2053
|
+
{
|
|
2054
|
+
id,
|
|
2055
|
+
tool: 'text',
|
|
2056
|
+
color,
|
|
2057
|
+
fontSize: DEFAULT_FONT_SIZE,
|
|
2058
|
+
text: trimmed,
|
|
2059
|
+
x,
|
|
2060
|
+
y,
|
|
2061
|
+
...normFmt(fmt),
|
|
2062
|
+
},
|
|
2063
|
+
];
|
|
2064
|
+
commitStrokes(prev, next, 'add text');
|
|
2065
|
+
if (annotSel) annotSel.replace(id);
|
|
2066
|
+
},
|
|
2067
|
+
[commitStrokes, color, annotSel]
|
|
2068
|
+
);
|
|
2069
|
+
|
|
2070
|
+
// Phase 21 — resolve what (if anything) is being edited, and route a single
|
|
2071
|
+
// commit call to the right writer. `editingId` doubles as the host id
|
|
2072
|
+
// (anchored) OR the sticky/standalone stroke id; `pendingText` is the
|
|
2073
|
+
// not-yet-born text caret.
|
|
2074
|
+
const editingTarget = useMemo<EditingTarget>(() => {
|
|
2075
|
+
if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
|
|
2076
|
+
if (!editingId) return null;
|
|
2077
|
+
const host = anchorsById.get(editingId);
|
|
2078
|
+
if (host) return { kind: 'anchored', anchorId: editingId, host };
|
|
2079
|
+
const s = strokesById.get(editingId);
|
|
2080
|
+
if (s?.tool === 'sticky') return { kind: 'sticky', sticky: s };
|
|
2081
|
+
if (s?.tool === 'section') return { kind: 'section', section: s };
|
|
2082
|
+
if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
|
|
2083
|
+
return { kind: 'standalone', text: s };
|
|
2084
|
+
return null;
|
|
2085
|
+
}, [pendingText, editingId, anchorsById, strokesById]);
|
|
2086
|
+
|
|
2087
|
+
const editingTargetRef = useRef(editingTarget);
|
|
2088
|
+
editingTargetRef.current = editingTarget;
|
|
2089
|
+
|
|
2090
|
+
const commitEditing = useCallback(
|
|
2091
|
+
(text: string, fmt?: EditorFmt) => {
|
|
2092
|
+
const target = editingTargetRef.current;
|
|
2093
|
+
setEditingId(null);
|
|
2094
|
+
setPendingText(null);
|
|
2095
|
+
if (!target) return;
|
|
2096
|
+
if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
|
|
2097
|
+
else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
|
|
2098
|
+
else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
|
|
2099
|
+
else if (target.kind === 'section') {
|
|
2100
|
+
const label = text.trim().replace(/\s*\n+\s*/g, ' ') || 'Section';
|
|
2101
|
+
if (label !== target.section.label) {
|
|
2102
|
+
strokesStoreRef.current.updateStroke(target.section.id, {
|
|
2103
|
+
label,
|
|
2104
|
+
} as Partial<Stroke>);
|
|
2105
|
+
}
|
|
2106
|
+
} else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text, fmt);
|
|
2107
|
+
},
|
|
2108
|
+
[commitText, commitStickyText, commitStandaloneText, createStandaloneText]
|
|
2109
|
+
);
|
|
2110
|
+
|
|
2111
|
+
const cancelEditing = useCallback(() => {
|
|
2112
|
+
setEditingId(null);
|
|
2113
|
+
setPendingText(null);
|
|
2114
|
+
}, []);
|
|
2115
|
+
|
|
2116
|
+
/**
|
|
2117
|
+
* FigJam v3 — copy/cut the (expanded) selection to the OS clipboard as a
|
|
2118
|
+
* `{"maudeStrokes":1}` JSON text payload. Shared by ⌘C/⌘X and the
|
|
2119
|
+
* right-click menu. Returns true when something was copied.
|
|
2120
|
+
*/
|
|
2121
|
+
const copySelection = useCallback(
|
|
2122
|
+
(cut: boolean): boolean => {
|
|
2123
|
+
if (!annotSel) return false;
|
|
2124
|
+
const sel = annotSel.selectedIds;
|
|
2125
|
+
if (sel.length === 0) return false;
|
|
2126
|
+
const store = strokesStoreRef.current;
|
|
2127
|
+
const expanded = new Set(expandIdsToGroups(sel, store.strokes));
|
|
2128
|
+
const payload = store.strokes.filter(
|
|
2129
|
+
(s) =>
|
|
2130
|
+
expanded.has(s.id) ||
|
|
2131
|
+
(s.tool === 'text' && s.anchorId != null && expanded.has(s.anchorId))
|
|
2132
|
+
);
|
|
2133
|
+
if (payload.length === 0) return false;
|
|
2134
|
+
try {
|
|
2135
|
+
void navigator.clipboard
|
|
2136
|
+
?.writeText(JSON.stringify({ maudeStrokes: 1, strokes: payload }))
|
|
2137
|
+
.catch(() => {
|
|
2138
|
+
/* clipboard permission denied — copy is best-effort */
|
|
2139
|
+
});
|
|
2140
|
+
} catch {
|
|
2141
|
+
/* clipboard API absent — non-fatal */
|
|
2142
|
+
}
|
|
2143
|
+
if (cut) {
|
|
2144
|
+
store.deleteStrokes([...expanded]);
|
|
2145
|
+
annotSel.clear();
|
|
2146
|
+
}
|
|
2147
|
+
return true;
|
|
2148
|
+
},
|
|
2149
|
+
[annotSel]
|
|
2150
|
+
);
|
|
2151
|
+
|
|
2152
|
+
/**
|
|
2153
|
+
* FigJam v3 — paste a strokes JSON payload (⌘V or the right-click menu).
|
|
2154
|
+
* Round-trips through the serializer + parser so a malformed foreign payload
|
|
2155
|
+
* coerces to valid strokes or drops; clones get fresh ids + a +16/+16 offset.
|
|
2156
|
+
*/
|
|
2157
|
+
const pasteStrokesText = useCallback(
|
|
2158
|
+
(txt: string): boolean => {
|
|
2159
|
+
if (!annotSel) return false;
|
|
2160
|
+
if (!txt.startsWith('{"maudeStrokes"')) return false;
|
|
2161
|
+
let parsed: { maudeStrokes?: number; strokes?: unknown } | null = null;
|
|
2162
|
+
try {
|
|
2163
|
+
parsed = JSON.parse(txt) as { maudeStrokes?: number; strokes?: unknown };
|
|
2164
|
+
} catch {
|
|
2165
|
+
return false;
|
|
2166
|
+
}
|
|
2167
|
+
if (parsed?.maudeStrokes !== 1 || !Array.isArray(parsed.strokes)) return false;
|
|
2168
|
+
let safe: Stroke[] = [];
|
|
2169
|
+
try {
|
|
2170
|
+
safe = svgToStrokes(strokesToSvg(parsed.strokes as Stroke[]));
|
|
2171
|
+
} catch {
|
|
2172
|
+
return false;
|
|
2173
|
+
}
|
|
2174
|
+
if (safe.length === 0) return false;
|
|
2175
|
+
const res = duplicateStrokes(
|
|
2176
|
+
safe,
|
|
2177
|
+
safe.map((s) => s.id),
|
|
2178
|
+
16,
|
|
2179
|
+
16
|
|
2180
|
+
);
|
|
2181
|
+
const clones = res.strokes.slice(safe.length);
|
|
2182
|
+
if (clones.length === 0) return false;
|
|
2183
|
+
const prev = strokesRef.current;
|
|
2184
|
+
// recompute keeps binds to hosts present in THIS canvas and strips the
|
|
2185
|
+
// cross-canvas danglers (endpoint frozen).
|
|
2186
|
+
const next = recomputeBoundArrows([...prev, ...clones]);
|
|
2187
|
+
commitStrokes(prev, next, `paste ${clones.length} stroke${clones.length === 1 ? '' : 's'}`);
|
|
2188
|
+
annotSel.replace(res.newIds);
|
|
2189
|
+
return true;
|
|
2190
|
+
},
|
|
2191
|
+
[annotSel, commitStrokes]
|
|
2192
|
+
);
|
|
2193
|
+
|
|
2194
|
+
/**
|
|
2195
|
+
* FigJam v3 — quick-create chain (⌘Enter): with a sticky or shape selected,
|
|
2196
|
+
* spawn a sibling of the same type/size/style to the right with its editor
|
|
2197
|
+
* active. Shapes ALSO get a bound connector source → sibling (FigJam quick-
|
|
2198
|
+
* create: shapes connect, stickies don't). Returns true when spawned.
|
|
2199
|
+
*/
|
|
2200
|
+
const chainCreate = useCallback(
|
|
2201
|
+
(sourceId: string): boolean => {
|
|
2202
|
+
const prev = strokesRef.current;
|
|
2203
|
+
const src = prev.find((s) => s.id === sourceId);
|
|
2204
|
+
if (!src) return false;
|
|
2205
|
+
if (
|
|
2206
|
+
src.tool !== 'sticky' &&
|
|
2207
|
+
src.tool !== 'rect' &&
|
|
2208
|
+
src.tool !== 'ellipse' &&
|
|
2209
|
+
src.tool !== 'polygon'
|
|
2210
|
+
) {
|
|
2211
|
+
return false;
|
|
2212
|
+
}
|
|
2213
|
+
const bb = strokeBBox(src);
|
|
2214
|
+
if (!bb) return false;
|
|
2215
|
+
const gap = src.tool === 'sticky' ? 40 : 64;
|
|
2216
|
+
const nid = rid();
|
|
2217
|
+
// The sibling copies style + size but starts loose (no group membership,
|
|
2218
|
+
// human provenance) and empty-bodied. Undefined-assignment (not rest-
|
|
2219
|
+
// destructuring) keeps the discriminated-union narrowing intact for the
|
|
2220
|
+
// branches below; the serializer treats undefined as absent.
|
|
2221
|
+
const bare = structuredClone(src);
|
|
2222
|
+
bare.groupIds = undefined;
|
|
2223
|
+
bare.author = undefined;
|
|
2224
|
+
let sibling: Stroke;
|
|
2225
|
+
if (bare.tool === 'ellipse') {
|
|
2226
|
+
sibling = { ...bare, id: nid, cx: bare.cx + bb.w + gap };
|
|
2227
|
+
} else if (bare.tool === 'sticky') {
|
|
2228
|
+
sibling = { ...bare, id: nid, x: bb.x + bb.w + gap, y: bb.y, text: '' };
|
|
2229
|
+
} else {
|
|
2230
|
+
sibling = { ...bare, id: nid, x: bb.x + bb.w + gap, y: bb.y };
|
|
2231
|
+
}
|
|
2232
|
+
let next: Stroke[] = [...prev, sibling];
|
|
2233
|
+
if (src.tool !== 'sticky') {
|
|
2234
|
+
const p1 = anchorPoint(src, 1, 0.5);
|
|
2235
|
+
const p2 = anchorPoint(sibling, 0, 0.5);
|
|
2236
|
+
if (p1 && p2) {
|
|
2237
|
+
next = [
|
|
2238
|
+
...next,
|
|
2239
|
+
{
|
|
2240
|
+
id: rid(),
|
|
2241
|
+
tool: 'arrow',
|
|
2242
|
+
color: resolveDefaultInk(theme),
|
|
2243
|
+
width: STROKE_WIDTH_THIN,
|
|
2244
|
+
x1: p1[0],
|
|
2245
|
+
y1: p1[1],
|
|
2246
|
+
x2: p2[0],
|
|
2247
|
+
y2: p2[1],
|
|
2248
|
+
startBind: { hostId: src.id, nx: 1, ny: 0.5 },
|
|
2249
|
+
endBind: { hostId: nid, nx: 0, ny: 0.5 },
|
|
2250
|
+
},
|
|
2251
|
+
];
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
commitStrokes(prev, next, `quick-create ${sibling.tool}`);
|
|
2255
|
+
annotSel?.replace(nid);
|
|
2256
|
+
// Sticky → body editor; rect/ellipse/polygon → anchored-text editor
|
|
2257
|
+
// (Wave G widened anchorsById to every closed shape).
|
|
2258
|
+
if (
|
|
2259
|
+
sibling.tool === 'sticky' ||
|
|
2260
|
+
sibling.tool === 'rect' ||
|
|
2261
|
+
sibling.tool === 'ellipse' ||
|
|
2262
|
+
sibling.tool === 'polygon'
|
|
2263
|
+
) {
|
|
2264
|
+
setEditingId(nid);
|
|
2265
|
+
}
|
|
2266
|
+
return true;
|
|
2267
|
+
},
|
|
2268
|
+
[commitStrokes, annotSel, theme]
|
|
2269
|
+
);
|
|
2270
|
+
|
|
2271
|
+
// FigJam v3 — ⌘Enter pressed INSIDE a sticky/anchored editor commits there
|
|
2272
|
+
// and asks the layer (via this event) to chain the next sibling. Deferred a
|
|
2273
|
+
// tick so the editor's commit lands in strokesRef first.
|
|
2274
|
+
useEffect(() => {
|
|
2275
|
+
if (typeof document === 'undefined') return;
|
|
2276
|
+
const onChain = (e: Event) => {
|
|
2277
|
+
const id = (e as CustomEvent<{ id?: string }>).detail?.id;
|
|
2278
|
+
if (!id) return;
|
|
2279
|
+
window.setTimeout(() => {
|
|
2280
|
+
chainCreate(id);
|
|
2281
|
+
}, 0);
|
|
2282
|
+
};
|
|
2283
|
+
document.addEventListener('maude:chain-create', onChain);
|
|
2284
|
+
return () => document.removeEventListener('maude:chain-create', onChain);
|
|
2285
|
+
}, [chainCreate]);
|
|
2286
|
+
|
|
2287
|
+
// FigJam v3 — the resize overlay (a sibling component that owns the arrow
|
|
2288
|
+
// endpoint handles) broadcasts the bind candidate while an endpoint drags;
|
|
2289
|
+
// the halo renders here because the SVG layer owns the world overlay.
|
|
2290
|
+
useEffect(() => {
|
|
2291
|
+
if (typeof document === 'undefined') return;
|
|
2292
|
+
const onHint = (e: Event) => {
|
|
2293
|
+
setBindHintId((e as CustomEvent<{ hostId?: string | null }>).detail?.hostId ?? null);
|
|
2294
|
+
};
|
|
2295
|
+
document.addEventListener('maude:bind-hint', onHint);
|
|
2296
|
+
return () => document.removeEventListener('maude:bind-hint', onHint);
|
|
2297
|
+
}, []);
|
|
2298
|
+
|
|
2299
|
+
// FigJam v3 — live size label + dimension-match halos while resizing (the
|
|
2300
|
+
// overlay broadcasts; the SVG layer paints).
|
|
2301
|
+
const [resizeInfo, setResizeInfo] = useState<{
|
|
2302
|
+
box: { x: number; y: number; w: number; h: number } | null;
|
|
2303
|
+
matchIds: string[];
|
|
2304
|
+
} | null>(null);
|
|
2305
|
+
useEffect(() => {
|
|
2306
|
+
if (typeof document === 'undefined') return;
|
|
2307
|
+
const onInfo = (e: Event) => {
|
|
2308
|
+
const detail = (
|
|
2309
|
+
e as CustomEvent<{
|
|
2310
|
+
box?: { x: number; y: number; w: number; h: number } | null;
|
|
2311
|
+
matchIds?: string[];
|
|
2312
|
+
}>
|
|
2313
|
+
).detail;
|
|
2314
|
+
setResizeInfo(detail?.box ? { box: detail.box, matchIds: detail.matchIds ?? [] } : null);
|
|
2315
|
+
};
|
|
2316
|
+
document.addEventListener('maude:resize-info', onInfo);
|
|
2317
|
+
return () => document.removeEventListener('maude:resize-info', onInfo);
|
|
2318
|
+
}, []);
|
|
2319
|
+
|
|
2320
|
+
// FigJam v3 — manipulation shortcuts: ⌘G group / ⌘⇧G ungroup, ⌘D duplicate,
|
|
2321
|
+
// ] [ ⌘] ⌘[ z-order, ⌘C/⌘X copy/cut (selection → OS clipboard as a JSON
|
|
2322
|
+
// text payload), ⌘Enter quick-create chain. Document capture, mirroring the
|
|
2323
|
+
// nudge handler below; the input-router never claims these combos.
|
|
2324
|
+
useEffect(() => {
|
|
2325
|
+
if (typeof document === 'undefined') return;
|
|
2326
|
+
if (!annotSel) return;
|
|
2327
|
+
const onKey = (e: KeyboardEvent) => {
|
|
2328
|
+
if (isEditable(e.target)) return;
|
|
2329
|
+
const store = strokesStoreRef.current;
|
|
2330
|
+
const sel = annotSel.selectedIds;
|
|
2331
|
+
const cmd = e.metaKey || e.ctrlKey;
|
|
2332
|
+
const k = e.key.toLowerCase();
|
|
2333
|
+
if (cmd && !e.altKey && k === 'g') {
|
|
2334
|
+
// ⌘G claims the browser's find-next ONLY when a selection exists.
|
|
2335
|
+
if (sel.length === 0) return;
|
|
2336
|
+
e.preventDefault();
|
|
2337
|
+
e.stopImmediatePropagation();
|
|
2338
|
+
if (e.shiftKey) {
|
|
2339
|
+
store.ungroupSelection(sel);
|
|
2340
|
+
} else {
|
|
2341
|
+
const members = store.groupSelection(sel);
|
|
2342
|
+
if (members) annotSel.replace(members);
|
|
2343
|
+
}
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
if (cmd && !e.shiftKey && !e.altKey && k === 'd') {
|
|
2347
|
+
if (sel.length === 0) return; // browser bookmark stays available
|
|
2348
|
+
e.preventDefault();
|
|
2349
|
+
e.stopImmediatePropagation();
|
|
2350
|
+
const ids = store.duplicateSelection(sel, 16, 16);
|
|
2351
|
+
if (ids.length) annotSel.replace(ids);
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
if ((e.key === ']' || e.key === '[') && !e.altKey && !e.shiftKey) {
|
|
2355
|
+
if (sel.length === 0) return;
|
|
2356
|
+
e.preventDefault();
|
|
2357
|
+
const op: ZOrderOp =
|
|
2358
|
+
e.key === ']' ? (cmd ? 'forward' : 'front') : cmd ? 'backward' : 'back';
|
|
2359
|
+
store.reorderSelection(sel, op);
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
if (cmd && !e.shiftKey && !e.altKey && (k === 'c' || k === 'x')) {
|
|
2363
|
+
if (sel.length === 0) return; // let the native copy run
|
|
2364
|
+
if (copySelection(k === 'x')) e.preventDefault();
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
if (cmd && e.key === 'Enter' && !e.shiftKey && !e.altKey) {
|
|
2368
|
+
if (sel.length !== 1) return;
|
|
2369
|
+
const only = sel[0];
|
|
2370
|
+
if (only && chainCreate(only)) e.preventDefault();
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
// FigJam v3 — plain Enter on a single text-capable stroke opens its
|
|
2374
|
+
// editor (FigJam: select a shape, press Enter, start typing).
|
|
2375
|
+
if (e.key === 'Enter' && !cmd && !e.shiftKey && !e.altKey) {
|
|
2376
|
+
if (sel.length !== 1) return;
|
|
2377
|
+
const only = strokesRef.current.find((x) => x.id === sel[0]);
|
|
2378
|
+
if (!only) return;
|
|
2379
|
+
if (
|
|
2380
|
+
only.tool === 'rect' ||
|
|
2381
|
+
only.tool === 'ellipse' ||
|
|
2382
|
+
only.tool === 'polygon' ||
|
|
2383
|
+
only.tool === 'sticky' ||
|
|
2384
|
+
(only.tool === 'text' && (only.anchorId == null || only.anchorId === ''))
|
|
2385
|
+
) {
|
|
2386
|
+
e.preventDefault();
|
|
2387
|
+
setEditingId(only.id);
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
};
|
|
2391
|
+
document.addEventListener('keydown', onKey, true);
|
|
2392
|
+
return () => document.removeEventListener('keydown', onKey, true);
|
|
2393
|
+
}, [annotSel, chainCreate, copySelection]);
|
|
2394
|
+
|
|
2395
|
+
// FigJam v3 — paste strokes. ⌘C serialized the selection as a JSON text
|
|
2396
|
+
// payload; this CAPTURE-phase listener claims it before the media-intake
|
|
2397
|
+
// hook (bubble phase) so a strokes payload never falls through to the URL/
|
|
2398
|
+
// link branch.
|
|
2399
|
+
useEffect(() => {
|
|
2400
|
+
if (typeof document === 'undefined') return;
|
|
2401
|
+
if (!annotSel) return;
|
|
2402
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
2403
|
+
if (isEditable(e.target)) return;
|
|
2404
|
+
const txt = e.clipboardData?.getData('text/plain') ?? '';
|
|
2405
|
+
if (!txt.startsWith('{"maudeStrokes"')) return;
|
|
2406
|
+
e.preventDefault();
|
|
2407
|
+
e.stopImmediatePropagation();
|
|
2408
|
+
pasteStrokesText(txt);
|
|
2409
|
+
};
|
|
2410
|
+
document.addEventListener('paste', onPaste, true);
|
|
2411
|
+
return () => document.removeEventListener('paste', onPaste, true);
|
|
2412
|
+
}, [annotSel, pasteStrokesText]);
|
|
2413
|
+
|
|
2414
|
+
// FigJam v3 — hover "Add text" affordance: an empty rect/ellipse hovered in
|
|
2415
|
+
// move mode shows a ghost label; double-click (existing) or Enter edits.
|
|
2416
|
+
const [addTextHintId, setAddTextHintId] = useState<string | null>(null);
|
|
2417
|
+
useEffect(() => {
|
|
2418
|
+
if (typeof document === 'undefined') return;
|
|
2419
|
+
if (tool !== 'move') {
|
|
2420
|
+
setAddTextHintId(null);
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
const onMove = (e: PointerEvent) => {
|
|
2424
|
+
const node = (e.target as Element | null)?.closest?.('[data-id][data-tool]');
|
|
2425
|
+
const t = node?.getAttribute('data-tool');
|
|
2426
|
+
const id = node?.getAttribute('data-id') ?? null;
|
|
2427
|
+
if (!id || (t !== 'rect' && t !== 'ellipse' && t !== 'polygon')) {
|
|
2428
|
+
setAddTextHintId(null);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
const hasText = strokesRef.current.some(
|
|
2432
|
+
(x) => x.tool === 'text' && x.anchorId === id && x.text.length > 0
|
|
2433
|
+
);
|
|
2434
|
+
setAddTextHintId(hasText ? null : id);
|
|
2435
|
+
};
|
|
2436
|
+
document.addEventListener('pointermove', onMove, { passive: true });
|
|
2437
|
+
return () => document.removeEventListener('pointermove', onMove);
|
|
2438
|
+
}, [tool]);
|
|
2439
|
+
|
|
2440
|
+
// FigJam v3 — connector draft: dragging from a connection dot (the side
|
|
2441
|
+
// magnets shown on a selected bindable shape) draws a BOUND curved
|
|
2442
|
+
// connector; releasing over another bindable shape binds the far end too
|
|
2443
|
+
// (⌘ keeps it free). The draft renders through the same arrow primitives.
|
|
2444
|
+
const [connDraft, setConnDraft] = useState<{
|
|
2445
|
+
x1: number;
|
|
2446
|
+
y1: number;
|
|
2447
|
+
x2: number;
|
|
2448
|
+
y2: number;
|
|
2449
|
+
startBind: { hostId: string; nx: number; ny: number };
|
|
2450
|
+
endBind?: { hostId: string; nx: number; ny: number };
|
|
2451
|
+
} | null>(null);
|
|
2452
|
+
const connDraftRef = useRef(connDraft);
|
|
2453
|
+
connDraftRef.current = connDraft;
|
|
2454
|
+
useEffect(() => {
|
|
2455
|
+
if (typeof document === 'undefined') return;
|
|
2456
|
+
if (tool !== 'move') return;
|
|
2457
|
+
const onDown = (e: PointerEvent) => {
|
|
2458
|
+
if (e.button !== 0) return;
|
|
2459
|
+
const dot = (e.target as Element | null)?.closest?.('.dc-annot-conn-dot');
|
|
2460
|
+
if (!dot) return;
|
|
2461
|
+
const hostId = dot.getAttribute('data-host') ?? '';
|
|
2462
|
+
const nx = Number.parseFloat(dot.getAttribute('data-nx') ?? '');
|
|
2463
|
+
const ny = Number.parseFloat(dot.getAttribute('data-ny') ?? '');
|
|
2464
|
+
const host = strokesRef.current.find((s) => s.id === hostId);
|
|
2465
|
+
if (!host || !Number.isFinite(nx) || !Number.isFinite(ny)) return;
|
|
2466
|
+
const pt = anchorPoint(host, nx, ny);
|
|
2467
|
+
if (!pt) return;
|
|
2468
|
+
e.preventDefault();
|
|
2469
|
+
e.stopImmediatePropagation();
|
|
2470
|
+
const pointerId = e.pointerId;
|
|
2471
|
+
const startBind = { hostId, nx, ny };
|
|
2472
|
+
setConnDraft({ x1: pt[0], y1: pt[1], x2: pt[0], y2: pt[1], startBind });
|
|
2473
|
+
const onMove = (mv: PointerEvent) => {
|
|
2474
|
+
if (mv.pointerId !== pointerId) return;
|
|
2475
|
+
const [wx, wy] = screenToWorld(mv.clientX, mv.clientY);
|
|
2476
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
2477
|
+
const cand =
|
|
2478
|
+
mv.metaKey || mv.ctrlKey
|
|
2479
|
+
? null
|
|
2480
|
+
: bindCandidate(
|
|
2481
|
+
wx,
|
|
2482
|
+
wy,
|
|
2483
|
+
strokesRef.current,
|
|
2484
|
+
BIND_THRESHOLD_PX / zoom,
|
|
2485
|
+
new Set([hostId])
|
|
2486
|
+
);
|
|
2487
|
+
setBindHintId(cand?.hostId ?? null);
|
|
2488
|
+
if (cand) {
|
|
2489
|
+
const target = strokesRef.current.find((s) => s.id === cand.hostId);
|
|
2490
|
+
const tp = target ? anchorPoint(target, cand.nx, cand.ny) : null;
|
|
2491
|
+
if (tp) {
|
|
2492
|
+
setConnDraft({ x1: pt[0], y1: pt[1], x2: tp[0], y2: tp[1], startBind, endBind: cand });
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
setConnDraft({ x1: pt[0], y1: pt[1], x2: wx, y2: wy, startBind });
|
|
2497
|
+
};
|
|
2498
|
+
const onUp = (up: PointerEvent) => {
|
|
2499
|
+
if (up.pointerId !== pointerId) return;
|
|
2500
|
+
document.removeEventListener('pointermove', onMove, true);
|
|
2501
|
+
document.removeEventListener('pointerup', onUp, true);
|
|
2502
|
+
document.removeEventListener('pointercancel', onUp, true);
|
|
2503
|
+
const draft = connDraftRef.current;
|
|
2504
|
+
setConnDraft(null);
|
|
2505
|
+
setBindHintId(null);
|
|
2506
|
+
if (!draft) return;
|
|
2507
|
+
// A bare tap on the dot creates nothing.
|
|
2508
|
+
if (Math.hypot(draft.x2 - draft.x1, draft.y2 - draft.y1) < 8) return;
|
|
2509
|
+
const arrow: ArrowStroke = {
|
|
2510
|
+
id: rid(),
|
|
2511
|
+
tool: 'arrow',
|
|
2512
|
+
color: resolveDefaultInk(theme),
|
|
2513
|
+
width: STROKE_WIDTH_THIN,
|
|
2514
|
+
x1: draft.x1,
|
|
2515
|
+
y1: draft.y1,
|
|
2516
|
+
x2: draft.x2,
|
|
2517
|
+
y2: draft.y2,
|
|
2518
|
+
lineType: 'curved',
|
|
2519
|
+
startBind: draft.startBind,
|
|
2520
|
+
...(draft.endBind ? { endBind: draft.endBind } : {}),
|
|
2521
|
+
};
|
|
2522
|
+
const prev = strokesRef.current;
|
|
2523
|
+
commitStrokes(prev, [...prev, arrow], 'draw connector');
|
|
2524
|
+
annotSel?.replace(arrow.id);
|
|
2525
|
+
if (arrow.endBind) {
|
|
2526
|
+
showOnceHint(
|
|
2527
|
+
'bind',
|
|
2528
|
+
'Arrow attached — it follows the shape now. Drag an endpoint to re-anchor, hold ⌘ to keep it free.'
|
|
2529
|
+
);
|
|
2530
|
+
}
|
|
2531
|
+
};
|
|
2532
|
+
document.addEventListener('pointermove', onMove, true);
|
|
2533
|
+
document.addEventListener('pointerup', onUp, true);
|
|
2534
|
+
document.addEventListener('pointercancel', onUp, true);
|
|
2535
|
+
};
|
|
2536
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
2537
|
+
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
2538
|
+
}, [tool, screenToWorld, commitStrokes, annotSel, theme]);
|
|
2539
|
+
|
|
2540
|
+
// FigJam v3 — right-click on a stroke SELECTS it (keeping a multi-selection
|
|
2541
|
+
// the press lands inside) and opens the annotation context menu (z-order,
|
|
2542
|
+
// group, copy/paste, delete). Capture phase on document so it claims the
|
|
2543
|
+
// event before the input-router's host-level contextmenu handler.
|
|
2544
|
+
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
|
2545
|
+
useEffect(() => {
|
|
2546
|
+
if (typeof document === 'undefined') return;
|
|
2547
|
+
if (tool !== 'move') return;
|
|
2548
|
+
if (!annotSel) return;
|
|
2549
|
+
const strokeAt = (target: Element | null): string | null => {
|
|
2550
|
+
if (target?.closest?.(CHROME_SELECTOR)) return null;
|
|
2551
|
+
const node = target?.closest?.('[data-id][data-tool]');
|
|
2552
|
+
const id = node?.getAttribute('data-id');
|
|
2553
|
+
if (!id || !strokesRef.current.some((s) => s.id === id)) return null;
|
|
2554
|
+
return id;
|
|
2555
|
+
};
|
|
2556
|
+
const onCtx = (e: MouseEvent) => {
|
|
2557
|
+
const id = strokeAt(e.target as Element | null);
|
|
2558
|
+
if (!id) return;
|
|
2559
|
+
e.preventDefault();
|
|
2560
|
+
e.stopImmediatePropagation();
|
|
2561
|
+
if (!annotSel.contains(id)) {
|
|
2562
|
+
annotSel.replace(expandIdsToGroups([id], strokesRef.current));
|
|
2563
|
+
}
|
|
2564
|
+
setCtxMenu({ x: e.clientX, y: e.clientY });
|
|
2565
|
+
};
|
|
2566
|
+
// Wave G — the input-router ALSO opens the shell canvas menu from a
|
|
2567
|
+
// right-button POINTERDOWN (classify maps button 2 → 'context-menu'), so
|
|
2568
|
+
// claiming only the contextmenu event left BOTH menus open. This document-
|
|
2569
|
+
// capture listener fires before the router's host-capture one and stops
|
|
2570
|
+
// propagation WITHOUT preventDefault, so the native contextmenu event
|
|
2571
|
+
// (which opens OUR menu above) still follows.
|
|
2572
|
+
const onDown = (e: PointerEvent) => {
|
|
2573
|
+
if (e.button !== 2) return;
|
|
2574
|
+
if (!strokeAt(e.target as Element | null)) return;
|
|
2575
|
+
e.stopImmediatePropagation();
|
|
2576
|
+
};
|
|
2577
|
+
document.addEventListener('contextmenu', onCtx, true);
|
|
2578
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
2579
|
+
return () => {
|
|
2580
|
+
document.removeEventListener('contextmenu', onCtx, true);
|
|
2581
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
2582
|
+
};
|
|
2583
|
+
}, [tool, annotSel]);
|
|
2584
|
+
|
|
2585
|
+
// Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
|
|
2586
|
+
useEffect(() => {
|
|
2587
|
+
if (typeof document === 'undefined') return;
|
|
2588
|
+
if (!annotSel) return;
|
|
2589
|
+
const onKey = (e: KeyboardEvent) => {
|
|
2590
|
+
if (isEditable(e.target)) return;
|
|
2591
|
+
if (annotSel.selectedIds.length === 0) return;
|
|
2592
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2593
|
+
const step = e.shiftKey ? 10 : 1;
|
|
2594
|
+
if (e.key === 'ArrowLeft') {
|
|
2595
|
+
e.preventDefault();
|
|
2596
|
+
strokesStore.translateStrokes(annotSel.selectedIds, -step, 0);
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
if (e.key === 'ArrowRight') {
|
|
2600
|
+
e.preventDefault();
|
|
2601
|
+
strokesStore.translateStrokes(annotSel.selectedIds, step, 0);
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
2604
|
+
if (e.key === 'ArrowUp') {
|
|
2605
|
+
e.preventDefault();
|
|
2606
|
+
strokesStore.translateStrokes(annotSel.selectedIds, 0, -step);
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
if (e.key === 'ArrowDown') {
|
|
2610
|
+
e.preventDefault();
|
|
2611
|
+
strokesStore.translateStrokes(annotSel.selectedIds, 0, step);
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
2615
|
+
e.preventDefault();
|
|
2616
|
+
strokesStore.deleteStrokes(annotSel.selectedIds);
|
|
2617
|
+
annotSel.clear();
|
|
2618
|
+
}
|
|
2619
|
+
};
|
|
2620
|
+
document.addEventListener('keydown', onKey, true);
|
|
2621
|
+
return () => document.removeEventListener('keydown', onKey, true);
|
|
2622
|
+
}, [annotSel, strokesStore]);
|
|
2623
|
+
|
|
2624
|
+
/** FigJam v3 — context-menu action dispatcher (shares the shortcut paths). */
|
|
2625
|
+
const onMenuAction = useCallback(
|
|
2626
|
+
(action: string) => {
|
|
2627
|
+
const store = strokesStoreRef.current;
|
|
2628
|
+
if (!annotSel) return;
|
|
2629
|
+
const sel = annotSel.selectedIds;
|
|
2630
|
+
if (action === 'copy') copySelection(false);
|
|
2631
|
+
else if (action === 'cut') copySelection(true);
|
|
2632
|
+
else if (action === 'paste') {
|
|
2633
|
+
try {
|
|
2634
|
+
void navigator.clipboard
|
|
2635
|
+
?.readText()
|
|
2636
|
+
.then((t) => {
|
|
2637
|
+
pasteStrokesText(t);
|
|
2638
|
+
})
|
|
2639
|
+
.catch(() => {
|
|
2640
|
+
/* clipboard read blocked — paste is best-effort from the menu */
|
|
2641
|
+
});
|
|
2642
|
+
} catch {
|
|
2643
|
+
/* clipboard API absent */
|
|
2644
|
+
}
|
|
2645
|
+
} else if (action === 'duplicate') {
|
|
2646
|
+
const ids = store.duplicateSelection(sel, 16, 16);
|
|
2647
|
+
if (ids.length) annotSel.replace(ids);
|
|
2648
|
+
} else if (action === 'delete') {
|
|
2649
|
+
store.deleteStrokes(sel);
|
|
2650
|
+
annotSel.clear();
|
|
2651
|
+
} else if (
|
|
2652
|
+
action === 'front' ||
|
|
2653
|
+
action === 'forward' ||
|
|
2654
|
+
action === 'backward' ||
|
|
2655
|
+
action === 'back'
|
|
2656
|
+
) {
|
|
2657
|
+
store.reorderSelection(sel, action);
|
|
2658
|
+
} else if (action === 'group') {
|
|
2659
|
+
const members = store.groupSelection(sel);
|
|
2660
|
+
if (members) annotSel.replace(members);
|
|
2661
|
+
} else if (action === 'ungroup') {
|
|
2662
|
+
store.ungroupSelection(sel);
|
|
2663
|
+
}
|
|
2664
|
+
},
|
|
2665
|
+
[annotSel, copySelection, pasteStrokesText]
|
|
2666
|
+
);
|
|
2667
|
+
|
|
2668
|
+
// FigJam v3 — first time a multi-selection lands, surface the group /
|
|
2669
|
+
// duplicate affordances once (behaviour-triggered, never a tour).
|
|
2670
|
+
const selCount = annotSel?.selectedIds.length ?? 0;
|
|
2671
|
+
useEffect(() => {
|
|
2672
|
+
if (selCount >= 2) {
|
|
2673
|
+
showOnceHint(
|
|
2674
|
+
'multi',
|
|
2675
|
+
'⌘G groups the selection · drag inside the box moves everything · ⌘D duplicates.'
|
|
2676
|
+
);
|
|
2677
|
+
}
|
|
2678
|
+
}, [selCount]);
|
|
2679
|
+
|
|
2680
|
+
// Selected stroke halos — bboxes in world coords, vector-effect non-scaling-stroke.
|
|
2681
|
+
const selectedStrokes = useMemo(() => {
|
|
2682
|
+
if (!annotSel || annotSel.selectedIds.length === 0) return [] as Stroke[];
|
|
2683
|
+
const out: Stroke[] = [];
|
|
2684
|
+
for (const id of annotSel.selectedIds) {
|
|
2685
|
+
const s = strokesById.get(id);
|
|
2686
|
+
if (s) out.push(s);
|
|
2687
|
+
}
|
|
2688
|
+
return out;
|
|
2689
|
+
}, [annotSel, strokesById]);
|
|
2690
|
+
|
|
2691
|
+
return (
|
|
2692
|
+
<StrokesStoreContext.Provider value={strokesStore}>
|
|
2693
|
+
<AnnotationsInput
|
|
2694
|
+
isActive={isActive}
|
|
2695
|
+
visible={visible}
|
|
2696
|
+
cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
|
|
2697
|
+
beginStroke={beginStroke}
|
|
2698
|
+
moveStroke={moveStroke}
|
|
2699
|
+
endStroke={endStroke}
|
|
2700
|
+
onLeave={() => setGhost(null)}
|
|
2701
|
+
/>
|
|
2702
|
+
{visible ? (
|
|
2703
|
+
<AnnotationsSvg
|
|
2704
|
+
worldRef={worldRef}
|
|
2705
|
+
strokes={renderStrokes}
|
|
2706
|
+
anchorsById={anchorsById}
|
|
2707
|
+
selectMode={tool === 'move'}
|
|
2708
|
+
selectedStrokes={selectedStrokes}
|
|
2709
|
+
marquee={marquee}
|
|
2710
|
+
snapGuides={snapGuides}
|
|
2711
|
+
bindHintId={bindHintId}
|
|
2712
|
+
resizeInfo={resizeInfo}
|
|
2713
|
+
connDraft={connDraft}
|
|
2714
|
+
addTextHintId={editingTarget ? null : addTextHintId}
|
|
2715
|
+
ghost={ghostPreview}
|
|
2716
|
+
editingTarget={editingTarget}
|
|
2717
|
+
inkColor={color}
|
|
2718
|
+
onCommitEdit={commitEditing}
|
|
2719
|
+
onCancelEdit={cancelEditing}
|
|
2720
|
+
/>
|
|
2721
|
+
) : null}
|
|
2722
|
+
<AnnotationContextToolbar
|
|
2723
|
+
editingId={
|
|
2724
|
+
editingTarget?.kind === 'anchored'
|
|
2725
|
+
? editingTarget.anchorId
|
|
2726
|
+
: editingTarget?.kind === 'sticky'
|
|
2727
|
+
? editingTarget.sticky.id
|
|
2728
|
+
: editingTarget?.kind === 'standalone'
|
|
2729
|
+
? editingTarget.text.id
|
|
2730
|
+
: null
|
|
2731
|
+
}
|
|
2732
|
+
/>
|
|
2733
|
+
{ctxMenu && annotSel ? (
|
|
2734
|
+
<AnnotationContextMenu
|
|
2735
|
+
pos={ctxMenu}
|
|
2736
|
+
selCount={annotSel.selectedIds.length}
|
|
2737
|
+
canUngroup={selectedStrokes.some((s) => (s.groupIds?.length ?? 0) > 0)}
|
|
2738
|
+
onAction={onMenuAction}
|
|
2739
|
+
onClose={() => setCtxMenu(null)}
|
|
2740
|
+
/>
|
|
2741
|
+
) : null}
|
|
2742
|
+
{visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
|
|
2743
|
+
{isActive ? (
|
|
2744
|
+
<AnnotationsChrome
|
|
2745
|
+
tool={tool}
|
|
2746
|
+
theme={theme}
|
|
2747
|
+
color={color}
|
|
2748
|
+
setColor={setColor}
|
|
2749
|
+
stickyColor={stickyColor}
|
|
2750
|
+
setStickyColor={setStickyColor}
|
|
2751
|
+
highlighterColor={highlighterColor}
|
|
2752
|
+
setHighlighterColor={setHighlighterColor}
|
|
2753
|
+
highlighterWidth={highlighterWidth}
|
|
2754
|
+
setHighlighterWidth={setHighlighterWidth}
|
|
2755
|
+
supportsFill={supportsFill}
|
|
2756
|
+
fill={fill}
|
|
2757
|
+
setFill={setFill}
|
|
2758
|
+
supportsThickness={supportsThickness}
|
|
2759
|
+
thickness={thickness}
|
|
2760
|
+
setThickness={setThickness}
|
|
2761
|
+
/>
|
|
2762
|
+
) : null}
|
|
2763
|
+
</StrokesStoreContext.Provider>
|
|
2764
|
+
);
|
|
2765
|
+
}
|
|
2766
|
+
AnnotationsLayer.displayName = 'AnnotationsLayer';
|
|
2767
|
+
|
|
2768
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2769
|
+
// Input — transparent overlay portaled into the host (.dc-canvas). Receives
|
|
2770
|
+
// pointer events for draw / erase ONLY; viewport gestures (middle-mouse,
|
|
2771
|
+
// space-pan, wheel) reach `useViewportController` because we never call
|
|
2772
|
+
// stopPropagation and the controller listens at the host level alongside us.
|
|
2773
|
+
|
|
2774
|
+
function AnnotationsInput({
|
|
2775
|
+
isActive,
|
|
2776
|
+
visible,
|
|
2777
|
+
cursor,
|
|
2778
|
+
beginStroke,
|
|
2779
|
+
moveStroke,
|
|
2780
|
+
endStroke,
|
|
2781
|
+
onLeave,
|
|
2782
|
+
}: {
|
|
2783
|
+
isActive: boolean;
|
|
2784
|
+
visible: boolean;
|
|
2785
|
+
/** Active-tool cursor (crosshair / text / cell) — applied to the capture
|
|
2786
|
+
* overlay so the affordance shows over the whole canvas while drawing. */
|
|
2787
|
+
cursor: string;
|
|
2788
|
+
beginStroke: (e: ReactPointerEvent<HTMLDivElement>, spaceHeld: boolean) => boolean;
|
|
2789
|
+
moveStroke: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
|
2790
|
+
endStroke: () => void;
|
|
2791
|
+
/** Phase 24 — clear the ghost placeholder when the pointer leaves the canvas. */
|
|
2792
|
+
onLeave: () => void;
|
|
2793
|
+
}) {
|
|
2794
|
+
const worldRef = useWorldRefContext();
|
|
2795
|
+
const host = worldRef?.current?.parentElement ?? null;
|
|
2796
|
+
const [, force] = useState({});
|
|
2797
|
+
// Host may not be attached on first commit; nudge a re-render once it is.
|
|
2798
|
+
useEffect(() => {
|
|
2799
|
+
if (host) return;
|
|
2800
|
+
const id = setTimeout(() => force({}), 0);
|
|
2801
|
+
return () => clearTimeout(id);
|
|
2802
|
+
}, [host]);
|
|
2803
|
+
|
|
2804
|
+
const spaceHeldRef = useRef(false);
|
|
2805
|
+
useEffect(() => {
|
|
2806
|
+
if (typeof document === 'undefined') return;
|
|
2807
|
+
const down = (e: KeyboardEvent) => {
|
|
2808
|
+
if (e.code === 'Space' && !isEditable(e.target)) spaceHeldRef.current = true;
|
|
2809
|
+
};
|
|
2810
|
+
const up = (e: KeyboardEvent) => {
|
|
2811
|
+
if (e.code === 'Space') spaceHeldRef.current = false;
|
|
2812
|
+
};
|
|
2813
|
+
document.addEventListener('keydown', down, true);
|
|
2814
|
+
document.addEventListener('keyup', up, true);
|
|
2815
|
+
return () => {
|
|
2816
|
+
document.removeEventListener('keydown', down, true);
|
|
2817
|
+
document.removeEventListener('keyup', up, true);
|
|
2818
|
+
};
|
|
2819
|
+
}, []);
|
|
2820
|
+
|
|
2821
|
+
if (!host) return null;
|
|
2822
|
+
const interactive = isActive && visible;
|
|
2823
|
+
return createPortal(
|
|
2824
|
+
<div
|
|
2825
|
+
className="dc-annot-input"
|
|
2826
|
+
aria-hidden="true"
|
|
2827
|
+
style={{
|
|
2828
|
+
pointerEvents: interactive ? 'auto' : 'none',
|
|
2829
|
+
cursor: interactive ? cursor : 'default',
|
|
2830
|
+
}}
|
|
2831
|
+
onPointerDown={(e) => {
|
|
2832
|
+
beginStroke(e, spaceHeldRef.current);
|
|
2833
|
+
}}
|
|
2834
|
+
onPointerMove={moveStroke}
|
|
2835
|
+
onPointerUp={endStroke}
|
|
2836
|
+
onPointerCancel={endStroke}
|
|
2837
|
+
onPointerLeave={onLeave}
|
|
2838
|
+
/>,
|
|
2839
|
+
host
|
|
2840
|
+
);
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2844
|
+
// SVG — portaled INTO `.dc-world` so the world's CSS zoom + translate apply
|
|
2845
|
+
// natively. `vector-effect="non-scaling-stroke"` keeps stroke px-thick at any
|
|
2846
|
+
// zoom level. `pointer-events: none` on the container — strokes are decorative
|
|
2847
|
+
// for now (Phase 5.1 Task 6 will reintroduce hit-test via the selection store).
|
|
2848
|
+
|
|
2849
|
+
function AnnotationsSvg({
|
|
2850
|
+
worldRef,
|
|
2851
|
+
strokes,
|
|
2852
|
+
anchorsById,
|
|
2853
|
+
selectMode,
|
|
2854
|
+
selectedStrokes,
|
|
2855
|
+
marquee,
|
|
2856
|
+
snapGuides,
|
|
2857
|
+
bindHintId,
|
|
2858
|
+
resizeInfo,
|
|
2859
|
+
connDraft,
|
|
2860
|
+
addTextHintId,
|
|
2861
|
+
ghost,
|
|
2862
|
+
editingTarget,
|
|
2863
|
+
inkColor,
|
|
2864
|
+
onCommitEdit,
|
|
2865
|
+
onCancelEdit,
|
|
2866
|
+
}: {
|
|
2867
|
+
worldRef: ReturnType<typeof useWorldRefContext>;
|
|
2868
|
+
strokes: readonly Stroke[];
|
|
2869
|
+
anchorsById: Map<string, AnchorHost>;
|
|
2870
|
+
selectMode: boolean;
|
|
2871
|
+
selectedStrokes: readonly Stroke[];
|
|
2872
|
+
marquee: { ax: number; ay: number; bx: number; by: number } | null;
|
|
2873
|
+
/** FigJam v3 — smart-guide lines painted while a drag is snapping. */
|
|
2874
|
+
snapGuides: SnapGuide[] | null;
|
|
2875
|
+
/** FigJam v3 — host a dragged arrow endpoint would bind to (accent halo). */
|
|
2876
|
+
bindHintId: string | null;
|
|
2877
|
+
/** FigJam v3 — live size label + dimension-match halos while resizing. */
|
|
2878
|
+
resizeInfo: {
|
|
2879
|
+
box: { x: number; y: number; w: number; h: number } | null;
|
|
2880
|
+
matchIds: string[];
|
|
2881
|
+
} | null;
|
|
2882
|
+
/** FigJam v3 — in-flight connector drawn from a connection dot. */
|
|
2883
|
+
connDraft: {
|
|
2884
|
+
x1: number;
|
|
2885
|
+
y1: number;
|
|
2886
|
+
x2: number;
|
|
2887
|
+
y2: number;
|
|
2888
|
+
startBind: { hostId: string; nx: number; ny: number };
|
|
2889
|
+
endBind?: { hostId: string; nx: number; ny: number };
|
|
2890
|
+
} | null;
|
|
2891
|
+
/** FigJam v3 — hovered empty shape that shows the "Add text" ghost label. */
|
|
2892
|
+
addTextHintId: string | null;
|
|
2893
|
+
ghost: GhostDescriptor | null;
|
|
2894
|
+
editingTarget: EditingTarget;
|
|
2895
|
+
/** Live default ink (theme-aware) for a not-yet-born pending text caret. */
|
|
2896
|
+
inkColor: string;
|
|
2897
|
+
onCommitEdit: (text: string, fmt?: EditorFmt) => void;
|
|
2898
|
+
onCancelEdit: () => void;
|
|
2899
|
+
}) {
|
|
2900
|
+
const [, force] = useState({});
|
|
2901
|
+
useEffect(() => {
|
|
2902
|
+
if (worldRef?.current) return;
|
|
2903
|
+
const id = setTimeout(() => force({}), 0);
|
|
2904
|
+
return () => clearTimeout(id);
|
|
2905
|
+
}, [worldRef]);
|
|
2906
|
+
const target = worldRef?.current ?? null;
|
|
2907
|
+
if (!target) return null;
|
|
2908
|
+
// A sticky whose body is being edited hides its read-only text so the
|
|
2909
|
+
// editor textarea (rendered below at the same bbox) isn't double-painted.
|
|
2910
|
+
const editingStickyId = editingTarget?.kind === 'sticky' ? editingTarget.sticky.id : null;
|
|
2911
|
+
const anchoredExisting =
|
|
2912
|
+
editingTarget?.kind === 'anchored'
|
|
2913
|
+
? (strokes.find((s) => s.tool === 'text' && s.anchorId === editingTarget.anchorId) as
|
|
2914
|
+
| TextStroke
|
|
2915
|
+
| undefined)
|
|
2916
|
+
: undefined;
|
|
2917
|
+
return createPortal(
|
|
2918
|
+
<svg className="dc-annot-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
|
2919
|
+
<defs>
|
|
2920
|
+
{/* Phase 21 — soft "lifted paper" drop shadow for sticky notes. */}
|
|
2921
|
+
<filter id="dc-sticky-shadow" x="-25%" y="-25%" width="150%" height="170%">
|
|
2922
|
+
<feDropShadow dx="0" dy="4" stdDeviation="8" floodColor="#000000" floodOpacity="0.28" />
|
|
2923
|
+
</filter>
|
|
2924
|
+
</defs>
|
|
2925
|
+
{strokes.map((s) => (
|
|
2926
|
+
<StrokeNode
|
|
2927
|
+
key={s.id}
|
|
2928
|
+
stroke={s}
|
|
2929
|
+
anchorsById={anchorsById}
|
|
2930
|
+
interactive={selectMode}
|
|
2931
|
+
editing={s.id === editingStickyId}
|
|
2932
|
+
/>
|
|
2933
|
+
))}
|
|
2934
|
+
{selectedStrokes.map((s) => (
|
|
2935
|
+
<SelectionHalo
|
|
2936
|
+
key={`halo-${s.id}`}
|
|
2937
|
+
stroke={s}
|
|
2938
|
+
anchorsById={anchorsById}
|
|
2939
|
+
multi={selectedStrokes.length > 1}
|
|
2940
|
+
/>
|
|
2941
|
+
))}
|
|
2942
|
+
<AnnotGroupBbox selectedStrokes={selectedStrokes} anchorsById={anchorsById} />
|
|
2943
|
+
{marquee ? (
|
|
2944
|
+
<rect
|
|
2945
|
+
className="dc-annot-marquee"
|
|
2946
|
+
x={Math.min(marquee.ax, marquee.bx)}
|
|
2947
|
+
y={Math.min(marquee.ay, marquee.by)}
|
|
2948
|
+
width={Math.abs(marquee.bx - marquee.ax)}
|
|
2949
|
+
height={Math.abs(marquee.by - marquee.ay)}
|
|
2950
|
+
vectorEffect="non-scaling-stroke"
|
|
2951
|
+
/>
|
|
2952
|
+
) : null}
|
|
2953
|
+
{/* FigJam v3 — smart guides: solid 1px accent lines at the snapped edge/
|
|
2954
|
+
center, painted only while a drag is actively snapping. */}
|
|
2955
|
+
{snapGuides?.map((g, i) => (
|
|
2956
|
+
<line
|
|
2957
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: guides are positional + rebuilt per move tick
|
|
2958
|
+
key={`guide-${i}`}
|
|
2959
|
+
x1={g.axis === 'x' ? g.at : g.from}
|
|
2960
|
+
y1={g.axis === 'x' ? g.from : g.at}
|
|
2961
|
+
x2={g.axis === 'x' ? g.at : g.to}
|
|
2962
|
+
y2={g.axis === 'x' ? g.to : g.at}
|
|
2963
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
2964
|
+
strokeWidth={1}
|
|
2965
|
+
vectorEffect="non-scaling-stroke"
|
|
2966
|
+
pointerEvents="none"
|
|
2967
|
+
/>
|
|
2968
|
+
))}
|
|
2969
|
+
{/* FigJam v3 — bind hint: halo the host a dragged arrow endpoint would
|
|
2970
|
+
magnetically attach to. */}
|
|
2971
|
+
<BindHintHalo strokes={strokes} bindHintId={bindHintId} />
|
|
2972
|
+
{/* FigJam v3 — connection dots on a single selected bindable shape;
|
|
2973
|
+
dragging one draws a bound connector (rendered below as a draft). */}
|
|
2974
|
+
{selectMode && !connDraft && selectedStrokes.length === 1 && selectedStrokes[0] ? (
|
|
2975
|
+
<ConnectorDots stroke={selectedStrokes[0]} />
|
|
2976
|
+
) : null}
|
|
2977
|
+
{connDraft ? (
|
|
2978
|
+
<g
|
|
2979
|
+
stroke={inkColor}
|
|
2980
|
+
strokeWidth={2.5}
|
|
2981
|
+
strokeLinecap="round"
|
|
2982
|
+
strokeLinejoin="round"
|
|
2983
|
+
vectorEffect="non-scaling-stroke"
|
|
2984
|
+
fill="none"
|
|
2985
|
+
pointerEvents="none"
|
|
2986
|
+
>
|
|
2987
|
+
{arrowPrimitives({
|
|
2988
|
+
x1: connDraft.x1,
|
|
2989
|
+
y1: connDraft.y1,
|
|
2990
|
+
x2: connDraft.x2,
|
|
2991
|
+
y2: connDraft.y2,
|
|
2992
|
+
width: 2.5,
|
|
2993
|
+
color: inkColor,
|
|
2994
|
+
lineType: 'curved',
|
|
2995
|
+
startBind: connDraft.startBind,
|
|
2996
|
+
...(connDraft.endBind ? { endBind: connDraft.endBind } : {}),
|
|
2997
|
+
}).map((prim, i) => renderArrowPrimitive(prim, i))}
|
|
2998
|
+
</g>
|
|
2999
|
+
) : null}
|
|
3000
|
+
{/* FigJam v3 — hover affordance: an empty shape invites text. */}
|
|
3001
|
+
<AddTextHint strokes={strokes} hintId={addTextHintId} />
|
|
3002
|
+
{/* FigJam v3 — resize chrome: live W × H label at the box corner plus a
|
|
3003
|
+
dashed halo on any neighbour whose dimension the resize just matched
|
|
3004
|
+
(the "same size as that one" quota). */}
|
|
3005
|
+
{resizeInfo?.box ? (
|
|
3006
|
+
<g pointerEvents="none">
|
|
3007
|
+
{resizeInfo.matchIds.map((id) => {
|
|
3008
|
+
const m = strokes.find((s) => s.id === id);
|
|
3009
|
+
const bb = m ? strokeBBox(m) : null;
|
|
3010
|
+
if (!bb) return null;
|
|
3011
|
+
return (
|
|
3012
|
+
<rect
|
|
3013
|
+
key={`dim-${id}`}
|
|
3014
|
+
x={bb.x - 2}
|
|
3015
|
+
y={bb.y - 2}
|
|
3016
|
+
width={bb.w + 4}
|
|
3017
|
+
height={bb.h + 4}
|
|
3018
|
+
fill="none"
|
|
3019
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
3020
|
+
strokeWidth={1.5}
|
|
3021
|
+
strokeDasharray="5 3"
|
|
3022
|
+
vectorEffect="non-scaling-stroke"
|
|
3023
|
+
rx={2}
|
|
3024
|
+
/>
|
|
3025
|
+
);
|
|
3026
|
+
})}
|
|
3027
|
+
<text
|
|
3028
|
+
x={resizeInfo.box.x + resizeInfo.box.w / 2}
|
|
3029
|
+
y={resizeInfo.box.y + resizeInfo.box.h + 18}
|
|
3030
|
+
textAnchor="middle"
|
|
3031
|
+
fontSize={11}
|
|
3032
|
+
fill="var(--maude-hud-accent, #d63b1f)"
|
|
3033
|
+
style={{
|
|
3034
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
3035
|
+
}}
|
|
3036
|
+
>
|
|
3037
|
+
{`${Math.round(resizeInfo.box.w)} × ${Math.round(resizeInfo.box.h)}`}
|
|
3038
|
+
</text>
|
|
3039
|
+
</g>
|
|
3040
|
+
) : null}
|
|
3041
|
+
{ghost ? <GhostPreview ghost={ghost} /> : null}
|
|
3042
|
+
{editingTarget?.kind === 'anchored' ? (
|
|
3043
|
+
<TextEditor
|
|
3044
|
+
anchorId={editingTarget.anchorId}
|
|
3045
|
+
host={editingTarget.host}
|
|
3046
|
+
existing={anchoredExisting}
|
|
3047
|
+
onCommit={(_anchorId, text, fmt) => onCommitEdit(text, fmt)}
|
|
3048
|
+
onCancel={onCancelEdit}
|
|
3049
|
+
/>
|
|
3050
|
+
) : null}
|
|
3051
|
+
{editingTarget?.kind === 'sticky' ? (
|
|
3052
|
+
<StickyEditor
|
|
3053
|
+
sticky={editingTarget.sticky}
|
|
3054
|
+
onCommit={onCommitEdit}
|
|
3055
|
+
onCancel={onCancelEdit}
|
|
3056
|
+
/>
|
|
3057
|
+
) : null}
|
|
3058
|
+
{editingTarget?.kind === 'standalone' ? (
|
|
3059
|
+
<StandaloneTextEditor
|
|
3060
|
+
x={editingTarget.text.x ?? 0}
|
|
3061
|
+
y={editingTarget.text.y ?? 0}
|
|
3062
|
+
fontSize={editingTarget.text.fontSize}
|
|
3063
|
+
color={editingTarget.text.color}
|
|
3064
|
+
initialText={editingTarget.text.text}
|
|
3065
|
+
bold={editingTarget.text.bold}
|
|
3066
|
+
italic={editingTarget.text.italic}
|
|
3067
|
+
strike={editingTarget.text.strike}
|
|
3068
|
+
underline={editingTarget.text.underline}
|
|
3069
|
+
align={editingTarget.text.align ?? 'left'}
|
|
3070
|
+
listType={editingTarget.text.listType}
|
|
3071
|
+
onCommit={onCommitEdit}
|
|
3072
|
+
onCancel={onCancelEdit}
|
|
3073
|
+
/>
|
|
3074
|
+
) : null}
|
|
3075
|
+
{editingTarget?.kind === 'pending' ? (
|
|
3076
|
+
<StandaloneTextEditor
|
|
3077
|
+
x={editingTarget.x}
|
|
3078
|
+
y={editingTarget.y}
|
|
3079
|
+
fontSize={DEFAULT_FONT_SIZE}
|
|
3080
|
+
color={inkColor}
|
|
3081
|
+
initialText=""
|
|
3082
|
+
onCommit={onCommitEdit}
|
|
3083
|
+
onCancel={onCancelEdit}
|
|
3084
|
+
/>
|
|
3085
|
+
) : null}
|
|
3086
|
+
{editingTarget?.kind === 'section' ? (
|
|
3087
|
+
<StandaloneTextEditor
|
|
3088
|
+
x={
|
|
3089
|
+
Math.min(editingTarget.section.x, editingTarget.section.x + editingTarget.section.w) + 2
|
|
3090
|
+
}
|
|
3091
|
+
y={
|
|
3092
|
+
Math.min(editingTarget.section.y, editingTarget.section.y + editingTarget.section.h) -
|
|
3093
|
+
SECTION_LABEL_H -
|
|
3094
|
+
2
|
|
3095
|
+
}
|
|
3096
|
+
fontSize={SECTION_LABEL_FONT}
|
|
3097
|
+
color={editingTarget.section.color}
|
|
3098
|
+
initialText={editingTarget.section.label}
|
|
3099
|
+
onCommit={onCommitEdit}
|
|
3100
|
+
onCancel={onCancelEdit}
|
|
3101
|
+
/>
|
|
3102
|
+
) : null}
|
|
3103
|
+
</svg>,
|
|
3104
|
+
target
|
|
3105
|
+
);
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
function TextEditor({
|
|
3109
|
+
anchorId,
|
|
3110
|
+
host,
|
|
3111
|
+
existing,
|
|
3112
|
+
onCommit,
|
|
3113
|
+
onCancel,
|
|
3114
|
+
}: {
|
|
3115
|
+
anchorId: string;
|
|
3116
|
+
host: AnchorHost | null;
|
|
3117
|
+
existing: TextStroke | undefined;
|
|
3118
|
+
onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
|
|
3119
|
+
onCancel: () => void;
|
|
3120
|
+
}) {
|
|
3121
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
3122
|
+
// Show list markers WHILE editing so the read↔edit swap doesn't flicker
|
|
3123
|
+
// (item 4c) — stripped back to raw text on commit.
|
|
3124
|
+
const initial = listPrefixedBody(existing?.text ?? '', existing?.listType);
|
|
3125
|
+
const initialRef = useRef(initial);
|
|
3126
|
+
initialRef.current = initial;
|
|
3127
|
+
// Cmd/Ctrl+B/I/U formatting while editing (item 4d).
|
|
3128
|
+
const {
|
|
3129
|
+
fmtRef,
|
|
3130
|
+
style: fmtStyle,
|
|
3131
|
+
onFormatKey,
|
|
3132
|
+
} = useEditorFormat({
|
|
3133
|
+
bold: existing?.bold,
|
|
3134
|
+
italic: existing?.italic,
|
|
3135
|
+
underline: existing?.underline,
|
|
3136
|
+
strike: existing?.strike,
|
|
3137
|
+
fontSize: existing?.fontSize ?? DEFAULT_FONT_SIZE,
|
|
3138
|
+
align: existing?.align ?? 'center',
|
|
3139
|
+
});
|
|
3140
|
+
|
|
3141
|
+
useEffect(() => {
|
|
3142
|
+
const el = ref.current;
|
|
3143
|
+
if (!el) return;
|
|
3144
|
+
el.focus();
|
|
3145
|
+
// Select all so a re-edit replaces existing text easily.
|
|
3146
|
+
try {
|
|
3147
|
+
const r = document.createRange();
|
|
3148
|
+
r.selectNodeContents(el);
|
|
3149
|
+
const sel = window.getSelection();
|
|
3150
|
+
if (sel) {
|
|
3151
|
+
sel.removeAllRanges();
|
|
3152
|
+
sel.addRange(r);
|
|
3153
|
+
}
|
|
3154
|
+
} catch {
|
|
3155
|
+
/* selection API blocked */
|
|
3156
|
+
}
|
|
3157
|
+
}, []);
|
|
3158
|
+
|
|
3159
|
+
// Commit on outside click; cancel-on-Esc handled in onKeyDown below.
|
|
3160
|
+
useEffect(() => {
|
|
3161
|
+
if (typeof document === 'undefined') return;
|
|
3162
|
+
const onDown = (e: PointerEvent) => {
|
|
3163
|
+
const el = ref.current;
|
|
3164
|
+
if (!el) return;
|
|
3165
|
+
if (el.contains(e.target as Node)) return;
|
|
3166
|
+
// FigJam v3 — the edit-mode text toolbar drives THIS editor; clicking
|
|
3167
|
+
// it must not commit-and-close the session.
|
|
3168
|
+
if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
|
|
3169
|
+
onCommit(
|
|
3170
|
+
anchorId,
|
|
3171
|
+
stripEditorMarkers(el.innerText || '', existing?.listType),
|
|
3172
|
+
fmtRef.current
|
|
3173
|
+
);
|
|
3174
|
+
};
|
|
3175
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
3176
|
+
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
3177
|
+
}, [anchorId, onCommit, existing?.listType, fmtRef]);
|
|
3178
|
+
|
|
3179
|
+
if (!host) return null;
|
|
3180
|
+
const bbox = strokeBBox(host);
|
|
3181
|
+
if (!bbox) return null;
|
|
3182
|
+
const fontSize = existing?.fontSize ?? DEFAULT_FONT_SIZE;
|
|
3183
|
+
// Phase 24 — match the committed render's bold / strike / align (anchored
|
|
3184
|
+
// default align = centre).
|
|
3185
|
+
const align = existing?.align ?? 'center';
|
|
3186
|
+
return (
|
|
3187
|
+
<foreignObject x={bbox.x} y={bbox.y} width={Math.max(20, bbox.w)} height={Math.max(20, bbox.h)}>
|
|
3188
|
+
<div
|
|
3189
|
+
ref={ref}
|
|
3190
|
+
className="dc-annot-editor"
|
|
3191
|
+
contentEditable
|
|
3192
|
+
suppressContentEditableWarning
|
|
3193
|
+
aria-label="Edit annotation text"
|
|
3194
|
+
style={{
|
|
3195
|
+
width: '100%',
|
|
3196
|
+
height: '100%',
|
|
3197
|
+
// Column flex (NOT row) so contentEditable line breaks stack
|
|
3198
|
+
// vertically; justify-center keeps the block vertically centred in
|
|
3199
|
+
// the host. The pre-Task-5 row-flex laid lines out side-by-side
|
|
3200
|
+
// (item 4a — the mangled multi-line look).
|
|
3201
|
+
display: 'flex',
|
|
3202
|
+
flexDirection: 'column',
|
|
3203
|
+
justifyContent: 'center',
|
|
3204
|
+
alignItems: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
|
|
3205
|
+
padding: '0 8px',
|
|
3206
|
+
boxSizing: 'border-box',
|
|
3207
|
+
textAlign: align,
|
|
3208
|
+
whiteSpace: 'pre-wrap',
|
|
3209
|
+
color: existing?.color ?? '#1a1a1a',
|
|
3210
|
+
fontSize: `${fontSize}px`,
|
|
3211
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
3212
|
+
...fmtStyle,
|
|
3213
|
+
lineHeight: 1.25,
|
|
3214
|
+
outline: 'none',
|
|
3215
|
+
background: 'transparent',
|
|
3216
|
+
cursor: 'text',
|
|
3217
|
+
}}
|
|
3218
|
+
onKeyDown={(e) => {
|
|
3219
|
+
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
3220
|
+
if (e.key === 'Escape') {
|
|
3221
|
+
e.preventDefault();
|
|
3222
|
+
onCancel();
|
|
3223
|
+
return;
|
|
3224
|
+
}
|
|
3225
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
3226
|
+
e.preventDefault();
|
|
3227
|
+
const el = ref.current;
|
|
3228
|
+
onCommit(
|
|
3229
|
+
anchorId,
|
|
3230
|
+
stripEditorMarkers(el?.innerText || '', existing?.listType),
|
|
3231
|
+
fmtRef.current
|
|
3232
|
+
);
|
|
3233
|
+
// FigJam v3 — ⌘Enter chains: commit, then spawn a CONNECTED
|
|
3234
|
+
// sibling shape (quick-create) from the host.
|
|
3235
|
+
document.dispatchEvent(
|
|
3236
|
+
new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
|
|
3237
|
+
);
|
|
3238
|
+
}
|
|
3239
|
+
}}
|
|
3240
|
+
>
|
|
3241
|
+
{initial}
|
|
3242
|
+
</div>
|
|
3243
|
+
</foreignObject>
|
|
3244
|
+
);
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
// Phase 21 — sticky body editor. A textarea hosted in a foreignObject at the
|
|
3248
|
+
// card's bbox, so it word-wraps + moves with CSS zoom natively. Commit on blur,
|
|
3249
|
+
// cancel on Esc; Enter inserts a newline (sticky is multi-line).
|
|
3250
|
+
function StickyEditor({
|
|
3251
|
+
sticky,
|
|
3252
|
+
onCommit,
|
|
3253
|
+
onCancel,
|
|
3254
|
+
}: {
|
|
3255
|
+
sticky: StickyStroke;
|
|
3256
|
+
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
3257
|
+
onCancel: () => void;
|
|
3258
|
+
}) {
|
|
3259
|
+
// A flex-centered contentEditable (NOT a textarea) so the edit view matches
|
|
3260
|
+
// the committed `.dc-sticky-body` exactly — text stays centered, no jump on
|
|
3261
|
+
// commit. Multi-line: Enter inserts a line break; Esc cancels; blur / Cmd+Enter
|
|
3262
|
+
// commit; Cmd/Ctrl+B/I/U format (unified with the other editors — item 4d).
|
|
3263
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
3264
|
+
const doneRef = useRef(false);
|
|
3265
|
+
const {
|
|
3266
|
+
fmtRef,
|
|
3267
|
+
style: fmtStyle,
|
|
3268
|
+
onFormatKey,
|
|
3269
|
+
} = useEditorFormat({
|
|
3270
|
+
bold: sticky.bold,
|
|
3271
|
+
italic: sticky.italic,
|
|
3272
|
+
underline: sticky.underline,
|
|
3273
|
+
strike: sticky.strike,
|
|
3274
|
+
fontSize: sticky.fontSize,
|
|
3275
|
+
align: sticky.align ?? 'left',
|
|
3276
|
+
});
|
|
3277
|
+
const commit = () => {
|
|
3278
|
+
if (doneRef.current) return;
|
|
3279
|
+
doneRef.current = true;
|
|
3280
|
+
onCommit(stripEditorMarkers(ref.current?.innerText ?? '', sticky.listType), fmtRef.current);
|
|
3281
|
+
};
|
|
3282
|
+
// FigJam v3 — a toolbar click steals focus for a tick; don't treat it as
|
|
3283
|
+
// "done editing" (the button's onMouseDown preventDefault usually stops the
|
|
3284
|
+
// blur, this guards the browsers where it doesn't).
|
|
3285
|
+
const onBlur = (e: { relatedTarget?: EventTarget | null }) => {
|
|
3286
|
+
const to = e.relatedTarget as Element | null;
|
|
3287
|
+
if (to?.closest?.('.dc-annot-ctx')) return;
|
|
3288
|
+
commit();
|
|
3289
|
+
};
|
|
3290
|
+
useEffect(() => {
|
|
3291
|
+
const el = ref.current;
|
|
3292
|
+
if (!el) return;
|
|
3293
|
+
el.focus();
|
|
3294
|
+
try {
|
|
3295
|
+
const r = document.createRange();
|
|
3296
|
+
r.selectNodeContents(el);
|
|
3297
|
+
const sel = window.getSelection();
|
|
3298
|
+
if (sel) {
|
|
3299
|
+
sel.removeAllRanges();
|
|
3300
|
+
sel.addRange(r);
|
|
3301
|
+
}
|
|
3302
|
+
} catch {
|
|
3303
|
+
/* selection API blocked */
|
|
3304
|
+
}
|
|
3305
|
+
}, []);
|
|
3306
|
+
const x = Math.min(sticky.x, sticky.x + sticky.w);
|
|
3307
|
+
const y = Math.min(sticky.y, sticky.y + sticky.h);
|
|
3308
|
+
const w = Math.abs(sticky.w);
|
|
3309
|
+
const h = Math.abs(sticky.h);
|
|
3310
|
+
return (
|
|
3311
|
+
<foreignObject x={x} y={y} width={w} height={h}>
|
|
3312
|
+
<div
|
|
3313
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
3314
|
+
ref={ref}
|
|
3315
|
+
className="dc-annot-editor dc-sticky-body"
|
|
3316
|
+
contentEditable
|
|
3317
|
+
suppressContentEditableWarning
|
|
3318
|
+
aria-label="Edit sticky note text"
|
|
3319
|
+
style={{ ...stickyBodyStyle(sticky), ...fmtStyle, outline: 'none', cursor: 'text' }}
|
|
3320
|
+
onBlur={onBlur}
|
|
3321
|
+
onKeyDown={(e) => {
|
|
3322
|
+
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
3323
|
+
if (e.key === 'Escape') {
|
|
3324
|
+
e.preventDefault();
|
|
3325
|
+
doneRef.current = true; // suppress the unmount blur-commit
|
|
3326
|
+
onCancel();
|
|
3327
|
+
return;
|
|
3328
|
+
}
|
|
3329
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
3330
|
+
e.preventDefault();
|
|
3331
|
+
commit();
|
|
3332
|
+
// FigJam v3 — ⌘Enter chains: commit this body, then ask the layer
|
|
3333
|
+
// to spawn + edit the next sticky beside it.
|
|
3334
|
+
document.dispatchEvent(
|
|
3335
|
+
new CustomEvent('maude:chain-create', { detail: { id: sticky.id } })
|
|
3336
|
+
);
|
|
3337
|
+
}
|
|
3338
|
+
}}
|
|
3339
|
+
>
|
|
3340
|
+
{/* Show the list markers while editing (item 4c) so the read↔edit swap
|
|
3341
|
+
doesn't flicker; stripped back to raw text on commit. */}
|
|
3342
|
+
{stickyBodyText(sticky)}
|
|
3343
|
+
</div>
|
|
3344
|
+
</foreignObject>
|
|
3345
|
+
);
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
// Phase 21 — standalone text editor. A single-line contentEditable box anchored
|
|
3349
|
+
// at the world (x, y). Enter / blur / outside-click commit; Esc cancels.
|
|
3350
|
+
function StandaloneTextEditor({
|
|
3351
|
+
x,
|
|
3352
|
+
y,
|
|
3353
|
+
fontSize,
|
|
3354
|
+
color,
|
|
3355
|
+
initialText,
|
|
3356
|
+
bold,
|
|
3357
|
+
italic,
|
|
3358
|
+
strike,
|
|
3359
|
+
underline,
|
|
3360
|
+
align,
|
|
3361
|
+
listType,
|
|
3362
|
+
onCommit,
|
|
3363
|
+
onCancel,
|
|
3364
|
+
}: {
|
|
3365
|
+
x: number;
|
|
3366
|
+
y: number;
|
|
3367
|
+
fontSize: number;
|
|
3368
|
+
color: string;
|
|
3369
|
+
initialText: string;
|
|
3370
|
+
bold?: boolean;
|
|
3371
|
+
italic?: boolean;
|
|
3372
|
+
strike?: boolean;
|
|
3373
|
+
underline?: boolean;
|
|
3374
|
+
align?: TextAlign;
|
|
3375
|
+
listType?: ListType;
|
|
3376
|
+
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
3377
|
+
onCancel: () => void;
|
|
3378
|
+
}) {
|
|
3379
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
3380
|
+
// Cmd/Ctrl+B/I/U formatting while editing (item 4d).
|
|
3381
|
+
const {
|
|
3382
|
+
fmtRef,
|
|
3383
|
+
style: fmtStyle,
|
|
3384
|
+
onFormatKey,
|
|
3385
|
+
} = useEditorFormat({
|
|
3386
|
+
bold,
|
|
3387
|
+
italic,
|
|
3388
|
+
underline,
|
|
3389
|
+
strike,
|
|
3390
|
+
fontSize,
|
|
3391
|
+
align: align ?? 'left',
|
|
3392
|
+
});
|
|
3393
|
+
// Single-fire commit guard — outside-click + blur can both fire in one tick;
|
|
3394
|
+
// without this the text would commit twice (two undo records). Markers shown
|
|
3395
|
+
// while editing (item 4c) are stripped back to raw text here on commit.
|
|
3396
|
+
const doneRef = useRef(false);
|
|
3397
|
+
const commitOnce = useCallback(
|
|
3398
|
+
(text: string) => {
|
|
3399
|
+
if (doneRef.current) return;
|
|
3400
|
+
doneRef.current = true;
|
|
3401
|
+
onCommit(stripEditorMarkers(text, listType), fmtRef.current);
|
|
3402
|
+
},
|
|
3403
|
+
[onCommit, listType, fmtRef]
|
|
3404
|
+
);
|
|
3405
|
+
useEffect(() => {
|
|
3406
|
+
const el = ref.current;
|
|
3407
|
+
if (!el) return;
|
|
3408
|
+
el.focus();
|
|
3409
|
+
try {
|
|
3410
|
+
const r = document.createRange();
|
|
3411
|
+
r.selectNodeContents(el);
|
|
3412
|
+
const sel = window.getSelection();
|
|
3413
|
+
if (sel) {
|
|
3414
|
+
sel.removeAllRanges();
|
|
3415
|
+
sel.addRange(r);
|
|
3416
|
+
}
|
|
3417
|
+
} catch {
|
|
3418
|
+
/* selection API blocked */
|
|
3419
|
+
}
|
|
3420
|
+
}, []);
|
|
3421
|
+
// Commit on outside click.
|
|
3422
|
+
useEffect(() => {
|
|
3423
|
+
if (typeof document === 'undefined') return;
|
|
3424
|
+
const onDown = (e: PointerEvent) => {
|
|
3425
|
+
const el = ref.current;
|
|
3426
|
+
if (!el) return;
|
|
3427
|
+
if (el.contains(e.target as Node)) return;
|
|
3428
|
+
// FigJam v3 — clicks into the edit-mode text toolbar keep the session.
|
|
3429
|
+
if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
|
|
3430
|
+
commitOnce(el.innerText || '');
|
|
3431
|
+
};
|
|
3432
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
3433
|
+
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
3434
|
+
}, [commitOnce]);
|
|
3435
|
+
return (
|
|
3436
|
+
// Generous box so multi-line text isn't clipped while typing (item 4a). The
|
|
3437
|
+
// empty area is transparent + pointer-pass-through (the SVG root is
|
|
3438
|
+
// pointer-events:none), so outside-click still commits.
|
|
3439
|
+
<foreignObject x={x} y={y} width={640} height={480}>
|
|
3440
|
+
<div
|
|
3441
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
3442
|
+
ref={ref}
|
|
3443
|
+
className="dc-annot-editor"
|
|
3444
|
+
contentEditable
|
|
3445
|
+
suppressContentEditableWarning
|
|
3446
|
+
aria-label="Edit text"
|
|
3447
|
+
style={{
|
|
3448
|
+
display: 'inline-block',
|
|
3449
|
+
minWidth: '8px',
|
|
3450
|
+
// pre-wrap so Enter inserts a real newline (multi-line text), not a
|
|
3451
|
+
// commit; long lines also wrap within the box.
|
|
3452
|
+
whiteSpace: 'pre-wrap',
|
|
3453
|
+
padding: '0 2px',
|
|
3454
|
+
color,
|
|
3455
|
+
fontSize: `${fontSize}px`,
|
|
3456
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
3457
|
+
...fmtStyle,
|
|
3458
|
+
textAlign: align ?? 'left',
|
|
3459
|
+
lineHeight: TEXT_LINE_HEIGHT,
|
|
3460
|
+
outline: 'none',
|
|
3461
|
+
background: 'transparent',
|
|
3462
|
+
cursor: 'text',
|
|
3463
|
+
}}
|
|
3464
|
+
onBlur={() => commitOnce(ref.current?.innerText || '')}
|
|
3465
|
+
onKeyDown={(e) => {
|
|
3466
|
+
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
3467
|
+
if (e.key === 'Escape') {
|
|
3468
|
+
e.preventDefault();
|
|
3469
|
+
// Mark done so the unmount blur that follows doesn't commit.
|
|
3470
|
+
doneRef.current = true;
|
|
3471
|
+
onCancel();
|
|
3472
|
+
return;
|
|
3473
|
+
}
|
|
3474
|
+
// Cmd/Ctrl+Enter commits; plain Enter inserts a newline (item 4a).
|
|
3475
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
3476
|
+
e.preventDefault();
|
|
3477
|
+
commitOnce(ref.current?.innerText || '');
|
|
3478
|
+
}
|
|
3479
|
+
}}
|
|
3480
|
+
>
|
|
3481
|
+
{listPrefixedBody(initialText, listType)}
|
|
3482
|
+
</div>
|
|
3483
|
+
</foreignObject>
|
|
3484
|
+
);
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
/**
|
|
3488
|
+
* FigJam v3 — centered ghost "Add text" label on a hovered EMPTY rect/ellipse
|
|
3489
|
+
* (FigJam shows the same invitation). Pure chrome; double-click / Enter edits.
|
|
3490
|
+
*/
|
|
3491
|
+
function AddTextHint({ strokes, hintId }: { strokes: readonly Stroke[]; hintId: string | null }) {
|
|
3492
|
+
if (!hintId) return null;
|
|
3493
|
+
const host = strokes.find((s) => s.id === hintId);
|
|
3494
|
+
if (!host || (host.tool !== 'rect' && host.tool !== 'ellipse' && host.tool !== 'polygon'))
|
|
3495
|
+
return null;
|
|
3496
|
+
const bb = strokeBBox(host);
|
|
3497
|
+
if (!bb || bb.w < 48 || bb.h < 28) return null;
|
|
3498
|
+
const rot = strokeRotation(host);
|
|
3499
|
+
const label = (
|
|
3500
|
+
<text
|
|
3501
|
+
x={bb.x + bb.w / 2}
|
|
3502
|
+
y={bb.y + bb.h / 2}
|
|
3503
|
+
textAnchor="middle"
|
|
3504
|
+
dominantBaseline="middle"
|
|
3505
|
+
fontSize={13}
|
|
3506
|
+
fill={host.color}
|
|
3507
|
+
opacity={0.45}
|
|
3508
|
+
pointerEvents="none"
|
|
3509
|
+
style={{ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)' }}
|
|
3510
|
+
>
|
|
3511
|
+
Add text
|
|
3512
|
+
</text>
|
|
3513
|
+
);
|
|
3514
|
+
if (rot === 0) return label;
|
|
3515
|
+
return (
|
|
3516
|
+
<g transform={`rotate(${rot} ${bb.x + bb.w / 2} ${bb.y + bb.h / 2})`} pointerEvents="none">
|
|
3517
|
+
{label}
|
|
3518
|
+
</g>
|
|
3519
|
+
);
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
/**
|
|
3523
|
+
* FigJam v3 — connection dots: the four side magnets of a selected bindable
|
|
3524
|
+
* shape, screen-constant size, accent-ringed. Dragging one starts a bound
|
|
3525
|
+
* connector (the layer owns the gesture; dots are in CHROME_SELECTOR so the
|
|
3526
|
+
* marquee/drag handler yields).
|
|
3527
|
+
*/
|
|
3528
|
+
function ConnectorDots({ stroke }: { stroke: Stroke }) {
|
|
3529
|
+
const controller = useViewportControllerContext();
|
|
3530
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
3531
|
+
if (!isBindable(stroke)) return null;
|
|
3532
|
+
const center = strokeCenter(stroke);
|
|
3533
|
+
if (!center) return null;
|
|
3534
|
+
const magnets: Array<[number, number]> = [
|
|
3535
|
+
[0.5, 0],
|
|
3536
|
+
[1, 0.5],
|
|
3537
|
+
[0.5, 1],
|
|
3538
|
+
[0, 0.5],
|
|
3539
|
+
];
|
|
3540
|
+
// FigJam parity — the dots float a step OUTSIDE the edge (along the outward
|
|
3541
|
+
// normal), which also keeps them clear of the mid-edge RESIZE handles that
|
|
3542
|
+
// sit exactly on the edge midpoints (DOM, higher layer — they'd swallow the
|
|
3543
|
+
// drag otherwise). Deriving the normal from center→anchor keeps rotated
|
|
3544
|
+
// shapes correct for free.
|
|
3545
|
+
const offset = 16 / zoom;
|
|
3546
|
+
return (
|
|
3547
|
+
<g>
|
|
3548
|
+
{magnets.map(([nx, ny]) => {
|
|
3549
|
+
const pt = anchorPoint(stroke, nx, ny);
|
|
3550
|
+
if (!pt) return null;
|
|
3551
|
+
const dx = pt[0] - center[0];
|
|
3552
|
+
const dy = pt[1] - center[1];
|
|
3553
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
3554
|
+
return (
|
|
3555
|
+
<circle
|
|
3556
|
+
key={`${nx}-${ny}`}
|
|
3557
|
+
className="dc-annot-conn-dot"
|
|
3558
|
+
data-host={stroke.id}
|
|
3559
|
+
data-nx={nx}
|
|
3560
|
+
data-ny={ny}
|
|
3561
|
+
cx={pt[0] + (dx / len) * offset}
|
|
3562
|
+
cy={pt[1] + (dy / len) * offset}
|
|
3563
|
+
r={5 / zoom}
|
|
3564
|
+
fill="var(--maude-hud-accent, #d63b1f)"
|
|
3565
|
+
stroke="var(--maude-chrome-bg-0, #ffffff)"
|
|
3566
|
+
strokeWidth={1.5 / zoom}
|
|
3567
|
+
pointerEvents="all"
|
|
3568
|
+
/>
|
|
3569
|
+
);
|
|
3570
|
+
})}
|
|
3571
|
+
</g>
|
|
3572
|
+
);
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
/**
|
|
3576
|
+
* FigJam v3 — right-click context menu for annotation strokes. Reuses the
|
|
3577
|
+
* `.dc-context-menu` visual language (stylesheet injected by context-menu.tsx)
|
|
3578
|
+
* so the annotation menu and the canvas menu read as one product surface.
|
|
3579
|
+
*/
|
|
3580
|
+
function AnnotationContextMenu({
|
|
3581
|
+
pos,
|
|
3582
|
+
selCount,
|
|
3583
|
+
canUngroup,
|
|
3584
|
+
onAction,
|
|
3585
|
+
onClose,
|
|
3586
|
+
}: {
|
|
3587
|
+
pos: { x: number; y: number };
|
|
3588
|
+
selCount: number;
|
|
3589
|
+
canUngroup: boolean;
|
|
3590
|
+
onAction: (action: string) => void;
|
|
3591
|
+
onClose: () => void;
|
|
3592
|
+
}) {
|
|
3593
|
+
ensureCtxMenuStyles();
|
|
3594
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
3595
|
+
const [at, setAt] = useState<{ x: number; y: number }>(pos);
|
|
3596
|
+
useEffect(() => {
|
|
3597
|
+
const el = ref.current;
|
|
3598
|
+
if (!el || typeof window === 'undefined') return;
|
|
3599
|
+
const r = el.getBoundingClientRect();
|
|
3600
|
+
let nx = pos.x;
|
|
3601
|
+
let ny = pos.y;
|
|
3602
|
+
if (nx + r.width > window.innerWidth - 8) nx = Math.max(8, window.innerWidth - r.width - 8);
|
|
3603
|
+
if (ny + r.height > window.innerHeight - 8) ny = Math.max(8, window.innerHeight - r.height - 8);
|
|
3604
|
+
if (nx !== at.x || ny !== at.y) setAt({ x: nx, y: ny });
|
|
3605
|
+
el.querySelector<HTMLButtonElement>('button.dc-menu-item:not([disabled])')?.focus();
|
|
3606
|
+
const onDown = (e: PointerEvent) => {
|
|
3607
|
+
if (!el.contains(e.target as Node)) onClose();
|
|
3608
|
+
};
|
|
3609
|
+
const onKey = (e: KeyboardEvent) => {
|
|
3610
|
+
if (e.key === 'Escape') {
|
|
3611
|
+
e.preventDefault();
|
|
3612
|
+
onClose();
|
|
3613
|
+
}
|
|
3614
|
+
};
|
|
3615
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
3616
|
+
document.addEventListener('keydown', onKey, true);
|
|
3617
|
+
return () => {
|
|
3618
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
3619
|
+
document.removeEventListener('keydown', onKey, true);
|
|
3620
|
+
};
|
|
3621
|
+
}, [pos, onClose, at.x, at.y]);
|
|
3622
|
+
const item = (
|
|
3623
|
+
id: string,
|
|
3624
|
+
label: string,
|
|
3625
|
+
shortcut?: string,
|
|
3626
|
+
opts?: { destructive?: boolean; disabled?: boolean }
|
|
3627
|
+
) => (
|
|
3628
|
+
<button
|
|
3629
|
+
type="button"
|
|
3630
|
+
role="menuitem"
|
|
3631
|
+
disabled={opts?.disabled}
|
|
3632
|
+
className={`dc-menu-item${opts?.destructive ? ' is-destructive' : ''}`}
|
|
3633
|
+
onClick={() => {
|
|
3634
|
+
if (opts?.disabled) return;
|
|
3635
|
+
onAction(id);
|
|
3636
|
+
onClose();
|
|
3637
|
+
}}
|
|
3638
|
+
>
|
|
3639
|
+
<span>{label}</span>
|
|
3640
|
+
{shortcut ? <span className="dc-menu-shortcut">{shortcut}</span> : null}
|
|
3641
|
+
</button>
|
|
3642
|
+
);
|
|
3643
|
+
return (
|
|
3644
|
+
<div
|
|
3645
|
+
ref={ref}
|
|
3646
|
+
className="dc-context-menu"
|
|
3647
|
+
role="menu"
|
|
3648
|
+
aria-label="Annotation actions"
|
|
3649
|
+
style={{ left: at.x, top: at.y }}
|
|
3650
|
+
>
|
|
3651
|
+
{item('copy', 'Copy', '⌘C')}
|
|
3652
|
+
{item('cut', 'Cut', '⌘X')}
|
|
3653
|
+
{item('paste', 'Paste', '⌘V')}
|
|
3654
|
+
{item('duplicate', 'Duplicate', '⌘D')}
|
|
3655
|
+
<div className="dc-menu-sep" aria-hidden="true" />
|
|
3656
|
+
{item('front', 'Bring to front', ']')}
|
|
3657
|
+
{item('forward', 'Bring forward', '⌘]')}
|
|
3658
|
+
{item('backward', 'Send backward', '⌘[')}
|
|
3659
|
+
{item('back', 'Send to back', '[')}
|
|
3660
|
+
<div className="dc-menu-sep" aria-hidden="true" />
|
|
3661
|
+
{item('group', 'Group selection', '⌘G', { disabled: selCount < 2 })}
|
|
3662
|
+
{canUngroup ? item('ungroup', 'Ungroup', '⌘⇧G') : null}
|
|
3663
|
+
<div className="dc-menu-sep" aria-hidden="true" />
|
|
3664
|
+
{item('delete', 'Delete', '⌫', { destructive: true })}
|
|
3665
|
+
</div>
|
|
3666
|
+
);
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
/**
|
|
3670
|
+
* FigJam v3 — accent halo on the host a dragged arrow endpoint would bind to.
|
|
3671
|
+
* Pure chrome (pointer-events:none); renders nothing when no candidate.
|
|
3672
|
+
*/
|
|
3673
|
+
function BindHintHalo({
|
|
3674
|
+
strokes,
|
|
3675
|
+
bindHintId,
|
|
3676
|
+
}: {
|
|
3677
|
+
strokes: readonly Stroke[];
|
|
3678
|
+
bindHintId: string | null;
|
|
3679
|
+
}) {
|
|
3680
|
+
if (!bindHintId) return null;
|
|
3681
|
+
const host = strokes.find((s) => s.id === bindHintId);
|
|
3682
|
+
if (!host) return null;
|
|
3683
|
+
const bbox = strokeBBox(host);
|
|
3684
|
+
if (!bbox) return null;
|
|
3685
|
+
const pad = 3;
|
|
3686
|
+
return (
|
|
3687
|
+
<rect
|
|
3688
|
+
x={bbox.x - pad}
|
|
3689
|
+
y={bbox.y - pad}
|
|
3690
|
+
width={bbox.w + pad * 2}
|
|
3691
|
+
height={bbox.h + pad * 2}
|
|
3692
|
+
fill="none"
|
|
3693
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
3694
|
+
strokeWidth={2}
|
|
3695
|
+
strokeOpacity={0.8}
|
|
3696
|
+
vectorEffect="non-scaling-stroke"
|
|
3697
|
+
pointerEvents="none"
|
|
3698
|
+
rx={3}
|
|
3699
|
+
/>
|
|
3700
|
+
);
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
function SelectionHalo({
|
|
3704
|
+
stroke,
|
|
3705
|
+
anchorsById,
|
|
3706
|
+
multi,
|
|
3707
|
+
}: {
|
|
3708
|
+
stroke: Stroke;
|
|
3709
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3710
|
+
multi: boolean;
|
|
3711
|
+
}) {
|
|
3712
|
+
const controller = useViewportControllerContext();
|
|
3713
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
3714
|
+
const bbox = strokeBBox(stroke, anchorsById);
|
|
3715
|
+
if (!bbox) return null;
|
|
3716
|
+
// T17 + post-Wave-2 fix — annotation halo idioms:
|
|
3717
|
+
// * Single select → 2 px solid border, NO ring, NO corner ticks.
|
|
3718
|
+
// The resize overlay (T23) renders the corner handles in screen-space,
|
|
3719
|
+
// so painting SVG ticks here too would duplicate them. The element
|
|
3720
|
+
// halo uses CSS box-shadow for the 18% ring; the SVG equivalent (a
|
|
3721
|
+
// second outline rect) reads as "double frame" rather than a halo —
|
|
3722
|
+
// user feedback flagged this immediately. Solid 2 px is enough signal
|
|
3723
|
+
// once the resize handles claim the corners.
|
|
3724
|
+
// * Multi member → 1.5 px solid full accent, no ring, no ticks (group
|
|
3725
|
+
// bbox above carries the container affordance).
|
|
3726
|
+
// Marquee STAYS dashed (drawn elsewhere) — dashed is reserved for the
|
|
3727
|
+
// ambient group-container + active-gesture idioms per DDR-046 rev 2.
|
|
3728
|
+
// Wave H — screen-constant breathing room (matches the resize handles,
|
|
3729
|
+
// which sit on the same padded frame — HALO_PAD_PX single source).
|
|
3730
|
+
const pad = HALO_PAD_PX / zoom;
|
|
3731
|
+
const halo = (
|
|
3732
|
+
<rect
|
|
3733
|
+
x={bbox.x - pad}
|
|
3734
|
+
y={bbox.y - pad}
|
|
3735
|
+
width={bbox.w + pad * 2}
|
|
3736
|
+
height={bbox.h + pad * 2}
|
|
3737
|
+
fill="none"
|
|
3738
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
3739
|
+
strokeWidth={multi ? 1.5 : 2}
|
|
3740
|
+
vectorEffect="non-scaling-stroke"
|
|
3741
|
+
pointerEvents="none"
|
|
3742
|
+
rx={2}
|
|
3743
|
+
/>
|
|
3744
|
+
);
|
|
3745
|
+
// FigJam v3 — the halo turns with a rotated stroke.
|
|
3746
|
+
const rot = strokeRotation(stroke);
|
|
3747
|
+
if (rot === 0) return halo;
|
|
3748
|
+
return <g transform={`rotate(${rot} ${bbox.x + bbox.w / 2} ${bbox.y + bbox.h / 2})`}>{halo}</g>;
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3751
|
+
// T17 — group bbox dashed rect for multi-stroke annotation selection. Mirrors
|
|
3752
|
+
// the element-side GroupBbox idiom (1 px dashed accent + 6 × 6 corner handles).
|
|
3753
|
+
function AnnotGroupBbox({
|
|
3754
|
+
selectedStrokes,
|
|
3755
|
+
anchorsById,
|
|
3756
|
+
}: {
|
|
3757
|
+
selectedStrokes: readonly Stroke[];
|
|
3758
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3759
|
+
}) {
|
|
3760
|
+
const controller = useViewportControllerContext();
|
|
3761
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
3762
|
+
if (selectedStrokes.length < 2) return null;
|
|
3763
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
3764
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
3765
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
3766
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
3767
|
+
let any = false;
|
|
3768
|
+
for (const s of selectedStrokes) {
|
|
3769
|
+
const b = strokeBBox(s, anchorsById);
|
|
3770
|
+
if (!b) continue;
|
|
3771
|
+
any = true;
|
|
3772
|
+
if (b.x < xMin) xMin = b.x;
|
|
3773
|
+
if (b.y < yMin) yMin = b.y;
|
|
3774
|
+
if (b.x + b.w > xMax) xMax = b.x + b.w;
|
|
3775
|
+
if (b.y + b.h > yMax) yMax = b.y + b.h;
|
|
3776
|
+
}
|
|
3777
|
+
if (!any) return null;
|
|
3778
|
+
// Wave H — screen-constant pad, one step wider than the single halo.
|
|
3779
|
+
const pad = (HALO_PAD_PX + 2) / zoom;
|
|
3780
|
+
const x = xMin - pad;
|
|
3781
|
+
const y = yMin - pad;
|
|
3782
|
+
const w = xMax - xMin + pad * 2;
|
|
3783
|
+
const h = yMax - yMin + pad * 2;
|
|
3784
|
+
const handle = 6;
|
|
3785
|
+
const inset = 3;
|
|
3786
|
+
const handles = [
|
|
3787
|
+
{ corner: 'nw', x: x - inset, y: y - inset },
|
|
3788
|
+
{ corner: 'ne', x: x + w - handle + inset, y: y - inset },
|
|
3789
|
+
{ corner: 'sw', x: x - inset, y: y + h - handle + inset },
|
|
3790
|
+
{ corner: 'se', x: x + w - handle + inset, y: y + h - handle + inset },
|
|
3791
|
+
];
|
|
3792
|
+
return (
|
|
3793
|
+
<g pointerEvents="none">
|
|
3794
|
+
<rect
|
|
3795
|
+
x={x}
|
|
3796
|
+
y={y}
|
|
3797
|
+
width={w}
|
|
3798
|
+
height={h}
|
|
3799
|
+
fill="none"
|
|
3800
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
3801
|
+
strokeWidth={1}
|
|
3802
|
+
strokeDasharray="4 3"
|
|
3803
|
+
vectorEffect="non-scaling-stroke"
|
|
3804
|
+
rx={2}
|
|
3805
|
+
/>
|
|
3806
|
+
{handles.map((c) => (
|
|
3807
|
+
<rect
|
|
3808
|
+
key={c.corner}
|
|
3809
|
+
x={c.x}
|
|
3810
|
+
y={c.y}
|
|
3811
|
+
width={handle}
|
|
3812
|
+
height={handle}
|
|
3813
|
+
fill="var(--maude-hud-accent, #d63b1f)"
|
|
3814
|
+
stroke="var(--maude-chrome-bg-0, #ffffff)"
|
|
3815
|
+
strokeWidth={1}
|
|
3816
|
+
vectorEffect="non-scaling-stroke"
|
|
3817
|
+
rx={1}
|
|
3818
|
+
/>
|
|
3819
|
+
))}
|
|
3820
|
+
</g>
|
|
3821
|
+
);
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3825
|
+
// Stroke renderer
|
|
3826
|
+
|
|
3827
|
+
/**
|
|
3828
|
+
* Phase 24 — inline style for a sticky body (read view + editor share it so the
|
|
3829
|
+
* read↔edit swap doesn't shift). Applies bold / strike / align atop the
|
|
3830
|
+
* `.dc-sticky-body` defaults (top-left).
|
|
3831
|
+
*/
|
|
3832
|
+
function stickyBodyStyle(s: StickyStroke): CSSProperties {
|
|
3833
|
+
const align = s.align ?? 'left';
|
|
3834
|
+
return {
|
|
3835
|
+
fontSize: `${s.fontSize}px`,
|
|
3836
|
+
fontWeight: s.bold ? 700 : undefined,
|
|
3837
|
+
fontStyle: s.italic ? 'italic' : undefined,
|
|
3838
|
+
textDecoration: textDecoCss(s.strike, s.underline),
|
|
3839
|
+
textAlign: align,
|
|
3840
|
+
justifyContent: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
|
|
3841
|
+
};
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
/** Sticky body content: raw text for a plain card, else per-line with list
|
|
3845
|
+
* markers prepended (item 4c — markers are render-only). */
|
|
3846
|
+
function stickyBodyText(s: StickyStroke): string {
|
|
3847
|
+
if (!s.listType) return s.text;
|
|
3848
|
+
return splitTextLines(s.text)
|
|
3849
|
+
.map((line, i) => listPrefixedLine(line, i, s.listType))
|
|
3850
|
+
.join('\n');
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
/**
|
|
3854
|
+
* Render the inner content of a `<text>` stroke: a single string for single-
|
|
3855
|
+
* line unstyled text (item 4a parity with the legacy form), else one `<tspan>`
|
|
3856
|
+
* per line with list markers prepended (item 4c). `tx` is the per-line origin;
|
|
3857
|
+
* `centered` lifts the block half its height for vertically-centred anchored
|
|
3858
|
+
* text. Mirrors `textInnerSvg` so the live + persisted geometry agree.
|
|
3859
|
+
*/
|
|
3860
|
+
function renderTextLines(
|
|
3861
|
+
text: string,
|
|
3862
|
+
fontSize: number,
|
|
3863
|
+
tx: number,
|
|
3864
|
+
centered: boolean,
|
|
3865
|
+
list?: ListType
|
|
3866
|
+
) {
|
|
3867
|
+
if (!list && !text.includes('\n')) return text;
|
|
3868
|
+
const lines = splitTextLines(text);
|
|
3869
|
+
return lines.map((line, i) => (
|
|
3870
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional + immutable per render
|
|
3871
|
+
<tspan key={i} x={tx} dy={textLineDy(i, fontSize, lines.length, centered)}>
|
|
3872
|
+
{listPrefixedLine(line, i, list)}
|
|
3873
|
+
</tspan>
|
|
3874
|
+
));
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3877
|
+
/**
|
|
3878
|
+
* Phase 24 — the translucent cursor-following ghost placeholder. Pure chrome:
|
|
3879
|
+
* `pointer-events:none`, never added to `strokes`, so it can't be selected,
|
|
3880
|
+
* hit-tested, erased, or persisted. Static (no animation) — reduced-motion safe.
|
|
3881
|
+
* Geometry mirrors what a click/tap would create at the cursor (shape +
|
|
3882
|
+
* SHAPE_DEFAULT_SIZE top-left at cursor; sticky default square; text I-beam).
|
|
3883
|
+
*/
|
|
3884
|
+
function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
|
|
3885
|
+
const { x, y } = ghost;
|
|
3886
|
+
if (ghost.kind === 'text') {
|
|
3887
|
+
const h = 22;
|
|
3888
|
+
return (
|
|
3889
|
+
<path
|
|
3890
|
+
d={`M${x - 4} ${y}H${x + 4}M${x} ${y}V${y + h}M${x - 4} ${y + h}H${x + 4}`}
|
|
3891
|
+
stroke={ghost.color}
|
|
3892
|
+
strokeWidth={1.5}
|
|
3893
|
+
strokeOpacity={0.5}
|
|
3894
|
+
fill="none"
|
|
3895
|
+
vectorEffect="non-scaling-stroke"
|
|
3896
|
+
pointerEvents="none"
|
|
3897
|
+
/>
|
|
3898
|
+
);
|
|
3899
|
+
}
|
|
3900
|
+
if (ghost.kind === 'sticky') {
|
|
3901
|
+
const s = STICKY_DEFAULT_W;
|
|
3902
|
+
return (
|
|
3903
|
+
<rect
|
|
3904
|
+
x={x}
|
|
3905
|
+
y={y}
|
|
3906
|
+
width={s}
|
|
3907
|
+
height={s}
|
|
3908
|
+
rx={STICKY_CORNER_RADIUS}
|
|
3909
|
+
ry={STICKY_CORNER_RADIUS}
|
|
3910
|
+
fill={ghost.color}
|
|
3911
|
+
fillOpacity={0.32}
|
|
3912
|
+
stroke={ghost.color}
|
|
3913
|
+
strokeOpacity={0.55}
|
|
3914
|
+
strokeWidth={1.5}
|
|
3915
|
+
vectorEffect="non-scaling-stroke"
|
|
3916
|
+
pointerEvents="none"
|
|
3917
|
+
/>
|
|
3918
|
+
);
|
|
3919
|
+
}
|
|
3920
|
+
// shape — dashed outline of the default-sized primitive at the cursor.
|
|
3921
|
+
const sz = SHAPE_DEFAULT_SIZE;
|
|
3922
|
+
const common = {
|
|
3923
|
+
fill: 'none',
|
|
3924
|
+
stroke: ghost.color,
|
|
3925
|
+
strokeWidth: 2,
|
|
3926
|
+
strokeOpacity: 0.5,
|
|
3927
|
+
strokeDasharray: '6 5',
|
|
3928
|
+
vectorEffect: 'non-scaling-stroke' as const,
|
|
3929
|
+
pointerEvents: 'none' as const,
|
|
3930
|
+
};
|
|
3931
|
+
if (ghost.shapeKind === 'circle') {
|
|
3932
|
+
return <ellipse cx={x + sz / 2} cy={y + sz / 2} rx={sz / 2} ry={sz / 2} {...common} />;
|
|
3933
|
+
}
|
|
3934
|
+
if (ghost.shapeKind === 'square' || ghost.shapeKind === 'rounded') {
|
|
3935
|
+
const r = ghost.shapeKind === 'rounded' ? 8 : 0;
|
|
3936
|
+
return <rect x={x} y={y} width={sz} height={sz} rx={r} ry={r} {...common} />;
|
|
3937
|
+
}
|
|
3938
|
+
return <polygon points={polygonPoints(ghost.shapeKind, x, y, sz, sz)} {...common} />;
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
/**
|
|
3942
|
+
* FigJam v3 — rotation wrapper. The base node renders axis-aligned geometry;
|
|
3943
|
+
* a rotated stroke wraps it in a `rotate()` group around its bbox center
|
|
3944
|
+
* (anchored text inherits its HOST's rotation so labels turn with the shape).
|
|
3945
|
+
* Pointer events pass through the group, so hit-testing + the ctx-toolbar's
|
|
3946
|
+
* getBoundingClientRect positioning keep working on the rotated form.
|
|
3947
|
+
*/
|
|
3948
|
+
function StrokeNode(props: {
|
|
3949
|
+
stroke: Stroke;
|
|
3950
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3951
|
+
interactive: boolean;
|
|
3952
|
+
editing?: boolean;
|
|
3953
|
+
}) {
|
|
3954
|
+
const { stroke, anchorsById } = props;
|
|
3955
|
+
let rot = strokeRotation(stroke);
|
|
3956
|
+
let pivot = rot !== 0 ? strokeCenter(stroke) : null;
|
|
3957
|
+
if (stroke.tool === 'text' && stroke.anchorId != null && stroke.anchorId !== '') {
|
|
3958
|
+
const host = anchorsById.get(stroke.anchorId);
|
|
3959
|
+
rot = host ? strokeRotation(host) : 0;
|
|
3960
|
+
pivot = rot !== 0 && host ? strokeCenter(host) : null;
|
|
3961
|
+
}
|
|
3962
|
+
const node = <StrokeNodeBase {...props} />;
|
|
3963
|
+
if (rot === 0 || !pivot) return node;
|
|
3964
|
+
return <g transform={`rotate(${rot} ${pivot[0]} ${pivot[1]})`}>{node}</g>;
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
function StrokeNodeBase({
|
|
3968
|
+
stroke,
|
|
3969
|
+
anchorsById,
|
|
3970
|
+
interactive,
|
|
3971
|
+
editing = false,
|
|
3972
|
+
}: {
|
|
3973
|
+
stroke: Stroke;
|
|
3974
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3975
|
+
interactive: boolean;
|
|
3976
|
+
/** Phase 21 — sticky-only: hide the read-only body while its editor is up. */
|
|
3977
|
+
editing?: boolean;
|
|
3978
|
+
}) {
|
|
3979
|
+
// In Move mode, individual stroke nodes claim pointer events so we can
|
|
3980
|
+
// hit-test them from the doc-level capture listener. In draw mode the
|
|
3981
|
+
// overlay above handles input, so the strokes themselves stay inert.
|
|
3982
|
+
const hitMode = interactive ? 'visiblePainted' : ('none' as const);
|
|
3983
|
+
const strokeHit = interactive ? 'stroke' : ('none' as const);
|
|
3984
|
+
if (stroke.tool === 'text') {
|
|
3985
|
+
// Anchored text renders centered in its host; standalone (Phase 21) renders
|
|
3986
|
+
// top-left-anchored at its own world (x, y). bold / italic / strike /
|
|
3987
|
+
// underline applied to the rendered <text>; multi-line + list markers via
|
|
3988
|
+
// renderTextLines (one <tspan> per line).
|
|
3989
|
+
const textStyle = {
|
|
3990
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
3991
|
+
fontWeight: stroke.bold ? 700 : undefined,
|
|
3992
|
+
fontStyle: stroke.italic ? 'italic' : undefined,
|
|
3993
|
+
textDecoration: textDecoCss(stroke.strike, stroke.underline),
|
|
3994
|
+
} as const;
|
|
3995
|
+
if (stroke.anchorId != null && stroke.anchorId !== '') {
|
|
3996
|
+
const host = anchorsById.get(stroke.anchorId);
|
|
3997
|
+
const bbox = host ? strokeBBox(host) : null;
|
|
3998
|
+
if (!bbox) return null;
|
|
3999
|
+
const cy = bbox.y + bbox.h / 2;
|
|
4000
|
+
const align = stroke.align ?? 'center';
|
|
4001
|
+
const pad = 8;
|
|
4002
|
+
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
4003
|
+
const tx =
|
|
4004
|
+
align === 'left'
|
|
4005
|
+
? bbox.x + pad
|
|
4006
|
+
: align === 'right'
|
|
4007
|
+
? bbox.x + bbox.w - pad
|
|
4008
|
+
: bbox.x + bbox.w / 2;
|
|
4009
|
+
return (
|
|
4010
|
+
<text
|
|
4011
|
+
data-id={stroke.id}
|
|
4012
|
+
data-tool="text"
|
|
4013
|
+
data-anchor-id={stroke.anchorId}
|
|
4014
|
+
data-font-size={stroke.fontSize}
|
|
4015
|
+
x={tx}
|
|
4016
|
+
y={cy}
|
|
4017
|
+
fill={stroke.color}
|
|
4018
|
+
fontSize={stroke.fontSize}
|
|
4019
|
+
textAnchor={anchor}
|
|
4020
|
+
dominantBaseline="middle"
|
|
4021
|
+
style={textStyle}
|
|
4022
|
+
>
|
|
4023
|
+
{renderTextLines(stroke.text, stroke.fontSize, tx, true, stroke.listType)}
|
|
4024
|
+
</text>
|
|
4025
|
+
);
|
|
4026
|
+
}
|
|
4027
|
+
const align = stroke.align ?? 'left';
|
|
4028
|
+
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
4029
|
+
const tx = stroke.x ?? 0;
|
|
4030
|
+
return (
|
|
4031
|
+
<text
|
|
4032
|
+
data-id={stroke.id}
|
|
4033
|
+
data-tool="text"
|
|
4034
|
+
data-font-size={stroke.fontSize}
|
|
4035
|
+
x={tx}
|
|
4036
|
+
y={stroke.y ?? 0}
|
|
4037
|
+
fill={stroke.color}
|
|
4038
|
+
fontSize={stroke.fontSize}
|
|
4039
|
+
textAnchor={anchor}
|
|
4040
|
+
dominantBaseline="hanging"
|
|
4041
|
+
pointerEvents={interactive ? 'visiblePainted' : 'none'}
|
|
4042
|
+
style={textStyle}
|
|
4043
|
+
>
|
|
4044
|
+
{renderTextLines(stroke.text, stroke.fontSize, tx, false, stroke.listType)}
|
|
4045
|
+
</text>
|
|
4046
|
+
);
|
|
4047
|
+
}
|
|
4048
|
+
if (stroke.tool === 'sticky') {
|
|
4049
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
4050
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4051
|
+
const w = Math.abs(stroke.w);
|
|
4052
|
+
const h = Math.abs(stroke.h);
|
|
4053
|
+
const r = stroke.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
4054
|
+
return (
|
|
4055
|
+
<g data-id={stroke.id} data-tool="sticky" pointerEvents={hitMode}>
|
|
4056
|
+
{/* Paper card: soft drop shadow + hairline edge so it reads as a
|
|
4057
|
+
lifted sticky, not a flat colored box (FigJam-style). The body is a
|
|
4058
|
+
path with a SHARP bottom-right corner (item 1) — TL/TR/BL rounded.
|
|
4059
|
+
The persisted form stays a <rect> (DDR), so this is render-only. */}
|
|
4060
|
+
<path
|
|
4061
|
+
d={stickyCornerPath(x, y, w, h, r)}
|
|
4062
|
+
fill={stroke.color}
|
|
4063
|
+
stroke="rgba(0,0,0,0.05)"
|
|
4064
|
+
strokeWidth={1}
|
|
4065
|
+
vectorEffect="non-scaling-stroke"
|
|
4066
|
+
filter="url(#dc-sticky-shadow)"
|
|
4067
|
+
/>
|
|
4068
|
+
{editing ? null : (
|
|
4069
|
+
<foreignObject x={x} y={y} width={w} height={h} pointerEvents="none">
|
|
4070
|
+
<div
|
|
4071
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
4072
|
+
className="dc-sticky-body"
|
|
4073
|
+
style={stickyBodyStyle(stroke)}
|
|
4074
|
+
>
|
|
4075
|
+
{stickyBodyText(stroke)}
|
|
4076
|
+
</div>
|
|
4077
|
+
</foreignObject>
|
|
4078
|
+
)}
|
|
4079
|
+
</g>
|
|
4080
|
+
);
|
|
4081
|
+
}
|
|
4082
|
+
if (stroke.tool === 'image') {
|
|
4083
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
4084
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4085
|
+
const w = Math.abs(stroke.w);
|
|
4086
|
+
const h = Math.abs(stroke.h);
|
|
4087
|
+
return (
|
|
4088
|
+
<image
|
|
4089
|
+
data-id={stroke.id}
|
|
4090
|
+
data-tool="image"
|
|
4091
|
+
x={x}
|
|
4092
|
+
y={y}
|
|
4093
|
+
width={w}
|
|
4094
|
+
height={h}
|
|
4095
|
+
href={resolveAssetHref(stroke.href)}
|
|
4096
|
+
preserveAspectRatio="xMidYMid meet"
|
|
4097
|
+
aria-label={stroke.alt || undefined}
|
|
4098
|
+
pointerEvents={hitMode}
|
|
4099
|
+
/>
|
|
4100
|
+
);
|
|
4101
|
+
}
|
|
4102
|
+
if (stroke.tool === 'link') {
|
|
4103
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
4104
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4105
|
+
const w = Math.abs(stroke.w);
|
|
4106
|
+
const h = Math.abs(stroke.h);
|
|
4107
|
+
const lay = linkCardLayout(x, y, w, h);
|
|
4108
|
+
const shownTitle = clampLinkTitle(stroke.title, lay.textMaxChars);
|
|
4109
|
+
const textFont = {
|
|
4110
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
4111
|
+
} as const;
|
|
4112
|
+
return (
|
|
4113
|
+
<g
|
|
4114
|
+
data-id={stroke.id}
|
|
4115
|
+
data-tool="link"
|
|
4116
|
+
data-url={stroke.url}
|
|
4117
|
+
data-title={stroke.title}
|
|
4118
|
+
data-domain={stroke.domain}
|
|
4119
|
+
pointerEvents={hitMode}
|
|
4120
|
+
>
|
|
4121
|
+
<rect
|
|
4122
|
+
x={x}
|
|
4123
|
+
y={y}
|
|
4124
|
+
width={w}
|
|
4125
|
+
height={h}
|
|
4126
|
+
rx={8}
|
|
4127
|
+
ry={8}
|
|
4128
|
+
fill={LINK_CARD_FILL}
|
|
4129
|
+
stroke={LINK_CARD_STROKE}
|
|
4130
|
+
strokeWidth={1}
|
|
4131
|
+
vectorEffect="non-scaling-stroke"
|
|
4132
|
+
filter="url(#dc-sticky-shadow)"
|
|
4133
|
+
/>
|
|
4134
|
+
<svg
|
|
4135
|
+
x={lay.glyph.x}
|
|
4136
|
+
y={lay.glyph.y}
|
|
4137
|
+
width={lay.glyph.size}
|
|
4138
|
+
height={lay.glyph.size}
|
|
4139
|
+
viewBox="0 0 24 24"
|
|
4140
|
+
fill="none"
|
|
4141
|
+
stroke={LINK_GLYPH_STROKE}
|
|
4142
|
+
strokeWidth={2}
|
|
4143
|
+
strokeLinecap="round"
|
|
4144
|
+
strokeLinejoin="round"
|
|
4145
|
+
aria-hidden="true"
|
|
4146
|
+
>
|
|
4147
|
+
<path d={LINK_GLYPH_D1} />
|
|
4148
|
+
<path d={LINK_GLYPH_D2} />
|
|
4149
|
+
</svg>
|
|
4150
|
+
<text
|
|
4151
|
+
x={lay.textX}
|
|
4152
|
+
y={lay.domain.y}
|
|
4153
|
+
fontSize={lay.domain.fontSize}
|
|
4154
|
+
fill={LINK_DOMAIN_FILL}
|
|
4155
|
+
dominantBaseline="hanging"
|
|
4156
|
+
style={textFont}
|
|
4157
|
+
>
|
|
4158
|
+
{stroke.domain}
|
|
4159
|
+
</text>
|
|
4160
|
+
<text
|
|
4161
|
+
x={lay.textX}
|
|
4162
|
+
y={lay.title.y}
|
|
4163
|
+
fontSize={lay.title.fontSize}
|
|
4164
|
+
fill={LINK_TITLE_FILL}
|
|
4165
|
+
fontWeight={600}
|
|
4166
|
+
dominantBaseline="hanging"
|
|
4167
|
+
style={textFont}
|
|
4168
|
+
>
|
|
4169
|
+
{shownTitle}
|
|
4170
|
+
</text>
|
|
4171
|
+
</g>
|
|
4172
|
+
);
|
|
4173
|
+
}
|
|
4174
|
+
if (stroke.tool === 'section') {
|
|
4175
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
4176
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4177
|
+
const w = Math.abs(stroke.w);
|
|
4178
|
+
const h = Math.abs(stroke.h);
|
|
4179
|
+
const chipW = Math.max(56, stroke.label.length * SECTION_LABEL_FONT * 0.62 + 18);
|
|
4180
|
+
return (
|
|
4181
|
+
<g data-id={stroke.id} data-tool="section">
|
|
4182
|
+
{/* Region body — pure backdrop, CLICK-THROUGH (FigJam: content on a
|
|
4183
|
+
section selects normally; the section is grabbed by border/chip). */}
|
|
4184
|
+
<rect
|
|
4185
|
+
x={x}
|
|
4186
|
+
y={y}
|
|
4187
|
+
width={w}
|
|
4188
|
+
height={h}
|
|
4189
|
+
rx={SECTION_CORNER_RADIUS}
|
|
4190
|
+
ry={SECTION_CORNER_RADIUS}
|
|
4191
|
+
fill={stroke.color}
|
|
4192
|
+
fillOpacity={0.07}
|
|
4193
|
+
stroke={stroke.color}
|
|
4194
|
+
strokeOpacity={0.45}
|
|
4195
|
+
strokeWidth={1.5}
|
|
4196
|
+
vectorEffect="non-scaling-stroke"
|
|
4197
|
+
pointerEvents="none"
|
|
4198
|
+
/>
|
|
4199
|
+
{/* Invisible border hit ring — the grabbable edge. */}
|
|
4200
|
+
{interactive ? (
|
|
4201
|
+
<rect
|
|
4202
|
+
x={x}
|
|
4203
|
+
y={y}
|
|
4204
|
+
width={w}
|
|
4205
|
+
height={h}
|
|
4206
|
+
rx={SECTION_CORNER_RADIUS}
|
|
4207
|
+
ry={SECTION_CORNER_RADIUS}
|
|
4208
|
+
fill="none"
|
|
4209
|
+
stroke="transparent"
|
|
4210
|
+
strokeWidth={12}
|
|
4211
|
+
vectorEffect="non-scaling-stroke"
|
|
4212
|
+
pointerEvents="stroke"
|
|
4213
|
+
/>
|
|
4214
|
+
) : null}
|
|
4215
|
+
{/* Label chip above the top-left corner — also a grab handle. */}
|
|
4216
|
+
<g pointerEvents={hitMode}>
|
|
4217
|
+
<rect
|
|
4218
|
+
x={x}
|
|
4219
|
+
y={y - SECTION_LABEL_H - 4}
|
|
4220
|
+
width={chipW}
|
|
4221
|
+
height={SECTION_LABEL_H}
|
|
4222
|
+
rx={5}
|
|
4223
|
+
ry={5}
|
|
4224
|
+
fill={stroke.color}
|
|
4225
|
+
fillOpacity={0.16}
|
|
4226
|
+
/>
|
|
4227
|
+
<text
|
|
4228
|
+
x={x + 9}
|
|
4229
|
+
y={y - SECTION_LABEL_H / 2 - 4}
|
|
4230
|
+
dominantBaseline="middle"
|
|
4231
|
+
fontSize={SECTION_LABEL_FONT}
|
|
4232
|
+
fill={stroke.color}
|
|
4233
|
+
style={{
|
|
4234
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
4235
|
+
}}
|
|
4236
|
+
>
|
|
4237
|
+
{stroke.label}
|
|
4238
|
+
</text>
|
|
4239
|
+
</g>
|
|
4240
|
+
</g>
|
|
4241
|
+
);
|
|
4242
|
+
}
|
|
4243
|
+
const common = {
|
|
4244
|
+
'data-id': stroke.id,
|
|
4245
|
+
'data-tool': stroke.tool,
|
|
4246
|
+
stroke: stroke.color,
|
|
4247
|
+
strokeWidth: stroke.width,
|
|
4248
|
+
strokeLinecap: 'round' as const,
|
|
4249
|
+
strokeLinejoin: 'round' as const,
|
|
4250
|
+
vectorEffect: 'non-scaling-stroke' as const,
|
|
4251
|
+
};
|
|
4252
|
+
if (stroke.tool === 'pen') {
|
|
4253
|
+
// Highlighter (item 8) — overlaps darken via multiply; the translucent hue
|
|
4254
|
+
// lives in `stroke.color`, the wide nib in `stroke.width`.
|
|
4255
|
+
return (
|
|
4256
|
+
<path
|
|
4257
|
+
{...common}
|
|
4258
|
+
fill="none"
|
|
4259
|
+
d={penPathD(stroke.points)}
|
|
4260
|
+
style={stroke.highlighter ? { mixBlendMode: 'multiply' } : undefined}
|
|
4261
|
+
pointerEvents={strokeHit}
|
|
4262
|
+
/>
|
|
4263
|
+
);
|
|
4264
|
+
}
|
|
4265
|
+
if (stroke.tool === 'rect') {
|
|
4266
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
4267
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4268
|
+
const r = stroke.cornerRadius ?? 0;
|
|
4269
|
+
return (
|
|
4270
|
+
<rect
|
|
4271
|
+
{...common}
|
|
4272
|
+
fill={stroke.fill ?? 'none'}
|
|
4273
|
+
x={x}
|
|
4274
|
+
y={y}
|
|
4275
|
+
width={Math.abs(stroke.w)}
|
|
4276
|
+
height={Math.abs(stroke.h)}
|
|
4277
|
+
rx={r}
|
|
4278
|
+
ry={r}
|
|
4279
|
+
strokeDasharray={stroke.dashed ? '6 4' : undefined}
|
|
4280
|
+
pointerEvents={hitMode}
|
|
4281
|
+
/>
|
|
4282
|
+
);
|
|
4283
|
+
}
|
|
4284
|
+
if (stroke.tool === 'ellipse') {
|
|
4285
|
+
return (
|
|
4286
|
+
<ellipse
|
|
4287
|
+
{...common}
|
|
4288
|
+
fill={stroke.fill ?? 'none'}
|
|
4289
|
+
cx={stroke.cx}
|
|
4290
|
+
cy={stroke.cy}
|
|
4291
|
+
rx={Math.max(0, stroke.rx)}
|
|
4292
|
+
ry={Math.max(0, stroke.ry)}
|
|
4293
|
+
strokeDasharray={stroke.dashed ? '6 4' : undefined}
|
|
4294
|
+
pointerEvents={hitMode}
|
|
4295
|
+
/>
|
|
4296
|
+
);
|
|
4297
|
+
}
|
|
4298
|
+
if (stroke.tool === 'polygon') {
|
|
4299
|
+
const nx = Math.min(stroke.x, stroke.x + stroke.w);
|
|
4300
|
+
const ny = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4301
|
+
return (
|
|
4302
|
+
<polygon
|
|
4303
|
+
{...common}
|
|
4304
|
+
data-shape={stroke.shape}
|
|
4305
|
+
fill={stroke.fill ?? 'none'}
|
|
4306
|
+
points={polygonPoints(stroke.shape, nx, ny, Math.abs(stroke.w), Math.abs(stroke.h))}
|
|
4307
|
+
strokeDasharray={stroke.dashed ? '6 4' : undefined}
|
|
4308
|
+
pointerEvents={hitMode}
|
|
4309
|
+
/>
|
|
4310
|
+
);
|
|
4311
|
+
}
|
|
4312
|
+
// arrow — Phase 24 renders the SAME ordered primitives the serializer emits
|
|
4313
|
+
// (canvas-arrowheads), so the on-canvas and persisted forms can never drift.
|
|
4314
|
+
return (
|
|
4315
|
+
<g {...common} fill="none" pointerEvents={hitMode}>
|
|
4316
|
+
{arrowPrimitives(stroke).map((p, i) => renderArrowPrimitive(p, i))}
|
|
4317
|
+
</g>
|
|
4318
|
+
);
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4321
|
+
/** Map one arrow primitive to JSX (heads inherit stroke from the parent <g>). */
|
|
4322
|
+
function renderArrowPrimitive(p: SvgPrimitive, key: number): JSX.Element {
|
|
4323
|
+
switch (p.el) {
|
|
4324
|
+
case 'line':
|
|
4325
|
+
return (
|
|
4326
|
+
<line
|
|
4327
|
+
key={key}
|
|
4328
|
+
x1={p.x1}
|
|
4329
|
+
y1={p.y1}
|
|
4330
|
+
x2={p.x2}
|
|
4331
|
+
y2={p.y2}
|
|
4332
|
+
strokeDasharray={p.dash ? '6 4' : undefined}
|
|
4333
|
+
/>
|
|
4334
|
+
);
|
|
4335
|
+
case 'path':
|
|
4336
|
+
return <path key={key} d={p.d} strokeDasharray={p.dash ? '6 4' : undefined} />;
|
|
4337
|
+
case 'polyline':
|
|
4338
|
+
return <polyline key={key} points={p.points} fill={p.fill} />;
|
|
4339
|
+
case 'polygon':
|
|
4340
|
+
return <polygon key={key} points={p.points} fill={p.fill} />;
|
|
4341
|
+
case 'circle':
|
|
4342
|
+
return <circle key={key} cx={p.cx} cy={p.cy} r={p.r} fill={p.fill} />;
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
|
|
4346
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4347
|
+
// Chrome — color swatches + (optional fill picker) + (optional thickness chip)
|
|
4348
|
+
// + presentation toggle + help button.
|
|
4349
|
+
|
|
4350
|
+
function AnnotationsChrome({
|
|
4351
|
+
tool,
|
|
4352
|
+
theme,
|
|
4353
|
+
color,
|
|
4354
|
+
setColor,
|
|
4355
|
+
stickyColor,
|
|
4356
|
+
setStickyColor,
|
|
4357
|
+
highlighterColor,
|
|
4358
|
+
setHighlighterColor,
|
|
4359
|
+
highlighterWidth,
|
|
4360
|
+
setHighlighterWidth,
|
|
4361
|
+
supportsFill,
|
|
4362
|
+
fill,
|
|
4363
|
+
setFill,
|
|
4364
|
+
supportsThickness,
|
|
4365
|
+
thickness,
|
|
4366
|
+
setThickness,
|
|
4367
|
+
}: {
|
|
4368
|
+
tool: Tool;
|
|
4369
|
+
/** Canvas theme — the ink swatch (slot 8) renders the themed default so the
|
|
4370
|
+
* active default reads true on dark canvases. */
|
|
4371
|
+
theme: string;
|
|
4372
|
+
color: string;
|
|
4373
|
+
setColor: (c: string) => void;
|
|
4374
|
+
stickyColor: string;
|
|
4375
|
+
setStickyColor: (c: string) => void;
|
|
4376
|
+
highlighterColor: string;
|
|
4377
|
+
setHighlighterColor: (c: string) => void;
|
|
4378
|
+
highlighterWidth: number;
|
|
4379
|
+
setHighlighterWidth: (w: number) => void;
|
|
4380
|
+
supportsFill: boolean;
|
|
4381
|
+
fill: string | null;
|
|
4382
|
+
setFill: (f: string | null) => void;
|
|
4383
|
+
supportsThickness: boolean;
|
|
4384
|
+
thickness: Thickness;
|
|
4385
|
+
setThickness: (t: Thickness) => void;
|
|
4386
|
+
}) {
|
|
4387
|
+
// Sticky tool picks a paper tint (its own palette); every other draw tool
|
|
4388
|
+
// picks ink from the stroke PALETTE.
|
|
4389
|
+
if (tool === 'sticky') {
|
|
4390
|
+
return (
|
|
4391
|
+
<div className="dc-annot-chrome" role="toolbar" aria-label="Sticky note tools">
|
|
4392
|
+
<div className="dc-annot-swatches" role="radiogroup" aria-label="Sticky color">
|
|
4393
|
+
{STICKY_PALETTE.map((c) => (
|
|
4394
|
+
<button
|
|
4395
|
+
key={c}
|
|
4396
|
+
type="button"
|
|
4397
|
+
className="dc-annot-sw"
|
|
4398
|
+
aria-pressed={c === stickyColor}
|
|
4399
|
+
aria-label={`Sticky color ${c}`}
|
|
4400
|
+
title={`Sticky color ${c}`}
|
|
4401
|
+
style={{ background: c }}
|
|
4402
|
+
onClick={() => setStickyColor(c)}
|
|
4403
|
+
/>
|
|
4404
|
+
))}
|
|
4405
|
+
</div>
|
|
4406
|
+
</div>
|
|
4407
|
+
);
|
|
4408
|
+
}
|
|
4409
|
+
// Highlighter picks a translucent marker hue (item 8) — its own palette,
|
|
4410
|
+
// mirroring sticky. Each swatch previews on a dark chip via its alpha.
|
|
4411
|
+
if (tool === 'highlighter') {
|
|
4412
|
+
return (
|
|
4413
|
+
<div className="dc-annot-chrome" role="toolbar" aria-label="Highlighter tools">
|
|
4414
|
+
<div className="dc-annot-swatches" role="radiogroup" aria-label="Highlighter color">
|
|
4415
|
+
{HIGHLIGHTER_PALETTE.map((c) => (
|
|
4416
|
+
<button
|
|
4417
|
+
key={c}
|
|
4418
|
+
type="button"
|
|
4419
|
+
className="dc-annot-sw"
|
|
4420
|
+
aria-pressed={c === highlighterColor}
|
|
4421
|
+
aria-label={`Highlighter color ${c}`}
|
|
4422
|
+
title={`Highlighter color ${c}`}
|
|
4423
|
+
style={{ background: c }}
|
|
4424
|
+
onClick={() => setHighlighterColor(c)}
|
|
4425
|
+
/>
|
|
4426
|
+
))}
|
|
4427
|
+
</div>
|
|
4428
|
+
<div className="dc-annot-sep" />
|
|
4429
|
+
{/* Nib width — three filled dots of increasing size (item 8). */}
|
|
4430
|
+
<div className="dc-annot-swatches" role="radiogroup" aria-label="Highlighter width">
|
|
4431
|
+
{HIGHLIGHTER_WIDTHS.map((w) => {
|
|
4432
|
+
const dot = Math.round(6 + (w / 28) * 8); // 8–14 px preview dot
|
|
4433
|
+
return (
|
|
4434
|
+
<button
|
|
4435
|
+
key={w}
|
|
4436
|
+
type="button"
|
|
4437
|
+
className="dc-annot-ibtn"
|
|
4438
|
+
aria-pressed={w === highlighterWidth}
|
|
4439
|
+
aria-label={`Highlighter width ${w}`}
|
|
4440
|
+
title={`Width ${w}px`}
|
|
4441
|
+
onClick={() => setHighlighterWidth(w)}
|
|
4442
|
+
>
|
|
4443
|
+
<span
|
|
4444
|
+
aria-hidden="true"
|
|
4445
|
+
style={{
|
|
4446
|
+
width: dot,
|
|
4447
|
+
height: dot,
|
|
4448
|
+
borderRadius: '50%',
|
|
4449
|
+
background: 'currentColor',
|
|
4450
|
+
display: 'inline-block',
|
|
4451
|
+
}}
|
|
4452
|
+
/>
|
|
4453
|
+
</button>
|
|
4454
|
+
);
|
|
4455
|
+
})}
|
|
4456
|
+
</div>
|
|
4457
|
+
</div>
|
|
4458
|
+
);
|
|
4459
|
+
}
|
|
4460
|
+
return (
|
|
4461
|
+
<div className="dc-annot-chrome" role="toolbar" aria-label="Annotation tools">
|
|
4462
|
+
<div className="dc-annot-swatches" role="radiogroup" aria-label="Stroke color">
|
|
4463
|
+
{STROKE_PALETTE.map((base, i) => {
|
|
4464
|
+
// The last slot is the default ink — render it themed so the active
|
|
4465
|
+
// default reads true on dark canvases (white-ish ink) without
|
|
4466
|
+
// touching the other hues. Key by the immutable base hex so a theme
|
|
4467
|
+
// flip recolours the swatch in place rather than remounting.
|
|
4468
|
+
const c = i === STROKE_PALETTE.length - 1 ? resolveDefaultInk(theme) : base;
|
|
4469
|
+
return (
|
|
4470
|
+
<button
|
|
4471
|
+
key={base}
|
|
4472
|
+
type="button"
|
|
4473
|
+
className="dc-annot-sw"
|
|
4474
|
+
aria-pressed={c === color}
|
|
4475
|
+
aria-label={`Color ${c}`}
|
|
4476
|
+
title={`Color ${c}`}
|
|
4477
|
+
style={{ background: c }}
|
|
4478
|
+
onClick={() => setColor(c)}
|
|
4479
|
+
/>
|
|
4480
|
+
);
|
|
4481
|
+
})}
|
|
4482
|
+
</div>
|
|
4483
|
+
{supportsFill ? (
|
|
4484
|
+
<>
|
|
4485
|
+
<div className="dc-annot-sep" />
|
|
4486
|
+
<div className="dc-annot-swatches" role="radiogroup" aria-label="Fill color">
|
|
4487
|
+
<button
|
|
4488
|
+
type="button"
|
|
4489
|
+
className="dc-annot-fill dc-annot-fill--none"
|
|
4490
|
+
aria-pressed={fill == null}
|
|
4491
|
+
aria-label="No fill"
|
|
4492
|
+
title="No fill"
|
|
4493
|
+
onClick={() => setFill(null)}
|
|
4494
|
+
/>
|
|
4495
|
+
{FILL_PALETTE.map((c) => (
|
|
4496
|
+
<button
|
|
4497
|
+
key={c}
|
|
4498
|
+
type="button"
|
|
4499
|
+
className="dc-annot-fill"
|
|
4500
|
+
aria-pressed={c === fill}
|
|
4501
|
+
aria-label={`Fill ${c}`}
|
|
4502
|
+
title={`Fill ${c}`}
|
|
4503
|
+
style={{ background: c }}
|
|
4504
|
+
onClick={() => setFill(c)}
|
|
4505
|
+
/>
|
|
4506
|
+
))}
|
|
4507
|
+
</div>
|
|
4508
|
+
</>
|
|
4509
|
+
) : null}
|
|
4510
|
+
{supportsThickness ? (
|
|
4511
|
+
<>
|
|
4512
|
+
<div className="dc-annot-sep" />
|
|
4513
|
+
<button
|
|
4514
|
+
type="button"
|
|
4515
|
+
className="dc-annot-ibtn"
|
|
4516
|
+
aria-label="Thin stroke"
|
|
4517
|
+
aria-pressed={thickness === STROKE_WIDTH_THIN}
|
|
4518
|
+
title="Thin (3px)"
|
|
4519
|
+
onClick={() => setThickness(STROKE_WIDTH_THIN)}
|
|
4520
|
+
>
|
|
4521
|
+
<IconLineThin />
|
|
4522
|
+
</button>
|
|
4523
|
+
<button
|
|
4524
|
+
type="button"
|
|
4525
|
+
className="dc-annot-ibtn"
|
|
4526
|
+
aria-label="Thick stroke"
|
|
4527
|
+
aria-pressed={thickness === STROKE_WIDTH_THICK}
|
|
4528
|
+
title="Thick (6px)"
|
|
4529
|
+
onClick={() => setThickness(STROKE_WIDTH_THICK)}
|
|
4530
|
+
>
|
|
4531
|
+
<IconLineThick />
|
|
4532
|
+
</button>
|
|
4533
|
+
</>
|
|
4534
|
+
) : null}
|
|
4535
|
+
</div>
|
|
4536
|
+
);
|
|
4537
|
+
}
|