@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
|
@@ -1,3402 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file annotations-layer.tsx — FigJam-style annotation overlay
|
|
3
|
-
* @scope plugins/design/dev-server/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 PointerEvent as ReactPointerEvent,
|
|
28
|
-
useCallback,
|
|
29
|
-
useContext,
|
|
30
|
-
useEffect,
|
|
31
|
-
useMemo,
|
|
32
|
-
useRef,
|
|
33
|
-
useState,
|
|
34
|
-
} from 'react';
|
|
35
|
-
import { createPortal } from 'react-dom';
|
|
36
|
-
|
|
37
|
-
import { AnnotationContextToolbar } from './annotations-context-toolbar.tsx';
|
|
38
|
-
import {
|
|
39
|
-
ARROW_HEADS,
|
|
40
|
-
type ArrowHead,
|
|
41
|
-
type ArrowLineType,
|
|
42
|
-
arrowPrimitives,
|
|
43
|
-
type SvgPrimitive,
|
|
44
|
-
} from './canvas-arrowheads.ts';
|
|
45
|
-
import { IconLineThick, IconLineThin } from './canvas-icons.tsx';
|
|
46
|
-
import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
|
|
47
|
-
import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
|
|
48
|
-
import { crossedDragThreshold, type Tool } from './input-router.tsx';
|
|
49
|
-
import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
|
|
50
|
-
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
51
|
-
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
52
|
-
import { useCollab } from './use-collab.tsx';
|
|
53
|
-
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
54
|
-
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
55
|
-
import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
56
|
-
|
|
57
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
-
// Types
|
|
59
|
-
|
|
60
|
-
type WorldPoint = readonly [number, number];
|
|
61
|
-
|
|
62
|
-
// Phase 24 — arrow style enums are OWNED by canvas-arrowheads.ts (so that
|
|
63
|
-
// module imports nothing back from here — no cycle, see DDR-067) and re-exported
|
|
64
|
-
// here for back-compat (context-toolbar etc. import them from this module).
|
|
65
|
-
export type { ArrowHead, ArrowLineType } from './canvas-arrowheads.ts';
|
|
66
|
-
/** Phase 24 — polygon shape primitives (diamond + the two triangle pointings). */
|
|
67
|
-
export type PolygonShape = 'diamond' | 'triangle' | 'triangle-down';
|
|
68
|
-
/** Phase 24 — horizontal alignment for text + sticky bodies. */
|
|
69
|
-
export type TextAlign = 'left' | 'center' | 'right';
|
|
70
|
-
|
|
71
|
-
/** Phase 24 — cursor-following ghost placeholder descriptor (pure chrome). */
|
|
72
|
-
type GhostDescriptor =
|
|
73
|
-
| { kind: 'text'; x: number; y: number; color: string }
|
|
74
|
-
| { kind: 'sticky'; x: number; y: number; color: string }
|
|
75
|
-
| { kind: 'shape'; x: number; y: number; shapeKind: ShapeKind; color: string };
|
|
76
|
-
|
|
77
|
-
export interface PenStroke {
|
|
78
|
-
id: string;
|
|
79
|
-
tool: 'pen';
|
|
80
|
-
color: string;
|
|
81
|
-
width: number;
|
|
82
|
-
points: WorldPoint[];
|
|
83
|
-
}
|
|
84
|
-
export interface RectStroke {
|
|
85
|
-
id: string;
|
|
86
|
-
tool: 'rect';
|
|
87
|
-
color: string;
|
|
88
|
-
width: number;
|
|
89
|
-
x: number;
|
|
90
|
-
y: number;
|
|
91
|
-
w: number;
|
|
92
|
-
h: number;
|
|
93
|
-
fill?: string | null;
|
|
94
|
-
/** Phase 21 — corner radius (rx/ry). Absent / 0 = sharp 90° corners (back-compat). */
|
|
95
|
-
cornerRadius?: number;
|
|
96
|
-
}
|
|
97
|
-
export interface EllipseStroke {
|
|
98
|
-
id: string;
|
|
99
|
-
tool: 'ellipse';
|
|
100
|
-
color: string;
|
|
101
|
-
width: number;
|
|
102
|
-
cx: number;
|
|
103
|
-
cy: number;
|
|
104
|
-
rx: number;
|
|
105
|
-
ry: number;
|
|
106
|
-
fill?: string | null;
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Phase 24 — diamond / triangle / triangle-down primitives. Stored as a bbox
|
|
110
|
-
* (x/y/w/h, exactly like a rect) + a `shape` discriminant; the actual SVG
|
|
111
|
-
* points are derived from the bbox at serialize + render time. Brand-new on
|
|
112
|
-
* disk (`<polygon data-tool="polygon" data-shape="…">`), so no back-compat
|
|
113
|
-
* constraint — only idempotent round-trip.
|
|
114
|
-
*/
|
|
115
|
-
export interface PolygonStroke {
|
|
116
|
-
id: string;
|
|
117
|
-
tool: 'polygon';
|
|
118
|
-
shape: PolygonShape;
|
|
119
|
-
color: string;
|
|
120
|
-
width: number;
|
|
121
|
-
x: number;
|
|
122
|
-
y: number;
|
|
123
|
-
w: number;
|
|
124
|
-
h: number;
|
|
125
|
-
fill?: string | null;
|
|
126
|
-
/** Dashed outline (stroke-dasharray). Absent / false = solid. */
|
|
127
|
-
dashed?: boolean;
|
|
128
|
-
}
|
|
129
|
-
export interface ArrowStroke {
|
|
130
|
-
id: string;
|
|
131
|
-
tool: 'arrow';
|
|
132
|
-
color: string;
|
|
133
|
-
width: number;
|
|
134
|
-
x1: number;
|
|
135
|
-
y1: number;
|
|
136
|
-
x2: number;
|
|
137
|
-
y2: number;
|
|
138
|
-
/** Head on the (x1,y1) start. Absent = 'none' (back-compat). Phase 24 widened the enum. */
|
|
139
|
-
startHead?: ArrowHead;
|
|
140
|
-
/** Head on the (x2,y2) end. Absent = 'triangle' (back-compat). Phase 24 widened the enum. */
|
|
141
|
-
endHead?: ArrowHead;
|
|
142
|
-
/** Phase 21 — dashed shaft (stroke-dasharray). Absent / false = solid. */
|
|
143
|
-
dashed?: boolean;
|
|
144
|
-
/** Phase 24 — shaft routing. Absent = 'straight' (back-compat). */
|
|
145
|
-
lineType?: ArrowLineType;
|
|
146
|
-
}
|
|
147
|
-
export interface TextStroke {
|
|
148
|
-
id: string;
|
|
149
|
-
tool: 'text';
|
|
150
|
-
color: string;
|
|
151
|
-
fontSize: number;
|
|
152
|
-
text: string;
|
|
153
|
-
/**
|
|
154
|
-
* Host shape id for anchored text (double-click a rect/ellipse). Phase 21
|
|
155
|
-
* relaxed this to optional: standalone text (the `text` tool) carries no
|
|
156
|
-
* anchor and renders at its own world `(x, y)` instead.
|
|
157
|
-
*/
|
|
158
|
-
anchorId?: string;
|
|
159
|
-
/** Phase 21 — world coords for standalone (unanchored) text. */
|
|
160
|
-
x?: number;
|
|
161
|
-
y?: number;
|
|
162
|
-
/** Phase 24 — bold weight. Absent / false = normal (back-compat). */
|
|
163
|
-
bold?: boolean;
|
|
164
|
-
/** Phase 24 — strikethrough. Absent / false = none (back-compat). */
|
|
165
|
-
strike?: boolean;
|
|
166
|
-
/**
|
|
167
|
-
* Phase 24 — horizontal alignment. Absent default differs by kind: anchored
|
|
168
|
-
* text = 'center' (legacy, byte-identical), standalone = 'left'.
|
|
169
|
-
*/
|
|
170
|
-
align?: TextAlign;
|
|
171
|
-
}
|
|
172
|
-
/** Phase 21 — sticky note: a paper-tone card with its own word-wrapped text. */
|
|
173
|
-
export interface StickyStroke {
|
|
174
|
-
id: string;
|
|
175
|
-
tool: 'sticky';
|
|
176
|
-
color: string;
|
|
177
|
-
x: number;
|
|
178
|
-
y: number;
|
|
179
|
-
w: number;
|
|
180
|
-
h: number;
|
|
181
|
-
text: string;
|
|
182
|
-
fontSize: number;
|
|
183
|
-
/** Corner radius; defaults to STICKY_CORNER_RADIUS (8 = soft). */
|
|
184
|
-
cornerRadius?: number;
|
|
185
|
-
/** Phase 24 — bold body weight. Absent / false = normal. */
|
|
186
|
-
bold?: boolean;
|
|
187
|
-
/** Phase 24 — strikethrough body. Absent / false = none. */
|
|
188
|
-
strike?: boolean;
|
|
189
|
-
/** Phase 24 — body alignment. Absent = 'left' (FigJam sticky default). */
|
|
190
|
-
align?: TextAlign;
|
|
191
|
-
}
|
|
192
|
-
export type Stroke =
|
|
193
|
-
| PenStroke
|
|
194
|
-
| RectStroke
|
|
195
|
-
| EllipseStroke
|
|
196
|
-
| PolygonStroke
|
|
197
|
-
| ArrowStroke
|
|
198
|
-
| TextStroke
|
|
199
|
-
| StickyStroke;
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Phase 21 — what the inline editor is currently bound to. `anchored` edits
|
|
203
|
-
* the text hosted by a rect/ellipse; `sticky` edits a card body; `standalone`
|
|
204
|
-
* re-edits a free text node; `pending` is a not-yet-born text caret (no stroke
|
|
205
|
-
* exists until real text is committed).
|
|
206
|
-
*/
|
|
207
|
-
type EditingTarget =
|
|
208
|
-
| { kind: 'anchored'; anchorId: string; host: RectStroke | EllipseStroke }
|
|
209
|
-
| { kind: 'sticky'; sticky: StickyStroke }
|
|
210
|
-
| { kind: 'standalone'; text: TextStroke }
|
|
211
|
-
| { kind: 'pending'; x: number; y: number }
|
|
212
|
-
| null;
|
|
213
|
-
|
|
214
|
-
// Phase 21 colour system — a single coherent hue family used everywhere.
|
|
215
|
-
// FigJam model: stroke (saturated ink) is INDEPENDENT of fill, and fills are
|
|
216
|
-
// light TINTS of the same hue (index-paired with STROKE_PALETTE). Stickies use
|
|
217
|
-
// their own lightened paper set (STICKY_PALETTE). Exported so the draw-time
|
|
218
|
-
// chrome AND the per-selection context toolbar share ONE palette instead of
|
|
219
|
-
// drifting apart.
|
|
220
|
-
export const STROKE_PALETTE = [
|
|
221
|
-
'#e5484d', // red (default — markup ink)
|
|
222
|
-
'#f2762a', // orange
|
|
223
|
-
'#e0a500', // amber
|
|
224
|
-
'#30a46c', // green
|
|
225
|
-
'#3b82f6', // blue
|
|
226
|
-
'#8b5cf6', // purple
|
|
227
|
-
'#e93d82', // pink
|
|
228
|
-
'#7c7c7c', // gray
|
|
229
|
-
'#1f1f1f', // ink
|
|
230
|
-
] as const;
|
|
231
|
-
type PaletteColor = (typeof STROKE_PALETTE)[number];
|
|
232
|
-
// Phase 24 — default markup ink is BLACK (the `#1f1f1f` ink swatch, slot 8) for
|
|
233
|
-
// EVERY ink tool (pen / shape / arrow / text). It's a palette member so the
|
|
234
|
-
// draw chrome + per-selection toolbar highlight it as the active swatch; the
|
|
235
|
-
// other hues stay one click away. (Stickies keep their warm-paper default —
|
|
236
|
-
// DEFAULT_STICKY_COLOR — they're paper, not ink.)
|
|
237
|
-
const DEFAULT_COLOR: PaletteColor = STROKE_PALETTE[8];
|
|
238
|
-
|
|
239
|
-
// Light tints, index-paired to STROKE_PALETTE — picking "blue fill" gives a
|
|
240
|
-
// pale blue wash under a saturated stroke, exactly like FigJam shapes.
|
|
241
|
-
export const FILL_PALETTE = [
|
|
242
|
-
'#fbe0e1', // red tint
|
|
243
|
-
'#fce6d6', // orange tint
|
|
244
|
-
'#fbeec2', // amber tint
|
|
245
|
-
'#d9f1e2', // green tint
|
|
246
|
-
'#e0ebfd', // blue tint
|
|
247
|
-
'#ebe3fc', // purple tint
|
|
248
|
-
'#fbdfeb', // pink tint
|
|
249
|
-
'#ededed', // gray tint
|
|
250
|
-
'#e7e7e7', // ink tint
|
|
251
|
-
] as const;
|
|
252
|
-
|
|
253
|
-
const STROKE_WIDTH_THIN = 3;
|
|
254
|
-
const STROKE_WIDTH_THICK = 6;
|
|
255
|
-
type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
|
|
256
|
-
|
|
257
|
-
const FONT_SIZE_MEDIUM = 14;
|
|
258
|
-
const DEFAULT_FONT_SIZE = FONT_SIZE_MEDIUM;
|
|
259
|
-
|
|
260
|
-
// Phase 24 — sticky-note paper tints. A muted/desaturated FigJam-style set
|
|
261
|
-
// (Image #2): a warm paper yellow default, then white/grey + soft pastels.
|
|
262
|
-
// Wholly separate from the stroke ink PALETTE and the translucent FILL_PALETTE
|
|
263
|
-
// so stickies read as "paper", not "ink". Slot 0 (yellow) is the default.
|
|
264
|
-
// Existing stickies keep their stored hex; only NEW stickies pick up the new
|
|
265
|
-
// default tint.
|
|
266
|
-
export const STICKY_PALETTE = [
|
|
267
|
-
'#fce8a6', // muted yellow (default — warm paper)
|
|
268
|
-
'#ffffff', // white
|
|
269
|
-
'#e6e4e0', // light grey
|
|
270
|
-
'#f7c5c0', // salmon
|
|
271
|
-
'#f8d2a6', // peach
|
|
272
|
-
'#bfe3c0', // mint
|
|
273
|
-
'#a9dbdb', // aqua
|
|
274
|
-
'#bcd2f0', // light blue
|
|
275
|
-
'#cfc4ec', // lavender
|
|
276
|
-
'#f3c4dd', // light pink
|
|
277
|
-
] as const;
|
|
278
|
-
const DEFAULT_STICKY_COLOR = STICKY_PALETTE[0];
|
|
279
|
-
const STICKY_CORNER_RADIUS = 8;
|
|
280
|
-
// Phase 24 — stickies are 1:1; the default tap size is a square.
|
|
281
|
-
const STICKY_DEFAULT_W = 200;
|
|
282
|
-
const STICKY_DEFAULT_H = 200;
|
|
283
|
-
const STICKY_MIN_SIZE = 40;
|
|
284
|
-
// Phase 24 — a bare tap with the Shape tool drops a default-sized shape at the
|
|
285
|
-
// tap point (FigJam parity: click commits, drag sizes). Square aspect.
|
|
286
|
-
const SHAPE_DEFAULT_SIZE = 120;
|
|
287
|
-
|
|
288
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
289
|
-
// Pure helpers — exported for unit tests.
|
|
290
|
-
|
|
291
|
-
export function rid(): string {
|
|
292
|
-
return `s_${Math.random().toString(36).slice(2, 10)}`;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function esc(s: string): string {
|
|
296
|
-
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
export function penPathD(points: readonly WorldPoint[]): string {
|
|
300
|
-
if (points.length === 0) return '';
|
|
301
|
-
const [first, ...rest] = points as readonly WorldPoint[];
|
|
302
|
-
if (!first) return '';
|
|
303
|
-
let d = `M${first[0]} ${first[1]}`;
|
|
304
|
-
for (const p of rest) d += ` L${p[0]} ${p[1]}`;
|
|
305
|
-
return d;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
|
|
309
|
-
// Re-exported so the existing test import (`from '../annotations-layer.tsx'`)
|
|
310
|
-
// and the byte-identical canary keep working.
|
|
311
|
-
export { arrowHeadPoints } from './canvas-arrowheads.ts';
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Phase 24 — polygon vertices derived from the bbox. `diamond` = the four
|
|
315
|
-
* edge-midpoints; `triangle` = apex-up; `triangle-down` = apex-down. Every
|
|
316
|
-
* shape's vertices span the FULL bbox, so a parse-back via the points' min/max
|
|
317
|
-
* recovers x/y/w/h exactly (idempotent round-trip).
|
|
318
|
-
*/
|
|
319
|
-
export function polygonVertices(
|
|
320
|
-
shape: PolygonShape,
|
|
321
|
-
x: number,
|
|
322
|
-
y: number,
|
|
323
|
-
w: number,
|
|
324
|
-
h: number
|
|
325
|
-
): Array<[number, number]> {
|
|
326
|
-
if (shape === 'diamond') {
|
|
327
|
-
return [
|
|
328
|
-
[x + w / 2, y],
|
|
329
|
-
[x + w, y + h / 2],
|
|
330
|
-
[x + w / 2, y + h],
|
|
331
|
-
[x, y + h / 2],
|
|
332
|
-
];
|
|
333
|
-
}
|
|
334
|
-
if (shape === 'triangle') {
|
|
335
|
-
return [
|
|
336
|
-
[x + w / 2, y],
|
|
337
|
-
[x + w, y + h],
|
|
338
|
-
[x, y + h],
|
|
339
|
-
];
|
|
340
|
-
}
|
|
341
|
-
// triangle-down — apex at the bottom.
|
|
342
|
-
return [
|
|
343
|
-
[x, y],
|
|
344
|
-
[x + w, y],
|
|
345
|
-
[x + w / 2, y + h],
|
|
346
|
-
];
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/** Vertices as an SVG `points` string. */
|
|
350
|
-
export function polygonPoints(
|
|
351
|
-
shape: PolygonShape,
|
|
352
|
-
x: number,
|
|
353
|
-
y: number,
|
|
354
|
-
w: number,
|
|
355
|
-
h: number
|
|
356
|
-
): string {
|
|
357
|
-
return polygonVertices(shape, x, y, w, h)
|
|
358
|
-
.map(([px, py]) => `${px},${py}`)
|
|
359
|
-
.join(' ');
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/** Even-odd ray-cast point-in-polygon test. */
|
|
363
|
-
function pointInPolygon(px: number, py: number, pts: ReadonlyArray<[number, number]>): boolean {
|
|
364
|
-
let inside = false;
|
|
365
|
-
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
366
|
-
const a = pts[i];
|
|
367
|
-
const b = pts[j];
|
|
368
|
-
if (!a || !b) continue;
|
|
369
|
-
const [xi, yi] = a;
|
|
370
|
-
const [xj, yj] = b;
|
|
371
|
-
if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) {
|
|
372
|
-
inside = !inside;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
return inside;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** Parse a polygon `points` string back into its bounding box. */
|
|
379
|
-
function polygonBBox(points: string): { x: number; y: number; w: number; h: number } | null {
|
|
380
|
-
let xMin = Number.POSITIVE_INFINITY;
|
|
381
|
-
let yMin = Number.POSITIVE_INFINITY;
|
|
382
|
-
let xMax = Number.NEGATIVE_INFINITY;
|
|
383
|
-
let yMax = Number.NEGATIVE_INFINITY;
|
|
384
|
-
for (const pair of points.trim().split(/\s+/)) {
|
|
385
|
-
const [px, py] = pair.split(',').map((n) => Number.parseFloat(n));
|
|
386
|
-
if (px == null || py == null || Number.isNaN(px) || Number.isNaN(py)) continue;
|
|
387
|
-
if (px < xMin) xMin = px;
|
|
388
|
-
if (px > xMax) xMax = px;
|
|
389
|
-
if (py < yMin) yMin = py;
|
|
390
|
-
if (py > yMax) yMax = py;
|
|
391
|
-
}
|
|
392
|
-
if (!Number.isFinite(xMin)) return null;
|
|
393
|
-
return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function strokeToSvgEl(s: Stroke): string {
|
|
397
|
-
if (s.tool === 'text') {
|
|
398
|
-
// Phase 21 — anchored text keeps the byte-identical Phase 5.1 form;
|
|
399
|
-
// standalone text (no anchorId) writes its own world x/y and omits
|
|
400
|
-
// data-anchor-id (so the parser routes it back to the standalone branch).
|
|
401
|
-
// Phase 24 — bold/strike/align serialize ONLY for non-default values, so a
|
|
402
|
-
// legacy text node stays byte-identical (weight/deco/alignAttr are empty).
|
|
403
|
-
const weight = s.bold ? ' font-weight="700"' : '';
|
|
404
|
-
const deco = s.strike ? ' text-decoration="line-through"' : '';
|
|
405
|
-
if (s.anchorId != null && s.anchorId !== '') {
|
|
406
|
-
const align = s.align ?? 'center'; // anchored default = centre (legacy)
|
|
407
|
-
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
408
|
-
const alignAttr = align !== 'center' ? ` data-align="${align}"` : '';
|
|
409
|
-
return `<text data-id="${esc(s.id)}" data-tool="text" data-anchor-id="${esc(
|
|
410
|
-
s.anchorId
|
|
411
|
-
)}" data-font-size="${s.fontSize}" fill="${esc(
|
|
412
|
-
s.color
|
|
413
|
-
)}"${weight}${deco} text-anchor="${anchor}" dominant-baseline="middle"${alignAttr}>${esc(
|
|
414
|
-
s.text
|
|
415
|
-
)}</text>`;
|
|
416
|
-
}
|
|
417
|
-
const tx = s.x ?? 0;
|
|
418
|
-
const ty = s.y ?? 0;
|
|
419
|
-
const align = s.align ?? 'left'; // standalone default = left
|
|
420
|
-
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
421
|
-
const alignAttr = align !== 'left' ? ` data-align="${align}"` : '';
|
|
422
|
-
return `<text data-id="${esc(s.id)}" data-tool="text" x="${tx}" y="${ty}" data-font-size="${
|
|
423
|
-
s.fontSize
|
|
424
|
-
}" fill="${esc(
|
|
425
|
-
s.color
|
|
426
|
-
)}"${weight}${deco} text-anchor="${anchor}" dominant-baseline="hanging"${alignAttr}>${esc(
|
|
427
|
-
s.text
|
|
428
|
-
)}</text>`;
|
|
429
|
-
}
|
|
430
|
-
if (s.tool === 'sticky') {
|
|
431
|
-
// Phase 21 — sticky body lives in an allowlisted <text> child so it
|
|
432
|
-
// survives sanitizeAnnotationSvg (which strips <foreignObject>, DDR-060
|
|
433
|
-
// F1). The live canvas re-renders this stroke with a foreignObject so the
|
|
434
|
-
// text word-wraps; the persisted <text> is the inert, sanitizer-safe form.
|
|
435
|
-
const r = s.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
436
|
-
const w = Math.max(0, s.w);
|
|
437
|
-
const h = Math.max(0, s.h);
|
|
438
|
-
// Phase 24 — bold/strike/align on the <g> data-attrs, emitted ONLY for
|
|
439
|
-
// non-default values (sticky default align = left) so Phase-21 stickies
|
|
440
|
-
// serialize byte-identically.
|
|
441
|
-
const align = s.align ?? 'left';
|
|
442
|
-
const styleAttrs =
|
|
443
|
-
(s.bold ? ' data-bold="1"' : '') +
|
|
444
|
-
(s.strike ? ' data-strike="1"' : '') +
|
|
445
|
-
(align !== 'left' ? ` data-align="${align}"` : '');
|
|
446
|
-
return `<g data-id="${esc(s.id)}" data-tool="sticky" data-r="${r}" data-fs="${
|
|
447
|
-
s.fontSize
|
|
448
|
-
}" fill="${esc(s.color)}"${styleAttrs}><rect x="${s.x}" y="${
|
|
449
|
-
s.y
|
|
450
|
-
}" width="${w}" height="${h}" rx="${r}" ry="${r}"/><text data-sticky-body="1" x="${
|
|
451
|
-
s.x + 12
|
|
452
|
-
}" y="${s.y + 12}" font-size="${
|
|
453
|
-
s.fontSize
|
|
454
|
-
}" fill="#1a1a1a" dominant-baseline="hanging">${esc(s.text)}</text></g>`;
|
|
455
|
-
}
|
|
456
|
-
const common = `data-id="${esc(s.id)}" data-tool="${s.tool}" stroke="${esc(s.color)}" stroke-width="${s.width}" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"`;
|
|
457
|
-
if (s.tool === 'pen') {
|
|
458
|
-
return `<path ${common} fill="none" d="${penPathD(s.points)}" pointer-events="stroke"/>`;
|
|
459
|
-
}
|
|
460
|
-
if (s.tool === 'rect') {
|
|
461
|
-
const fill = s.fill ? esc(s.fill) : 'none';
|
|
462
|
-
// Phase 21 — corner radius: append rx/ry/data-r ONLY when > 0 so legacy
|
|
463
|
-
// sharp-corner rects serialize byte-identically (Task 10 canary).
|
|
464
|
-
const r = s.cornerRadius ?? 0;
|
|
465
|
-
const round = r > 0 ? ` rx="${r}" ry="${r}" data-r="${r}"` : '';
|
|
466
|
-
return `<rect ${common} fill="${fill}" x="${s.x}" y="${s.y}" width="${Math.max(
|
|
467
|
-
0,
|
|
468
|
-
s.w
|
|
469
|
-
)}" height="${Math.max(0, s.h)}"${round}/>`;
|
|
470
|
-
}
|
|
471
|
-
if (s.tool === 'ellipse') {
|
|
472
|
-
const fill = s.fill ? esc(s.fill) : 'none';
|
|
473
|
-
return `<ellipse ${common} fill="${fill}" cx="${s.cx}" cy="${s.cy}" rx="${Math.max(
|
|
474
|
-
0,
|
|
475
|
-
s.rx
|
|
476
|
-
)}" ry="${Math.max(0, s.ry)}"/>`;
|
|
477
|
-
}
|
|
478
|
-
if (s.tool === 'polygon') {
|
|
479
|
-
// Phase 24 — bbox-derived points + data-shape. Normalize the bbox so a
|
|
480
|
-
// negative-extent (mid-flip) stroke serializes idempotently.
|
|
481
|
-
const nx = Math.min(s.x, s.x + s.w);
|
|
482
|
-
const ny = Math.min(s.y, s.y + s.h);
|
|
483
|
-
const nw = Math.abs(s.w);
|
|
484
|
-
const nh = Math.abs(s.h);
|
|
485
|
-
const fill = s.fill ? esc(s.fill) : 'none';
|
|
486
|
-
const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
|
|
487
|
-
const dashAttr = s.dashed ? ' data-dash="1"' : '';
|
|
488
|
-
return `<polygon ${common} fill="${fill}" data-shape="${s.shape}" points="${polygonPoints(
|
|
489
|
-
s.shape,
|
|
490
|
-
nx,
|
|
491
|
-
ny,
|
|
492
|
-
nw,
|
|
493
|
-
nh
|
|
494
|
-
)}"${dash}${dashAttr}/>`;
|
|
495
|
-
}
|
|
496
|
-
// arrow — Phase 24 reduces to ordered SVG primitives (canvas-arrowheads), the
|
|
497
|
-
// same primitives StrokeNode renders. Defaults (startHead 'none', endHead
|
|
498
|
-
// 'triangle', lineType 'straight', solid) reduce to exactly
|
|
499
|
-
// [<line>, <polyline fill=color>] → the byte-identical Phase 5.1 form. data-*
|
|
500
|
-
// attrs appear only for non-default values.
|
|
501
|
-
const startHead = s.startHead ?? 'none';
|
|
502
|
-
const endHead = s.endHead ?? 'triangle';
|
|
503
|
-
const lineType = s.lineType ?? 'straight';
|
|
504
|
-
const dashed = s.dashed ?? false;
|
|
505
|
-
// esc() every interpolated value (defence-in-depth, Phase 24 security review
|
|
506
|
-
// DDR-067) — heads are clamped on parse, but a value reaching serialize must
|
|
507
|
-
// never be able to break out of the attribute.
|
|
508
|
-
const dataAttrs =
|
|
509
|
-
(startHead !== 'none' ? ` data-start-head="${esc(startHead)}"` : '') +
|
|
510
|
-
(endHead !== 'triangle' ? ` data-end-head="${esc(endHead)}"` : '') +
|
|
511
|
-
(lineType !== 'straight' ? ` data-line-type="${esc(lineType)}"` : '') +
|
|
512
|
-
(dashed ? ' data-dash="1"' : '');
|
|
513
|
-
const body = arrowPrimitives(s).map(svgPrimitiveToString).join('');
|
|
514
|
-
return `<g ${common} fill="none"${dataAttrs}>${body}</g>`;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/** Format one arrow SVG primitive for the persisted string (byte-identical to
|
|
518
|
-
* the Phase-5.1 `<line>`/`<polyline>` forms for the legacy default arrow). */
|
|
519
|
-
function svgPrimitiveToString(p: SvgPrimitive): string {
|
|
520
|
-
const dash = 'dash' in p && p.dash ? ' stroke-dasharray="6 4"' : '';
|
|
521
|
-
switch (p.el) {
|
|
522
|
-
case 'line':
|
|
523
|
-
return `<line x1="${p.x1}" y1="${p.y1}" x2="${p.x2}" y2="${p.y2}"${dash}/>`;
|
|
524
|
-
case 'path':
|
|
525
|
-
return `<path d="${p.d}"${dash}/>`;
|
|
526
|
-
case 'polyline':
|
|
527
|
-
return `<polyline points="${p.points}" fill="${esc(p.fill)}"/>`;
|
|
528
|
-
case 'polygon':
|
|
529
|
-
return `<polygon points="${p.points}" fill="${esc(p.fill)}"/>`;
|
|
530
|
-
case 'circle':
|
|
531
|
-
return `<circle cx="${p.cx}" cy="${p.cy}" r="${p.r}" fill="${esc(p.fill)}"/>`;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
export function strokesToSvg(strokes: readonly Stroke[]): string {
|
|
536
|
-
const header = '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">';
|
|
537
|
-
if (strokes.length === 0) return `${header}</svg>`;
|
|
538
|
-
const body = strokes.map(strokeToSvgEl).join('');
|
|
539
|
-
return `${header}${body}</svg>`;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
function parsePathD(d: string): WorldPoint[] {
|
|
543
|
-
const out: WorldPoint[] = [];
|
|
544
|
-
const re = /[ML]\s*(-?\d+(?:\.\d+)?)\s*[\s,]\s*(-?\d+(?:\.\d+)?)/g;
|
|
545
|
-
let m: RegExpExecArray | null;
|
|
546
|
-
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
|
|
547
|
-
while ((m = re.exec(d)) !== null) {
|
|
548
|
-
const [, x = '0', y = '0'] = m;
|
|
549
|
-
out.push([Number.parseFloat(x), Number.parseFloat(y)]);
|
|
550
|
-
}
|
|
551
|
-
return out;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
function parseFill(raw: string | null): string | null {
|
|
555
|
-
if (!raw) return null;
|
|
556
|
-
const v = raw.trim().toLowerCase();
|
|
557
|
-
if (!v || v === 'none' || v === 'transparent') return null;
|
|
558
|
-
return raw;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
* Phase 24 — recover an arrow's two endpoints from its shaft. A straight arrow
|
|
563
|
-
* persists a `<line>`; a curved/elbow arrow persists a `<path>` whose first and
|
|
564
|
-
* last coordinate pairs are the endpoints (the bow control / elbow corner sit
|
|
565
|
-
* between them, so first-pair = start, last-pair = end recovers the ends
|
|
566
|
-
* exactly → idempotent re-serialize).
|
|
567
|
-
*/
|
|
568
|
-
function arrowEndpoints(el: Element): { x1: number; y1: number; x2: number; y2: number } | null {
|
|
569
|
-
const line = el.querySelector('line');
|
|
570
|
-
if (line) {
|
|
571
|
-
return {
|
|
572
|
-
x1: Number.parseFloat(line.getAttribute('x1') || '0'),
|
|
573
|
-
y1: Number.parseFloat(line.getAttribute('y1') || '0'),
|
|
574
|
-
x2: Number.parseFloat(line.getAttribute('x2') || '0'),
|
|
575
|
-
y2: Number.parseFloat(line.getAttribute('y2') || '0'),
|
|
576
|
-
};
|
|
577
|
-
}
|
|
578
|
-
const path = el.querySelector('path');
|
|
579
|
-
if (path) {
|
|
580
|
-
const nums = (path.getAttribute('d') || '').match(/-?\d+(?:\.\d+)?/g);
|
|
581
|
-
if (nums && nums.length >= 4) {
|
|
582
|
-
return {
|
|
583
|
-
x1: Number.parseFloat(nums[0] as string),
|
|
584
|
-
y1: Number.parseFloat(nums[1] as string),
|
|
585
|
-
x2: Number.parseFloat(nums[nums.length - 2] as string),
|
|
586
|
-
y2: Number.parseFloat(nums[nums.length - 1] as string),
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
return null;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
export function svgToStrokes(svgText: string): Stroke[] {
|
|
594
|
-
const text = (svgText ?? '').trim();
|
|
595
|
-
if (!text) return [];
|
|
596
|
-
if (typeof DOMParser === 'undefined') return [];
|
|
597
|
-
try {
|
|
598
|
-
const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
|
|
599
|
-
if (doc.querySelector('parsererror')) return [];
|
|
600
|
-
const out: Stroke[] = [];
|
|
601
|
-
for (const el of Array.from(doc.querySelectorAll('[data-tool]'))) {
|
|
602
|
-
const tool = el.getAttribute('data-tool');
|
|
603
|
-
const id = el.getAttribute('data-id') || rid();
|
|
604
|
-
const color = el.getAttribute('stroke') || el.getAttribute('fill') || DEFAULT_COLOR;
|
|
605
|
-
const width = Number.parseFloat(el.getAttribute('stroke-width') || '2') || 2;
|
|
606
|
-
if (tool === 'pen') {
|
|
607
|
-
const d = el.getAttribute('d') || '';
|
|
608
|
-
const points = parsePathD(d);
|
|
609
|
-
if (points.length) out.push({ id, tool: 'pen', color, width, points });
|
|
610
|
-
continue;
|
|
611
|
-
}
|
|
612
|
-
if (tool === 'sticky') {
|
|
613
|
-
// Phase 21 — sticky reads geometry off its <rect> child, paper tint
|
|
614
|
-
// off the group fill, body text off the inner <text>.
|
|
615
|
-
const rectEl = el.querySelector('rect');
|
|
616
|
-
const x = Number.parseFloat(rectEl?.getAttribute('x') || '0');
|
|
617
|
-
const y = Number.parseFloat(rectEl?.getAttribute('y') || '0');
|
|
618
|
-
const w = Number.parseFloat(rectEl?.getAttribute('width') || '0');
|
|
619
|
-
const h = Number.parseFloat(rectEl?.getAttribute('height') || '0');
|
|
620
|
-
const cornerRadius =
|
|
621
|
-
Number.parseFloat(el.getAttribute('data-r') || String(STICKY_CORNER_RADIUS)) || 0;
|
|
622
|
-
const fontSize =
|
|
623
|
-
Number.parseFloat(el.getAttribute('data-fs') || String(DEFAULT_FONT_SIZE)) ||
|
|
624
|
-
DEFAULT_FONT_SIZE;
|
|
625
|
-
const stickyColor = el.getAttribute('fill') || DEFAULT_STICKY_COLOR;
|
|
626
|
-
const body = el.querySelector('text');
|
|
627
|
-
const sticky: StickyStroke = {
|
|
628
|
-
id,
|
|
629
|
-
tool: 'sticky',
|
|
630
|
-
color: stickyColor,
|
|
631
|
-
x,
|
|
632
|
-
y,
|
|
633
|
-
w,
|
|
634
|
-
h,
|
|
635
|
-
text: body?.textContent ?? '',
|
|
636
|
-
fontSize,
|
|
637
|
-
cornerRadius,
|
|
638
|
-
};
|
|
639
|
-
// Phase 24 — style attrs; absent ⇒ defaults (normal / left), left unset.
|
|
640
|
-
if (el.getAttribute('data-bold') === '1') sticky.bold = true;
|
|
641
|
-
if (el.getAttribute('data-strike') === '1') sticky.strike = true;
|
|
642
|
-
const sticAlign = el.getAttribute('data-align');
|
|
643
|
-
if (sticAlign === 'left' || sticAlign === 'center' || sticAlign === 'right') {
|
|
644
|
-
sticky.align = sticAlign;
|
|
645
|
-
}
|
|
646
|
-
out.push(sticky);
|
|
647
|
-
continue;
|
|
648
|
-
}
|
|
649
|
-
if (tool === 'rect') {
|
|
650
|
-
const x = Number.parseFloat(el.getAttribute('x') || '0');
|
|
651
|
-
const y = Number.parseFloat(el.getAttribute('y') || '0');
|
|
652
|
-
const w = Number.parseFloat(el.getAttribute('width') || '0');
|
|
653
|
-
const h = Number.parseFloat(el.getAttribute('height') || '0');
|
|
654
|
-
const fill = parseFill(el.getAttribute('fill'));
|
|
655
|
-
// Phase 21 — corner radius; absent ⇒ 0 (sharp, back-compat).
|
|
656
|
-
const cornerRadius = Number.parseFloat(el.getAttribute('data-r') || '0') || 0;
|
|
657
|
-
out.push({ id, tool: 'rect', color, width, x, y, w, h, fill, cornerRadius });
|
|
658
|
-
continue;
|
|
659
|
-
}
|
|
660
|
-
if (tool === 'ellipse') {
|
|
661
|
-
const cx = Number.parseFloat(el.getAttribute('cx') || '0');
|
|
662
|
-
const cy = Number.parseFloat(el.getAttribute('cy') || '0');
|
|
663
|
-
const rx = Number.parseFloat(el.getAttribute('rx') || '0');
|
|
664
|
-
const ry = Number.parseFloat(el.getAttribute('ry') || '0');
|
|
665
|
-
const fill = parseFill(el.getAttribute('fill'));
|
|
666
|
-
out.push({ id, tool: 'ellipse', color, width, cx, cy, rx, ry, fill });
|
|
667
|
-
continue;
|
|
668
|
-
}
|
|
669
|
-
if (tool === 'polygon') {
|
|
670
|
-
// Phase 24 — recover the bbox from the points; shape from data-shape.
|
|
671
|
-
const shapeRaw = el.getAttribute('data-shape');
|
|
672
|
-
const shape: PolygonShape =
|
|
673
|
-
shapeRaw === 'triangle' || shapeRaw === 'triangle-down' ? shapeRaw : 'diamond';
|
|
674
|
-
const bb = polygonBBox(el.getAttribute('points') || '');
|
|
675
|
-
if (bb) {
|
|
676
|
-
const fill = parseFill(el.getAttribute('fill'));
|
|
677
|
-
const poly: PolygonStroke = {
|
|
678
|
-
id,
|
|
679
|
-
tool: 'polygon',
|
|
680
|
-
shape,
|
|
681
|
-
color,
|
|
682
|
-
width,
|
|
683
|
-
x: bb.x,
|
|
684
|
-
y: bb.y,
|
|
685
|
-
w: bb.w,
|
|
686
|
-
h: bb.h,
|
|
687
|
-
fill,
|
|
688
|
-
};
|
|
689
|
-
const dashRaw = el.getAttribute('data-dash');
|
|
690
|
-
if (dashRaw === '1' || dashRaw === 'true') poly.dashed = true;
|
|
691
|
-
out.push(poly);
|
|
692
|
-
}
|
|
693
|
-
continue;
|
|
694
|
-
}
|
|
695
|
-
if (tool === 'arrow') {
|
|
696
|
-
// Phase 24 — shaft is a <line> (straight) OR a <path> (curved/elbow).
|
|
697
|
-
// Recover the two endpoints from whichever is present.
|
|
698
|
-
const ends = arrowEndpoints(el);
|
|
699
|
-
if (ends) {
|
|
700
|
-
const arrow: ArrowStroke = {
|
|
701
|
-
id,
|
|
702
|
-
tool: 'arrow',
|
|
703
|
-
color,
|
|
704
|
-
width,
|
|
705
|
-
x1: ends.x1,
|
|
706
|
-
y1: ends.y1,
|
|
707
|
-
x2: ends.x2,
|
|
708
|
-
y2: ends.y2,
|
|
709
|
-
};
|
|
710
|
-
// Heads + dash + line-type. The serializer writes a data-* attribute
|
|
711
|
-
// only for a NON-default value, so a legacy arrow carries none of
|
|
712
|
-
// these and stays { startHead/endHead/dashed/lineType: undefined } →
|
|
713
|
-
// defaults on re-serialize (byte-identical, canary). Phase 24 widened
|
|
714
|
-
// the head enum, so read the literal value rather than match a single
|
|
715
|
-
// string.
|
|
716
|
-
// Clamp to the known head vocabulary — an out-of-vocab / poisoned
|
|
717
|
-
// value (hub-pushed SVG) is rejected, never cast through unchecked
|
|
718
|
-
// (Phase 24 security review, DDR-067).
|
|
719
|
-
const sh = el.getAttribute('data-start-head');
|
|
720
|
-
if (sh && ARROW_HEADS.has(sh)) arrow.startHead = sh as ArrowHead;
|
|
721
|
-
const eh = el.getAttribute('data-end-head');
|
|
722
|
-
if (eh && ARROW_HEADS.has(eh)) arrow.endHead = eh as ArrowHead;
|
|
723
|
-
const lt = el.getAttribute('data-line-type');
|
|
724
|
-
if (lt === 'curved' || lt === 'elbow' || lt === 'straight') arrow.lineType = lt;
|
|
725
|
-
const dashRaw = el.getAttribute('data-dash');
|
|
726
|
-
if (dashRaw === '1' || dashRaw === 'true') arrow.dashed = true;
|
|
727
|
-
out.push(arrow);
|
|
728
|
-
}
|
|
729
|
-
continue;
|
|
730
|
-
}
|
|
731
|
-
if (tool === 'text') {
|
|
732
|
-
const rawAnchor = el.getAttribute('data-anchor-id');
|
|
733
|
-
const fontSize =
|
|
734
|
-
Number.parseFloat(el.getAttribute('data-font-size') || String(DEFAULT_FONT_SIZE)) ||
|
|
735
|
-
DEFAULT_FONT_SIZE;
|
|
736
|
-
const inkColor = el.getAttribute('fill') || color;
|
|
737
|
-
const body = (el.textContent || '').trim();
|
|
738
|
-
// Phase 24 — bold / strike / align. `data-align` is the round-trip
|
|
739
|
-
// source of truth (text-anchor is derived from it). Absent ⇒ default
|
|
740
|
-
// (normal / per-kind align), left unset so legacy nodes round-trip.
|
|
741
|
-
const isBold = el.getAttribute('font-weight') === '700';
|
|
742
|
-
const isStrike = (el.getAttribute('text-decoration') || '').includes('line-through');
|
|
743
|
-
const da = el.getAttribute('data-align');
|
|
744
|
-
const align: TextAlign | undefined =
|
|
745
|
-
da === 'left' || da === 'center' || da === 'right' ? da : undefined;
|
|
746
|
-
// Phase 21 — standalone text (no data-anchor-id) carries world x/y
|
|
747
|
-
// instead of a host id.
|
|
748
|
-
if (!rawAnchor) {
|
|
749
|
-
const t: TextStroke = {
|
|
750
|
-
id,
|
|
751
|
-
tool: 'text',
|
|
752
|
-
color: inkColor,
|
|
753
|
-
fontSize,
|
|
754
|
-
text: body,
|
|
755
|
-
x: Number.parseFloat(el.getAttribute('x') || '0'),
|
|
756
|
-
y: Number.parseFloat(el.getAttribute('y') || '0'),
|
|
757
|
-
};
|
|
758
|
-
if (isBold) t.bold = true;
|
|
759
|
-
if (isStrike) t.strike = true;
|
|
760
|
-
if (align) t.align = align;
|
|
761
|
-
out.push(t);
|
|
762
|
-
continue;
|
|
763
|
-
}
|
|
764
|
-
const t: TextStroke = {
|
|
765
|
-
id,
|
|
766
|
-
tool: 'text',
|
|
767
|
-
color: inkColor,
|
|
768
|
-
fontSize,
|
|
769
|
-
text: body,
|
|
770
|
-
anchorId: rawAnchor,
|
|
771
|
-
};
|
|
772
|
-
if (isBold) t.bold = true;
|
|
773
|
-
if (isStrike) t.strike = true;
|
|
774
|
-
if (align) t.align = align;
|
|
775
|
-
out.push(t);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
return out;
|
|
779
|
-
} catch {
|
|
780
|
-
return [];
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
function pointSegmentDist(
|
|
785
|
-
px: number,
|
|
786
|
-
py: number,
|
|
787
|
-
ax: number,
|
|
788
|
-
ay: number,
|
|
789
|
-
bx: number,
|
|
790
|
-
by: number
|
|
791
|
-
): number {
|
|
792
|
-
const dx = bx - ax;
|
|
793
|
-
const dy = by - ay;
|
|
794
|
-
const len2 = dx * dx + dy * dy;
|
|
795
|
-
if (len2 === 0) return Math.hypot(px - ax, py - ay);
|
|
796
|
-
let t = ((px - ax) * dx + (py - ay) * dy) / len2;
|
|
797
|
-
t = Math.max(0, Math.min(1, t));
|
|
798
|
-
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): boolean {
|
|
802
|
-
if (s.tool === 'text') {
|
|
803
|
-
// Anchored text isn't independently hit-testable (it inherits its host).
|
|
804
|
-
// Standalone text (Phase 21) uses its synthetic bbox so the eraser can
|
|
805
|
-
// reach it.
|
|
806
|
-
if (s.anchorId != null && s.anchorId !== '') return false;
|
|
807
|
-
const bb = strokeBBox(s);
|
|
808
|
-
if (!bb) return false;
|
|
809
|
-
return (
|
|
810
|
-
wx >= bb.x - tol && wx <= bb.x + bb.w + tol && wy >= bb.y - tol && wy <= bb.y + bb.h + tol
|
|
811
|
-
);
|
|
812
|
-
}
|
|
813
|
-
const t = Math.max(tol, 'width' in s ? s.width : 2);
|
|
814
|
-
if (s.tool === 'sticky') {
|
|
815
|
-
// Sticky is a solid paper card — filled-rect hit anywhere inside.
|
|
816
|
-
const xMin = Math.min(s.x, s.x + s.w);
|
|
817
|
-
const xMax = Math.max(s.x, s.x + s.w);
|
|
818
|
-
const yMin = Math.min(s.y, s.y + s.h);
|
|
819
|
-
const yMax = Math.max(s.y, s.y + s.h);
|
|
820
|
-
return wx >= xMin - t && wx <= xMax + t && wy >= yMin - t && wy <= yMax + t;
|
|
821
|
-
}
|
|
822
|
-
if (s.tool === 'pen') {
|
|
823
|
-
if (s.points.length === 1) {
|
|
824
|
-
const p = s.points[0] as WorldPoint;
|
|
825
|
-
return Math.hypot(wx - p[0], wy - p[1]) <= t;
|
|
826
|
-
}
|
|
827
|
-
for (let i = 1; i < s.points.length; i++) {
|
|
828
|
-
const a = s.points[i - 1] as WorldPoint;
|
|
829
|
-
const b = s.points[i] as WorldPoint;
|
|
830
|
-
if (pointSegmentDist(wx, wy, a[0], a[1], b[0], b[1]) <= t) return true;
|
|
831
|
-
}
|
|
832
|
-
return false;
|
|
833
|
-
}
|
|
834
|
-
if (s.tool === 'arrow') {
|
|
835
|
-
return pointSegmentDist(wx, wy, s.x1, s.y1, s.x2, s.y2) <= t;
|
|
836
|
-
}
|
|
837
|
-
if (s.tool === 'ellipse') {
|
|
838
|
-
// Inside-ellipse hit when filled; on the perimeter otherwise.
|
|
839
|
-
if (s.rx <= 0 || s.ry <= 0) return false;
|
|
840
|
-
const nx = (wx - s.cx) / s.rx;
|
|
841
|
-
const ny = (wy - s.cy) / s.ry;
|
|
842
|
-
const d = nx * nx + ny * ny;
|
|
843
|
-
if (s.fill) return d <= 1.0 + t / Math.max(s.rx, s.ry);
|
|
844
|
-
// Stroke-only: hit if normalized distance is within a band around 1.
|
|
845
|
-
const band = t / Math.max(s.rx, s.ry);
|
|
846
|
-
const dist = Math.abs(Math.sqrt(d) - 1);
|
|
847
|
-
return dist <= band;
|
|
848
|
-
}
|
|
849
|
-
if (s.tool === 'polygon') {
|
|
850
|
-
const nx = Math.min(s.x, s.x + s.w);
|
|
851
|
-
const ny = Math.min(s.y, s.y + s.h);
|
|
852
|
-
const pts = polygonVertices(s.shape, nx, ny, Math.abs(s.w), Math.abs(s.h));
|
|
853
|
-
// Filled → inside-hit; always allow an edge-proximity hit (covers the
|
|
854
|
-
// stroke-only outline + a tolerance band on a filled shape).
|
|
855
|
-
if (s.fill && pointInPolygon(wx, wy, pts)) return true;
|
|
856
|
-
for (let i = 0; i < pts.length; i++) {
|
|
857
|
-
const a = pts[i] as [number, number];
|
|
858
|
-
const b = pts[(i + 1) % pts.length] as [number, number];
|
|
859
|
-
if (pointSegmentDist(wx, wy, a[0], a[1], b[0], b[1]) <= t) return true;
|
|
860
|
-
}
|
|
861
|
-
return false;
|
|
862
|
-
}
|
|
863
|
-
// rect — inside when filled, edge-only otherwise.
|
|
864
|
-
const x = s.x;
|
|
865
|
-
const y = s.y;
|
|
866
|
-
const x2 = x + s.w;
|
|
867
|
-
const y2 = y + s.h;
|
|
868
|
-
const xMin = Math.min(x, x2);
|
|
869
|
-
const xMax = Math.max(x, x2);
|
|
870
|
-
const yMin = Math.min(y, y2);
|
|
871
|
-
const yMax = Math.max(y, y2);
|
|
872
|
-
if (s.fill) {
|
|
873
|
-
return wx >= xMin - t && wx <= xMax + t && wy >= yMin - t && wy <= yMax + t;
|
|
874
|
-
}
|
|
875
|
-
if (wx < xMin - t || wx > xMax + t) return false;
|
|
876
|
-
if (wy < yMin - t || wy > yMax + t) return false;
|
|
877
|
-
const onLeft = Math.abs(wx - x) <= t;
|
|
878
|
-
const onRight = Math.abs(wx - x2) <= t;
|
|
879
|
-
const onTop = Math.abs(wy - y) <= t;
|
|
880
|
-
const onBottom = Math.abs(wy - y2) <= t;
|
|
881
|
-
return onLeft || onRight || onTop || onBottom;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
/** Flip a negative-extent box so x/y is the top-left and w/h are positive. */
|
|
885
|
-
function normalizeBox<T extends { x: number; y: number; w: number; h: number }>(r: T): T {
|
|
886
|
-
if (r.w >= 0 && r.h >= 0) return r;
|
|
887
|
-
return {
|
|
888
|
-
...r,
|
|
889
|
-
x: Math.min(r.x, r.x + r.w),
|
|
890
|
-
y: Math.min(r.y, r.y + r.h),
|
|
891
|
-
w: Math.abs(r.w),
|
|
892
|
-
h: Math.abs(r.h),
|
|
893
|
-
};
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
/**
|
|
897
|
-
* Phase 24 — draw-time resize modifiers (FigJam parity, mirror of the
|
|
898
|
-
* `use-annotation-resize.tsx` set so create + resize feel identical):
|
|
899
|
-
* • `shift` — lock to 1:1 (square / circle); the larger drag axis sets the
|
|
900
|
-
* side, each axis keeps its own sign so the drag direction holds.
|
|
901
|
-
* • `alt` — grow from the pointer-down point as CENTER (symmetric).
|
|
902
|
-
* With neither held the box is `{ x: down, w: cursor − down }` — byte-identical
|
|
903
|
-
* to the previous corner-drag math.
|
|
904
|
-
*/
|
|
905
|
-
export interface DrawMods {
|
|
906
|
-
shift: boolean;
|
|
907
|
-
alt: boolean;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/** Constrain a draw drag (`ax,ay` = pointer-down anchor; `wx,wy` = cursor). */
|
|
911
|
-
export function constrainDrawBox(
|
|
912
|
-
ax: number,
|
|
913
|
-
ay: number,
|
|
914
|
-
wx: number,
|
|
915
|
-
wy: number,
|
|
916
|
-
mods: DrawMods
|
|
917
|
-
): { x: number; y: number; w: number; h: number } {
|
|
918
|
-
let dx = wx - ax;
|
|
919
|
-
let dy = wy - ay;
|
|
920
|
-
if (mods.shift) {
|
|
921
|
-
const side = Math.max(Math.abs(dx), Math.abs(dy));
|
|
922
|
-
dx = (dx < 0 ? -1 : 1) * side;
|
|
923
|
-
dy = (dy < 0 ? -1 : 1) * side;
|
|
924
|
-
}
|
|
925
|
-
if (mods.alt) {
|
|
926
|
-
// Anchor is the center → span ±|d| on each axis around it.
|
|
927
|
-
return { x: ax - dx, y: ay - dy, w: 2 * dx, h: 2 * dy };
|
|
928
|
-
}
|
|
929
|
-
return { x: ax, y: ay, w: dx, h: dy };
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
/**
|
|
933
|
-
* Apply the draw-time modifiers to the in-progress stroke. Shared by the
|
|
934
|
-
* pointer-move handler and the live keydown/keyup re-apply, so holding Shift /
|
|
935
|
-
* Alt updates the draft even without moving the cursor. `anchor` is the
|
|
936
|
-
* pointer-down point; pen / text carry no box so they pass through unchanged.
|
|
937
|
-
*/
|
|
938
|
-
export function applyDrawModifiers(
|
|
939
|
-
cur: Stroke,
|
|
940
|
-
anchor: { x: number; y: number },
|
|
941
|
-
wx: number,
|
|
942
|
-
wy: number,
|
|
943
|
-
mods: DrawMods
|
|
944
|
-
): Stroke {
|
|
945
|
-
if (cur.tool === 'rect' || cur.tool === 'polygon') {
|
|
946
|
-
const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, mods);
|
|
947
|
-
return { ...cur, x: b.x, y: b.y, w: b.w, h: b.h };
|
|
948
|
-
}
|
|
949
|
-
if (cur.tool === 'ellipse') {
|
|
950
|
-
const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, mods);
|
|
951
|
-
return {
|
|
952
|
-
...cur,
|
|
953
|
-
cx: b.x + b.w / 2,
|
|
954
|
-
cy: b.y + b.h / 2,
|
|
955
|
-
rx: Math.abs(b.w) / 2,
|
|
956
|
-
ry: Math.abs(b.h) / 2,
|
|
957
|
-
};
|
|
958
|
-
}
|
|
959
|
-
if (cur.tool === 'sticky') {
|
|
960
|
-
// Stickies are always 1:1 — force the square constraint; Alt still centers.
|
|
961
|
-
const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, { shift: true, alt: mods.alt });
|
|
962
|
-
return { ...cur, x: b.x, y: b.y, w: b.w, h: b.h };
|
|
963
|
-
}
|
|
964
|
-
if (cur.tool === 'arrow') {
|
|
965
|
-
let x2 = wx;
|
|
966
|
-
let y2 = wy;
|
|
967
|
-
if (mods.shift) {
|
|
968
|
-
// Snap the shaft to the nearest 45° around the anchor (its midpoint
|
|
969
|
-
// under Alt), keeping the cursor's distance.
|
|
970
|
-
const dx = wx - anchor.x;
|
|
971
|
-
const dy = wy - anchor.y;
|
|
972
|
-
const dist = Math.hypot(dx, dy);
|
|
973
|
-
const step = Math.PI / 4;
|
|
974
|
-
const ang = Math.round(Math.atan2(dy, dx) / step) * step;
|
|
975
|
-
x2 = anchor.x + Math.cos(ang) * dist;
|
|
976
|
-
y2 = anchor.y + Math.sin(ang) * dist;
|
|
977
|
-
}
|
|
978
|
-
if (mods.alt) {
|
|
979
|
-
// Anchor is the midpoint → the start end mirrors the dragged end.
|
|
980
|
-
return { ...cur, x1: 2 * anchor.x - x2, y1: 2 * anchor.y - y2, x2, y2 };
|
|
981
|
-
}
|
|
982
|
-
return { ...cur, x1: anchor.x, y1: anchor.y, x2, y2 };
|
|
983
|
-
}
|
|
984
|
-
return cur;
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
function normalizeRect(r: RectStroke): RectStroke {
|
|
988
|
-
return normalizeBox(r);
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// Phase 21 — sticky shares rect's drag-to-create flip (x = min, w = abs(w)).
|
|
992
|
-
function normalizeSticky(s: StickyStroke): StickyStroke {
|
|
993
|
-
return normalizeBox(s);
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
function isStrokeMeaningful(s: Stroke): boolean {
|
|
997
|
-
if (s.tool === 'pen') return s.points.length >= 2;
|
|
998
|
-
if (s.tool === 'rect') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
|
|
999
|
-
if (s.tool === 'polygon') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
|
|
1000
|
-
if (s.tool === 'ellipse') return s.rx >= 2 && s.ry >= 2;
|
|
1001
|
-
if (s.tool === 'text') return s.text.trim().length > 0;
|
|
1002
|
-
// Sticky below a readable floor is discarded like a 2×2 rect.
|
|
1003
|
-
if (s.tool === 'sticky')
|
|
1004
|
-
return Math.abs(s.w) >= STICKY_MIN_SIZE && Math.abs(s.h) >= STICKY_MIN_SIZE;
|
|
1005
|
-
return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
export function strokeBBox(
|
|
1009
|
-
s: Stroke,
|
|
1010
|
-
anchors?: Map<string, RectStroke | EllipseStroke>
|
|
1011
|
-
): { x: number; y: number; w: number; h: number } | null {
|
|
1012
|
-
if (s.tool === 'pen') {
|
|
1013
|
-
if (!s.points.length) return null;
|
|
1014
|
-
let xMin = Number.POSITIVE_INFINITY;
|
|
1015
|
-
let xMax = Number.NEGATIVE_INFINITY;
|
|
1016
|
-
let yMin = Number.POSITIVE_INFINITY;
|
|
1017
|
-
let yMax = Number.NEGATIVE_INFINITY;
|
|
1018
|
-
for (const [px, py] of s.points) {
|
|
1019
|
-
if (px < xMin) xMin = px;
|
|
1020
|
-
if (px > xMax) xMax = px;
|
|
1021
|
-
if (py < yMin) yMin = py;
|
|
1022
|
-
if (py > yMax) yMax = py;
|
|
1023
|
-
}
|
|
1024
|
-
return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
|
|
1025
|
-
}
|
|
1026
|
-
if (s.tool === 'rect' || s.tool === 'polygon') {
|
|
1027
|
-
return {
|
|
1028
|
-
x: Math.min(s.x, s.x + s.w),
|
|
1029
|
-
y: Math.min(s.y, s.y + s.h),
|
|
1030
|
-
w: Math.abs(s.w),
|
|
1031
|
-
h: Math.abs(s.h),
|
|
1032
|
-
};
|
|
1033
|
-
}
|
|
1034
|
-
if (s.tool === 'ellipse') {
|
|
1035
|
-
return { x: s.cx - s.rx, y: s.cy - s.ry, w: s.rx * 2, h: s.ry * 2 };
|
|
1036
|
-
}
|
|
1037
|
-
if (s.tool === 'arrow') {
|
|
1038
|
-
return {
|
|
1039
|
-
x: Math.min(s.x1, s.x2),
|
|
1040
|
-
y: Math.min(s.y1, s.y2),
|
|
1041
|
-
w: Math.abs(s.x2 - s.x1),
|
|
1042
|
-
h: Math.abs(s.y2 - s.y1),
|
|
1043
|
-
};
|
|
1044
|
-
}
|
|
1045
|
-
if (s.tool === 'sticky') {
|
|
1046
|
-
return {
|
|
1047
|
-
x: Math.min(s.x, s.x + s.w),
|
|
1048
|
-
y: Math.min(s.y, s.y + s.h),
|
|
1049
|
-
w: Math.abs(s.w),
|
|
1050
|
-
h: Math.abs(s.h),
|
|
1051
|
-
};
|
|
1052
|
-
}
|
|
1053
|
-
// text — anchored inherits its host's bbox; standalone (Phase 21) gets a
|
|
1054
|
-
// synthetic bbox from its world (x, y) so it's selectable and the context
|
|
1055
|
-
// toolbar can position against it.
|
|
1056
|
-
if (s.anchorId != null && s.anchorId !== '') {
|
|
1057
|
-
const host = anchors?.get(s.anchorId);
|
|
1058
|
-
return host ? strokeBBox(host) : null;
|
|
1059
|
-
}
|
|
1060
|
-
const tx = s.x ?? 0;
|
|
1061
|
-
const ty = s.y ?? 0;
|
|
1062
|
-
return {
|
|
1063
|
-
x: tx,
|
|
1064
|
-
y: ty,
|
|
1065
|
-
w: Math.max(8, s.text.length * s.fontSize * 0.55),
|
|
1066
|
-
h: s.fontSize * 1.2,
|
|
1067
|
-
};
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
function isEditable(t: EventTarget | null): boolean {
|
|
1071
|
-
if (!t || !(t as HTMLElement).tagName) return false;
|
|
1072
|
-
const el = t as HTMLElement;
|
|
1073
|
-
const tag = el.tagName;
|
|
1074
|
-
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
|
1075
|
-
if (el.isContentEditable) return true;
|
|
1076
|
-
return false;
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
function deriveFile(): string | undefined {
|
|
1080
|
-
if (typeof window === 'undefined') return undefined;
|
|
1081
|
-
try {
|
|
1082
|
-
const p = window.location.pathname;
|
|
1083
|
-
if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
|
|
1084
|
-
const qs = new URLSearchParams(window.location.search);
|
|
1085
|
-
const canvas = qs.get('canvas') ?? '';
|
|
1086
|
-
const designRel = (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
|
|
1087
|
-
return `${designRel}/${canvas}`;
|
|
1088
|
-
}
|
|
1089
|
-
return decodeURIComponent(p).replace(/^\//, '');
|
|
1090
|
-
} catch {
|
|
1091
|
-
return undefined;
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
1096
|
-
// Styles
|
|
1097
|
-
|
|
1098
|
-
const ANNOT_CSS = `
|
|
1099
|
-
.dc-annot-chrome {
|
|
1100
|
-
/* Stacks directly above the centered tool toolbar (which is bottom:16px,
|
|
1101
|
-
32px tall → top edge ~ bottom:48px). 8 px gap → chrome at bottom:60px.
|
|
1102
|
-
Phase 21 — dark "marker tray" matching the FigJam selection bar. */
|
|
1103
|
-
position: absolute;
|
|
1104
|
-
left: 50%;
|
|
1105
|
-
bottom: 64px;
|
|
1106
|
-
transform: translateX(-50%);
|
|
1107
|
-
display: flex;
|
|
1108
|
-
align-items: center;
|
|
1109
|
-
gap: 2px;
|
|
1110
|
-
background: #26262b;
|
|
1111
|
-
border: 1px solid rgba(255,255,255,0.08);
|
|
1112
|
-
border-radius: 12px;
|
|
1113
|
-
padding: 5px 8px;
|
|
1114
|
-
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
1115
|
-
font-size: 11px;
|
|
1116
|
-
color: rgba(255,255,255,0.85);
|
|
1117
|
-
z-index: 6;
|
|
1118
|
-
box-shadow: 0 8px 28px rgba(0,0,0,0.34), 0 2px 6px rgba(0,0,0,0.22);
|
|
1119
|
-
user-select: none;
|
|
1120
|
-
}
|
|
1121
|
-
.dc-annot-chrome .dc-annot-swatches { display: flex; align-items: center; gap: 1px; }
|
|
1122
|
-
.dc-annot-chrome .dc-annot-sw {
|
|
1123
|
-
width: 20px;
|
|
1124
|
-
height: 20px;
|
|
1125
|
-
border-radius: 50%;
|
|
1126
|
-
border: 1px solid rgba(255,255,255,0.16);
|
|
1127
|
-
cursor: pointer;
|
|
1128
|
-
padding: 0;
|
|
1129
|
-
appearance: none;
|
|
1130
|
-
transition: transform 80ms ease;
|
|
1131
|
-
}
|
|
1132
|
-
.dc-annot-chrome .dc-annot-sw:hover { transform: scale(1.1); }
|
|
1133
|
-
.dc-annot-chrome .dc-annot-sw[aria-pressed="true"] {
|
|
1134
|
-
box-shadow: 0 0 0 2px #26262b, 0 0 0 3px rgba(255,255,255,0.92);
|
|
1135
|
-
border-color: transparent;
|
|
1136
|
-
}
|
|
1137
|
-
.dc-annot-chrome .dc-annot-sw:focus-visible {
|
|
1138
|
-
outline: 2px solid #ffffff;
|
|
1139
|
-
outline-offset: 1px;
|
|
1140
|
-
}
|
|
1141
|
-
.dc-annot-chrome .dc-annot-sep {
|
|
1142
|
-
width: 1px;
|
|
1143
|
-
height: 16px;
|
|
1144
|
-
align-self: center;
|
|
1145
|
-
background: rgba(255,255,255,0.09);
|
|
1146
|
-
margin: 0 4px;
|
|
1147
|
-
}
|
|
1148
|
-
.dc-annot-chrome .dc-annot-fill {
|
|
1149
|
-
width: 20px;
|
|
1150
|
-
height: 20px;
|
|
1151
|
-
border-radius: 50%;
|
|
1152
|
-
border: 1px solid rgba(255,255,255,0.16);
|
|
1153
|
-
cursor: pointer;
|
|
1154
|
-
padding: 0;
|
|
1155
|
-
appearance: none;
|
|
1156
|
-
position: relative;
|
|
1157
|
-
transition: transform 80ms ease;
|
|
1158
|
-
}
|
|
1159
|
-
.dc-annot-chrome .dc-annot-fill:hover { transform: scale(1.1); }
|
|
1160
|
-
.dc-annot-chrome .dc-annot-fill:focus-visible {
|
|
1161
|
-
outline: 2px solid #ffffff;
|
|
1162
|
-
outline-offset: 1px;
|
|
1163
|
-
}
|
|
1164
|
-
.dc-annot-chrome .dc-annot-fill--none {
|
|
1165
|
-
background: #3a3a40;
|
|
1166
|
-
}
|
|
1167
|
-
.dc-annot-chrome .dc-annot-fill--none::after {
|
|
1168
|
-
content: "";
|
|
1169
|
-
position: absolute; inset: 4px;
|
|
1170
|
-
border-radius: 50%;
|
|
1171
|
-
background:
|
|
1172
|
-
linear-gradient(135deg, transparent 44%, rgba(255,255,255,0.55) 44%, rgba(255,255,255,0.55) 56%, transparent 56%);
|
|
1173
|
-
}
|
|
1174
|
-
.dc-annot-chrome .dc-annot-fill[aria-pressed="true"] {
|
|
1175
|
-
box-shadow: 0 0 0 2px #26262b, 0 0 0 3px rgba(255,255,255,0.92);
|
|
1176
|
-
border-color: transparent;
|
|
1177
|
-
}
|
|
1178
|
-
/* Phase 21 — icon buttons (light glyph on dark, white-tint active). */
|
|
1179
|
-
.dc-annot-chrome .dc-annot-ibtn {
|
|
1180
|
-
appearance: none;
|
|
1181
|
-
background: transparent;
|
|
1182
|
-
border: 0;
|
|
1183
|
-
border-radius: 7px;
|
|
1184
|
-
width: 26px;
|
|
1185
|
-
height: 26px;
|
|
1186
|
-
display: inline-flex;
|
|
1187
|
-
align-items: center;
|
|
1188
|
-
justify-content: center;
|
|
1189
|
-
color: rgba(255,255,255,0.78);
|
|
1190
|
-
cursor: pointer;
|
|
1191
|
-
padding: 0;
|
|
1192
|
-
transition: background-color 80ms linear, color 80ms linear;
|
|
1193
|
-
}
|
|
1194
|
-
.dc-annot-chrome .dc-annot-ibtn:hover {
|
|
1195
|
-
background: rgba(255,255,255,0.1);
|
|
1196
|
-
color: #ffffff;
|
|
1197
|
-
}
|
|
1198
|
-
.dc-annot-chrome .dc-annot-ibtn[aria-pressed="true"] {
|
|
1199
|
-
background: rgba(255,255,255,0.18);
|
|
1200
|
-
color: #ffffff;
|
|
1201
|
-
}
|
|
1202
|
-
.dc-annot-chrome .dc-annot-ibtn:focus-visible {
|
|
1203
|
-
outline: 2px solid #ffffff;
|
|
1204
|
-
outline-offset: -2px;
|
|
1205
|
-
}
|
|
1206
|
-
@media (prefers-reduced-motion: reduce) {
|
|
1207
|
-
.dc-annot-chrome .dc-annot-ibtn, .dc-annot-chrome .dc-annot-sw, .dc-annot-chrome .dc-annot-fill { transition: none; }
|
|
1208
|
-
}
|
|
1209
|
-
.dc-annot-input {
|
|
1210
|
-
position: absolute;
|
|
1211
|
-
inset: 0;
|
|
1212
|
-
z-index: 4;
|
|
1213
|
-
}
|
|
1214
|
-
.dc-annot-svg {
|
|
1215
|
-
position: absolute;
|
|
1216
|
-
left: 0;
|
|
1217
|
-
top: 0;
|
|
1218
|
-
/*
|
|
1219
|
-
* .dc-world has no intrinsic dimensions — its children render via absolute
|
|
1220
|
-
* positioning. An SVG inside with width:100%/height:100% resolves to 0 px
|
|
1221
|
-
* and Chrome clips children even under overflow:visible. We hardcode a
|
|
1222
|
-
* very large width/height instead so the SVG viewport easily covers any
|
|
1223
|
-
* world-coord stroke. vector-effect="non-scaling-stroke" on every stroke
|
|
1224
|
-
* keeps thickness px-constant under CSS zoom; overflow:visible covers the
|
|
1225
|
-
* rare edge case of a stroke straying outside this 200k box.
|
|
1226
|
-
*/
|
|
1227
|
-
width: 200000px;
|
|
1228
|
-
height: 200000px;
|
|
1229
|
-
overflow: visible;
|
|
1230
|
-
pointer-events: none;
|
|
1231
|
-
}
|
|
1232
|
-
/* Drag-select marquee — rendered while user is dragging to select strokes. */
|
|
1233
|
-
.dc-annot-marquee {
|
|
1234
|
-
pointer-events: none;
|
|
1235
|
-
fill: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
|
|
1236
|
-
stroke: var(--maude-hud-accent, #d63b1f);
|
|
1237
|
-
stroke-width: 1;
|
|
1238
|
-
stroke-dasharray: 4 3;
|
|
1239
|
-
}
|
|
1240
|
-
/* Phase 24 — sticky-note body. Word-wrapped multi-line text inside the card's
|
|
1241
|
-
foreignObject. Text sits TOP-LEFT (FigJam parity); the editor contentEditable
|
|
1242
|
-
mirrors the same box metrics so the read-edit swap doesn't shift the text.
|
|
1243
|
-
text-align is overridden inline per-sticky when align is not left. */
|
|
1244
|
-
.dc-sticky-body {
|
|
1245
|
-
width: 100%;
|
|
1246
|
-
height: 100%;
|
|
1247
|
-
box-sizing: border-box;
|
|
1248
|
-
padding: 14px 16px;
|
|
1249
|
-
display: flex;
|
|
1250
|
-
align-items: flex-start;
|
|
1251
|
-
justify-content: flex-start;
|
|
1252
|
-
text-align: left;
|
|
1253
|
-
color: #2a2a28;
|
|
1254
|
-
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
1255
|
-
line-height: 1.35;
|
|
1256
|
-
white-space: pre-wrap;
|
|
1257
|
-
overflow-wrap: anywhere;
|
|
1258
|
-
overflow: hidden;
|
|
1259
|
-
}
|
|
1260
|
-
/* Phase 24 — while editing an annotation's text (a text label OR a sticky body,
|
|
1261
|
-
both carry the dc-annot-editor class), force the I-beam. The important flag
|
|
1262
|
-
plus the class selector beat use-tool-mode's blanket star-cursor rule (you
|
|
1263
|
-
usually open the editor from MOVE mode, whose move glyph would otherwise sit
|
|
1264
|
-
over the text you're typing into). The element's inline text cursor can't win
|
|
1265
|
-
that fight on its own — a non-important inline style loses to !important.
|
|
1266
|
-
See DDR-067. (No backticks in this comment: the whole block is a JS template
|
|
1267
|
-
literal, so a backtick here would terminate the string.) */
|
|
1268
|
-
.dc-annot-editor, .dc-annot-editor * { cursor: text !important; }
|
|
1269
|
-
`.trim();
|
|
1270
|
-
|
|
1271
|
-
function ensureAnnotStyles(): void {
|
|
1272
|
-
if (typeof document === 'undefined') return;
|
|
1273
|
-
if (document.getElementById('dc-annot-css')) return;
|
|
1274
|
-
const s = document.createElement('style');
|
|
1275
|
-
s.id = 'dc-annot-css';
|
|
1276
|
-
s.textContent = ANNOT_CSS;
|
|
1277
|
-
document.head.appendChild(s);
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
1281
|
-
// Strokes store — lifted out of the layer so the contextual toolbar (Phase 5.1
|
|
1282
|
-
// Task 8) can mutate strokes without prop-drilling.
|
|
1283
|
-
|
|
1284
|
-
export interface StrokesStoreValue {
|
|
1285
|
-
strokes: Stroke[];
|
|
1286
|
-
setStrokes: (next: Stroke[]) => void;
|
|
1287
|
-
updateStroke: (id: string, patch: Partial<Stroke>) => void;
|
|
1288
|
-
deleteStrokes: (ids: string[]) => void;
|
|
1289
|
-
translateStrokes: (ids: string[], dx: number, dy: number) => void;
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
const StrokesStoreContext = createContext<StrokesStoreValue | null>(null);
|
|
1293
|
-
|
|
1294
|
-
export function useStrokesStore(): StrokesStoreValue | null {
|
|
1295
|
-
return useContext(StrokesStoreContext);
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
function translateOne(s: Stroke, dx: number, dy: number): Stroke {
|
|
1299
|
-
if (s.tool === 'pen') {
|
|
1300
|
-
return { ...s, points: s.points.map(([x, y]) => [x + dx, y + dy] as WorldPoint) };
|
|
1301
|
-
}
|
|
1302
|
-
if (s.tool === 'rect' || s.tool === 'polygon') return { ...s, x: s.x + dx, y: s.y + dy };
|
|
1303
|
-
if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
|
|
1304
|
-
if (s.tool === 'arrow')
|
|
1305
|
-
return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
|
|
1306
|
-
if (s.tool === 'sticky') return { ...s, x: s.x + dx, y: s.y + dy };
|
|
1307
|
-
// text — anchored inherits its host's bbox (moves with the host); standalone
|
|
1308
|
-
// (Phase 21) carries its own world (x, y) and translates directly.
|
|
1309
|
-
if (s.anchorId != null && s.anchorId !== '') return s;
|
|
1310
|
-
return { ...s, x: (s.x ?? 0) + dx, y: (s.y ?? 0) + dy };
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
/**
|
|
1314
|
-
* Reference-equal stroke comparison — true when the two arrays carry the same
|
|
1315
|
-
* stroke object references in the same order. Used by the annotation drag
|
|
1316
|
-
* onPointerUp to skip pushing an undo record when the gesture didn't actually
|
|
1317
|
-
* move anything (zero movement OR snapshot mapped through a no-op translate
|
|
1318
|
-
* back to the original references — `translateOne` short-circuits when dx=dy=0
|
|
1319
|
-
* because new objects are still created, so we compare references defensively).
|
|
1320
|
-
*/
|
|
1321
|
-
export function strokesShallowEqual(a: readonly Stroke[], b: readonly Stroke[]): boolean {
|
|
1322
|
-
if (a === b) return true;
|
|
1323
|
-
if (a.length !== b.length) return false;
|
|
1324
|
-
for (let i = 0; i < a.length; i++) {
|
|
1325
|
-
if (a[i] !== b[i]) return false;
|
|
1326
|
-
}
|
|
1327
|
-
return true;
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
// Annotations visibility now lives in use-annotations-visibility.tsx so the
|
|
1331
|
-
// ToolPalette (a sibling under CanvasRouter, not a descendant of this layer)
|
|
1332
|
-
// can read the same state. Re-exported here for back-compat.
|
|
1333
|
-
export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
1334
|
-
|
|
1335
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
1336
|
-
// Component
|
|
1337
|
-
|
|
1338
|
-
export function AnnotationsLayer() {
|
|
1339
|
-
ensureAnnotStyles();
|
|
1340
|
-
const { tool, setTool, sticky, tools, shapeKind } = useToolMode();
|
|
1341
|
-
const controller = useViewportControllerContext();
|
|
1342
|
-
const vp = controller?.viewport ?? null;
|
|
1343
|
-
const worldRef = useWorldRefContext();
|
|
1344
|
-
const annotSel = useAnnotationSelectionOptional();
|
|
1345
|
-
const elementSel = useSelectionSetOptional();
|
|
1346
|
-
|
|
1347
|
-
const [strokes, setStrokesState] = useState<Stroke[]>([]);
|
|
1348
|
-
const [drawing, setDrawing] = useState<Stroke | null>(null);
|
|
1349
|
-
const [color, setColor] = useState<string>(DEFAULT_COLOR);
|
|
1350
|
-
const [fill, setFill] = useState<string | null>(null);
|
|
1351
|
-
const [thickness, setThickness] = useState<Thickness>(STROKE_WIDTH_THIN);
|
|
1352
|
-
// Phase 21 — draw-time paper tint for the sticky tool (recolor-after via the
|
|
1353
|
-
// context toolbar). Separate from `color` (ink for pen/rect/text/arrow).
|
|
1354
|
-
const [stickyColor, setStickyColor] = useState<string>(DEFAULT_STICKY_COLOR);
|
|
1355
|
-
// Phase 21 — a standalone-text caret waiting for its first keystroke. No
|
|
1356
|
-
// stroke exists yet (mirrors anchored text: the stroke is born on commit,
|
|
1357
|
-
// so an abandoned empty caret leaves nothing behind / no undo record).
|
|
1358
|
-
const [pendingText, setPendingText] = useState<{ x: number; y: number } | null>(null);
|
|
1359
|
-
// Phase 24 — ghost placeholder: world coords the cursor is hovering while a
|
|
1360
|
-
// shape/sticky/text tool is armed and nothing is being drawn yet. Pure chrome
|
|
1361
|
-
// (low-opacity, pointer-events:none) — never selectable, hit-tested, or saved.
|
|
1362
|
-
const [ghost, setGhost] = useState<{ x: number; y: number } | null>(null);
|
|
1363
|
-
const visibilityCtx = useAnnotationsVisibility();
|
|
1364
|
-
const visible = visibilityCtx?.visible ?? true;
|
|
1365
|
-
const setVisible = useCallback(
|
|
1366
|
-
(next: boolean | ((cur: boolean) => boolean)) => {
|
|
1367
|
-
if (!visibilityCtx) return;
|
|
1368
|
-
const v =
|
|
1369
|
-
typeof next === 'function'
|
|
1370
|
-
? (next as (cur: boolean) => boolean)(visibilityCtx.visible)
|
|
1371
|
-
: next;
|
|
1372
|
-
visibilityCtx.setVisible(v);
|
|
1373
|
-
},
|
|
1374
|
-
[visibilityCtx]
|
|
1375
|
-
);
|
|
1376
|
-
const [editingId, setEditingId] = useState<string | null>(null);
|
|
1377
|
-
|
|
1378
|
-
const fileRef = useRef<string | undefined>(undefined);
|
|
1379
|
-
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1380
|
-
const drawingRef = useRef<Stroke | null>(null);
|
|
1381
|
-
drawingRef.current = drawing;
|
|
1382
|
-
// Phase 24 — pointer-down anchor + last cursor (world coords) for the active
|
|
1383
|
-
// draw, so the resize modifiers (Shift 1:1 / Alt from-center) can re-constrain
|
|
1384
|
-
// the draft on a bare keydown/keyup, not just on pointer-move.
|
|
1385
|
-
const drawAnchorRef = useRef<{ x: number; y: number } | null>(null);
|
|
1386
|
-
const lastDrawPointRef = useRef<{ x: number; y: number } | null>(null);
|
|
1387
|
-
/**
|
|
1388
|
-
* Phase 20 — latest strokes mirror so command builders can read the
|
|
1389
|
-
* pre-mutation snapshot synchronously (React state isn't refreshed
|
|
1390
|
-
* between rapid taps in the same tick).
|
|
1391
|
-
*/
|
|
1392
|
-
const strokesRef = useRef<Stroke[]>(strokes);
|
|
1393
|
-
strokesRef.current = strokes;
|
|
1394
|
-
|
|
1395
|
-
const isDraw =
|
|
1396
|
-
tool === 'pen' || tool === 'shape' || tool === 'arrow' || tool === 'sticky' || tool === 'text';
|
|
1397
|
-
const isErase = tool === 'eraser';
|
|
1398
|
-
const isActive = isDraw || isErase;
|
|
1399
|
-
// T20 / Phase 24 — every shape primitive carries stroke weight (FigJam ships
|
|
1400
|
-
// thickness on all of them). The annotation toolbar reads supportsThickness
|
|
1401
|
-
// to decide whether to render the Thin / Thick chips.
|
|
1402
|
-
const supportsThickness = tool === 'pen' || tool === 'arrow' || tool === 'shape';
|
|
1403
|
-
const supportsFill = tool === 'shape';
|
|
1404
|
-
// Phase 24 — tools that show a cursor-following ghost placeholder.
|
|
1405
|
-
const ghostCapable = tool === 'shape' || tool === 'sticky' || tool === 'text';
|
|
1406
|
-
|
|
1407
|
-
// Clear the ghost when the active tool stops being ghost-capable (or
|
|
1408
|
-
// visibility toggles) so a stale ghost never lingers after a tool change.
|
|
1409
|
-
useEffect(() => {
|
|
1410
|
-
if (!ghostCapable || !visible) setGhost(null);
|
|
1411
|
-
}, [ghostCapable, visible]);
|
|
1412
|
-
|
|
1413
|
-
// Load existing annotations on mount.
|
|
1414
|
-
// Phase 8 Task 5 — seed lastAppliedSvgRef so the first Y.Map observe (when
|
|
1415
|
-
// collab connects shortly after this fetch lands) doesn't re-apply the
|
|
1416
|
-
// same content we just hydrated from REST.
|
|
1417
|
-
const lastAppliedSvgRef = useRef<string>('');
|
|
1418
|
-
useEffect(() => {
|
|
1419
|
-
const file = deriveFile();
|
|
1420
|
-
fileRef.current = file;
|
|
1421
|
-
if (!file) return;
|
|
1422
|
-
let cancelled = false;
|
|
1423
|
-
void fetch(`/_api/annotations?file=${encodeURIComponent(file)}`, {
|
|
1424
|
-
headers: { Accept: 'image/svg+xml' },
|
|
1425
|
-
})
|
|
1426
|
-
.then((r) => (r.ok ? r.text() : ''))
|
|
1427
|
-
.then((text) => {
|
|
1428
|
-
if (cancelled) return;
|
|
1429
|
-
const loaded = svgToStrokes(text);
|
|
1430
|
-
if (loaded.length) {
|
|
1431
|
-
setStrokesState(loaded);
|
|
1432
|
-
lastAppliedSvgRef.current = text;
|
|
1433
|
-
}
|
|
1434
|
-
})
|
|
1435
|
-
.catch(() => {
|
|
1436
|
-
/* network blip — start with an empty annotation set */
|
|
1437
|
-
});
|
|
1438
|
-
return () => {
|
|
1439
|
-
cancelled = true;
|
|
1440
|
-
};
|
|
1441
|
-
}, []);
|
|
1442
|
-
|
|
1443
|
-
// Phase 8 Task 5 — observe the Y.Map.annotations for live updates from
|
|
1444
|
-
// other tabs. Bail when the incoming SVG STRING is identical to the one
|
|
1445
|
-
// we last applied (covers the local echo round-trip without missing real
|
|
1446
|
-
// foreign changes). The prior length+first/last-id check was wrong: a
|
|
1447
|
-
// resize / move keeps the same id list, so all three predicates matched
|
|
1448
|
-
// even though geometry changed — foreign edits silently disappeared.
|
|
1449
|
-
const collab = useCollab();
|
|
1450
|
-
useEffect(() => {
|
|
1451
|
-
if (!collab) return;
|
|
1452
|
-
const map = collab.doc.getMap<string>('annotations');
|
|
1453
|
-
const apply = () => {
|
|
1454
|
-
const svg = map.get('svg');
|
|
1455
|
-
if (typeof svg !== 'string' || !svg) return;
|
|
1456
|
-
if (svg === lastAppliedSvgRef.current) return;
|
|
1457
|
-
lastAppliedSvgRef.current = svg;
|
|
1458
|
-
setStrokesState(svgToStrokes(svg));
|
|
1459
|
-
};
|
|
1460
|
-
apply();
|
|
1461
|
-
map.observe(apply);
|
|
1462
|
-
return () => {
|
|
1463
|
-
try {
|
|
1464
|
-
map.unobserve(apply);
|
|
1465
|
-
} catch {
|
|
1466
|
-
/* doc destroyed before unmount */
|
|
1467
|
-
}
|
|
1468
|
-
};
|
|
1469
|
-
}, [collab]);
|
|
1470
|
-
|
|
1471
|
-
const undoStack = useUndoStackOptional();
|
|
1472
|
-
const undoSinks = useUndoSinks();
|
|
1473
|
-
const undoStackRef = useRef(undoStack);
|
|
1474
|
-
undoStackRef.current = undoStack;
|
|
1475
|
-
|
|
1476
|
-
/**
|
|
1477
|
-
* Apply a `Stroke[]` snapshot: update local React state AND fire-and-forget
|
|
1478
|
-
* PUT to the server. Used as the `putFn` injected into the
|
|
1479
|
-
* `AnnotationStrokesCommand` — both the initial push AND every undo/redo
|
|
1480
|
-
* replay route through here, so the iframe's `strokes` state always
|
|
1481
|
-
* tracks the server. (Without the setStrokesState here, Cmd+Z would
|
|
1482
|
-
* silently PUT the prior SVG but the canvas would keep painting the
|
|
1483
|
-
* post-edit strokes until the user reloaded.)
|
|
1484
|
-
*
|
|
1485
|
-
* The 200 ms scheduled-save debounce (legacy path) is cleared the moment
|
|
1486
|
-
* we push a command, so the server only sees one PUT per edit instead
|
|
1487
|
-
* of two-step racing.
|
|
1488
|
-
*/
|
|
1489
|
-
const putStrokes = useCallback((next: readonly Stroke[]) => {
|
|
1490
|
-
setStrokesState(next as Stroke[]);
|
|
1491
|
-
const file = fileRef.current;
|
|
1492
|
-
if (!file) return Promise.resolve();
|
|
1493
|
-
const svg = strokesToSvg(next);
|
|
1494
|
-
// Phase 8 Task 5 — record the SVG we just authored locally so the
|
|
1495
|
-
// server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
|
|
1496
|
-
// Y.Map.observe) doesn't trigger a redundant setStrokesState.
|
|
1497
|
-
lastAppliedSvgRef.current = svg;
|
|
1498
|
-
return fetch('/_api/annotations', {
|
|
1499
|
-
method: 'PUT',
|
|
1500
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1501
|
-
body: JSON.stringify({ file, svg }),
|
|
1502
|
-
})
|
|
1503
|
-
.then(() => undefined)
|
|
1504
|
-
.catch(() => {
|
|
1505
|
-
/* swallow — user sees uncommitted state until the next stroke */
|
|
1506
|
-
});
|
|
1507
|
-
}, []);
|
|
1508
|
-
|
|
1509
|
-
// Register the strokes put sink with the undo provider so the rebuilt
|
|
1510
|
-
// AnnotationStrokesCommand (after a canvas switch + return) routes through
|
|
1511
|
-
// THIS iframe's React state, not the gone iframe's stale closures.
|
|
1512
|
-
useEffect(() => {
|
|
1513
|
-
undoSinks.setSink('strokesPutFn', putStrokes);
|
|
1514
|
-
return () => undoSinks.setSink('strokesPutFn', undefined);
|
|
1515
|
-
}, [undoSinks, putStrokes]);
|
|
1516
|
-
|
|
1517
|
-
/**
|
|
1518
|
-
* Single entry point for every stroke mutation. Builds an undo record
|
|
1519
|
-
* and pushes onto the stack — `push()` rebuilds the command via the
|
|
1520
|
-
* registered `strokesPutFn` sink and calls `cmd.do() = putStrokes(next)`,
|
|
1521
|
-
* which both updates local state and PUTs. Cancels any pending debounced
|
|
1522
|
-
* save first — DDR-050 gotcha: a queued auto-save flushing AFTER our PUT
|
|
1523
|
-
* would race the stack into a stale state.
|
|
1524
|
-
*/
|
|
1525
|
-
const commitStrokes = useCallback(
|
|
1526
|
-
(prev: readonly Stroke[], next: readonly Stroke[], label?: string) => {
|
|
1527
|
-
if (saveTimerRef.current) {
|
|
1528
|
-
clearTimeout(saveTimerRef.current);
|
|
1529
|
-
saveTimerRef.current = null;
|
|
1530
|
-
}
|
|
1531
|
-
const record = buildAnnotationStrokesRecord({
|
|
1532
|
-
before: prev,
|
|
1533
|
-
after: next,
|
|
1534
|
-
...(label ? { label } : {}),
|
|
1535
|
-
});
|
|
1536
|
-
void undoStackRef.current.push(record);
|
|
1537
|
-
},
|
|
1538
|
-
[]
|
|
1539
|
-
);
|
|
1540
|
-
|
|
1541
|
-
const setStrokes = useCallback(
|
|
1542
|
-
(next: Stroke[]) => {
|
|
1543
|
-
const prev = strokesRef.current;
|
|
1544
|
-
commitStrokes(prev, next);
|
|
1545
|
-
},
|
|
1546
|
-
[commitStrokes]
|
|
1547
|
-
);
|
|
1548
|
-
|
|
1549
|
-
const strokesStore = useMemo<StrokesStoreValue>(() => {
|
|
1550
|
-
const updateStroke = (id: string, patch: Partial<Stroke>): void => {
|
|
1551
|
-
const prev = strokesRef.current;
|
|
1552
|
-
const next = prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s));
|
|
1553
|
-
commitStrokes(prev, next);
|
|
1554
|
-
};
|
|
1555
|
-
const deleteStrokes = (ids: string[]): void => {
|
|
1556
|
-
const set = new Set(ids);
|
|
1557
|
-
const prev = strokesRef.current;
|
|
1558
|
-
const next = prev.filter(
|
|
1559
|
-
(s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
|
|
1560
|
-
);
|
|
1561
|
-
if (next.length === prev.length) return;
|
|
1562
|
-
commitStrokes(prev, next);
|
|
1563
|
-
};
|
|
1564
|
-
const translateStrokes = (ids: string[], dx: number, dy: number): void => {
|
|
1565
|
-
const set = new Set(ids);
|
|
1566
|
-
const prev = strokesRef.current;
|
|
1567
|
-
const next = prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s));
|
|
1568
|
-
commitStrokes(prev, next, `move ${ids.length} stroke${ids.length === 1 ? '' : 's'}`);
|
|
1569
|
-
};
|
|
1570
|
-
return {
|
|
1571
|
-
strokes,
|
|
1572
|
-
setStrokes,
|
|
1573
|
-
updateStroke,
|
|
1574
|
-
deleteStrokes,
|
|
1575
|
-
translateStrokes,
|
|
1576
|
-
};
|
|
1577
|
-
}, [strokes, setStrokes, commitStrokes]);
|
|
1578
|
-
|
|
1579
|
-
// Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
|
|
1580
|
-
// dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
|
|
1581
|
-
// (those providers are above us); we own visibility + annotation-select-all
|
|
1582
|
-
// because they read this layer's local state.
|
|
1583
|
-
useEffect(() => {
|
|
1584
|
-
if (typeof window === 'undefined') return;
|
|
1585
|
-
const onMessage = (e: MessageEvent) => {
|
|
1586
|
-
const m = e.data as { dgn?: string; visible?: boolean } | null;
|
|
1587
|
-
if (!m || typeof m !== 'object' || !m.dgn) return;
|
|
1588
|
-
if (m.dgn === 'view-annotations') {
|
|
1589
|
-
if (typeof m.visible === 'boolean') setVisible(m.visible);
|
|
1590
|
-
return;
|
|
1591
|
-
}
|
|
1592
|
-
if (m.dgn === 'annotation-select-all') {
|
|
1593
|
-
if (annotSel) annotSel.replace(strokes.map((s) => s.id));
|
|
1594
|
-
}
|
|
1595
|
-
};
|
|
1596
|
-
window.addEventListener('message', onMessage);
|
|
1597
|
-
return () => window.removeEventListener('message', onMessage);
|
|
1598
|
-
}, [annotSel, strokes, setVisible]);
|
|
1599
|
-
|
|
1600
|
-
// Document-level toggle: Shift+P (presentation). Annotation-shortcut help is
|
|
1601
|
-
// owned by the dev-server menubar (Help button); we no longer ship an
|
|
1602
|
-
// in-canvas help dialog from this layer.
|
|
1603
|
-
useEffect(() => {
|
|
1604
|
-
if (typeof document === 'undefined') return;
|
|
1605
|
-
const onKey = (e: KeyboardEvent) => {
|
|
1606
|
-
if (isEditable(e.target)) return;
|
|
1607
|
-
if (e.key === 'P' && e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
1608
|
-
e.preventDefault();
|
|
1609
|
-
setVisible((v) => !v);
|
|
1610
|
-
}
|
|
1611
|
-
};
|
|
1612
|
-
document.addEventListener('keydown', onKey, true);
|
|
1613
|
-
return () => document.removeEventListener('keydown', onKey, true);
|
|
1614
|
-
}, [setVisible]);
|
|
1615
|
-
|
|
1616
|
-
const screenToWorld = useCallback(
|
|
1617
|
-
(cx: number, cy: number): [number, number] => {
|
|
1618
|
-
const v = vp ?? { x: 0, y: 0, zoom: 1 };
|
|
1619
|
-
const z = v.zoom || 1;
|
|
1620
|
-
return [(cx - v.x) / z, (cy - v.y) / z];
|
|
1621
|
-
},
|
|
1622
|
-
[vp]
|
|
1623
|
-
);
|
|
1624
|
-
|
|
1625
|
-
const eraseAt = useCallback(
|
|
1626
|
-
(wx: number, wy: number) => {
|
|
1627
|
-
const zoom = vp?.zoom || 1;
|
|
1628
|
-
const tol = 8 / zoom;
|
|
1629
|
-
const prev = strokesRef.current;
|
|
1630
|
-
for (let i = prev.length - 1; i >= 0; i--) {
|
|
1631
|
-
const candidate = prev[i];
|
|
1632
|
-
if (candidate && strokeHitTest(candidate, wx, wy, tol)) {
|
|
1633
|
-
const removedId = candidate.id;
|
|
1634
|
-
const next = prev
|
|
1635
|
-
.slice(0, i)
|
|
1636
|
-
.concat(prev.slice(i + 1))
|
|
1637
|
-
.filter((s) => !(s.tool === 'text' && s.anchorId === removedId));
|
|
1638
|
-
commitStrokes(prev, next, 'erase 1 stroke');
|
|
1639
|
-
return;
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
},
|
|
1643
|
-
[vp, commitStrokes]
|
|
1644
|
-
);
|
|
1645
|
-
|
|
1646
|
-
const beginStroke = useCallback(
|
|
1647
|
-
(e: ReactPointerEvent<HTMLDivElement>, spaceHeld: boolean) => {
|
|
1648
|
-
if (!isActive || !visible) return false;
|
|
1649
|
-
if (e.button !== 0) return false;
|
|
1650
|
-
if (spaceHeld) return false;
|
|
1651
|
-
if (e.metaKey || e.ctrlKey) return false;
|
|
1652
|
-
// We do NOT stopPropagation — viewport-controller listens on the host
|
|
1653
|
-
// ancestor and never claims a bare-left/no-space pointerdown anyway.
|
|
1654
|
-
e.preventDefault();
|
|
1655
|
-
setGhost(null); // a draw is starting — the ghost placeholder is done
|
|
1656
|
-
try {
|
|
1657
|
-
(e.target as Element & { setPointerCapture?: (id: number) => void }).setPointerCapture?.(
|
|
1658
|
-
e.pointerId
|
|
1659
|
-
);
|
|
1660
|
-
} catch {
|
|
1661
|
-
/* some browsers reject capture on synthetic events */
|
|
1662
|
-
}
|
|
1663
|
-
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1664
|
-
drawAnchorRef.current = { x: wx, y: wy };
|
|
1665
|
-
lastDrawPointRef.current = { x: wx, y: wy };
|
|
1666
|
-
if (isErase) {
|
|
1667
|
-
eraseAt(wx, wy);
|
|
1668
|
-
return true;
|
|
1669
|
-
}
|
|
1670
|
-
const id = rid();
|
|
1671
|
-
const width: number = supportsThickness ? thickness : STROKE_WIDTH_THIN;
|
|
1672
|
-
const activeFill = supportsFill ? fill : null;
|
|
1673
|
-
if (tool === 'pen') {
|
|
1674
|
-
setDrawing({ id, tool: 'pen', color, width, points: [[wx, wy]] });
|
|
1675
|
-
} else if (tool === 'shape') {
|
|
1676
|
-
// Phase 24 — the single Shape tool maps its kind onto a stroke type:
|
|
1677
|
-
// circle → ellipse; square/rounded → rect (cornerRadius 0 / 8);
|
|
1678
|
-
// diamond/triangle/triangle-down → polygon.
|
|
1679
|
-
if (shapeKind === 'circle') {
|
|
1680
|
-
setDrawing({
|
|
1681
|
-
id,
|
|
1682
|
-
tool: 'ellipse',
|
|
1683
|
-
color,
|
|
1684
|
-
width,
|
|
1685
|
-
cx: wx,
|
|
1686
|
-
cy: wy,
|
|
1687
|
-
rx: 0,
|
|
1688
|
-
ry: 0,
|
|
1689
|
-
fill: activeFill,
|
|
1690
|
-
});
|
|
1691
|
-
} else if (shapeKind === 'square' || shapeKind === 'rounded') {
|
|
1692
|
-
setDrawing({
|
|
1693
|
-
id,
|
|
1694
|
-
tool: 'rect',
|
|
1695
|
-
color,
|
|
1696
|
-
width,
|
|
1697
|
-
x: wx,
|
|
1698
|
-
y: wy,
|
|
1699
|
-
w: 0,
|
|
1700
|
-
h: 0,
|
|
1701
|
-
fill: activeFill,
|
|
1702
|
-
cornerRadius: shapeKind === 'rounded' ? 8 : 0,
|
|
1703
|
-
});
|
|
1704
|
-
} else {
|
|
1705
|
-
setDrawing({
|
|
1706
|
-
id,
|
|
1707
|
-
tool: 'polygon',
|
|
1708
|
-
shape: shapeKind,
|
|
1709
|
-
color,
|
|
1710
|
-
width,
|
|
1711
|
-
x: wx,
|
|
1712
|
-
y: wy,
|
|
1713
|
-
w: 0,
|
|
1714
|
-
h: 0,
|
|
1715
|
-
fill: activeFill,
|
|
1716
|
-
});
|
|
1717
|
-
}
|
|
1718
|
-
} else if (tool === 'arrow') {
|
|
1719
|
-
setDrawing({
|
|
1720
|
-
id,
|
|
1721
|
-
tool: 'arrow',
|
|
1722
|
-
color,
|
|
1723
|
-
width,
|
|
1724
|
-
x1: wx,
|
|
1725
|
-
y1: wy,
|
|
1726
|
-
x2: wx,
|
|
1727
|
-
y2: wy,
|
|
1728
|
-
});
|
|
1729
|
-
} else if (tool === 'sticky') {
|
|
1730
|
-
// Phase 21 — drag-create a paper card. Default size if the user just
|
|
1731
|
-
// taps (no drag) is applied in endStroke.
|
|
1732
|
-
setDrawing({
|
|
1733
|
-
id,
|
|
1734
|
-
tool: 'sticky',
|
|
1735
|
-
color: stickyColor,
|
|
1736
|
-
x: wx,
|
|
1737
|
-
y: wy,
|
|
1738
|
-
w: 0,
|
|
1739
|
-
h: 0,
|
|
1740
|
-
text: '',
|
|
1741
|
-
fontSize: DEFAULT_FONT_SIZE,
|
|
1742
|
-
cornerRadius: STICKY_CORNER_RADIUS,
|
|
1743
|
-
});
|
|
1744
|
-
} else if (tool === 'text') {
|
|
1745
|
-
// Phase 21 — single click drops an editable caret at the click point.
|
|
1746
|
-
// No stroke is created until the user commits real text (mirrors the
|
|
1747
|
-
// anchored double-click flow), so an empty caret leaves nothing behind.
|
|
1748
|
-
setPendingText({ x: wx, y: wy });
|
|
1749
|
-
if (annotSel) annotSel.clear();
|
|
1750
|
-
const stickyOnText = sticky.locked && sticky.tool === 'text';
|
|
1751
|
-
if (!stickyOnText) setTool('move');
|
|
1752
|
-
return true;
|
|
1753
|
-
}
|
|
1754
|
-
return true;
|
|
1755
|
-
},
|
|
1756
|
-
[
|
|
1757
|
-
tool,
|
|
1758
|
-
shapeKind,
|
|
1759
|
-
color,
|
|
1760
|
-
fill,
|
|
1761
|
-
thickness,
|
|
1762
|
-
stickyColor,
|
|
1763
|
-
supportsThickness,
|
|
1764
|
-
supportsFill,
|
|
1765
|
-
isActive,
|
|
1766
|
-
isErase,
|
|
1767
|
-
visible,
|
|
1768
|
-
screenToWorld,
|
|
1769
|
-
eraseAt,
|
|
1770
|
-
annotSel,
|
|
1771
|
-
sticky,
|
|
1772
|
-
setTool,
|
|
1773
|
-
]
|
|
1774
|
-
);
|
|
1775
|
-
|
|
1776
|
-
const moveStroke = useCallback(
|
|
1777
|
-
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1778
|
-
if (!isActive || !visible) return;
|
|
1779
|
-
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1780
|
-
if (isErase) {
|
|
1781
|
-
if ((e.buttons & 1) === 0) return;
|
|
1782
|
-
eraseAt(wx, wy);
|
|
1783
|
-
return;
|
|
1784
|
-
}
|
|
1785
|
-
// Phase 24 — ghost placeholder. While nothing is being drawn, track the
|
|
1786
|
-
// cursor so a translucent preview can follow it; an active draw hides it.
|
|
1787
|
-
if (drawingRef.current == null) {
|
|
1788
|
-
if (ghostCapable) setGhost({ x: wx, y: wy });
|
|
1789
|
-
return;
|
|
1790
|
-
}
|
|
1791
|
-
setGhost(null);
|
|
1792
|
-
lastDrawPointRef.current = { x: wx, y: wy };
|
|
1793
|
-
const anchor = drawAnchorRef.current;
|
|
1794
|
-
const mods: DrawMods = { shift: e.shiftKey, alt: e.altKey };
|
|
1795
|
-
setDrawing((cur) => {
|
|
1796
|
-
if (!cur) return cur;
|
|
1797
|
-
if (cur.tool === 'pen') {
|
|
1798
|
-
const last = cur.points[cur.points.length - 1] as WorldPoint | undefined;
|
|
1799
|
-
if (last && Math.hypot(wx - last[0], wy - last[1]) < 1) return cur;
|
|
1800
|
-
return { ...cur, points: [...cur.points, [wx, wy] as WorldPoint] };
|
|
1801
|
-
}
|
|
1802
|
-
// Phase 24 — Shift (1:1) / Alt (from-center) apply to every box + arrow
|
|
1803
|
-
// shape, mirroring the resize handles. The anchor is the pointer-down
|
|
1804
|
-
// point; without it (shouldn't happen mid-draw) fall back to no change.
|
|
1805
|
-
if (!anchor) return cur;
|
|
1806
|
-
return applyDrawModifiers(cur, anchor, wx, wy, mods);
|
|
1807
|
-
});
|
|
1808
|
-
},
|
|
1809
|
-
[isActive, isErase, visible, screenToWorld, eraseAt, ghostCapable]
|
|
1810
|
-
);
|
|
1811
|
-
|
|
1812
|
-
const endStroke = useCallback(() => {
|
|
1813
|
-
if (!isActive || !visible) return;
|
|
1814
|
-
if (isErase) return;
|
|
1815
|
-
const cur = drawingRef.current;
|
|
1816
|
-
if (!cur) return;
|
|
1817
|
-
let final: Stroke | null = cur;
|
|
1818
|
-
// Phase 24 — a bare tap (both axes below the drag threshold) drops a
|
|
1819
|
-
// default-sized shape at the tap point so "click to place" works like
|
|
1820
|
-
// FigJam; a real drag sizes it. A thin/degenerate drag still gets discarded
|
|
1821
|
-
// by isStrokeMeaningful below.
|
|
1822
|
-
const isTap = (w: number, h: number) => Math.abs(w) < 4 && Math.abs(h) < 4;
|
|
1823
|
-
if (cur.tool === 'rect') {
|
|
1824
|
-
const norm = normalizeRect(cur);
|
|
1825
|
-
final = isTap(norm.w, norm.h)
|
|
1826
|
-
? { ...norm, x: cur.x, y: cur.y, w: SHAPE_DEFAULT_SIZE, h: SHAPE_DEFAULT_SIZE }
|
|
1827
|
-
: norm;
|
|
1828
|
-
} else if (cur.tool === 'polygon') {
|
|
1829
|
-
const norm = normalizeBox(cur);
|
|
1830
|
-
final = isTap(norm.w, norm.h)
|
|
1831
|
-
? { ...norm, x: cur.x, y: cur.y, w: SHAPE_DEFAULT_SIZE, h: SHAPE_DEFAULT_SIZE }
|
|
1832
|
-
: norm;
|
|
1833
|
-
} else if (cur.tool === 'ellipse') {
|
|
1834
|
-
final =
|
|
1835
|
-
cur.rx < 2 && cur.ry < 2
|
|
1836
|
-
? { ...cur, rx: SHAPE_DEFAULT_SIZE / 2, ry: SHAPE_DEFAULT_SIZE / 2 }
|
|
1837
|
-
: cur;
|
|
1838
|
-
} else if (cur.tool === 'sticky') {
|
|
1839
|
-
const norm = normalizeSticky(cur);
|
|
1840
|
-
// A bare tap (or a drag too small to be a usable card) drops a
|
|
1841
|
-
// default-sized note at the tap point — FigJam parity.
|
|
1842
|
-
final =
|
|
1843
|
-
Math.abs(norm.w) < STICKY_MIN_SIZE || Math.abs(norm.h) < STICKY_MIN_SIZE
|
|
1844
|
-
? { ...norm, w: STICKY_DEFAULT_W, h: STICKY_DEFAULT_H }
|
|
1845
|
-
: norm;
|
|
1846
|
-
}
|
|
1847
|
-
if (final && !isStrokeMeaningful(final)) final = null;
|
|
1848
|
-
if (final) {
|
|
1849
|
-
const committed = final;
|
|
1850
|
-
const prev = strokesRef.current;
|
|
1851
|
-
const next = [...prev, committed];
|
|
1852
|
-
commitStrokes(prev, next, `draw ${committed.tool}`);
|
|
1853
|
-
// T18 — auto-select the freshly drawn shape so the user can immediately
|
|
1854
|
-
// see + adjust it. annotSel is optional (some test harnesses mount
|
|
1855
|
-
// AnnotationsLayer without the provider), so guard the call.
|
|
1856
|
-
if (annotSel) annotSel.replace(committed.id);
|
|
1857
|
-
// Phase 21 — a fresh sticky opens in edit mode (FigJam parity: drop a
|
|
1858
|
-
// note, type immediately). Only meaningful deviation from rect/ellipse.
|
|
1859
|
-
if (committed.tool === 'sticky') setEditingId(committed.id);
|
|
1860
|
-
}
|
|
1861
|
-
// T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
|
|
1862
|
-
// mode is locked on this tool. Sticky lets the user draw many shapes in a
|
|
1863
|
-
// row (canonical pattern: tldraw double-click to lock). Eraser stays
|
|
1864
|
-
// armed by default — that tool is destructive, not constructive.
|
|
1865
|
-
const toolJustUsed = cur.tool;
|
|
1866
|
-
if (toolJustUsed !== 'eraser') {
|
|
1867
|
-
const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
|
|
1868
|
-
if (!stickyOnThis) setTool('move');
|
|
1869
|
-
}
|
|
1870
|
-
drawAnchorRef.current = null;
|
|
1871
|
-
lastDrawPointRef.current = null;
|
|
1872
|
-
setDrawing(null);
|
|
1873
|
-
}, [isActive, isErase, visible, commitStrokes, annotSel, setTool, sticky]);
|
|
1874
|
-
|
|
1875
|
-
// T21 — abort a mid-stroke draw without committing. Dispatched by the
|
|
1876
|
-
// canvas-shell Esc handler (`maude:cancel-stroke`). Safe to call when
|
|
1877
|
-
// nothing is being drawn — the early-return on drawingRef keeps it
|
|
1878
|
-
// a no-op.
|
|
1879
|
-
const cancelStroke = useCallback(() => {
|
|
1880
|
-
if (!drawingRef.current) return;
|
|
1881
|
-
drawAnchorRef.current = null;
|
|
1882
|
-
lastDrawPointRef.current = null;
|
|
1883
|
-
setDrawing(null);
|
|
1884
|
-
}, []);
|
|
1885
|
-
|
|
1886
|
-
useEffect(() => {
|
|
1887
|
-
if (typeof document === 'undefined') return;
|
|
1888
|
-
const onCancel = () => cancelStroke();
|
|
1889
|
-
document.addEventListener('maude:cancel-stroke', onCancel);
|
|
1890
|
-
return () => document.removeEventListener('maude:cancel-stroke', onCancel);
|
|
1891
|
-
}, [cancelStroke]);
|
|
1892
|
-
|
|
1893
|
-
// Phase 24 — holding/releasing Shift or Alt mid-draw re-constrains the draft
|
|
1894
|
-
// at the last cursor position (FigJam: the modifier engages while held, no
|
|
1895
|
-
// pointer-move needed). Pen / text carry no box, so they're skipped.
|
|
1896
|
-
useEffect(() => {
|
|
1897
|
-
if (typeof document === 'undefined') return;
|
|
1898
|
-
const onKey = (e: KeyboardEvent) => {
|
|
1899
|
-
const cur = drawingRef.current;
|
|
1900
|
-
const anchor = drawAnchorRef.current;
|
|
1901
|
-
const p = lastDrawPointRef.current;
|
|
1902
|
-
if (!cur || !anchor || !p) return;
|
|
1903
|
-
if (e.key !== 'Shift' && e.key !== 'Alt') return;
|
|
1904
|
-
if (cur.tool === 'pen' || cur.tool === 'text') return;
|
|
1905
|
-
e.preventDefault();
|
|
1906
|
-
const mods: DrawMods = { shift: e.shiftKey, alt: e.altKey };
|
|
1907
|
-
setDrawing((c) => (c ? applyDrawModifiers(c, anchor, p.x, p.y, mods) : c));
|
|
1908
|
-
};
|
|
1909
|
-
document.addEventListener('keydown', onKey, true);
|
|
1910
|
-
document.addEventListener('keyup', onKey, true);
|
|
1911
|
-
return () => {
|
|
1912
|
-
document.removeEventListener('keydown', onKey, true);
|
|
1913
|
-
document.removeEventListener('keyup', onKey, true);
|
|
1914
|
-
};
|
|
1915
|
-
}, []);
|
|
1916
|
-
|
|
1917
|
-
const renderStrokes = useMemo(
|
|
1918
|
-
() => (drawing ? [...strokes, drawing] : strokes),
|
|
1919
|
-
[strokes, drawing]
|
|
1920
|
-
);
|
|
1921
|
-
|
|
1922
|
-
// Phase 24 — the ghost descriptor handed to the SVG layer. Suppressed while a
|
|
1923
|
-
// draw is in progress (the real preview takes over) so the two never overlap.
|
|
1924
|
-
const ghostPreview = useMemo<GhostDescriptor | null>(() => {
|
|
1925
|
-
if (!ghost || !ghostCapable || drawing) return null;
|
|
1926
|
-
if (tool === 'text') return { kind: 'text', x: ghost.x, y: ghost.y, color };
|
|
1927
|
-
if (tool === 'sticky') return { kind: 'sticky', x: ghost.x, y: ghost.y, color: stickyColor };
|
|
1928
|
-
return { kind: 'shape', x: ghost.x, y: ghost.y, shapeKind, color };
|
|
1929
|
-
}, [ghost, ghostCapable, drawing, tool, shapeKind, color, stickyColor]);
|
|
1930
|
-
|
|
1931
|
-
const anchorsById = useMemo(() => {
|
|
1932
|
-
const map = new Map<string, RectStroke | EllipseStroke>();
|
|
1933
|
-
for (const s of strokes) {
|
|
1934
|
-
if (s.tool === 'rect' || s.tool === 'ellipse') map.set(s.id, s);
|
|
1935
|
-
}
|
|
1936
|
-
return map;
|
|
1937
|
-
}, [strokes]);
|
|
1938
|
-
|
|
1939
|
-
const strokesById = useMemo(() => {
|
|
1940
|
-
const map = new Map<string, Stroke>();
|
|
1941
|
-
for (const s of strokes) map.set(s.id, s);
|
|
1942
|
-
return map;
|
|
1943
|
-
}, [strokes]);
|
|
1944
|
-
|
|
1945
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
1946
|
-
// Move-tool selection + drag (Phase 5.1 Tasks 6 + 7). Single doc-level
|
|
1947
|
-
// capture pointerdown listener:
|
|
1948
|
-
// - target is a stroke → select (replace, or add with Shift)
|
|
1949
|
-
// - bare click on empty world → clear annotation selection
|
|
1950
|
-
// - Cmd / Cmd+Shift falls through to element-selection (we bail).
|
|
1951
|
-
// Once a stroke is selected, clicking inside its bbox starts a drag.
|
|
1952
|
-
|
|
1953
|
-
/**
|
|
1954
|
-
* Annotation drag state. Snapshot of strokes captured at pointerdown so the
|
|
1955
|
-
* whole gesture (pointermove × N → pointerup) commits as ONE undo record at
|
|
1956
|
-
* release time. Without the snapshot, each pointermove tick became its own
|
|
1957
|
-
* `translateStrokes` call and each call became its own undo record — Cmd+Z
|
|
1958
|
-
* had to be pressed dozens of times to walk back a single drag.
|
|
1959
|
-
*/
|
|
1960
|
-
const dragStateRef = useRef<{
|
|
1961
|
-
pointerId: number;
|
|
1962
|
-
startWX: number;
|
|
1963
|
-
startWY: number;
|
|
1964
|
-
movedIds: string[];
|
|
1965
|
-
snapshot: Stroke[];
|
|
1966
|
-
} | null>(null);
|
|
1967
|
-
|
|
1968
|
-
// Drag-select marquee state. World-coord rectangle (anchor + cursor); the
|
|
1969
|
-
// cursor end animates with pointermove. `null` = no marquee active.
|
|
1970
|
-
const [marquee, setMarquee] = useState<{
|
|
1971
|
-
ax: number;
|
|
1972
|
-
ay: number;
|
|
1973
|
-
bx: number;
|
|
1974
|
-
by: number;
|
|
1975
|
-
} | null>(null);
|
|
1976
|
-
|
|
1977
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: commitStrokes/strokesStore are stable refs from parent context; manual dep list reflects effect-trigger semantics, not internal callbacks
|
|
1978
|
-
useEffect(() => {
|
|
1979
|
-
if (typeof document === 'undefined') return;
|
|
1980
|
-
if (tool !== 'move') return;
|
|
1981
|
-
if (!annotSel) return;
|
|
1982
|
-
|
|
1983
|
-
const findStrokeId = (el: Element | null): string | null => {
|
|
1984
|
-
const node = el?.closest?.('[data-id][data-tool]') ?? null;
|
|
1985
|
-
const id = node?.getAttribute('data-id') ?? null;
|
|
1986
|
-
const t = node?.getAttribute('data-tool') ?? null;
|
|
1987
|
-
if (
|
|
1988
|
-
id &&
|
|
1989
|
-
t &&
|
|
1990
|
-
(t === 'pen' ||
|
|
1991
|
-
t === 'rect' ||
|
|
1992
|
-
t === 'ellipse' ||
|
|
1993
|
-
t === 'polygon' ||
|
|
1994
|
-
t === 'arrow' ||
|
|
1995
|
-
t === 'text' ||
|
|
1996
|
-
t === 'sticky')
|
|
1997
|
-
) {
|
|
1998
|
-
return id;
|
|
1999
|
-
}
|
|
2000
|
-
return null;
|
|
2001
|
-
};
|
|
2002
|
-
|
|
2003
|
-
// Chrome elements never deselect. Includes the per-shape context toolbar,
|
|
2004
|
-
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
2005
|
-
// right-click menu. Clicks on these route to their own handlers.
|
|
2006
|
-
const CHROME_SELECTOR =
|
|
2007
|
-
'.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-editor';
|
|
2008
|
-
|
|
2009
|
-
const onDown = (e: PointerEvent) => {
|
|
2010
|
-
if (e.button !== 0) return;
|
|
2011
|
-
if (e.metaKey || e.ctrlKey) return; // escape hatch into element-selection
|
|
2012
|
-
const target = e.target as Element | null;
|
|
2013
|
-
if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
|
|
2014
|
-
const strokeId = findStrokeId(target);
|
|
2015
|
-
// When pointerdown lands inside an artboard but not on a stroke, the
|
|
2016
|
-
// gesture belongs to artboard-drag / element-marquee — not the
|
|
2017
|
-
// annotation marquee. Bailing here keeps the annotation marquee from
|
|
2018
|
-
// racing the artboard drag (post-Wave-3 user grievance G5).
|
|
2019
|
-
if (!strokeId && target?.closest?.('[data-dc-screen]')) return;
|
|
2020
|
-
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
2021
|
-
const startClientX = e.clientX;
|
|
2022
|
-
const startClientY = e.clientY;
|
|
2023
|
-
|
|
2024
|
-
// Stroke hit — select + start drag-translate of the group ─────────
|
|
2025
|
-
if (strokeId) {
|
|
2026
|
-
e.preventDefault();
|
|
2027
|
-
e.stopImmediatePropagation();
|
|
2028
|
-
elementSel?.clear();
|
|
2029
|
-
let ids: string[];
|
|
2030
|
-
if (e.shiftKey) {
|
|
2031
|
-
annotSel.add(strokeId);
|
|
2032
|
-
ids = annotSel.contains(strokeId)
|
|
2033
|
-
? annotSel.selectedIds
|
|
2034
|
-
: [...annotSel.selectedIds, strokeId];
|
|
2035
|
-
} else if (annotSel.contains(strokeId)) {
|
|
2036
|
-
ids = annotSel.selectedIds;
|
|
2037
|
-
} else {
|
|
2038
|
-
annotSel.replace(strokeId);
|
|
2039
|
-
ids = [strokeId];
|
|
2040
|
-
}
|
|
2041
|
-
// Capture a snapshot of all strokes at drag start. Every pointermove
|
|
2042
|
-
// re-translates FROM the snapshot using the cumulative cursor delta
|
|
2043
|
-
// (NOT a delta-from-last-frame mutation), so dragging back to origin
|
|
2044
|
-
// restores positions exactly. Optimistic state-only updates during
|
|
2045
|
-
// the move; ONE undo record + ONE server PUT fires on pointerup.
|
|
2046
|
-
const dragSnapshot = strokesRef.current.slice();
|
|
2047
|
-
dragStateRef.current = {
|
|
2048
|
-
pointerId: e.pointerId,
|
|
2049
|
-
startWX: wx,
|
|
2050
|
-
startWY: wy,
|
|
2051
|
-
movedIds: ids,
|
|
2052
|
-
snapshot: dragSnapshot,
|
|
2053
|
-
};
|
|
2054
|
-
const movedSet = new Set(ids);
|
|
2055
|
-
const onMove = (mv: PointerEvent) => {
|
|
2056
|
-
const st = dragStateRef.current;
|
|
2057
|
-
if (!st || mv.pointerId !== st.pointerId) return;
|
|
2058
|
-
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
2059
|
-
const dx = cwx - st.startWX;
|
|
2060
|
-
const dy = cwy - st.startWY;
|
|
2061
|
-
// Drag-back-to-origin: restore exact references so the pointerup
|
|
2062
|
-
// shallow-equality check skips committing a no-op record.
|
|
2063
|
-
const next =
|
|
2064
|
-
dx === 0 && dy === 0
|
|
2065
|
-
? st.snapshot
|
|
2066
|
-
: st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s));
|
|
2067
|
-
// Local React state only. No commitStrokes — no PUT, no undo push.
|
|
2068
|
-
setStrokesState(next);
|
|
2069
|
-
};
|
|
2070
|
-
const onUp = (up: PointerEvent) => {
|
|
2071
|
-
const st = dragStateRef.current;
|
|
2072
|
-
if (!st || up.pointerId !== st.pointerId) return;
|
|
2073
|
-
dragStateRef.current = null;
|
|
2074
|
-
document.removeEventListener('pointermove', onMove, true);
|
|
2075
|
-
document.removeEventListener('pointerup', onUp, true);
|
|
2076
|
-
document.removeEventListener('pointercancel', onUp, true);
|
|
2077
|
-
// Commit the gesture as ONE record. Skip on zero-movement
|
|
2078
|
-
// (click without drag past threshold or drag back to origin).
|
|
2079
|
-
const final = strokesRef.current;
|
|
2080
|
-
if (strokesShallowEqual(st.snapshot, final)) return;
|
|
2081
|
-
commitStrokes(
|
|
2082
|
-
st.snapshot,
|
|
2083
|
-
final,
|
|
2084
|
-
`move ${st.movedIds.length} stroke${st.movedIds.length === 1 ? '' : 's'}`
|
|
2085
|
-
);
|
|
2086
|
-
};
|
|
2087
|
-
document.addEventListener('pointermove', onMove, true);
|
|
2088
|
-
document.addEventListener('pointerup', onUp, true);
|
|
2089
|
-
document.addEventListener('pointercancel', onUp, true);
|
|
2090
|
-
return;
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
// Empty world — start a drag-select gesture. A bare click without
|
|
2094
|
-
// moving clears annotation selection (post-Wave-3 feedback: click-to-
|
|
2095
|
-
// deselect is back; Esc also still works).
|
|
2096
|
-
const addToSelection = e.shiftKey;
|
|
2097
|
-
let moved = false;
|
|
2098
|
-
const onMove = (mv: PointerEvent) => {
|
|
2099
|
-
if (!moved && !crossedDragThreshold(startClientX, startClientY, mv.clientX, mv.clientY)) {
|
|
2100
|
-
return;
|
|
2101
|
-
}
|
|
2102
|
-
moved = true;
|
|
2103
|
-
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
2104
|
-
setMarquee({ ax: wx, ay: wy, bx: cwx, by: cwy });
|
|
2105
|
-
};
|
|
2106
|
-
const onUp = (_up: PointerEvent) => {
|
|
2107
|
-
document.removeEventListener('pointermove', onMove, true);
|
|
2108
|
-
document.removeEventListener('pointerup', onUp, true);
|
|
2109
|
-
document.removeEventListener('pointercancel', onUp, true);
|
|
2110
|
-
if (!moved) {
|
|
2111
|
-
// Click without movement on empty world → clear annotation
|
|
2112
|
-
// selection (post-Wave-3 user feedback). Shift-click preserves
|
|
2113
|
-
// existing selection for additive-mode workflows.
|
|
2114
|
-
if (!addToSelection) annotSel.clear();
|
|
2115
|
-
return;
|
|
2116
|
-
}
|
|
2117
|
-
const final = marqueeRef.current;
|
|
2118
|
-
setMarquee(null);
|
|
2119
|
-
if (!final) return;
|
|
2120
|
-
const xMin = Math.min(final.ax, final.bx);
|
|
2121
|
-
const xMax = Math.max(final.ax, final.bx);
|
|
2122
|
-
const yMin = Math.min(final.ay, final.by);
|
|
2123
|
-
const yMax = Math.max(final.ay, final.by);
|
|
2124
|
-
const hits: string[] = [];
|
|
2125
|
-
for (const s of strokesStoreRef.current.strokes) {
|
|
2126
|
-
// Anchored text inherits its host's bbox (selected with the host);
|
|
2127
|
-
// standalone text (Phase 21) has its own synthetic bbox and IS
|
|
2128
|
-
// marquee-selectable.
|
|
2129
|
-
if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') continue;
|
|
2130
|
-
const bb = strokeBBox(s);
|
|
2131
|
-
if (!bb) continue;
|
|
2132
|
-
if (bb.x + bb.w >= xMin && bb.x <= xMax && bb.y + bb.h >= yMin && bb.y <= yMax) {
|
|
2133
|
-
hits.push(s.id);
|
|
2134
|
-
}
|
|
2135
|
-
}
|
|
2136
|
-
// Marquee that captured no strokes — preserve existing selection.
|
|
2137
|
-
if (hits.length === 0) return;
|
|
2138
|
-
if (addToSelection) annotSel.add(hits);
|
|
2139
|
-
else annotSel.replace(hits);
|
|
2140
|
-
};
|
|
2141
|
-
document.addEventListener('pointermove', onMove, true);
|
|
2142
|
-
document.addEventListener('pointerup', onUp, true);
|
|
2143
|
-
document.addEventListener('pointercancel', onUp, true);
|
|
2144
|
-
};
|
|
2145
|
-
|
|
2146
|
-
document.addEventListener('pointerdown', onDown, true);
|
|
2147
|
-
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
2148
|
-
// commitStrokes is included defensively (it is a stable useCallback([]) ref,
|
|
2149
|
-
// so this never re-binds the listener) to remove the latent stale-closure
|
|
2150
|
-
// trap flagged in the Phase 24 frontend review.
|
|
2151
|
-
}, [tool, annotSel, elementSel, screenToWorld, strokesStore, commitStrokes]);
|
|
2152
|
-
|
|
2153
|
-
// Latest marquee + strokes refs for the doc-level pointerup callback
|
|
2154
|
-
// (avoids re-binding the listener on every state tick).
|
|
2155
|
-
const marqueeRef = useRef(marquee);
|
|
2156
|
-
marqueeRef.current = marquee;
|
|
2157
|
-
const strokesStoreRef = useRef(strokesStore);
|
|
2158
|
-
strokesStoreRef.current = strokesStore;
|
|
2159
|
-
|
|
2160
|
-
// Double-click enters text-edit mode: rect/ellipse (anchored text), sticky
|
|
2161
|
-
// (its own body), or a standalone text node (re-edit in place). Anchored text
|
|
2162
|
-
// nodes are edited via their host, so a data-anchor-id text node is skipped.
|
|
2163
|
-
useEffect(() => {
|
|
2164
|
-
if (typeof document === 'undefined') return;
|
|
2165
|
-
if (tool !== 'move') return;
|
|
2166
|
-
const onDbl = (e: MouseEvent) => {
|
|
2167
|
-
const target = e.target as Element | null;
|
|
2168
|
-
const node = target?.closest?.('[data-id][data-tool]');
|
|
2169
|
-
if (!node) return;
|
|
2170
|
-
const id = node.getAttribute('data-id');
|
|
2171
|
-
const t = node.getAttribute('data-tool');
|
|
2172
|
-
if (!id) return;
|
|
2173
|
-
if (t === 'rect' || t === 'ellipse' || t === 'sticky') {
|
|
2174
|
-
e.preventDefault();
|
|
2175
|
-
setEditingId(id);
|
|
2176
|
-
return;
|
|
2177
|
-
}
|
|
2178
|
-
if (t === 'text' && !node.getAttribute('data-anchor-id')) {
|
|
2179
|
-
e.preventDefault();
|
|
2180
|
-
setEditingId(id);
|
|
2181
|
-
}
|
|
2182
|
-
};
|
|
2183
|
-
document.addEventListener('dblclick', onDbl, true);
|
|
2184
|
-
return () => document.removeEventListener('dblclick', onDbl, true);
|
|
2185
|
-
}, [tool]);
|
|
2186
|
-
|
|
2187
|
-
const commitText = useCallback(
|
|
2188
|
-
(anchorId: string, text: string) => {
|
|
2189
|
-
const trimmed = text.trim();
|
|
2190
|
-
const prev = strokesRef.current;
|
|
2191
|
-
const existing = prev.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
|
|
2192
|
-
| TextStroke
|
|
2193
|
-
| undefined;
|
|
2194
|
-
let next: Stroke[];
|
|
2195
|
-
let label = 'edit text';
|
|
2196
|
-
if (trimmed.length === 0) {
|
|
2197
|
-
if (!existing) return; // nothing to do
|
|
2198
|
-
next = prev.filter((s) => s.id !== existing.id);
|
|
2199
|
-
label = 'delete text';
|
|
2200
|
-
} else if (existing) {
|
|
2201
|
-
if (existing.text === trimmed) return; // identity edit
|
|
2202
|
-
next = prev.map((s) => (s.id === existing.id ? { ...existing, text: trimmed } : s));
|
|
2203
|
-
} else {
|
|
2204
|
-
next = [
|
|
2205
|
-
...prev,
|
|
2206
|
-
{
|
|
2207
|
-
id: rid(),
|
|
2208
|
-
tool: 'text',
|
|
2209
|
-
color: '#1a1a1a',
|
|
2210
|
-
fontSize: DEFAULT_FONT_SIZE,
|
|
2211
|
-
text: trimmed,
|
|
2212
|
-
anchorId,
|
|
2213
|
-
} as TextStroke,
|
|
2214
|
-
];
|
|
2215
|
-
label = 'add text';
|
|
2216
|
-
}
|
|
2217
|
-
commitStrokes(prev, next, label);
|
|
2218
|
-
},
|
|
2219
|
-
[commitStrokes]
|
|
2220
|
-
);
|
|
2221
|
-
|
|
2222
|
-
// Phase 21 — sticky body edit. Sticky text is freeform (newlines preserved,
|
|
2223
|
-
// no trim) and the card persists even when blank, so this only updates text.
|
|
2224
|
-
const commitStickyText = useCallback(
|
|
2225
|
-
(id: string, text: string) => {
|
|
2226
|
-
const prev = strokesRef.current;
|
|
2227
|
-
const existing = prev.find((s) => s.id === id && s.tool === 'sticky') as
|
|
2228
|
-
| StickyStroke
|
|
2229
|
-
| undefined;
|
|
2230
|
-
if (!existing || existing.text === text) return;
|
|
2231
|
-
const next = prev.map((s) => (s.id === id ? { ...existing, text } : s));
|
|
2232
|
-
commitStrokes(prev, next, 'edit sticky');
|
|
2233
|
-
},
|
|
2234
|
-
[commitStrokes]
|
|
2235
|
-
);
|
|
2236
|
-
|
|
2237
|
-
// Phase 21 — re-edit an EXISTING standalone text node. Empty text deletes it
|
|
2238
|
-
// (same rule as anchored text).
|
|
2239
|
-
const commitStandaloneText = useCallback(
|
|
2240
|
-
(id: string, text: string) => {
|
|
2241
|
-
const trimmed = text.trim();
|
|
2242
|
-
const prev = strokesRef.current;
|
|
2243
|
-
const existing = prev.find((s) => s.id === id && s.tool === 'text') as TextStroke | undefined;
|
|
2244
|
-
if (!existing) return;
|
|
2245
|
-
if (trimmed.length === 0) {
|
|
2246
|
-
commitStrokes(
|
|
2247
|
-
prev,
|
|
2248
|
-
prev.filter((s) => s.id !== id),
|
|
2249
|
-
'delete text'
|
|
2250
|
-
);
|
|
2251
|
-
return;
|
|
2252
|
-
}
|
|
2253
|
-
if (existing.text === trimmed) return;
|
|
2254
|
-
commitStrokes(
|
|
2255
|
-
prev,
|
|
2256
|
-
prev.map((s) => (s.id === id ? { ...existing, text: trimmed } : s)),
|
|
2257
|
-
'edit text'
|
|
2258
|
-
);
|
|
2259
|
-
},
|
|
2260
|
-
[commitStrokes]
|
|
2261
|
-
);
|
|
2262
|
-
|
|
2263
|
-
// Phase 21 — born-on-commit standalone text (from the text-tool caret). An
|
|
2264
|
-
// empty caret persists nothing — ONE undo record only when real text lands.
|
|
2265
|
-
const createStandaloneText = useCallback(
|
|
2266
|
-
(x: number, y: number, text: string) => {
|
|
2267
|
-
const trimmed = text.trim();
|
|
2268
|
-
if (trimmed.length === 0) return;
|
|
2269
|
-
const prev = strokesRef.current;
|
|
2270
|
-
const id = rid();
|
|
2271
|
-
const next: Stroke[] = [
|
|
2272
|
-
...prev,
|
|
2273
|
-
{ id, tool: 'text', color, fontSize: DEFAULT_FONT_SIZE, text: trimmed, x, y },
|
|
2274
|
-
];
|
|
2275
|
-
commitStrokes(prev, next, 'add text');
|
|
2276
|
-
if (annotSel) annotSel.replace(id);
|
|
2277
|
-
},
|
|
2278
|
-
[commitStrokes, color, annotSel]
|
|
2279
|
-
);
|
|
2280
|
-
|
|
2281
|
-
// Phase 21 — resolve what (if anything) is being edited, and route a single
|
|
2282
|
-
// commit call to the right writer. `editingId` doubles as the host id
|
|
2283
|
-
// (anchored) OR the sticky/standalone stroke id; `pendingText` is the
|
|
2284
|
-
// not-yet-born text caret.
|
|
2285
|
-
const editingTarget = useMemo<EditingTarget>(() => {
|
|
2286
|
-
if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
|
|
2287
|
-
if (!editingId) return null;
|
|
2288
|
-
const host = anchorsById.get(editingId);
|
|
2289
|
-
if (host) return { kind: 'anchored', anchorId: editingId, host };
|
|
2290
|
-
const s = strokesById.get(editingId);
|
|
2291
|
-
if (s?.tool === 'sticky') return { kind: 'sticky', sticky: s };
|
|
2292
|
-
if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
|
|
2293
|
-
return { kind: 'standalone', text: s };
|
|
2294
|
-
return null;
|
|
2295
|
-
}, [pendingText, editingId, anchorsById, strokesById]);
|
|
2296
|
-
|
|
2297
|
-
const editingTargetRef = useRef(editingTarget);
|
|
2298
|
-
editingTargetRef.current = editingTarget;
|
|
2299
|
-
|
|
2300
|
-
const commitEditing = useCallback(
|
|
2301
|
-
(text: string) => {
|
|
2302
|
-
const target = editingTargetRef.current;
|
|
2303
|
-
setEditingId(null);
|
|
2304
|
-
setPendingText(null);
|
|
2305
|
-
if (!target) return;
|
|
2306
|
-
if (target.kind === 'anchored') commitText(target.anchorId, text);
|
|
2307
|
-
else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text);
|
|
2308
|
-
else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text);
|
|
2309
|
-
else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text);
|
|
2310
|
-
},
|
|
2311
|
-
[commitText, commitStickyText, commitStandaloneText, createStandaloneText]
|
|
2312
|
-
);
|
|
2313
|
-
|
|
2314
|
-
const cancelEditing = useCallback(() => {
|
|
2315
|
-
setEditingId(null);
|
|
2316
|
-
setPendingText(null);
|
|
2317
|
-
}, []);
|
|
2318
|
-
|
|
2319
|
-
// Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
|
|
2320
|
-
useEffect(() => {
|
|
2321
|
-
if (typeof document === 'undefined') return;
|
|
2322
|
-
if (!annotSel) return;
|
|
2323
|
-
const onKey = (e: KeyboardEvent) => {
|
|
2324
|
-
if (isEditable(e.target)) return;
|
|
2325
|
-
if (annotSel.selectedIds.length === 0) return;
|
|
2326
|
-
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2327
|
-
const step = e.shiftKey ? 10 : 1;
|
|
2328
|
-
if (e.key === 'ArrowLeft') {
|
|
2329
|
-
e.preventDefault();
|
|
2330
|
-
strokesStore.translateStrokes(annotSel.selectedIds, -step, 0);
|
|
2331
|
-
return;
|
|
2332
|
-
}
|
|
2333
|
-
if (e.key === 'ArrowRight') {
|
|
2334
|
-
e.preventDefault();
|
|
2335
|
-
strokesStore.translateStrokes(annotSel.selectedIds, step, 0);
|
|
2336
|
-
return;
|
|
2337
|
-
}
|
|
2338
|
-
if (e.key === 'ArrowUp') {
|
|
2339
|
-
e.preventDefault();
|
|
2340
|
-
strokesStore.translateStrokes(annotSel.selectedIds, 0, -step);
|
|
2341
|
-
return;
|
|
2342
|
-
}
|
|
2343
|
-
if (e.key === 'ArrowDown') {
|
|
2344
|
-
e.preventDefault();
|
|
2345
|
-
strokesStore.translateStrokes(annotSel.selectedIds, 0, step);
|
|
2346
|
-
return;
|
|
2347
|
-
}
|
|
2348
|
-
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
2349
|
-
e.preventDefault();
|
|
2350
|
-
strokesStore.deleteStrokes(annotSel.selectedIds);
|
|
2351
|
-
annotSel.clear();
|
|
2352
|
-
}
|
|
2353
|
-
};
|
|
2354
|
-
document.addEventListener('keydown', onKey, true);
|
|
2355
|
-
return () => document.removeEventListener('keydown', onKey, true);
|
|
2356
|
-
}, [annotSel, strokesStore]);
|
|
2357
|
-
|
|
2358
|
-
// Selected stroke halos — bboxes in world coords, vector-effect non-scaling-stroke.
|
|
2359
|
-
const selectedStrokes = useMemo(() => {
|
|
2360
|
-
if (!annotSel || annotSel.selectedIds.length === 0) return [] as Stroke[];
|
|
2361
|
-
const out: Stroke[] = [];
|
|
2362
|
-
for (const id of annotSel.selectedIds) {
|
|
2363
|
-
const s = strokesById.get(id);
|
|
2364
|
-
if (s) out.push(s);
|
|
2365
|
-
}
|
|
2366
|
-
return out;
|
|
2367
|
-
}, [annotSel, strokesById]);
|
|
2368
|
-
|
|
2369
|
-
return (
|
|
2370
|
-
<StrokesStoreContext.Provider value={strokesStore}>
|
|
2371
|
-
<>
|
|
2372
|
-
<AnnotationsInput
|
|
2373
|
-
isActive={isActive}
|
|
2374
|
-
visible={visible}
|
|
2375
|
-
cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
|
|
2376
|
-
beginStroke={beginStroke}
|
|
2377
|
-
moveStroke={moveStroke}
|
|
2378
|
-
endStroke={endStroke}
|
|
2379
|
-
onLeave={() => setGhost(null)}
|
|
2380
|
-
/>
|
|
2381
|
-
{visible ? (
|
|
2382
|
-
<AnnotationsSvg
|
|
2383
|
-
worldRef={worldRef}
|
|
2384
|
-
strokes={renderStrokes}
|
|
2385
|
-
anchorsById={anchorsById}
|
|
2386
|
-
selectMode={tool === 'move'}
|
|
2387
|
-
selectedStrokes={selectedStrokes}
|
|
2388
|
-
marquee={marquee}
|
|
2389
|
-
ghost={ghostPreview}
|
|
2390
|
-
editingTarget={editingTarget}
|
|
2391
|
-
onCommitEdit={commitEditing}
|
|
2392
|
-
onCancelEdit={cancelEditing}
|
|
2393
|
-
/>
|
|
2394
|
-
) : null}
|
|
2395
|
-
<AnnotationContextToolbar />
|
|
2396
|
-
{visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
|
|
2397
|
-
{isActive ? (
|
|
2398
|
-
<AnnotationsChrome
|
|
2399
|
-
tool={tool}
|
|
2400
|
-
color={color}
|
|
2401
|
-
setColor={setColor}
|
|
2402
|
-
stickyColor={stickyColor}
|
|
2403
|
-
setStickyColor={setStickyColor}
|
|
2404
|
-
supportsFill={supportsFill}
|
|
2405
|
-
fill={fill}
|
|
2406
|
-
setFill={setFill}
|
|
2407
|
-
supportsThickness={supportsThickness}
|
|
2408
|
-
thickness={thickness}
|
|
2409
|
-
setThickness={setThickness}
|
|
2410
|
-
/>
|
|
2411
|
-
) : null}
|
|
2412
|
-
</>
|
|
2413
|
-
</StrokesStoreContext.Provider>
|
|
2414
|
-
);
|
|
2415
|
-
}
|
|
2416
|
-
AnnotationsLayer.displayName = 'AnnotationsLayer';
|
|
2417
|
-
|
|
2418
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2419
|
-
// Input — transparent overlay portaled into the host (.dc-canvas). Receives
|
|
2420
|
-
// pointer events for draw / erase ONLY; viewport gestures (middle-mouse,
|
|
2421
|
-
// space-pan, wheel) reach `useViewportController` because we never call
|
|
2422
|
-
// stopPropagation and the controller listens at the host level alongside us.
|
|
2423
|
-
|
|
2424
|
-
function AnnotationsInput({
|
|
2425
|
-
isActive,
|
|
2426
|
-
visible,
|
|
2427
|
-
cursor,
|
|
2428
|
-
beginStroke,
|
|
2429
|
-
moveStroke,
|
|
2430
|
-
endStroke,
|
|
2431
|
-
onLeave,
|
|
2432
|
-
}: {
|
|
2433
|
-
isActive: boolean;
|
|
2434
|
-
visible: boolean;
|
|
2435
|
-
/** Active-tool cursor (crosshair / text / cell) — applied to the capture
|
|
2436
|
-
* overlay so the affordance shows over the whole canvas while drawing. */
|
|
2437
|
-
cursor: string;
|
|
2438
|
-
beginStroke: (e: ReactPointerEvent<HTMLDivElement>, spaceHeld: boolean) => boolean;
|
|
2439
|
-
moveStroke: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
|
2440
|
-
endStroke: () => void;
|
|
2441
|
-
/** Phase 24 — clear the ghost placeholder when the pointer leaves the canvas. */
|
|
2442
|
-
onLeave: () => void;
|
|
2443
|
-
}) {
|
|
2444
|
-
const worldRef = useWorldRefContext();
|
|
2445
|
-
const host = worldRef?.current?.parentElement ?? null;
|
|
2446
|
-
const [, force] = useState({});
|
|
2447
|
-
// Host may not be attached on first commit; nudge a re-render once it is.
|
|
2448
|
-
useEffect(() => {
|
|
2449
|
-
if (host) return;
|
|
2450
|
-
const id = setTimeout(() => force({}), 0);
|
|
2451
|
-
return () => clearTimeout(id);
|
|
2452
|
-
}, [host]);
|
|
2453
|
-
|
|
2454
|
-
const spaceHeldRef = useRef(false);
|
|
2455
|
-
useEffect(() => {
|
|
2456
|
-
if (typeof document === 'undefined') return;
|
|
2457
|
-
const down = (e: KeyboardEvent) => {
|
|
2458
|
-
if (e.code === 'Space' && !isEditable(e.target)) spaceHeldRef.current = true;
|
|
2459
|
-
};
|
|
2460
|
-
const up = (e: KeyboardEvent) => {
|
|
2461
|
-
if (e.code === 'Space') spaceHeldRef.current = false;
|
|
2462
|
-
};
|
|
2463
|
-
document.addEventListener('keydown', down, true);
|
|
2464
|
-
document.addEventListener('keyup', up, true);
|
|
2465
|
-
return () => {
|
|
2466
|
-
document.removeEventListener('keydown', down, true);
|
|
2467
|
-
document.removeEventListener('keyup', up, true);
|
|
2468
|
-
};
|
|
2469
|
-
}, []);
|
|
2470
|
-
|
|
2471
|
-
if (!host) return null;
|
|
2472
|
-
const interactive = isActive && visible;
|
|
2473
|
-
return createPortal(
|
|
2474
|
-
<div
|
|
2475
|
-
className="dc-annot-input"
|
|
2476
|
-
aria-hidden="true"
|
|
2477
|
-
style={{
|
|
2478
|
-
pointerEvents: interactive ? 'auto' : 'none',
|
|
2479
|
-
cursor: interactive ? cursor : 'default',
|
|
2480
|
-
}}
|
|
2481
|
-
onPointerDown={(e) => {
|
|
2482
|
-
beginStroke(e, spaceHeldRef.current);
|
|
2483
|
-
}}
|
|
2484
|
-
onPointerMove={moveStroke}
|
|
2485
|
-
onPointerUp={endStroke}
|
|
2486
|
-
onPointerCancel={endStroke}
|
|
2487
|
-
onPointerLeave={onLeave}
|
|
2488
|
-
/>,
|
|
2489
|
-
host
|
|
2490
|
-
);
|
|
2491
|
-
}
|
|
2492
|
-
|
|
2493
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2494
|
-
// SVG — portaled INTO `.dc-world` so the world's CSS zoom + translate apply
|
|
2495
|
-
// natively. `vector-effect="non-scaling-stroke"` keeps stroke px-thick at any
|
|
2496
|
-
// zoom level. `pointer-events: none` on the container — strokes are decorative
|
|
2497
|
-
// for now (Phase 5.1 Task 6 will reintroduce hit-test via the selection store).
|
|
2498
|
-
|
|
2499
|
-
function AnnotationsSvg({
|
|
2500
|
-
worldRef,
|
|
2501
|
-
strokes,
|
|
2502
|
-
anchorsById,
|
|
2503
|
-
selectMode,
|
|
2504
|
-
selectedStrokes,
|
|
2505
|
-
marquee,
|
|
2506
|
-
ghost,
|
|
2507
|
-
editingTarget,
|
|
2508
|
-
onCommitEdit,
|
|
2509
|
-
onCancelEdit,
|
|
2510
|
-
}: {
|
|
2511
|
-
worldRef: ReturnType<typeof useWorldRefContext>;
|
|
2512
|
-
strokes: readonly Stroke[];
|
|
2513
|
-
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
2514
|
-
selectMode: boolean;
|
|
2515
|
-
selectedStrokes: readonly Stroke[];
|
|
2516
|
-
marquee: { ax: number; ay: number; bx: number; by: number } | null;
|
|
2517
|
-
ghost: GhostDescriptor | null;
|
|
2518
|
-
editingTarget: EditingTarget;
|
|
2519
|
-
onCommitEdit: (text: string) => void;
|
|
2520
|
-
onCancelEdit: () => void;
|
|
2521
|
-
}) {
|
|
2522
|
-
const [, force] = useState({});
|
|
2523
|
-
useEffect(() => {
|
|
2524
|
-
if (worldRef?.current) return;
|
|
2525
|
-
const id = setTimeout(() => force({}), 0);
|
|
2526
|
-
return () => clearTimeout(id);
|
|
2527
|
-
}, [worldRef]);
|
|
2528
|
-
const target = worldRef?.current ?? null;
|
|
2529
|
-
if (!target) return null;
|
|
2530
|
-
// A sticky whose body is being edited hides its read-only text so the
|
|
2531
|
-
// editor textarea (rendered below at the same bbox) isn't double-painted.
|
|
2532
|
-
const editingStickyId = editingTarget?.kind === 'sticky' ? editingTarget.sticky.id : null;
|
|
2533
|
-
const anchoredExisting =
|
|
2534
|
-
editingTarget?.kind === 'anchored'
|
|
2535
|
-
? (strokes.find((s) => s.tool === 'text' && s.anchorId === editingTarget.anchorId) as
|
|
2536
|
-
| TextStroke
|
|
2537
|
-
| undefined)
|
|
2538
|
-
: undefined;
|
|
2539
|
-
return createPortal(
|
|
2540
|
-
<svg className="dc-annot-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
|
2541
|
-
<defs>
|
|
2542
|
-
{/* Phase 21 — soft "lifted paper" drop shadow for sticky notes. */}
|
|
2543
|
-
<filter id="dc-sticky-shadow" x="-25%" y="-25%" width="150%" height="170%">
|
|
2544
|
-
<feDropShadow dx="0" dy="4" stdDeviation="8" floodColor="#000000" floodOpacity="0.28" />
|
|
2545
|
-
</filter>
|
|
2546
|
-
</defs>
|
|
2547
|
-
{strokes.map((s) => (
|
|
2548
|
-
<StrokeNode
|
|
2549
|
-
key={s.id}
|
|
2550
|
-
stroke={s}
|
|
2551
|
-
anchorsById={anchorsById}
|
|
2552
|
-
interactive={selectMode}
|
|
2553
|
-
editing={s.id === editingStickyId}
|
|
2554
|
-
/>
|
|
2555
|
-
))}
|
|
2556
|
-
{selectedStrokes.map((s) => (
|
|
2557
|
-
<SelectionHalo
|
|
2558
|
-
key={`halo-${s.id}`}
|
|
2559
|
-
stroke={s}
|
|
2560
|
-
anchorsById={anchorsById}
|
|
2561
|
-
multi={selectedStrokes.length > 1}
|
|
2562
|
-
/>
|
|
2563
|
-
))}
|
|
2564
|
-
<AnnotGroupBbox selectedStrokes={selectedStrokes} anchorsById={anchorsById} />
|
|
2565
|
-
{marquee ? (
|
|
2566
|
-
<rect
|
|
2567
|
-
className="dc-annot-marquee"
|
|
2568
|
-
x={Math.min(marquee.ax, marquee.bx)}
|
|
2569
|
-
y={Math.min(marquee.ay, marquee.by)}
|
|
2570
|
-
width={Math.abs(marquee.bx - marquee.ax)}
|
|
2571
|
-
height={Math.abs(marquee.by - marquee.ay)}
|
|
2572
|
-
vectorEffect="non-scaling-stroke"
|
|
2573
|
-
/>
|
|
2574
|
-
) : null}
|
|
2575
|
-
{ghost ? <GhostPreview ghost={ghost} /> : null}
|
|
2576
|
-
{editingTarget?.kind === 'anchored' ? (
|
|
2577
|
-
<TextEditor
|
|
2578
|
-
anchorId={editingTarget.anchorId}
|
|
2579
|
-
host={editingTarget.host}
|
|
2580
|
-
existing={anchoredExisting}
|
|
2581
|
-
onCommit={(_anchorId, text) => onCommitEdit(text)}
|
|
2582
|
-
onCancel={onCancelEdit}
|
|
2583
|
-
/>
|
|
2584
|
-
) : null}
|
|
2585
|
-
{editingTarget?.kind === 'sticky' ? (
|
|
2586
|
-
<StickyEditor
|
|
2587
|
-
sticky={editingTarget.sticky}
|
|
2588
|
-
onCommit={onCommitEdit}
|
|
2589
|
-
onCancel={onCancelEdit}
|
|
2590
|
-
/>
|
|
2591
|
-
) : null}
|
|
2592
|
-
{editingTarget?.kind === 'standalone' ? (
|
|
2593
|
-
<StandaloneTextEditor
|
|
2594
|
-
x={editingTarget.text.x ?? 0}
|
|
2595
|
-
y={editingTarget.text.y ?? 0}
|
|
2596
|
-
fontSize={editingTarget.text.fontSize}
|
|
2597
|
-
color={editingTarget.text.color}
|
|
2598
|
-
initialText={editingTarget.text.text}
|
|
2599
|
-
bold={editingTarget.text.bold}
|
|
2600
|
-
strike={editingTarget.text.strike}
|
|
2601
|
-
align={editingTarget.text.align ?? 'left'}
|
|
2602
|
-
onCommit={onCommitEdit}
|
|
2603
|
-
onCancel={onCancelEdit}
|
|
2604
|
-
/>
|
|
2605
|
-
) : null}
|
|
2606
|
-
{editingTarget?.kind === 'pending' ? (
|
|
2607
|
-
<StandaloneTextEditor
|
|
2608
|
-
x={editingTarget.x}
|
|
2609
|
-
y={editingTarget.y}
|
|
2610
|
-
fontSize={DEFAULT_FONT_SIZE}
|
|
2611
|
-
color={DEFAULT_COLOR}
|
|
2612
|
-
initialText=""
|
|
2613
|
-
onCommit={onCommitEdit}
|
|
2614
|
-
onCancel={onCancelEdit}
|
|
2615
|
-
/>
|
|
2616
|
-
) : null}
|
|
2617
|
-
</svg>,
|
|
2618
|
-
target
|
|
2619
|
-
);
|
|
2620
|
-
}
|
|
2621
|
-
|
|
2622
|
-
function TextEditor({
|
|
2623
|
-
anchorId,
|
|
2624
|
-
host,
|
|
2625
|
-
existing,
|
|
2626
|
-
onCommit,
|
|
2627
|
-
onCancel,
|
|
2628
|
-
}: {
|
|
2629
|
-
anchorId: string;
|
|
2630
|
-
host: RectStroke | EllipseStroke | null;
|
|
2631
|
-
existing: TextStroke | undefined;
|
|
2632
|
-
onCommit: (anchorId: string, text: string) => void;
|
|
2633
|
-
onCancel: () => void;
|
|
2634
|
-
}) {
|
|
2635
|
-
const ref = useRef<HTMLDivElement | null>(null);
|
|
2636
|
-
const initial = existing?.text ?? '';
|
|
2637
|
-
const initialRef = useRef(initial);
|
|
2638
|
-
initialRef.current = initial;
|
|
2639
|
-
|
|
2640
|
-
useEffect(() => {
|
|
2641
|
-
const el = ref.current;
|
|
2642
|
-
if (!el) return;
|
|
2643
|
-
el.focus();
|
|
2644
|
-
// Select all so a re-edit replaces existing text easily.
|
|
2645
|
-
try {
|
|
2646
|
-
const r = document.createRange();
|
|
2647
|
-
r.selectNodeContents(el);
|
|
2648
|
-
const sel = window.getSelection();
|
|
2649
|
-
if (sel) {
|
|
2650
|
-
sel.removeAllRanges();
|
|
2651
|
-
sel.addRange(r);
|
|
2652
|
-
}
|
|
2653
|
-
} catch {
|
|
2654
|
-
/* selection API blocked */
|
|
2655
|
-
}
|
|
2656
|
-
}, []);
|
|
2657
|
-
|
|
2658
|
-
// Commit on outside click; cancel-on-Esc handled in onKeyDown below.
|
|
2659
|
-
useEffect(() => {
|
|
2660
|
-
if (typeof document === 'undefined') return;
|
|
2661
|
-
const onDown = (e: PointerEvent) => {
|
|
2662
|
-
const el = ref.current;
|
|
2663
|
-
if (!el) return;
|
|
2664
|
-
if (el.contains(e.target as Node)) return;
|
|
2665
|
-
onCommit(anchorId, el.innerText || '');
|
|
2666
|
-
};
|
|
2667
|
-
document.addEventListener('pointerdown', onDown, true);
|
|
2668
|
-
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
2669
|
-
}, [anchorId, onCommit]);
|
|
2670
|
-
|
|
2671
|
-
if (!host) return null;
|
|
2672
|
-
const bbox = strokeBBox(host);
|
|
2673
|
-
if (!bbox) return null;
|
|
2674
|
-
const fontSize = existing?.fontSize ?? DEFAULT_FONT_SIZE;
|
|
2675
|
-
// Phase 24 — match the committed render's bold / strike / align (anchored
|
|
2676
|
-
// default align = centre).
|
|
2677
|
-
const align = existing?.align ?? 'center';
|
|
2678
|
-
return (
|
|
2679
|
-
<foreignObject x={bbox.x} y={bbox.y} width={Math.max(20, bbox.w)} height={Math.max(20, bbox.h)}>
|
|
2680
|
-
<div
|
|
2681
|
-
ref={ref}
|
|
2682
|
-
className="dc-annot-editor"
|
|
2683
|
-
contentEditable
|
|
2684
|
-
suppressContentEditableWarning
|
|
2685
|
-
aria-label="Edit annotation text"
|
|
2686
|
-
style={{
|
|
2687
|
-
width: '100%',
|
|
2688
|
-
height: '100%',
|
|
2689
|
-
display: 'flex',
|
|
2690
|
-
alignItems: 'center',
|
|
2691
|
-
justifyContent:
|
|
2692
|
-
align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
|
|
2693
|
-
padding: '0 8px',
|
|
2694
|
-
boxSizing: 'border-box',
|
|
2695
|
-
textAlign: align,
|
|
2696
|
-
color: existing?.color ?? '#1a1a1a',
|
|
2697
|
-
fontSize: `${fontSize}px`,
|
|
2698
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
2699
|
-
fontWeight: existing?.bold ? 700 : undefined,
|
|
2700
|
-
textDecoration: existing?.strike ? 'line-through' : undefined,
|
|
2701
|
-
lineHeight: 1.25,
|
|
2702
|
-
outline: 'none',
|
|
2703
|
-
background: 'transparent',
|
|
2704
|
-
cursor: 'text',
|
|
2705
|
-
}}
|
|
2706
|
-
onKeyDown={(e) => {
|
|
2707
|
-
if (e.key === 'Escape') {
|
|
2708
|
-
e.preventDefault();
|
|
2709
|
-
onCancel();
|
|
2710
|
-
return;
|
|
2711
|
-
}
|
|
2712
|
-
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
2713
|
-
e.preventDefault();
|
|
2714
|
-
const el = ref.current;
|
|
2715
|
-
onCommit(anchorId, el?.innerText || '');
|
|
2716
|
-
}
|
|
2717
|
-
}}
|
|
2718
|
-
>
|
|
2719
|
-
{initial}
|
|
2720
|
-
</div>
|
|
2721
|
-
</foreignObject>
|
|
2722
|
-
);
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
|
-
// Phase 21 — sticky body editor. A textarea hosted in a foreignObject at the
|
|
2726
|
-
// card's bbox, so it word-wraps + moves with CSS zoom natively. Commit on blur,
|
|
2727
|
-
// cancel on Esc; Enter inserts a newline (sticky is multi-line).
|
|
2728
|
-
function StickyEditor({
|
|
2729
|
-
sticky,
|
|
2730
|
-
onCommit,
|
|
2731
|
-
onCancel,
|
|
2732
|
-
}: {
|
|
2733
|
-
sticky: StickyStroke;
|
|
2734
|
-
onCommit: (text: string) => void;
|
|
2735
|
-
onCancel: () => void;
|
|
2736
|
-
}) {
|
|
2737
|
-
// A flex-centered contentEditable (NOT a textarea) so the edit view matches
|
|
2738
|
-
// the committed `.dc-sticky-body` exactly — text stays centered, no jump on
|
|
2739
|
-
// commit. Multi-line: Enter inserts a line break; Esc cancels; blur commits.
|
|
2740
|
-
const ref = useRef<HTMLDivElement | null>(null);
|
|
2741
|
-
useEffect(() => {
|
|
2742
|
-
const el = ref.current;
|
|
2743
|
-
if (!el) return;
|
|
2744
|
-
el.focus();
|
|
2745
|
-
try {
|
|
2746
|
-
const r = document.createRange();
|
|
2747
|
-
r.selectNodeContents(el);
|
|
2748
|
-
const sel = window.getSelection();
|
|
2749
|
-
if (sel) {
|
|
2750
|
-
sel.removeAllRanges();
|
|
2751
|
-
sel.addRange(r);
|
|
2752
|
-
}
|
|
2753
|
-
} catch {
|
|
2754
|
-
/* selection API blocked */
|
|
2755
|
-
}
|
|
2756
|
-
}, []);
|
|
2757
|
-
const x = Math.min(sticky.x, sticky.x + sticky.w);
|
|
2758
|
-
const y = Math.min(sticky.y, sticky.y + sticky.h);
|
|
2759
|
-
const w = Math.abs(sticky.w);
|
|
2760
|
-
const h = Math.abs(sticky.h);
|
|
2761
|
-
return (
|
|
2762
|
-
<foreignObject x={x} y={y} width={w} height={h}>
|
|
2763
|
-
<div
|
|
2764
|
-
xmlns="http://www.w3.org/1999/xhtml"
|
|
2765
|
-
ref={ref}
|
|
2766
|
-
className="dc-annot-editor dc-sticky-body"
|
|
2767
|
-
contentEditable
|
|
2768
|
-
suppressContentEditableWarning
|
|
2769
|
-
aria-label="Edit sticky note text"
|
|
2770
|
-
style={{ ...stickyBodyStyle(sticky), outline: 'none', cursor: 'text' }}
|
|
2771
|
-
onBlur={() => onCommit(ref.current?.innerText ?? '')}
|
|
2772
|
-
onKeyDown={(e) => {
|
|
2773
|
-
if (e.key === 'Escape') {
|
|
2774
|
-
e.preventDefault();
|
|
2775
|
-
onCancel();
|
|
2776
|
-
}
|
|
2777
|
-
}}
|
|
2778
|
-
>
|
|
2779
|
-
{sticky.text}
|
|
2780
|
-
</div>
|
|
2781
|
-
</foreignObject>
|
|
2782
|
-
);
|
|
2783
|
-
}
|
|
2784
|
-
|
|
2785
|
-
// Phase 21 — standalone text editor. A single-line contentEditable box anchored
|
|
2786
|
-
// at the world (x, y). Enter / blur / outside-click commit; Esc cancels.
|
|
2787
|
-
function StandaloneTextEditor({
|
|
2788
|
-
x,
|
|
2789
|
-
y,
|
|
2790
|
-
fontSize,
|
|
2791
|
-
color,
|
|
2792
|
-
initialText,
|
|
2793
|
-
bold,
|
|
2794
|
-
strike,
|
|
2795
|
-
align,
|
|
2796
|
-
onCommit,
|
|
2797
|
-
onCancel,
|
|
2798
|
-
}: {
|
|
2799
|
-
x: number;
|
|
2800
|
-
y: number;
|
|
2801
|
-
fontSize: number;
|
|
2802
|
-
color: string;
|
|
2803
|
-
initialText: string;
|
|
2804
|
-
bold?: boolean;
|
|
2805
|
-
strike?: boolean;
|
|
2806
|
-
align?: TextAlign;
|
|
2807
|
-
onCommit: (text: string) => void;
|
|
2808
|
-
onCancel: () => void;
|
|
2809
|
-
}) {
|
|
2810
|
-
const ref = useRef<HTMLDivElement | null>(null);
|
|
2811
|
-
useEffect(() => {
|
|
2812
|
-
const el = ref.current;
|
|
2813
|
-
if (!el) return;
|
|
2814
|
-
el.focus();
|
|
2815
|
-
try {
|
|
2816
|
-
const r = document.createRange();
|
|
2817
|
-
r.selectNodeContents(el);
|
|
2818
|
-
const sel = window.getSelection();
|
|
2819
|
-
if (sel) {
|
|
2820
|
-
sel.removeAllRanges();
|
|
2821
|
-
sel.addRange(r);
|
|
2822
|
-
}
|
|
2823
|
-
} catch {
|
|
2824
|
-
/* selection API blocked */
|
|
2825
|
-
}
|
|
2826
|
-
}, []);
|
|
2827
|
-
// Commit on outside click.
|
|
2828
|
-
useEffect(() => {
|
|
2829
|
-
if (typeof document === 'undefined') return;
|
|
2830
|
-
const onDown = (e: PointerEvent) => {
|
|
2831
|
-
const el = ref.current;
|
|
2832
|
-
if (!el) return;
|
|
2833
|
-
if (el.contains(e.target as Node)) return;
|
|
2834
|
-
onCommit(el.innerText || '');
|
|
2835
|
-
};
|
|
2836
|
-
document.addEventListener('pointerdown', onDown, true);
|
|
2837
|
-
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
2838
|
-
}, [onCommit]);
|
|
2839
|
-
return (
|
|
2840
|
-
<foreignObject x={x} y={y} width={600} height={Math.max(24, fontSize * 1.6)}>
|
|
2841
|
-
<div
|
|
2842
|
-
xmlns="http://www.w3.org/1999/xhtml"
|
|
2843
|
-
ref={ref}
|
|
2844
|
-
className="dc-annot-editor"
|
|
2845
|
-
contentEditable
|
|
2846
|
-
suppressContentEditableWarning
|
|
2847
|
-
aria-label="Edit text"
|
|
2848
|
-
style={{
|
|
2849
|
-
display: 'inline-block',
|
|
2850
|
-
minWidth: '8px',
|
|
2851
|
-
whiteSpace: 'pre',
|
|
2852
|
-
padding: '0 2px',
|
|
2853
|
-
color,
|
|
2854
|
-
fontSize: `${fontSize}px`,
|
|
2855
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
2856
|
-
fontWeight: bold ? 700 : undefined,
|
|
2857
|
-
textDecoration: strike ? 'line-through' : undefined,
|
|
2858
|
-
textAlign: align ?? 'left',
|
|
2859
|
-
lineHeight: 1.2,
|
|
2860
|
-
outline: 'none',
|
|
2861
|
-
background: 'transparent',
|
|
2862
|
-
cursor: 'text',
|
|
2863
|
-
}}
|
|
2864
|
-
onKeyDown={(e) => {
|
|
2865
|
-
if (e.key === 'Escape') {
|
|
2866
|
-
e.preventDefault();
|
|
2867
|
-
onCancel();
|
|
2868
|
-
return;
|
|
2869
|
-
}
|
|
2870
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
2871
|
-
e.preventDefault();
|
|
2872
|
-
onCommit(ref.current?.innerText || '');
|
|
2873
|
-
}
|
|
2874
|
-
}}
|
|
2875
|
-
>
|
|
2876
|
-
{initialText}
|
|
2877
|
-
</div>
|
|
2878
|
-
</foreignObject>
|
|
2879
|
-
);
|
|
2880
|
-
}
|
|
2881
|
-
|
|
2882
|
-
function SelectionHalo({
|
|
2883
|
-
stroke,
|
|
2884
|
-
anchorsById,
|
|
2885
|
-
multi,
|
|
2886
|
-
}: {
|
|
2887
|
-
stroke: Stroke;
|
|
2888
|
-
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
2889
|
-
multi: boolean;
|
|
2890
|
-
}) {
|
|
2891
|
-
const bbox = strokeBBox(stroke, anchorsById);
|
|
2892
|
-
if (!bbox) return null;
|
|
2893
|
-
// T17 + post-Wave-2 fix — annotation halo idioms:
|
|
2894
|
-
// * Single select → 2 px solid border, NO ring, NO corner ticks.
|
|
2895
|
-
// The resize overlay (T23) renders the corner handles in screen-space,
|
|
2896
|
-
// so painting SVG ticks here too would duplicate them. The element
|
|
2897
|
-
// halo uses CSS box-shadow for the 18% ring; the SVG equivalent (a
|
|
2898
|
-
// second outline rect) reads as "double frame" rather than a halo —
|
|
2899
|
-
// user feedback flagged this immediately. Solid 2 px is enough signal
|
|
2900
|
-
// once the resize handles claim the corners.
|
|
2901
|
-
// * Multi member → 1.5 px solid full accent, no ring, no ticks (group
|
|
2902
|
-
// bbox above carries the container affordance).
|
|
2903
|
-
// Marquee STAYS dashed (drawn elsewhere) — dashed is reserved for the
|
|
2904
|
-
// ambient group-container + active-gesture idioms per DDR-046 rev 2.
|
|
2905
|
-
const pad = 4;
|
|
2906
|
-
return (
|
|
2907
|
-
<rect
|
|
2908
|
-
x={bbox.x - pad}
|
|
2909
|
-
y={bbox.y - pad}
|
|
2910
|
-
width={bbox.w + pad * 2}
|
|
2911
|
-
height={bbox.h + pad * 2}
|
|
2912
|
-
fill="none"
|
|
2913
|
-
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
2914
|
-
strokeWidth={multi ? 1.5 : 2}
|
|
2915
|
-
vectorEffect="non-scaling-stroke"
|
|
2916
|
-
pointerEvents="none"
|
|
2917
|
-
rx={2}
|
|
2918
|
-
/>
|
|
2919
|
-
);
|
|
2920
|
-
}
|
|
2921
|
-
|
|
2922
|
-
// T17 — group bbox dashed rect for multi-stroke annotation selection. Mirrors
|
|
2923
|
-
// the element-side GroupBbox idiom (1 px dashed accent + 6 × 6 corner handles).
|
|
2924
|
-
function AnnotGroupBbox({
|
|
2925
|
-
selectedStrokes,
|
|
2926
|
-
anchorsById,
|
|
2927
|
-
}: {
|
|
2928
|
-
selectedStrokes: readonly Stroke[];
|
|
2929
|
-
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
2930
|
-
}) {
|
|
2931
|
-
if (selectedStrokes.length < 2) return null;
|
|
2932
|
-
let xMin = Number.POSITIVE_INFINITY;
|
|
2933
|
-
let yMin = Number.POSITIVE_INFINITY;
|
|
2934
|
-
let xMax = Number.NEGATIVE_INFINITY;
|
|
2935
|
-
let yMax = Number.NEGATIVE_INFINITY;
|
|
2936
|
-
let any = false;
|
|
2937
|
-
for (const s of selectedStrokes) {
|
|
2938
|
-
const b = strokeBBox(s, anchorsById);
|
|
2939
|
-
if (!b) continue;
|
|
2940
|
-
any = true;
|
|
2941
|
-
if (b.x < xMin) xMin = b.x;
|
|
2942
|
-
if (b.y < yMin) yMin = b.y;
|
|
2943
|
-
if (b.x + b.w > xMax) xMax = b.x + b.w;
|
|
2944
|
-
if (b.y + b.h > yMax) yMax = b.y + b.h;
|
|
2945
|
-
}
|
|
2946
|
-
if (!any) return null;
|
|
2947
|
-
const pad = 6;
|
|
2948
|
-
const x = xMin - pad;
|
|
2949
|
-
const y = yMin - pad;
|
|
2950
|
-
const w = xMax - xMin + pad * 2;
|
|
2951
|
-
const h = yMax - yMin + pad * 2;
|
|
2952
|
-
const handle = 6;
|
|
2953
|
-
const inset = 3;
|
|
2954
|
-
const handles = [
|
|
2955
|
-
{ corner: 'nw', x: x - inset, y: y - inset },
|
|
2956
|
-
{ corner: 'ne', x: x + w - handle + inset, y: y - inset },
|
|
2957
|
-
{ corner: 'sw', x: x - inset, y: y + h - handle + inset },
|
|
2958
|
-
{ corner: 'se', x: x + w - handle + inset, y: y + h - handle + inset },
|
|
2959
|
-
];
|
|
2960
|
-
return (
|
|
2961
|
-
<g pointerEvents="none">
|
|
2962
|
-
<rect
|
|
2963
|
-
x={x}
|
|
2964
|
-
y={y}
|
|
2965
|
-
width={w}
|
|
2966
|
-
height={h}
|
|
2967
|
-
fill="none"
|
|
2968
|
-
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
2969
|
-
strokeWidth={1}
|
|
2970
|
-
strokeDasharray="4 3"
|
|
2971
|
-
vectorEffect="non-scaling-stroke"
|
|
2972
|
-
rx={2}
|
|
2973
|
-
/>
|
|
2974
|
-
{handles.map((c) => (
|
|
2975
|
-
<rect
|
|
2976
|
-
key={c.corner}
|
|
2977
|
-
x={c.x}
|
|
2978
|
-
y={c.y}
|
|
2979
|
-
width={handle}
|
|
2980
|
-
height={handle}
|
|
2981
|
-
fill="var(--maude-hud-accent, #d63b1f)"
|
|
2982
|
-
stroke="var(--maude-chrome-bg-0, #ffffff)"
|
|
2983
|
-
strokeWidth={1}
|
|
2984
|
-
vectorEffect="non-scaling-stroke"
|
|
2985
|
-
rx={1}
|
|
2986
|
-
/>
|
|
2987
|
-
))}
|
|
2988
|
-
</g>
|
|
2989
|
-
);
|
|
2990
|
-
}
|
|
2991
|
-
|
|
2992
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2993
|
-
// Stroke renderer
|
|
2994
|
-
|
|
2995
|
-
/**
|
|
2996
|
-
* Phase 24 — inline style for a sticky body (read view + editor share it so the
|
|
2997
|
-
* read↔edit swap doesn't shift). Applies bold / strike / align atop the
|
|
2998
|
-
* `.dc-sticky-body` defaults (top-left).
|
|
2999
|
-
*/
|
|
3000
|
-
function stickyBodyStyle(s: StickyStroke): CSSProperties {
|
|
3001
|
-
const align = s.align ?? 'left';
|
|
3002
|
-
return {
|
|
3003
|
-
fontSize: `${s.fontSize}px`,
|
|
3004
|
-
fontWeight: s.bold ? 700 : undefined,
|
|
3005
|
-
textDecoration: s.strike ? 'line-through' : undefined,
|
|
3006
|
-
textAlign: align,
|
|
3007
|
-
justifyContent: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
|
|
3008
|
-
};
|
|
3009
|
-
}
|
|
3010
|
-
|
|
3011
|
-
/**
|
|
3012
|
-
* Phase 24 — the translucent cursor-following ghost placeholder. Pure chrome:
|
|
3013
|
-
* `pointer-events:none`, never added to `strokes`, so it can't be selected,
|
|
3014
|
-
* hit-tested, erased, or persisted. Static (no animation) — reduced-motion safe.
|
|
3015
|
-
* Geometry mirrors what a click/tap would create at the cursor (shape +
|
|
3016
|
-
* SHAPE_DEFAULT_SIZE top-left at cursor; sticky default square; text I-beam).
|
|
3017
|
-
*/
|
|
3018
|
-
function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
|
|
3019
|
-
const { x, y } = ghost;
|
|
3020
|
-
if (ghost.kind === 'text') {
|
|
3021
|
-
const h = 22;
|
|
3022
|
-
return (
|
|
3023
|
-
<path
|
|
3024
|
-
d={`M${x - 4} ${y}H${x + 4}M${x} ${y}V${y + h}M${x - 4} ${y + h}H${x + 4}`}
|
|
3025
|
-
stroke={ghost.color}
|
|
3026
|
-
strokeWidth={1.5}
|
|
3027
|
-
strokeOpacity={0.5}
|
|
3028
|
-
fill="none"
|
|
3029
|
-
vectorEffect="non-scaling-stroke"
|
|
3030
|
-
pointerEvents="none"
|
|
3031
|
-
/>
|
|
3032
|
-
);
|
|
3033
|
-
}
|
|
3034
|
-
if (ghost.kind === 'sticky') {
|
|
3035
|
-
const s = STICKY_DEFAULT_W;
|
|
3036
|
-
return (
|
|
3037
|
-
<rect
|
|
3038
|
-
x={x}
|
|
3039
|
-
y={y}
|
|
3040
|
-
width={s}
|
|
3041
|
-
height={s}
|
|
3042
|
-
rx={STICKY_CORNER_RADIUS}
|
|
3043
|
-
ry={STICKY_CORNER_RADIUS}
|
|
3044
|
-
fill={ghost.color}
|
|
3045
|
-
fillOpacity={0.32}
|
|
3046
|
-
stroke={ghost.color}
|
|
3047
|
-
strokeOpacity={0.55}
|
|
3048
|
-
strokeWidth={1.5}
|
|
3049
|
-
vectorEffect="non-scaling-stroke"
|
|
3050
|
-
pointerEvents="none"
|
|
3051
|
-
/>
|
|
3052
|
-
);
|
|
3053
|
-
}
|
|
3054
|
-
// shape — dashed outline of the default-sized primitive at the cursor.
|
|
3055
|
-
const sz = SHAPE_DEFAULT_SIZE;
|
|
3056
|
-
const common = {
|
|
3057
|
-
fill: 'none',
|
|
3058
|
-
stroke: ghost.color,
|
|
3059
|
-
strokeWidth: 2,
|
|
3060
|
-
strokeOpacity: 0.5,
|
|
3061
|
-
strokeDasharray: '6 5',
|
|
3062
|
-
vectorEffect: 'non-scaling-stroke' as const,
|
|
3063
|
-
pointerEvents: 'none' as const,
|
|
3064
|
-
};
|
|
3065
|
-
if (ghost.shapeKind === 'circle') {
|
|
3066
|
-
return <ellipse cx={x + sz / 2} cy={y + sz / 2} rx={sz / 2} ry={sz / 2} {...common} />;
|
|
3067
|
-
}
|
|
3068
|
-
if (ghost.shapeKind === 'square' || ghost.shapeKind === 'rounded') {
|
|
3069
|
-
const r = ghost.shapeKind === 'rounded' ? 8 : 0;
|
|
3070
|
-
return <rect x={x} y={y} width={sz} height={sz} rx={r} ry={r} {...common} />;
|
|
3071
|
-
}
|
|
3072
|
-
return <polygon points={polygonPoints(ghost.shapeKind, x, y, sz, sz)} {...common} />;
|
|
3073
|
-
}
|
|
3074
|
-
|
|
3075
|
-
function StrokeNode({
|
|
3076
|
-
stroke,
|
|
3077
|
-
anchorsById,
|
|
3078
|
-
interactive,
|
|
3079
|
-
editing = false,
|
|
3080
|
-
}: {
|
|
3081
|
-
stroke: Stroke;
|
|
3082
|
-
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
3083
|
-
interactive: boolean;
|
|
3084
|
-
/** Phase 21 — sticky-only: hide the read-only body while its editor is up. */
|
|
3085
|
-
editing?: boolean;
|
|
3086
|
-
}) {
|
|
3087
|
-
// In Move mode, individual stroke nodes claim pointer events so we can
|
|
3088
|
-
// hit-test them from the doc-level capture listener. In draw mode the
|
|
3089
|
-
// overlay above handles input, so the strokes themselves stay inert.
|
|
3090
|
-
const hitMode = interactive ? 'visiblePainted' : ('none' as const);
|
|
3091
|
-
const strokeHit = interactive ? 'stroke' : ('none' as const);
|
|
3092
|
-
if (stroke.tool === 'text') {
|
|
3093
|
-
// Anchored text renders centered in its host; standalone (Phase 21) renders
|
|
3094
|
-
// top-left-anchored at its own world (x, y).
|
|
3095
|
-
// Phase 24 — bold / strike / align applied to the rendered <text>.
|
|
3096
|
-
const textStyle = {
|
|
3097
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
3098
|
-
fontWeight: stroke.bold ? 700 : undefined,
|
|
3099
|
-
textDecoration: stroke.strike ? 'line-through' : undefined,
|
|
3100
|
-
} as const;
|
|
3101
|
-
if (stroke.anchorId != null && stroke.anchorId !== '') {
|
|
3102
|
-
const host = anchorsById.get(stroke.anchorId);
|
|
3103
|
-
const bbox = host ? strokeBBox(host) : null;
|
|
3104
|
-
if (!bbox) return null;
|
|
3105
|
-
const cy = bbox.y + bbox.h / 2;
|
|
3106
|
-
const align = stroke.align ?? 'center';
|
|
3107
|
-
const pad = 8;
|
|
3108
|
-
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
3109
|
-
const tx =
|
|
3110
|
-
align === 'left'
|
|
3111
|
-
? bbox.x + pad
|
|
3112
|
-
: align === 'right'
|
|
3113
|
-
? bbox.x + bbox.w - pad
|
|
3114
|
-
: bbox.x + bbox.w / 2;
|
|
3115
|
-
return (
|
|
3116
|
-
<text
|
|
3117
|
-
data-id={stroke.id}
|
|
3118
|
-
data-tool="text"
|
|
3119
|
-
data-anchor-id={stroke.anchorId}
|
|
3120
|
-
data-font-size={stroke.fontSize}
|
|
3121
|
-
x={tx}
|
|
3122
|
-
y={cy}
|
|
3123
|
-
fill={stroke.color}
|
|
3124
|
-
fontSize={stroke.fontSize}
|
|
3125
|
-
textAnchor={anchor}
|
|
3126
|
-
dominantBaseline="middle"
|
|
3127
|
-
style={textStyle}
|
|
3128
|
-
>
|
|
3129
|
-
{stroke.text}
|
|
3130
|
-
</text>
|
|
3131
|
-
);
|
|
3132
|
-
}
|
|
3133
|
-
const align = stroke.align ?? 'left';
|
|
3134
|
-
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
3135
|
-
return (
|
|
3136
|
-
<text
|
|
3137
|
-
data-id={stroke.id}
|
|
3138
|
-
data-tool="text"
|
|
3139
|
-
data-font-size={stroke.fontSize}
|
|
3140
|
-
x={stroke.x ?? 0}
|
|
3141
|
-
y={stroke.y ?? 0}
|
|
3142
|
-
fill={stroke.color}
|
|
3143
|
-
fontSize={stroke.fontSize}
|
|
3144
|
-
textAnchor={anchor}
|
|
3145
|
-
dominantBaseline="hanging"
|
|
3146
|
-
pointerEvents={interactive ? 'visiblePainted' : 'none'}
|
|
3147
|
-
style={textStyle}
|
|
3148
|
-
>
|
|
3149
|
-
{stroke.text}
|
|
3150
|
-
</text>
|
|
3151
|
-
);
|
|
3152
|
-
}
|
|
3153
|
-
if (stroke.tool === 'sticky') {
|
|
3154
|
-
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
3155
|
-
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
3156
|
-
const w = Math.abs(stroke.w);
|
|
3157
|
-
const h = Math.abs(stroke.h);
|
|
3158
|
-
const r = stroke.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
3159
|
-
return (
|
|
3160
|
-
<g data-id={stroke.id} data-tool="sticky" pointerEvents={hitMode}>
|
|
3161
|
-
{/* Paper card: soft drop shadow + hairline edge so it reads as a
|
|
3162
|
-
lifted sticky, not a flat colored box (FigJam-style). */}
|
|
3163
|
-
<rect
|
|
3164
|
-
x={x}
|
|
3165
|
-
y={y}
|
|
3166
|
-
width={w}
|
|
3167
|
-
height={h}
|
|
3168
|
-
rx={r}
|
|
3169
|
-
ry={r}
|
|
3170
|
-
fill={stroke.color}
|
|
3171
|
-
stroke="rgba(0,0,0,0.05)"
|
|
3172
|
-
strokeWidth={1}
|
|
3173
|
-
vectorEffect="non-scaling-stroke"
|
|
3174
|
-
filter="url(#dc-sticky-shadow)"
|
|
3175
|
-
/>
|
|
3176
|
-
{editing ? null : (
|
|
3177
|
-
<foreignObject x={x} y={y} width={w} height={h} pointerEvents="none">
|
|
3178
|
-
<div
|
|
3179
|
-
xmlns="http://www.w3.org/1999/xhtml"
|
|
3180
|
-
className="dc-sticky-body"
|
|
3181
|
-
style={stickyBodyStyle(stroke)}
|
|
3182
|
-
>
|
|
3183
|
-
{stroke.text}
|
|
3184
|
-
</div>
|
|
3185
|
-
</foreignObject>
|
|
3186
|
-
)}
|
|
3187
|
-
</g>
|
|
3188
|
-
);
|
|
3189
|
-
}
|
|
3190
|
-
const common = {
|
|
3191
|
-
'data-id': stroke.id,
|
|
3192
|
-
'data-tool': stroke.tool,
|
|
3193
|
-
stroke: stroke.color,
|
|
3194
|
-
strokeWidth: stroke.width,
|
|
3195
|
-
strokeLinecap: 'round' as const,
|
|
3196
|
-
strokeLinejoin: 'round' as const,
|
|
3197
|
-
vectorEffect: 'non-scaling-stroke' as const,
|
|
3198
|
-
};
|
|
3199
|
-
if (stroke.tool === 'pen') {
|
|
3200
|
-
return <path {...common} fill="none" d={penPathD(stroke.points)} pointerEvents={strokeHit} />;
|
|
3201
|
-
}
|
|
3202
|
-
if (stroke.tool === 'rect') {
|
|
3203
|
-
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
3204
|
-
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
3205
|
-
const r = stroke.cornerRadius ?? 0;
|
|
3206
|
-
return (
|
|
3207
|
-
<rect
|
|
3208
|
-
{...common}
|
|
3209
|
-
fill={stroke.fill ?? 'none'}
|
|
3210
|
-
x={x}
|
|
3211
|
-
y={y}
|
|
3212
|
-
width={Math.abs(stroke.w)}
|
|
3213
|
-
height={Math.abs(stroke.h)}
|
|
3214
|
-
rx={r}
|
|
3215
|
-
ry={r}
|
|
3216
|
-
pointerEvents={hitMode}
|
|
3217
|
-
/>
|
|
3218
|
-
);
|
|
3219
|
-
}
|
|
3220
|
-
if (stroke.tool === 'ellipse') {
|
|
3221
|
-
return (
|
|
3222
|
-
<ellipse
|
|
3223
|
-
{...common}
|
|
3224
|
-
fill={stroke.fill ?? 'none'}
|
|
3225
|
-
cx={stroke.cx}
|
|
3226
|
-
cy={stroke.cy}
|
|
3227
|
-
rx={Math.max(0, stroke.rx)}
|
|
3228
|
-
ry={Math.max(0, stroke.ry)}
|
|
3229
|
-
pointerEvents={hitMode}
|
|
3230
|
-
/>
|
|
3231
|
-
);
|
|
3232
|
-
}
|
|
3233
|
-
if (stroke.tool === 'polygon') {
|
|
3234
|
-
const nx = Math.min(stroke.x, stroke.x + stroke.w);
|
|
3235
|
-
const ny = Math.min(stroke.y, stroke.y + stroke.h);
|
|
3236
|
-
return (
|
|
3237
|
-
<polygon
|
|
3238
|
-
{...common}
|
|
3239
|
-
data-shape={stroke.shape}
|
|
3240
|
-
fill={stroke.fill ?? 'none'}
|
|
3241
|
-
points={polygonPoints(stroke.shape, nx, ny, Math.abs(stroke.w), Math.abs(stroke.h))}
|
|
3242
|
-
strokeDasharray={stroke.dashed ? '6 4' : undefined}
|
|
3243
|
-
pointerEvents={hitMode}
|
|
3244
|
-
/>
|
|
3245
|
-
);
|
|
3246
|
-
}
|
|
3247
|
-
// arrow — Phase 24 renders the SAME ordered primitives the serializer emits
|
|
3248
|
-
// (canvas-arrowheads), so the on-canvas and persisted forms can never drift.
|
|
3249
|
-
return (
|
|
3250
|
-
<g {...common} fill="none" pointerEvents={hitMode}>
|
|
3251
|
-
{arrowPrimitives(stroke).map((p, i) => renderArrowPrimitive(p, i))}
|
|
3252
|
-
</g>
|
|
3253
|
-
);
|
|
3254
|
-
}
|
|
3255
|
-
|
|
3256
|
-
/** Map one arrow primitive to JSX (heads inherit stroke from the parent <g>). */
|
|
3257
|
-
function renderArrowPrimitive(p: SvgPrimitive, key: number): JSX.Element {
|
|
3258
|
-
switch (p.el) {
|
|
3259
|
-
case 'line':
|
|
3260
|
-
return (
|
|
3261
|
-
<line
|
|
3262
|
-
key={key}
|
|
3263
|
-
x1={p.x1}
|
|
3264
|
-
y1={p.y1}
|
|
3265
|
-
x2={p.x2}
|
|
3266
|
-
y2={p.y2}
|
|
3267
|
-
strokeDasharray={p.dash ? '6 4' : undefined}
|
|
3268
|
-
/>
|
|
3269
|
-
);
|
|
3270
|
-
case 'path':
|
|
3271
|
-
return <path key={key} d={p.d} strokeDasharray={p.dash ? '6 4' : undefined} />;
|
|
3272
|
-
case 'polyline':
|
|
3273
|
-
return <polyline key={key} points={p.points} fill={p.fill} />;
|
|
3274
|
-
case 'polygon':
|
|
3275
|
-
return <polygon key={key} points={p.points} fill={p.fill} />;
|
|
3276
|
-
case 'circle':
|
|
3277
|
-
return <circle key={key} cx={p.cx} cy={p.cy} r={p.r} fill={p.fill} />;
|
|
3278
|
-
}
|
|
3279
|
-
}
|
|
3280
|
-
|
|
3281
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
3282
|
-
// Chrome — color swatches + (optional fill picker) + (optional thickness chip)
|
|
3283
|
-
// + presentation toggle + help button.
|
|
3284
|
-
|
|
3285
|
-
function AnnotationsChrome({
|
|
3286
|
-
tool,
|
|
3287
|
-
color,
|
|
3288
|
-
setColor,
|
|
3289
|
-
stickyColor,
|
|
3290
|
-
setStickyColor,
|
|
3291
|
-
supportsFill,
|
|
3292
|
-
fill,
|
|
3293
|
-
setFill,
|
|
3294
|
-
supportsThickness,
|
|
3295
|
-
thickness,
|
|
3296
|
-
setThickness,
|
|
3297
|
-
}: {
|
|
3298
|
-
tool: Tool;
|
|
3299
|
-
color: string;
|
|
3300
|
-
setColor: (c: string) => void;
|
|
3301
|
-
stickyColor: string;
|
|
3302
|
-
setStickyColor: (c: string) => void;
|
|
3303
|
-
supportsFill: boolean;
|
|
3304
|
-
fill: string | null;
|
|
3305
|
-
setFill: (f: string | null) => void;
|
|
3306
|
-
supportsThickness: boolean;
|
|
3307
|
-
thickness: Thickness;
|
|
3308
|
-
setThickness: (t: Thickness) => void;
|
|
3309
|
-
}) {
|
|
3310
|
-
// Sticky tool picks a paper tint (its own palette); every other draw tool
|
|
3311
|
-
// picks ink from the stroke PALETTE.
|
|
3312
|
-
if (tool === 'sticky') {
|
|
3313
|
-
return (
|
|
3314
|
-
<div className="dc-annot-chrome" role="toolbar" aria-label="Sticky note tools">
|
|
3315
|
-
<div className="dc-annot-swatches" role="radiogroup" aria-label="Sticky color">
|
|
3316
|
-
{STICKY_PALETTE.map((c) => (
|
|
3317
|
-
<button
|
|
3318
|
-
key={c}
|
|
3319
|
-
type="button"
|
|
3320
|
-
className="dc-annot-sw"
|
|
3321
|
-
aria-pressed={c === stickyColor}
|
|
3322
|
-
aria-label={`Sticky color ${c}`}
|
|
3323
|
-
title={`Sticky color ${c}`}
|
|
3324
|
-
style={{ background: c }}
|
|
3325
|
-
onClick={() => setStickyColor(c)}
|
|
3326
|
-
/>
|
|
3327
|
-
))}
|
|
3328
|
-
</div>
|
|
3329
|
-
</div>
|
|
3330
|
-
);
|
|
3331
|
-
}
|
|
3332
|
-
return (
|
|
3333
|
-
<div className="dc-annot-chrome" role="toolbar" aria-label="Annotation tools">
|
|
3334
|
-
<div className="dc-annot-swatches" role="radiogroup" aria-label="Stroke color">
|
|
3335
|
-
{STROKE_PALETTE.map((c) => (
|
|
3336
|
-
<button
|
|
3337
|
-
key={c}
|
|
3338
|
-
type="button"
|
|
3339
|
-
className="dc-annot-sw"
|
|
3340
|
-
aria-pressed={c === color}
|
|
3341
|
-
aria-label={`Color ${c}`}
|
|
3342
|
-
title={`Color ${c}`}
|
|
3343
|
-
style={{ background: c }}
|
|
3344
|
-
onClick={() => setColor(c)}
|
|
3345
|
-
/>
|
|
3346
|
-
))}
|
|
3347
|
-
</div>
|
|
3348
|
-
{supportsFill ? (
|
|
3349
|
-
<>
|
|
3350
|
-
<div className="dc-annot-sep" />
|
|
3351
|
-
<div className="dc-annot-swatches" role="radiogroup" aria-label="Fill color">
|
|
3352
|
-
<button
|
|
3353
|
-
type="button"
|
|
3354
|
-
className="dc-annot-fill dc-annot-fill--none"
|
|
3355
|
-
aria-pressed={fill == null}
|
|
3356
|
-
aria-label="No fill"
|
|
3357
|
-
title="No fill"
|
|
3358
|
-
onClick={() => setFill(null)}
|
|
3359
|
-
/>
|
|
3360
|
-
{FILL_PALETTE.map((c) => (
|
|
3361
|
-
<button
|
|
3362
|
-
key={c}
|
|
3363
|
-
type="button"
|
|
3364
|
-
className="dc-annot-fill"
|
|
3365
|
-
aria-pressed={c === fill}
|
|
3366
|
-
aria-label={`Fill ${c}`}
|
|
3367
|
-
title={`Fill ${c}`}
|
|
3368
|
-
style={{ background: c }}
|
|
3369
|
-
onClick={() => setFill(c)}
|
|
3370
|
-
/>
|
|
3371
|
-
))}
|
|
3372
|
-
</div>
|
|
3373
|
-
</>
|
|
3374
|
-
) : null}
|
|
3375
|
-
{supportsThickness ? (
|
|
3376
|
-
<>
|
|
3377
|
-
<div className="dc-annot-sep" />
|
|
3378
|
-
<button
|
|
3379
|
-
type="button"
|
|
3380
|
-
className="dc-annot-ibtn"
|
|
3381
|
-
aria-label="Thin stroke"
|
|
3382
|
-
aria-pressed={thickness === STROKE_WIDTH_THIN}
|
|
3383
|
-
title="Thin (3px)"
|
|
3384
|
-
onClick={() => setThickness(STROKE_WIDTH_THIN)}
|
|
3385
|
-
>
|
|
3386
|
-
<IconLineThin />
|
|
3387
|
-
</button>
|
|
3388
|
-
<button
|
|
3389
|
-
type="button"
|
|
3390
|
-
className="dc-annot-ibtn"
|
|
3391
|
-
aria-label="Thick stroke"
|
|
3392
|
-
aria-pressed={thickness === STROKE_WIDTH_THICK}
|
|
3393
|
-
title="Thick (6px)"
|
|
3394
|
-
onClick={() => setThickness(STROKE_WIDTH_THICK)}
|
|
3395
|
-
>
|
|
3396
|
-
<IconLineThick />
|
|
3397
|
-
</button>
|
|
3398
|
-
</>
|
|
3399
|
-
) : null}
|
|
3400
|
-
</div>
|
|
3401
|
-
);
|
|
3402
|
-
}
|