@1agh/maude 0.28.1 → 0.29.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 +2 -2
- package/{plugins/design/dev-server → apps/studio}/ai-banner.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/annotations-context-toolbar.tsx +285 -5
- package/{plugins/design/dev-server → apps/studio}/annotations-layer.tsx +1206 -119
- package/{plugins/design/dev-server → apps/studio}/api.ts +486 -32
- 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/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 +438 -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 +5 -3
- package/{plugins/design/dev-server → apps/studio}/canvas-arrowheads.ts +1 -1
- 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 +46 -12
- package/{plugins/design/dev-server → apps/studio}/canvas-icons.tsx +60 -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 +56 -41
- package/{plugins/design/dev-server → apps/studio}/client/app.jsx +1713 -530
- package/apps/studio/client/canvas-url.js +81 -0
- package/{plugins/design/dev-server → apps/studio}/client/comments-overlay.css +41 -27
- package/{plugins/design/dev-server → apps/studio}/client/index.html +2 -1
- package/apps/studio/client/styles/1-tokens-maude.css +296 -0
- package/apps/studio/client/styles/3-shell-maude.css +459 -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 +357 -34
- package/apps/studio/client/styles/5-maude-overrides.css +123 -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 +191 -0
- package/apps/studio/client/tour/usage-tour.js +35 -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 +1 -1
- 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 +1 -1
- package/{plugins/design/dev-server → apps/studio}/comments-overlay.tsx +25 -6
- package/{plugins/design/dev-server → apps/studio}/context-menu.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/contextual-toolbar.tsx +4 -7
- package/{plugins/design/dev-server → apps/studio}/cursors-overlay.tsx +1 -1
- package/apps/studio/dist/client.bundle.js +22825 -0
- package/apps/studio/dist/comment-mount.js +1960 -0
- package/apps/studio/dist/styles.css +5695 -0
- package/{plugins/design/dev-server → apps/studio}/dom-selection.ts +75 -4
- 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 +13 -1
- package/{plugins/design/dev-server → apps/studio}/http.ts +102 -1
- package/{plugins/design/dev-server → apps/studio}/input-router.tsx +8 -1
- package/{plugins/design/dev-server → apps/studio}/inspect.ts +20 -3
- package/{plugins/design/dev-server → apps/studio}/marquee-overlay.tsx +5 -2
- package/{plugins/design/dev-server → apps/studio}/participants-chrome.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/paths.ts +15 -11
- package/{plugins/design/dev-server → apps/studio}/server.ts +18 -0
- package/{plugins/design/dev-server → apps/studio}/sync/index.ts +1 -1
- 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-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/{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 +17 -1
- package/apps/studio/test/canvas-url.test.ts +99 -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/tour-overlay.test.tsx +56 -0
- package/{plugins/design/dev-server → apps/studio}/test/use-annotation-resize.test.ts +43 -0
- package/{plugins/design/dev-server → apps/studio}/test/use-tool-mode.test.tsx +4 -1
- 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 +4 -69
- package/{plugins/design/dev-server → apps/studio}/undo-hud.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/undo-stack.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/use-agent-presence.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/use-annotation-resize.tsx +40 -7
- package/{plugins/design/dev-server → apps/studio}/use-annotation-selection.tsx +1 -1
- 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 +1 -1
- package/apps/studio/use-canvas-media-drop.tsx +343 -0
- package/{plugins/design/dev-server → apps/studio}/use-collab.tsx +1 -1
- 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 +9 -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 +3 -1
- package/{plugins/design/dev-server → apps/studio}/use-undo-stack.tsx +1 -1
- package/apps/studio/whats-new.json +139 -0
- package/apps/studio/whats-new.schema.json +70 -0
- package/apps/studio/whats-new.ts +87 -0
- package/cli/bin/maude.mjs +10 -0
- package/cli/commands/design.mjs +15 -7
- package/cli/commands/design.test.mjs +36 -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/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/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/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 → 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/_png-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-edit.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}/history.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/agent.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/atomic-write.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/codec.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/connection-state.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/migrate-seed.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/origins.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/projection.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/status.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/activity.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-edit.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-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-room.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/csp-canvas-shell.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-migrate.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-agent.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-codec.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-connection-state.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-hardening.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-hubs-config.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-runtime.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-status.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-artboard-drag.test.ts +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-collab.test.ts +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,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file annotations-layer.tsx — FigJam-style annotation overlay
|
|
3
|
-
* @scope
|
|
3
|
+
* @scope apps/studio/annotations-layer.tsx
|
|
4
4
|
* @purpose Portal-rendered draw layer. Strokes live in world coords and
|
|
5
5
|
* render INSIDE `.dc-world` via `createPortal`, so CSS `zoom` +
|
|
6
6
|
* `translate` on the world move them in lockstep with artboards
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import {
|
|
25
25
|
type CSSProperties,
|
|
26
26
|
createContext,
|
|
27
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
27
28
|
type PointerEvent as ReactPointerEvent,
|
|
28
29
|
useCallback,
|
|
29
30
|
useContext,
|
|
@@ -49,6 +50,14 @@ import { crossedDragThreshold, type Tool } from './input-router.tsx';
|
|
|
49
50
|
import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
|
|
50
51
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
51
52
|
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
53
|
+
import {
|
|
54
|
+
isHttpUrl,
|
|
55
|
+
linkDomain,
|
|
56
|
+
prettifyUrl,
|
|
57
|
+
showCanvasToast,
|
|
58
|
+
uploadAsset,
|
|
59
|
+
useCanvasMediaDrop,
|
|
60
|
+
} from './use-canvas-media-drop.tsx';
|
|
52
61
|
import { useCollab } from './use-collab.tsx';
|
|
53
62
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
54
63
|
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
@@ -80,6 +89,13 @@ export interface PenStroke {
|
|
|
80
89
|
color: string;
|
|
81
90
|
width: number;
|
|
82
91
|
points: WorldPoint[];
|
|
92
|
+
/**
|
|
93
|
+
* Highlighter (item 8). A `highlighter:true` pen reuses ALL pen draw / erase /
|
|
94
|
+
* hit-test / translate logic; it just renders wide + translucent with
|
|
95
|
+
* `mix-blend-mode:multiply` (overlaps darken) and carries a translucent
|
|
96
|
+
* marker colour. Absent / false = a normal solid pen (back-compat).
|
|
97
|
+
*/
|
|
98
|
+
highlighter?: boolean;
|
|
83
99
|
}
|
|
84
100
|
export interface RectStroke {
|
|
85
101
|
id: string;
|
|
@@ -93,6 +109,8 @@ export interface RectStroke {
|
|
|
93
109
|
fill?: string | null;
|
|
94
110
|
/** Phase 21 — corner radius (rx/ry). Absent / 0 = sharp 90° corners (back-compat). */
|
|
95
111
|
cornerRadius?: number;
|
|
112
|
+
/** Dashed outline (stroke-dasharray). Absent / false = solid (back-compat). */
|
|
113
|
+
dashed?: boolean;
|
|
96
114
|
}
|
|
97
115
|
export interface EllipseStroke {
|
|
98
116
|
id: string;
|
|
@@ -104,6 +122,8 @@ export interface EllipseStroke {
|
|
|
104
122
|
rx: number;
|
|
105
123
|
ry: number;
|
|
106
124
|
fill?: string | null;
|
|
125
|
+
/** Dashed outline (stroke-dasharray). Absent / false = solid (back-compat). */
|
|
126
|
+
dashed?: boolean;
|
|
107
127
|
}
|
|
108
128
|
/**
|
|
109
129
|
* Phase 24 — diamond / triangle / triangle-down primitives. Stored as a bbox
|
|
@@ -163,6 +183,12 @@ export interface TextStroke {
|
|
|
163
183
|
bold?: boolean;
|
|
164
184
|
/** Phase 24 — strikethrough. Absent / false = none (back-compat). */
|
|
165
185
|
strike?: boolean;
|
|
186
|
+
/** Italic style (item 4b). Absent / false = upright (back-compat). */
|
|
187
|
+
italic?: boolean;
|
|
188
|
+
/** Underline (item 4b). Combined with strike into one text-decoration. */
|
|
189
|
+
underline?: boolean;
|
|
190
|
+
/** List style (item 4c). Markers are render-only — never stored in `text`. */
|
|
191
|
+
listType?: ListType;
|
|
166
192
|
/**
|
|
167
193
|
* Phase 24 — horizontal alignment. Absent default differs by kind: anchored
|
|
168
194
|
* text = 'center' (legacy, byte-identical), standalone = 'left'.
|
|
@@ -186,9 +212,59 @@ export interface StickyStroke {
|
|
|
186
212
|
bold?: boolean;
|
|
187
213
|
/** Phase 24 — strikethrough body. Absent / false = none. */
|
|
188
214
|
strike?: boolean;
|
|
215
|
+
/** Italic body (item 4b). Absent / false = upright. */
|
|
216
|
+
italic?: boolean;
|
|
217
|
+
/** Underline body (item 4b). Combined with strike into one text-decoration. */
|
|
218
|
+
underline?: boolean;
|
|
219
|
+
/** List style (item 4c). Markers are render-only — never stored in `text`. */
|
|
220
|
+
listType?: ListType;
|
|
189
221
|
/** Phase 24 — body alignment. Absent = 'left' (FigJam sticky default). */
|
|
190
222
|
align?: TextAlign;
|
|
191
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Phase 23 — dropped / pasted raster image. Free-floating, rect-shaped, moves
|
|
226
|
+
* and resizes like any annotation. `href` is ALWAYS a relative
|
|
227
|
+
* `assets/<sha8>.<ext>` path (never a data: URL — keeps the persisted SVG under
|
|
228
|
+
* its 1 MB cap and matches the sanitizer's `<image>` href allowlist). The live
|
|
229
|
+
* canvas may briefly render an optimistic `blob:` href before the upload swaps
|
|
230
|
+
* it to the content-addressed path; only the `assets/…` form is ever persisted.
|
|
231
|
+
*/
|
|
232
|
+
export interface ImageStroke {
|
|
233
|
+
id: string;
|
|
234
|
+
tool: 'image';
|
|
235
|
+
x: number;
|
|
236
|
+
y: number;
|
|
237
|
+
w: number;
|
|
238
|
+
h: number;
|
|
239
|
+
href: string;
|
|
240
|
+
/**
|
|
241
|
+
* Alt text. Persisted in `data-alt` and emitted as `aria-label` on the
|
|
242
|
+
* `<image>`, so it travels with the exported / saved SVG (where AT reads it).
|
|
243
|
+
* NOTE: in the LIVE canvas the whole annotation SVG root is `aria-hidden`
|
|
244
|
+
* (editor chrome — AT shouldn't be flooded by decorative strokes), so the live
|
|
245
|
+
* in-canvas `aria-label` is pruned; the alt's audience is the export. Absent ⇒ ''.
|
|
246
|
+
*/
|
|
247
|
+
alt?: string;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Phase 23 — pasted / dropped URL rendered as a client-only preview chip. NO
|
|
251
|
+
* server fetch and NO external favicon (the dev-server stays zero-egress —
|
|
252
|
+
* DDR-054/060). `title` comes from the clipboard/DnD `text/html` anchor text
|
|
253
|
+
* when present, else the prettified URL; `domain` is `new URL(url).hostname`.
|
|
254
|
+
* Persists as an allowlisted `<g>` (rect + vector glyph + two `<text>` runs) —
|
|
255
|
+
* the click-to-open handler reads `data-url`, no `<a href>` is ever stored.
|
|
256
|
+
*/
|
|
257
|
+
export interface LinkStroke {
|
|
258
|
+
id: string;
|
|
259
|
+
tool: 'link';
|
|
260
|
+
x: number;
|
|
261
|
+
y: number;
|
|
262
|
+
w: number;
|
|
263
|
+
h: number;
|
|
264
|
+
url: string;
|
|
265
|
+
title: string;
|
|
266
|
+
domain: string;
|
|
267
|
+
}
|
|
192
268
|
export type Stroke =
|
|
193
269
|
| PenStroke
|
|
194
270
|
| RectStroke
|
|
@@ -196,7 +272,9 @@ export type Stroke =
|
|
|
196
272
|
| PolygonStroke
|
|
197
273
|
| ArrowStroke
|
|
198
274
|
| TextStroke
|
|
199
|
-
| StickyStroke
|
|
275
|
+
| StickyStroke
|
|
276
|
+
| ImageStroke
|
|
277
|
+
| LinkStroke;
|
|
200
278
|
|
|
201
279
|
/**
|
|
202
280
|
* Phase 21 — what the inline editor is currently bound to. `anchored` edits
|
|
@@ -235,6 +313,45 @@ type PaletteColor = (typeof STROKE_PALETTE)[number];
|
|
|
235
313
|
// other hues stay one click away. (Stickies keep their warm-paper default —
|
|
236
314
|
// DEFAULT_STICKY_COLOR — they're paper, not ink.)
|
|
237
315
|
const DEFAULT_COLOR: PaletteColor = STROKE_PALETTE[8];
|
|
316
|
+
// Annotation polish — the LIVE default ink follows the canvas theme so a
|
|
317
|
+
// freshly-armed pen/shape/arrow/text reads true on dark canvases (the
|
|
318
|
+
// `#1f1f1f` ink is near-invisible on a dark mock). Light → the `#1f1f1f`
|
|
319
|
+
// ink slot; dark → a light ink that reads on dark. This is the live draw
|
|
320
|
+
// default ONLY — `DEFAULT_COLOR` stays the parse fallback (round-trip
|
|
321
|
+
// determinism + back-compat), and stored strokes keep their literal hex
|
|
322
|
+
// (FigJam parity — no retroactive recolour).
|
|
323
|
+
const DEFAULT_INK_DARK = '#ededed';
|
|
324
|
+
export function resolveDefaultInk(theme: string): string {
|
|
325
|
+
return theme === 'dark' ? DEFAULT_INK_DARK : DEFAULT_COLOR;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The canvas-shell CHROME theme — `data-maude-theme` on `<html>`, default
|
|
330
|
+
* 'dark'. This (NOT the DS `data-theme`, which is deliberately separate and
|
|
331
|
+
* themes only artboard palettes — canvas-shell.tsx) is what flips the canvas
|
|
332
|
+
* BACKGROUND the annotation ink sits on, so the theme-aware default ink follows
|
|
333
|
+
* it. Re-resolves on `data-maude-theme` mutation (the dark/light toggle posts it
|
|
334
|
+
* into the iframe after mount, so we re-read once on mount too).
|
|
335
|
+
*/
|
|
336
|
+
function readChromeTheme(): 'light' | 'dark' {
|
|
337
|
+
if (typeof document === 'undefined') return 'dark';
|
|
338
|
+
return document.documentElement.dataset.maudeTheme === 'light' ? 'light' : 'dark';
|
|
339
|
+
}
|
|
340
|
+
function useCanvasChromeTheme(): 'light' | 'dark' {
|
|
341
|
+
const [theme, setTheme] = useState<'light' | 'dark'>(readChromeTheme);
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
if (typeof document === 'undefined') return;
|
|
344
|
+
const sync = () => setTheme(readChromeTheme());
|
|
345
|
+
sync(); // catch a value stamped between the initializer and this effect
|
|
346
|
+
const obs = new MutationObserver(sync);
|
|
347
|
+
obs.observe(document.documentElement, {
|
|
348
|
+
attributes: true,
|
|
349
|
+
attributeFilter: ['data-maude-theme'],
|
|
350
|
+
});
|
|
351
|
+
return () => obs.disconnect();
|
|
352
|
+
}, []);
|
|
353
|
+
return theme;
|
|
354
|
+
}
|
|
238
355
|
|
|
239
356
|
// Light tints, index-paired to STROKE_PALETTE — picking "blue fill" gives a
|
|
240
357
|
// pale blue wash under a saturated stroke, exactly like FigJam shapes.
|
|
@@ -250,6 +367,24 @@ export const FILL_PALETTE = [
|
|
|
250
367
|
'#e7e7e7', // ink tint
|
|
251
368
|
] as const;
|
|
252
369
|
|
|
370
|
+
// Neutral fill wash for the ink slot (no paired hue) — light vs dark canvas.
|
|
371
|
+
const NEUTRAL_FILL_LIGHT = FILL_PALETTE[8]; // '#e7e7e7'
|
|
372
|
+
const NEUTRAL_FILL_DARK = '#2a2a2a';
|
|
373
|
+
/**
|
|
374
|
+
* Annotation polish (item 2) — the LIVE default fill for a freshly-armed Shape
|
|
375
|
+
* tool. A coloured ink maps to its index-paired light tint (FigJam: a saturated
|
|
376
|
+
* outline over a pale wash of the same hue); the ink slot / themed-dark ink /
|
|
377
|
+
* any unknown hex maps to a neutral wash. "No fill" stays one click away (the
|
|
378
|
+
* chrome's None swatch) and, once picked, sticks (fillTouchedRef). Stored
|
|
379
|
+
* shapes keep their literal fill — only NEW shapes pick up this default.
|
|
380
|
+
*/
|
|
381
|
+
export function defaultFillFor(color: string, theme: string): string {
|
|
382
|
+
const idx = STROKE_PALETTE.indexOf(color as PaletteColor);
|
|
383
|
+
// Coloured ink (slots 0–7) → its paired tint; ink slot (8) / unknown → neutral.
|
|
384
|
+
if (idx >= 0 && idx < FILL_PALETTE.length - 1) return FILL_PALETTE[idx];
|
|
385
|
+
return theme === 'dark' ? NEUTRAL_FILL_DARK : NEUTRAL_FILL_LIGHT;
|
|
386
|
+
}
|
|
387
|
+
|
|
253
388
|
const STROKE_WIDTH_THIN = 3;
|
|
254
389
|
const STROKE_WIDTH_THICK = 6;
|
|
255
390
|
type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
|
|
@@ -277,6 +412,22 @@ export const STICKY_PALETTE = [
|
|
|
277
412
|
] as const;
|
|
278
413
|
const DEFAULT_STICKY_COLOR = STICKY_PALETTE[0];
|
|
279
414
|
const STICKY_CORNER_RADIUS = 8;
|
|
415
|
+
|
|
416
|
+
// Annotation polish (item 8) — highlighter marker hues. Translucent 8-digit hex
|
|
417
|
+
// (RRGGBBAA, ~50% alpha) so overlaps darken under `mix-blend-mode:multiply`.
|
|
418
|
+
// Yellow is the default; green / pink / blue follow. Wholly separate from the
|
|
419
|
+
// ink PALETTE — the highlighter draws a soft wash, not a saturated line.
|
|
420
|
+
export const HIGHLIGHTER_PALETTE = [
|
|
421
|
+
'#ffe24d80', // yellow (default)
|
|
422
|
+
'#7ce8a080', // green
|
|
423
|
+
'#ff9ed180', // pink
|
|
424
|
+
'#7ec5ff80', // blue
|
|
425
|
+
] as const;
|
|
426
|
+
const DEFAULT_HIGHLIGHTER_COLOR = HIGHLIGHTER_PALETTE[0];
|
|
427
|
+
// Highlighter marker nib widths (item 8) — three sizes (thin / medium / thick),
|
|
428
|
+
// all wider than the pen. Default medium.
|
|
429
|
+
const HIGHLIGHTER_WIDTHS = [10, 18, 28] as const;
|
|
430
|
+
const DEFAULT_HIGHLIGHTER_WIDTH = HIGHLIGHTER_WIDTHS[1];
|
|
280
431
|
// Phase 24 — stickies are 1:1; the default tap size is a square.
|
|
281
432
|
const STICKY_DEFAULT_W = 200;
|
|
282
433
|
const STICKY_DEFAULT_H = 200;
|
|
@@ -285,6 +436,42 @@ const STICKY_MIN_SIZE = 40;
|
|
|
285
436
|
// tap point (FigJam parity: click commits, drag sizes). Square aspect.
|
|
286
437
|
const SHAPE_DEFAULT_SIZE = 120;
|
|
287
438
|
|
|
439
|
+
// Phase 23 — image + link media strokes.
|
|
440
|
+
/** Below this side an image stroke is discarded as an accidental micro-drop. */
|
|
441
|
+
const IMAGE_MIN_SIZE = 16;
|
|
442
|
+
/** Longest side a freshly dropped/pasted image is scaled down to (world px). */
|
|
443
|
+
export const IMAGE_MAX_DROP_SIDE = 480;
|
|
444
|
+
const LINK_DEFAULT_W = 260;
|
|
445
|
+
const LINK_DEFAULT_H = 76;
|
|
446
|
+
const LINK_CARD_FILL = '#ffffff';
|
|
447
|
+
const LINK_CARD_STROKE = '#d4d4d8';
|
|
448
|
+
const LINK_DOMAIN_FILL = '#71717a';
|
|
449
|
+
const LINK_TITLE_FILL = '#18181b';
|
|
450
|
+
const LINK_GLYPH_STROKE = '#52525b';
|
|
451
|
+
// Lucide "link" icon (24×24 viewBox) — two interlocked loops. ONE source for the
|
|
452
|
+
// serialized nested-<svg> glyph AND the StrokeNode render so re-serialize stays
|
|
453
|
+
// byte-stable. The parser ignores the glyph entirely (it reads data-* + the
|
|
454
|
+
// <rect> geometry), so render/serialize only need to agree visually.
|
|
455
|
+
const LINK_GLYPH_D1 = 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71';
|
|
456
|
+
const LINK_GLYPH_D2 = 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71';
|
|
457
|
+
|
|
458
|
+
/** Card text positions, derived purely from the bbox (idempotent round-trip). */
|
|
459
|
+
function linkCardLayout(x: number, y: number, w: number, h: number) {
|
|
460
|
+
const textX = x + 48;
|
|
461
|
+
return {
|
|
462
|
+
glyph: { x: x + 16, y: y + h / 2 - 10, size: 20 },
|
|
463
|
+
textX,
|
|
464
|
+
domain: { y: y + h / 2 - 14, fontSize: 11 },
|
|
465
|
+
title: { y: y + h / 2, fontSize: 13 },
|
|
466
|
+
textMaxChars: Math.max(8, Math.floor((w - 60) / 7)),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Clamp a link title to the card's character budget (pure → byte-stable). */
|
|
471
|
+
function clampLinkTitle(title: string, maxChars: number): string {
|
|
472
|
+
return title.length > maxChars ? `${title.slice(0, Math.max(1, maxChars - 1))}…` : title;
|
|
473
|
+
}
|
|
474
|
+
|
|
288
475
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
289
476
|
// Pure helpers — exported for unit tests.
|
|
290
477
|
|
|
@@ -296,6 +483,19 @@ function esc(s: string): string {
|
|
|
296
483
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
297
484
|
}
|
|
298
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Phase 23 — attribute-safe escape: `esc()` plus `>`. The legacy text/sticky
|
|
488
|
+
* paths only put user text in element CONTENT (where a bare `>` is harmless),
|
|
489
|
+
* so `esc()` never escaped it; but the media strokes carry user text (pasted
|
|
490
|
+
* link title/url, image alt) inside ATTRIBUTES (data-title / data-url / data-alt
|
|
491
|
+
* / href). A bare `>` there would prematurely close the tag and confuse the
|
|
492
|
+
* `[^>]*>` element scan in `sanitizeAnnotationSvg`. Use this for every media
|
|
493
|
+
* attribute value; element CONTENT keeps plain `esc()`.
|
|
494
|
+
*/
|
|
495
|
+
function escAttr(s: string): string {
|
|
496
|
+
return esc(s).replace(/>/g, '>');
|
|
497
|
+
}
|
|
498
|
+
|
|
299
499
|
export function penPathD(points: readonly WorldPoint[]): string {
|
|
300
500
|
if (points.length === 0) return '';
|
|
301
501
|
const [first, ...rest] = points as readonly WorldPoint[];
|
|
@@ -305,6 +505,202 @@ export function penPathD(points: readonly WorldPoint[]): string {
|
|
|
305
505
|
return d;
|
|
306
506
|
}
|
|
307
507
|
|
|
508
|
+
// ── Multi-line text (item 4a) ────────────────────────────────────────────────
|
|
509
|
+
// SVG <text> ignores `\n`, so multi-line annotation text must render as one
|
|
510
|
+
// <tspan> per line. The serialized + live forms share this geometry; single-
|
|
511
|
+
// line text keeps the legacy single-run form (no tspan) so the canary holds.
|
|
512
|
+
|
|
513
|
+
/** Line-height multiplier for multi-line annotation text. */
|
|
514
|
+
const TEXT_LINE_HEIGHT = 1.25;
|
|
515
|
+
|
|
516
|
+
/** Split a text body into its display lines. */
|
|
517
|
+
export function splitTextLines(text: string): string[] {
|
|
518
|
+
return text.split('\n');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Annotation polish (item 4c) — list style for text + sticky bodies. */
|
|
522
|
+
export type ListType = 'bullet' | 'number';
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Render-time list marker prefix for one line. Markers are PRESENTATION ONLY —
|
|
526
|
+
* never stored in `text` (DDR) — so the stored string stays clean and
|
|
527
|
+
* contentEditable editing is sane. Bullet → `• `; number → `${i + 1}. `.
|
|
528
|
+
*/
|
|
529
|
+
function listPrefixedLine(line: string, index: number, list?: ListType): string {
|
|
530
|
+
if (!list) return line;
|
|
531
|
+
return list === 'bullet' ? `• ${line}` : `${index + 1}. ${line}`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Inverse of {@link listPrefixedLine} — strip a render-time marker on parse. */
|
|
535
|
+
function stripListPrefix(line: string, index: number, list?: ListType): string {
|
|
536
|
+
if (!list) return line;
|
|
537
|
+
const marker = list === 'bullet' ? '• ' : `${index + 1}. `;
|
|
538
|
+
return line.startsWith(marker) ? line.slice(marker.length) : line;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Prefix every line of a body with its list marker (for the editor display). */
|
|
542
|
+
export function listPrefixedBody(text: string, list?: ListType): string {
|
|
543
|
+
if (!list) return text;
|
|
544
|
+
return splitTextLines(text)
|
|
545
|
+
.map((line, i) => listPrefixedLine(line, i, list))
|
|
546
|
+
.join('\n');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Strip list markers off editor `innerText` on commit (item 4c). Generic — a
|
|
551
|
+
* `•` bullet OR any leading `N. ` number is removed once per line, regardless of
|
|
552
|
+
* the index the user actually typed, so re-numbering while editing round-trips
|
|
553
|
+
* cleanly (the stored text stays marker-free; the read view re-derives markers).
|
|
554
|
+
*/
|
|
555
|
+
export function stripEditorMarkers(text: string, list?: ListType): string {
|
|
556
|
+
if (!list) return text;
|
|
557
|
+
const re = list === 'bullet' ? /^• / : /^\d+\.\s/;
|
|
558
|
+
return splitTextLines(text)
|
|
559
|
+
.map((line) => line.replace(re, ''))
|
|
560
|
+
.join('\n');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Combined `text-decoration` SVG attribute for strike + underline (item 4b).
|
|
565
|
+
* Strike-only stays `text-decoration="line-through"` (byte-identical to the
|
|
566
|
+
* legacy Phase-24 form); both → `line-through underline`; neither → empty.
|
|
567
|
+
*/
|
|
568
|
+
function textDecoAttr(strike?: boolean, underline?: boolean): string {
|
|
569
|
+
const vals: string[] = [];
|
|
570
|
+
if (strike) vals.push('line-through');
|
|
571
|
+
if (underline) vals.push('underline');
|
|
572
|
+
return vals.length ? ` text-decoration="${vals.join(' ')}"` : '';
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** CSS `text-decoration` value for the live render (strike + underline). */
|
|
576
|
+
function textDecoCss(strike?: boolean, underline?: boolean): string | undefined {
|
|
577
|
+
const vals: string[] = [];
|
|
578
|
+
if (strike) vals.push('line-through');
|
|
579
|
+
if (underline) vals.push('underline');
|
|
580
|
+
return vals.length ? vals.join(' ') : undefined;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Inline text formatting carried by an editor through commit (item 4b/4d
|
|
585
|
+
* unification) — so Cmd+B / Cmd+I / Cmd+U toggled WHILE editing land on the
|
|
586
|
+
* stroke. `strike` rides along unchanged (no shortcut; toolbar-only).
|
|
587
|
+
*/
|
|
588
|
+
export interface EditorFmt {
|
|
589
|
+
bold?: boolean;
|
|
590
|
+
italic?: boolean;
|
|
591
|
+
underline?: boolean;
|
|
592
|
+
strike?: boolean;
|
|
593
|
+
}
|
|
594
|
+
/** Normalize an EditorFmt → only-true keys kept; false becomes undefined so the
|
|
595
|
+
* serialize-only-when-set invariant + byte-identical canary hold. */
|
|
596
|
+
function normFmt(fmt?: EditorFmt): EditorFmt {
|
|
597
|
+
return {
|
|
598
|
+
bold: fmt?.bold || undefined,
|
|
599
|
+
italic: fmt?.italic || undefined,
|
|
600
|
+
underline: fmt?.underline || undefined,
|
|
601
|
+
strike: fmt?.strike || undefined,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/** True when a stroke's existing formatting already matches `fmt` (so a pure
|
|
605
|
+
* identity edit can short-circuit without a redundant undo record). */
|
|
606
|
+
function fmtEqual(s: EditorFmt, fmt?: EditorFmt): boolean {
|
|
607
|
+
if (!fmt) return true;
|
|
608
|
+
return (
|
|
609
|
+
!!s.bold === !!fmt.bold &&
|
|
610
|
+
!!s.italic === !!fmt.italic &&
|
|
611
|
+
!!s.underline === !!fmt.underline &&
|
|
612
|
+
!!s.strike === !!fmt.strike
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Shared inline-formatting state for the three text editors (sticky / anchored /
|
|
618
|
+
* standalone) — the unification surface (item 4d). Cmd/Ctrl + B / I / U toggle
|
|
619
|
+
* bold / italic / underline WHILE editing (preventing the browser's native
|
|
620
|
+
* execCommand, which would inject markup the model can't read), preview live via
|
|
621
|
+
* `style`, and commit on the stroke via `fmtRef`. `strike` rides along unchanged
|
|
622
|
+
* (toolbar-only — no universal shortcut). One hook = identical behaviour across
|
|
623
|
+
* all three editors.
|
|
624
|
+
*/
|
|
625
|
+
function useEditorFormat(initial: EditorFmt): {
|
|
626
|
+
fmtRef: { current: EditorFmt };
|
|
627
|
+
style: CSSProperties;
|
|
628
|
+
onFormatKey: (e: ReactKeyboardEvent) => boolean;
|
|
629
|
+
} {
|
|
630
|
+
const [bold, setBold] = useState(!!initial.bold);
|
|
631
|
+
const [italic, setItalic] = useState(!!initial.italic);
|
|
632
|
+
const [underline, setUnderline] = useState(!!initial.underline);
|
|
633
|
+
const strike = !!initial.strike;
|
|
634
|
+
const fmtRef = useRef<EditorFmt>({ bold, italic, underline, strike });
|
|
635
|
+
fmtRef.current = { bold, italic, underline, strike };
|
|
636
|
+
const style: CSSProperties = {
|
|
637
|
+
fontWeight: bold ? 700 : undefined,
|
|
638
|
+
fontStyle: italic ? 'italic' : undefined,
|
|
639
|
+
textDecoration: textDecoCss(strike, underline),
|
|
640
|
+
};
|
|
641
|
+
const onFormatKey = useCallback((e: ReactKeyboardEvent): boolean => {
|
|
642
|
+
if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false;
|
|
643
|
+
const k = e.key.toLowerCase();
|
|
644
|
+
if (k === 'b') {
|
|
645
|
+
e.preventDefault();
|
|
646
|
+
setBold((v) => !v);
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
if (k === 'i') {
|
|
650
|
+
e.preventDefault();
|
|
651
|
+
setItalic((v) => !v);
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
if (k === 'u') {
|
|
655
|
+
e.preventDefault();
|
|
656
|
+
setUnderline((v) => !v);
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
return false;
|
|
660
|
+
}, []);
|
|
661
|
+
return { fmtRef, style, onFormatKey };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Per-line baseline offset (`dy`). Line 0 sits at the anchor when top-anchored
|
|
666
|
+
* (hanging) or is lifted half the block height when vertically centred
|
|
667
|
+
* (anchored-in-host); every later line advances one line-height.
|
|
668
|
+
*/
|
|
669
|
+
function textLineDy(i: number, fontSize: number, lineCount: number, centered: boolean): number {
|
|
670
|
+
const lh = fontSize * TEXT_LINE_HEIGHT;
|
|
671
|
+
if (i > 0) return lh;
|
|
672
|
+
return centered ? (-(lineCount - 1) / 2) * lh : 0;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Inner content for a serialized `<text>` stroke: the legacy single esc'd run
|
|
677
|
+
* when there's no newline (byte-identical, canary-safe), else one `<tspan>` per
|
|
678
|
+
* line. `x` is set on each tspan for standalone text (resets the line origin);
|
|
679
|
+
* anchored text omits it (the persisted form carries no absolute position —
|
|
680
|
+
* geometry is resolved against the host at render time). A `list` prefix
|
|
681
|
+
* (bullet / number) is prepended per line at render time only (DDR — never
|
|
682
|
+
* stored in `text`).
|
|
683
|
+
*/
|
|
684
|
+
function textInnerSvg(
|
|
685
|
+
text: string,
|
|
686
|
+
fontSize: number,
|
|
687
|
+
centered: boolean,
|
|
688
|
+
x: number | undefined,
|
|
689
|
+
list?: ListType
|
|
690
|
+
): string {
|
|
691
|
+
if (!list && !text.includes('\n')) return esc(listPrefixedLine(text, 0, list));
|
|
692
|
+
const lines = splitTextLines(text);
|
|
693
|
+
const xAttr = x != null ? ` x="${x}"` : '';
|
|
694
|
+
return lines
|
|
695
|
+
.map(
|
|
696
|
+
(line, i) =>
|
|
697
|
+
`<tspan${xAttr} dy="${textLineDy(i, fontSize, lines.length, centered)}">${esc(
|
|
698
|
+
listPrefixedLine(line, i, list)
|
|
699
|
+
)}</tspan>`
|
|
700
|
+
)
|
|
701
|
+
.join('');
|
|
702
|
+
}
|
|
703
|
+
|
|
308
704
|
// Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
|
|
309
705
|
// Re-exported so the existing test import (`from '../annotations-layer.tsx'`)
|
|
310
706
|
// and the byte-identical canary keep working.
|
|
@@ -359,6 +755,28 @@ export function polygonPoints(
|
|
|
359
755
|
.join(' ');
|
|
360
756
|
}
|
|
361
757
|
|
|
758
|
+
/**
|
|
759
|
+
* Annotation polish (item 1) — a rounded-rect `d` with TL/TR/BL rounded at `r`
|
|
760
|
+
* and the **bottom-right corner SHARP** (the FigJam sticky-note silhouette). The
|
|
761
|
+
* radius is clamped to half the smaller side so it never self-overlaps. Used by
|
|
762
|
+
* `StrokeNode`'s LIVE sticky render only; the persisted form (`strokeToSvgEl`)
|
|
763
|
+
* stays a plain `<rect>` (DDR — zero canary / sanitizer / parse impact).
|
|
764
|
+
*/
|
|
765
|
+
export function stickyCornerPath(x: number, y: number, w: number, h: number, r: number): string {
|
|
766
|
+
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
|
|
767
|
+
return [
|
|
768
|
+
`M${x + rr} ${y}`,
|
|
769
|
+
`L${x + w - rr} ${y}`,
|
|
770
|
+
`Q${x + w} ${y} ${x + w} ${y + rr}`,
|
|
771
|
+
`L${x + w} ${y + h}`, // sharp bottom-right
|
|
772
|
+
`L${x + rr} ${y + h}`,
|
|
773
|
+
`Q${x} ${y + h} ${x} ${y + h - rr}`,
|
|
774
|
+
`L${x} ${y + rr}`,
|
|
775
|
+
`Q${x} ${y} ${x + rr} ${y}`,
|
|
776
|
+
'Z',
|
|
777
|
+
].join(' ');
|
|
778
|
+
}
|
|
779
|
+
|
|
362
780
|
/** Even-odd ray-cast point-in-polygon test. */
|
|
363
781
|
function pointInPolygon(px: number, py: number, pts: ReadonlyArray<[number, number]>): boolean {
|
|
364
782
|
let inside = false;
|
|
@@ -398,10 +816,14 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
398
816
|
// Phase 21 — anchored text keeps the byte-identical Phase 5.1 form;
|
|
399
817
|
// standalone text (no anchorId) writes its own world x/y and omits
|
|
400
818
|
// data-anchor-id (so the parser routes it back to the standalone branch).
|
|
401
|
-
//
|
|
402
|
-
// legacy text node stays byte-identical (
|
|
819
|
+
// bold/italic/strike/underline/align/list serialize ONLY for non-default
|
|
820
|
+
// values, so a legacy text node stays byte-identical (every added fragment
|
|
821
|
+
// is empty). Multi-line text emits one <tspan> per line (item 4a); a
|
|
822
|
+
// single-line unstyled run stays the legacy single esc'd text.
|
|
403
823
|
const weight = s.bold ? ' font-weight="700"' : '';
|
|
404
|
-
const
|
|
824
|
+
const italic = s.italic ? ' font-style="italic"' : '';
|
|
825
|
+
const deco = textDecoAttr(s.strike, s.underline);
|
|
826
|
+
const listAttr = s.listType ? ` data-list="${s.listType}"` : '';
|
|
405
827
|
if (s.anchorId != null && s.anchorId !== '') {
|
|
406
828
|
const align = s.align ?? 'center'; // anchored default = centre (legacy)
|
|
407
829
|
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
@@ -410,8 +832,12 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
410
832
|
s.anchorId
|
|
411
833
|
)}" data-font-size="${s.fontSize}" fill="${esc(
|
|
412
834
|
s.color
|
|
413
|
-
)}"${weight}${deco} text-anchor="${anchor}" dominant-baseline="middle"${alignAttr}>${
|
|
414
|
-
s.text
|
|
835
|
+
)}"${weight}${italic}${deco}${listAttr} text-anchor="${anchor}" dominant-baseline="middle"${alignAttr}>${textInnerSvg(
|
|
836
|
+
s.text,
|
|
837
|
+
s.fontSize,
|
|
838
|
+
true,
|
|
839
|
+
undefined,
|
|
840
|
+
s.listType
|
|
415
841
|
)}</text>`;
|
|
416
842
|
}
|
|
417
843
|
const tx = s.x ?? 0;
|
|
@@ -423,8 +849,12 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
423
849
|
s.fontSize
|
|
424
850
|
}" fill="${esc(
|
|
425
851
|
s.color
|
|
426
|
-
)}"${weight}${deco} text-anchor="${anchor}" dominant-baseline="hanging"${alignAttr}>${
|
|
427
|
-
s.text
|
|
852
|
+
)}"${weight}${italic}${deco}${listAttr} text-anchor="${anchor}" dominant-baseline="hanging"${alignAttr}>${textInnerSvg(
|
|
853
|
+
s.text,
|
|
854
|
+
s.fontSize,
|
|
855
|
+
false,
|
|
856
|
+
tx,
|
|
857
|
+
s.listType
|
|
428
858
|
)}</text>`;
|
|
429
859
|
}
|
|
430
860
|
if (s.tool === 'sticky') {
|
|
@@ -435,14 +865,18 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
435
865
|
const r = s.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
436
866
|
const w = Math.max(0, s.w);
|
|
437
867
|
const h = Math.max(0, s.h);
|
|
438
|
-
//
|
|
439
|
-
// non-default values (sticky default align = left) so Phase-21
|
|
440
|
-
// serialize byte-identically.
|
|
868
|
+
// bold/italic/strike/underline/align/list on the <g> data-attrs, emitted
|
|
869
|
+
// ONLY for non-default values (sticky default align = left) so Phase-21
|
|
870
|
+
// stickies serialize byte-identically. The body <text> stays raw text —
|
|
871
|
+
// list markers are render-only (item 4c), never persisted.
|
|
441
872
|
const align = s.align ?? 'left';
|
|
442
873
|
const styleAttrs =
|
|
443
874
|
(s.bold ? ' data-bold="1"' : '') +
|
|
875
|
+
(s.italic ? ' data-italic="1"' : '') +
|
|
444
876
|
(s.strike ? ' data-strike="1"' : '') +
|
|
445
|
-
(
|
|
877
|
+
(s.underline ? ' data-underline="1"' : '') +
|
|
878
|
+
(align !== 'left' ? ` data-align="${align}"` : '') +
|
|
879
|
+
(s.listType ? ` data-list="${s.listType}"` : '');
|
|
446
880
|
return `<g data-id="${esc(s.id)}" data-tool="sticky" data-r="${r}" data-fs="${
|
|
447
881
|
s.fontSize
|
|
448
882
|
}" fill="${esc(s.color)}"${styleAttrs}><rect x="${s.x}" y="${
|
|
@@ -453,9 +887,52 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
453
887
|
s.fontSize
|
|
454
888
|
}" fill="#1a1a1a" dominant-baseline="hanging">${esc(s.text)}</text></g>`;
|
|
455
889
|
}
|
|
890
|
+
if (s.tool === 'image') {
|
|
891
|
+
// Phase 23 — `href` is ALWAYS a relative assets/<sha8>.<ext> path (asserted
|
|
892
|
+
// on create + re-validated by the sanitizer's <image> href allowlist). Alt
|
|
893
|
+
// text persists in `data-alt` + is emitted as `aria-label` for the exported
|
|
894
|
+
// SVG (the live annotation root is aria-hidden — see ImageStroke.alt).
|
|
895
|
+
const nx = Math.min(s.x, s.x + s.w);
|
|
896
|
+
const ny = Math.min(s.y, s.y + s.h);
|
|
897
|
+
const nw = Math.abs(s.w);
|
|
898
|
+
const nh = Math.abs(s.h);
|
|
899
|
+
const altAttr = s.alt ? ` data-alt="${escAttr(s.alt)}"` : '';
|
|
900
|
+
return `<image data-id="${esc(s.id)}" data-tool="image" x="${nx}" y="${ny}" width="${nw}" height="${nh}" href="${escAttr(
|
|
901
|
+
s.href
|
|
902
|
+
)}" preserveAspectRatio="xMidYMid meet"${altAttr}/>`;
|
|
903
|
+
}
|
|
904
|
+
if (s.tool === 'link') {
|
|
905
|
+
// Phase 23 — client-only preview chip. data-url/title/domain are the
|
|
906
|
+
// round-trip source of truth; the inner rect/glyph/text are the inert,
|
|
907
|
+
// sanitizer-safe visual (no <a href> persisted — click-to-open reads
|
|
908
|
+
// data-url client-side and validates http(s) before window.open).
|
|
909
|
+
const nx = Math.min(s.x, s.x + s.w);
|
|
910
|
+
const ny = Math.min(s.y, s.y + s.h);
|
|
911
|
+
const nw = Math.abs(s.w);
|
|
912
|
+
const nh = Math.abs(s.h);
|
|
913
|
+
const lay = linkCardLayout(nx, ny, nw, nh);
|
|
914
|
+
const shownTitle = clampLinkTitle(s.title, lay.textMaxChars);
|
|
915
|
+
return (
|
|
916
|
+
`<g data-id="${esc(s.id)}" data-tool="link" data-url="${escAttr(s.url)}" data-title="${escAttr(
|
|
917
|
+
s.title
|
|
918
|
+
)}" data-domain="${escAttr(s.domain)}">` +
|
|
919
|
+
`<rect x="${nx}" y="${ny}" width="${nw}" height="${nh}" rx="8" ry="8" fill="${LINK_CARD_FILL}" stroke="${LINK_CARD_STROKE}" stroke-width="1"/>` +
|
|
920
|
+
`<svg x="${lay.glyph.x}" y="${lay.glyph.y}" width="${lay.glyph.size}" height="${lay.glyph.size}" viewBox="0 0 24 24" fill="none" stroke="${LINK_GLYPH_STROKE}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${LINK_GLYPH_D1}"/><path d="${LINK_GLYPH_D2}"/></svg>` +
|
|
921
|
+
`<text x="${lay.textX}" y="${lay.domain.y}" font-size="${lay.domain.fontSize}" fill="${LINK_DOMAIN_FILL}" dominant-baseline="hanging">${esc(
|
|
922
|
+
s.domain
|
|
923
|
+
)}</text>` +
|
|
924
|
+
`<text x="${lay.textX}" y="${lay.title.y}" font-size="${lay.title.fontSize}" fill="${LINK_TITLE_FILL}" font-weight="600" dominant-baseline="hanging">${esc(
|
|
925
|
+
shownTitle
|
|
926
|
+
)}</text>` +
|
|
927
|
+
`</g>`
|
|
928
|
+
);
|
|
929
|
+
}
|
|
456
930
|
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
931
|
if (s.tool === 'pen') {
|
|
458
|
-
|
|
932
|
+
// Highlighter (item 8) — data-highlighter ONLY when true so a normal pen
|
|
933
|
+
// stays byte-identical (canary).
|
|
934
|
+
const hl = s.highlighter ? ' data-highlighter="1"' : '';
|
|
935
|
+
return `<path ${common} fill="none" d="${penPathD(s.points)}"${hl} pointer-events="stroke"/>`;
|
|
459
936
|
}
|
|
460
937
|
if (s.tool === 'rect') {
|
|
461
938
|
const fill = s.fill ? esc(s.fill) : 'none';
|
|
@@ -463,17 +940,23 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
463
940
|
// sharp-corner rects serialize byte-identically (Task 10 canary).
|
|
464
941
|
const r = s.cornerRadius ?? 0;
|
|
465
942
|
const round = r > 0 ? ` rx="${r}" ry="${r}" data-r="${r}"` : '';
|
|
943
|
+
// Dashed outline (item 7) — emitted ONLY when true (mirror polygon/arrow)
|
|
944
|
+
// so a legacy solid rect stays byte-identical.
|
|
945
|
+
const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
|
|
946
|
+
const dashAttr = s.dashed ? ' data-dash="1"' : '';
|
|
466
947
|
return `<rect ${common} fill="${fill}" x="${s.x}" y="${s.y}" width="${Math.max(
|
|
467
948
|
0,
|
|
468
949
|
s.w
|
|
469
|
-
)}" height="${Math.max(0, s.h)}"${round}/>`;
|
|
950
|
+
)}" height="${Math.max(0, s.h)}"${round}${dash}${dashAttr}/>`;
|
|
470
951
|
}
|
|
471
952
|
if (s.tool === 'ellipse') {
|
|
472
953
|
const fill = s.fill ? esc(s.fill) : 'none';
|
|
954
|
+
const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
|
|
955
|
+
const dashAttr = s.dashed ? ' data-dash="1"' : '';
|
|
473
956
|
return `<ellipse ${common} fill="${fill}" cx="${s.cx}" cy="${s.cy}" rx="${Math.max(
|
|
474
957
|
0,
|
|
475
958
|
s.rx
|
|
476
|
-
)}" ry="${Math.max(0, s.ry)}"/>`;
|
|
959
|
+
)}" ry="${Math.max(0, s.ry)}"${dash}${dashAttr}/>`;
|
|
477
960
|
}
|
|
478
961
|
if (s.tool === 'polygon') {
|
|
479
962
|
// Phase 24 — bbox-derived points + data-shape. Normalize the bbox so a
|
|
@@ -606,7 +1089,12 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
606
1089
|
if (tool === 'pen') {
|
|
607
1090
|
const d = el.getAttribute('d') || '';
|
|
608
1091
|
const points = parsePathD(d);
|
|
609
|
-
if (points.length)
|
|
1092
|
+
if (points.length) {
|
|
1093
|
+
const pen: PenStroke = { id, tool: 'pen', color, width, points };
|
|
1094
|
+
// Highlighter flag; absent ⇒ undefined so a normal pen round-trips.
|
|
1095
|
+
if (el.getAttribute('data-highlighter') === '1') pen.highlighter = true;
|
|
1096
|
+
out.push(pen);
|
|
1097
|
+
}
|
|
610
1098
|
continue;
|
|
611
1099
|
}
|
|
612
1100
|
if (tool === 'sticky') {
|
|
@@ -636,13 +1124,17 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
636
1124
|
fontSize,
|
|
637
1125
|
cornerRadius,
|
|
638
1126
|
};
|
|
639
|
-
//
|
|
1127
|
+
// Style attrs; absent ⇒ defaults (normal / left), left unset.
|
|
640
1128
|
if (el.getAttribute('data-bold') === '1') sticky.bold = true;
|
|
1129
|
+
if (el.getAttribute('data-italic') === '1') sticky.italic = true;
|
|
641
1130
|
if (el.getAttribute('data-strike') === '1') sticky.strike = true;
|
|
1131
|
+
if (el.getAttribute('data-underline') === '1') sticky.underline = true;
|
|
642
1132
|
const sticAlign = el.getAttribute('data-align');
|
|
643
1133
|
if (sticAlign === 'left' || sticAlign === 'center' || sticAlign === 'right') {
|
|
644
1134
|
sticky.align = sticAlign;
|
|
645
1135
|
}
|
|
1136
|
+
const sticList = el.getAttribute('data-list');
|
|
1137
|
+
if (sticList === 'bullet' || sticList === 'number') sticky.listType = sticList;
|
|
646
1138
|
out.push(sticky);
|
|
647
1139
|
continue;
|
|
648
1140
|
}
|
|
@@ -654,7 +1146,11 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
654
1146
|
const fill = parseFill(el.getAttribute('fill'));
|
|
655
1147
|
// Phase 21 — corner radius; absent ⇒ 0 (sharp, back-compat).
|
|
656
1148
|
const cornerRadius = Number.parseFloat(el.getAttribute('data-r') || '0') || 0;
|
|
657
|
-
|
|
1149
|
+
const rect: RectStroke = { id, tool: 'rect', color, width, x, y, w, h, fill, cornerRadius };
|
|
1150
|
+
// Dashed (item 7); absent ⇒ undefined so a solid rect round-trips.
|
|
1151
|
+
const rectDash = el.getAttribute('data-dash');
|
|
1152
|
+
if (rectDash === '1' || rectDash === 'true') rect.dashed = true;
|
|
1153
|
+
out.push(rect);
|
|
658
1154
|
continue;
|
|
659
1155
|
}
|
|
660
1156
|
if (tool === 'ellipse') {
|
|
@@ -663,7 +1159,10 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
663
1159
|
const rx = Number.parseFloat(el.getAttribute('rx') || '0');
|
|
664
1160
|
const ry = Number.parseFloat(el.getAttribute('ry') || '0');
|
|
665
1161
|
const fill = parseFill(el.getAttribute('fill'));
|
|
666
|
-
|
|
1162
|
+
const ell: EllipseStroke = { id, tool: 'ellipse', color, width, cx, cy, rx, ry, fill };
|
|
1163
|
+
const ellDash = el.getAttribute('data-dash');
|
|
1164
|
+
if (ellDash === '1' || ellDash === 'true') ell.dashed = true;
|
|
1165
|
+
out.push(ell);
|
|
667
1166
|
continue;
|
|
668
1167
|
}
|
|
669
1168
|
if (tool === 'polygon') {
|
|
@@ -728,18 +1227,66 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
728
1227
|
}
|
|
729
1228
|
continue;
|
|
730
1229
|
}
|
|
1230
|
+
if (tool === 'image') {
|
|
1231
|
+
// Phase 23 — geometry off the element; href is whatever survived the
|
|
1232
|
+
// sanitizer (a valid assets/<sha8>.<ext> path, or '' if it was stripped
|
|
1233
|
+
// — an external/data:/`..` href is dropped server-side, so a poisoned
|
|
1234
|
+
// SVG round-trips to an inert empty-href stroke that fetches nothing).
|
|
1235
|
+
const x = Number.parseFloat(el.getAttribute('x') || '0');
|
|
1236
|
+
const y = Number.parseFloat(el.getAttribute('y') || '0');
|
|
1237
|
+
const w = Number.parseFloat(el.getAttribute('width') || '0');
|
|
1238
|
+
const h = Number.parseFloat(el.getAttribute('height') || '0');
|
|
1239
|
+
const href = el.getAttribute('href') || el.getAttribute('xlink:href') || '';
|
|
1240
|
+
const img: ImageStroke = { id, tool: 'image', x, y, w, h, href };
|
|
1241
|
+
const alt = el.getAttribute('data-alt');
|
|
1242
|
+
if (alt) img.alt = alt;
|
|
1243
|
+
out.push(img);
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (tool === 'link') {
|
|
1247
|
+
// Phase 23 — data-* are the source of truth; geometry off the <rect>
|
|
1248
|
+
// child (mirrors sticky). Defensive: missing title ⇒ domain.
|
|
1249
|
+
const rectEl = el.querySelector('rect');
|
|
1250
|
+
const x = Number.parseFloat(rectEl?.getAttribute('x') || '0');
|
|
1251
|
+
const y = Number.parseFloat(rectEl?.getAttribute('y') || '0');
|
|
1252
|
+
const w = Number.parseFloat(rectEl?.getAttribute('width') || String(LINK_DEFAULT_W));
|
|
1253
|
+
const h = Number.parseFloat(rectEl?.getAttribute('height') || String(LINK_DEFAULT_H));
|
|
1254
|
+
const url = el.getAttribute('data-url') || '';
|
|
1255
|
+
const domain = el.getAttribute('data-domain') || '';
|
|
1256
|
+
const title = el.getAttribute('data-title') || domain || url;
|
|
1257
|
+
out.push({ id, tool: 'link', x, y, w, h, url, title, domain });
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
731
1260
|
if (tool === 'text') {
|
|
732
1261
|
const rawAnchor = el.getAttribute('data-anchor-id');
|
|
733
1262
|
const fontSize =
|
|
734
1263
|
Number.parseFloat(el.getAttribute('data-font-size') || String(DEFAULT_FONT_SIZE)) ||
|
|
735
1264
|
DEFAULT_FONT_SIZE;
|
|
736
1265
|
const inkColor = el.getAttribute('fill') || color;
|
|
737
|
-
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
1266
|
+
// List style (item 4c) — read FIRST so per-line markers can be stripped
|
|
1267
|
+
// off the parsed text (markers are render-only; never stored).
|
|
1268
|
+
const listRaw = el.getAttribute('data-list');
|
|
1269
|
+
const listType: ListType | undefined =
|
|
1270
|
+
listRaw === 'bullet' || listRaw === 'number' ? listRaw : undefined;
|
|
1271
|
+
// Multi-line text (item 4a) — one <tspan> per line. Recover `\n` by
|
|
1272
|
+
// joining tspan text content (markers stripped); a legacy single-run
|
|
1273
|
+
// <text> has no tspans → read its trimmed textContent.
|
|
1274
|
+
const tspans = el.querySelectorAll('tspan');
|
|
1275
|
+
const body =
|
|
1276
|
+
tspans.length > 0
|
|
1277
|
+
? Array.from(tspans)
|
|
1278
|
+
.map((t, i) => stripListPrefix(t.textContent ?? '', i, listType))
|
|
1279
|
+
.join('\n')
|
|
1280
|
+
: stripListPrefix((el.textContent || '').trim(), 0, listType);
|
|
1281
|
+
// bold / italic / strike / underline / align. `data-align` is the
|
|
1282
|
+
// round-trip source of truth (text-anchor is derived from it). Absent ⇒
|
|
1283
|
+
// default (normal / per-kind align), left unset so legacy nodes
|
|
1284
|
+
// round-trip.
|
|
741
1285
|
const isBold = el.getAttribute('font-weight') === '700';
|
|
742
|
-
const
|
|
1286
|
+
const isItalic = el.getAttribute('font-style') === 'italic';
|
|
1287
|
+
const decoAttr = el.getAttribute('text-decoration') || '';
|
|
1288
|
+
const isStrike = decoAttr.includes('line-through');
|
|
1289
|
+
const isUnderline = decoAttr.includes('underline');
|
|
743
1290
|
const da = el.getAttribute('data-align');
|
|
744
1291
|
const align: TextAlign | undefined =
|
|
745
1292
|
da === 'left' || da === 'center' || da === 'right' ? da : undefined;
|
|
@@ -756,7 +1303,10 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
756
1303
|
y: Number.parseFloat(el.getAttribute('y') || '0'),
|
|
757
1304
|
};
|
|
758
1305
|
if (isBold) t.bold = true;
|
|
1306
|
+
if (isItalic) t.italic = true;
|
|
759
1307
|
if (isStrike) t.strike = true;
|
|
1308
|
+
if (isUnderline) t.underline = true;
|
|
1309
|
+
if (listType) t.listType = listType;
|
|
760
1310
|
if (align) t.align = align;
|
|
761
1311
|
out.push(t);
|
|
762
1312
|
continue;
|
|
@@ -770,7 +1320,10 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
770
1320
|
anchorId: rawAnchor,
|
|
771
1321
|
};
|
|
772
1322
|
if (isBold) t.bold = true;
|
|
1323
|
+
if (isItalic) t.italic = true;
|
|
773
1324
|
if (isStrike) t.strike = true;
|
|
1325
|
+
if (isUnderline) t.underline = true;
|
|
1326
|
+
if (listType) t.listType = listType;
|
|
774
1327
|
if (align) t.align = align;
|
|
775
1328
|
out.push(t);
|
|
776
1329
|
}
|
|
@@ -811,8 +1364,8 @@ export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): b
|
|
|
811
1364
|
);
|
|
812
1365
|
}
|
|
813
1366
|
const t = Math.max(tol, 'width' in s ? s.width : 2);
|
|
814
|
-
if (s.tool === 'sticky') {
|
|
815
|
-
// Sticky
|
|
1367
|
+
if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link') {
|
|
1368
|
+
// Sticky / image / link are solid cards — filled-rect hit anywhere inside.
|
|
816
1369
|
const xMin = Math.min(s.x, s.x + s.w);
|
|
817
1370
|
const xMax = Math.max(s.x, s.x + s.w);
|
|
818
1371
|
const yMin = Math.min(s.y, s.y + s.h);
|
|
@@ -1002,6 +1555,9 @@ function isStrokeMeaningful(s: Stroke): boolean {
|
|
|
1002
1555
|
// Sticky below a readable floor is discarded like a 2×2 rect.
|
|
1003
1556
|
if (s.tool === 'sticky')
|
|
1004
1557
|
return Math.abs(s.w) >= STICKY_MIN_SIZE && Math.abs(s.h) >= STICKY_MIN_SIZE;
|
|
1558
|
+
// Phase 23 — an image needs real extent; a link needs a non-empty URL.
|
|
1559
|
+
if (s.tool === 'image') return Math.abs(s.w) >= IMAGE_MIN_SIZE && Math.abs(s.h) >= IMAGE_MIN_SIZE;
|
|
1560
|
+
if (s.tool === 'link') return s.url.trim().length > 0;
|
|
1005
1561
|
return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
|
|
1006
1562
|
}
|
|
1007
1563
|
|
|
@@ -1042,7 +1598,8 @@ export function strokeBBox(
|
|
|
1042
1598
|
h: Math.abs(s.y2 - s.y1),
|
|
1043
1599
|
};
|
|
1044
1600
|
}
|
|
1045
|
-
if (s.tool === 'sticky') {
|
|
1601
|
+
if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link') {
|
|
1602
|
+
// Phase 23 — image + link are rect-shaped media, same bbox as a sticky card.
|
|
1046
1603
|
return {
|
|
1047
1604
|
x: Math.min(s.x, s.x + s.w),
|
|
1048
1605
|
y: Math.min(s.y, s.y + s.h),
|
|
@@ -1059,11 +1616,18 @@ export function strokeBBox(
|
|
|
1059
1616
|
}
|
|
1060
1617
|
const tx = s.x ?? 0;
|
|
1061
1618
|
const ty = s.y ?? 0;
|
|
1619
|
+
// Multi-line text (item 4a) — the bbox spans the longest line (width) and all
|
|
1620
|
+
// lines (height) so the selection halo / hit-test / eraser cover the whole
|
|
1621
|
+
// block, not just line one. List markers widen each line by 2–3 chars.
|
|
1622
|
+
const lines = splitTextLines(s.text);
|
|
1623
|
+
const markerPad = s.listType ? 3 : 0;
|
|
1624
|
+
const longest = lines.reduce((m, l) => Math.max(m, l.length + markerPad), 0);
|
|
1062
1625
|
return {
|
|
1063
1626
|
x: tx,
|
|
1064
1627
|
y: ty,
|
|
1065
|
-
w: Math.max(8,
|
|
1066
|
-
|
|
1628
|
+
w: Math.max(8, longest * s.fontSize * 0.55),
|
|
1629
|
+
// Single-line keeps the legacy 1.2 height; multi-line grows by line count.
|
|
1630
|
+
h: lines.length <= 1 ? s.fontSize * 1.2 : lines.length * s.fontSize * TEXT_LINE_HEIGHT,
|
|
1067
1631
|
};
|
|
1068
1632
|
}
|
|
1069
1633
|
|
|
@@ -1092,6 +1656,38 @@ function deriveFile(): string | undefined {
|
|
|
1092
1656
|
}
|
|
1093
1657
|
}
|
|
1094
1658
|
|
|
1659
|
+
/**
|
|
1660
|
+
* Phase 23 — the served designRoot prefix for the current canvas document. An
|
|
1661
|
+
* image stroke persists its href as a RELATIVE `assets/<sha8>.<ext>` path (the
|
|
1662
|
+
* shape the sanitizer allowlists), but a bare relative href resolves against
|
|
1663
|
+
* `/_canvas-shell.html` → `/assets/…`, which the canvas-origin gate 403s. The
|
|
1664
|
+
* real served path is `/<designRel>/assets/…`, so the render must prefix it.
|
|
1665
|
+
*/
|
|
1666
|
+
function canvasDesignRel(): string {
|
|
1667
|
+
if (typeof window === 'undefined') return '.design';
|
|
1668
|
+
try {
|
|
1669
|
+
const p = window.location.pathname;
|
|
1670
|
+
if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
|
|
1671
|
+
const qs = new URLSearchParams(window.location.search);
|
|
1672
|
+
return (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
|
|
1673
|
+
}
|
|
1674
|
+
// Direct `/<designRel>/<group>/<canvas>.tsx` route → the first path segment.
|
|
1675
|
+
const seg = decodeURIComponent(p).replace(/^\/+/, '').split('/')[0];
|
|
1676
|
+
return seg || '.design';
|
|
1677
|
+
} catch {
|
|
1678
|
+
return '.design';
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* Resolve an image stroke href for the browser. The persisted `assets/<name>`
|
|
1684
|
+
* form is rewritten to the served `/<designRel>/assets/<name>` path; an
|
|
1685
|
+
* optimistic `blob:`/`data:` preview href (pre-upload) is used verbatim.
|
|
1686
|
+
*/
|
|
1687
|
+
function resolveAssetHref(href: string): string {
|
|
1688
|
+
return /^assets\//.test(href) ? `/${canvasDesignRel()}/${href}` : href;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1095
1691
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1096
1692
|
// Styles
|
|
1097
1693
|
|
|
@@ -1303,7 +1899,8 @@ function translateOne(s: Stroke, dx: number, dy: number): Stroke {
|
|
|
1303
1899
|
if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
|
|
1304
1900
|
if (s.tool === 'arrow')
|
|
1305
1901
|
return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
|
|
1306
|
-
if (s.tool === 'sticky'
|
|
1902
|
+
if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link')
|
|
1903
|
+
return { ...s, x: s.x + dx, y: s.y + dy };
|
|
1307
1904
|
// text — anchored inherits its host's bbox (moves with the host); standalone
|
|
1308
1905
|
// (Phase 21) carries its own world (x, y) and translates directly.
|
|
1309
1906
|
if (s.anchorId != null && s.anchorId !== '') return s;
|
|
@@ -1338,6 +1935,7 @@ export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
|
1338
1935
|
export function AnnotationsLayer() {
|
|
1339
1936
|
ensureAnnotStyles();
|
|
1340
1937
|
const { tool, setTool, sticky, tools, shapeKind } = useToolMode();
|
|
1938
|
+
const theme = useCanvasChromeTheme();
|
|
1341
1939
|
const controller = useViewportControllerContext();
|
|
1342
1940
|
const vp = controller?.viewport ?? null;
|
|
1343
1941
|
const worldRef = useWorldRefContext();
|
|
@@ -1346,12 +1944,42 @@ export function AnnotationsLayer() {
|
|
|
1346
1944
|
|
|
1347
1945
|
const [strokes, setStrokesState] = useState<Stroke[]>([]);
|
|
1348
1946
|
const [drawing, setDrawing] = useState<Stroke | null>(null);
|
|
1349
|
-
|
|
1350
|
-
|
|
1947
|
+
// Theme-aware live default ink (items 3/5/6). Initialized from the current
|
|
1948
|
+
// theme; tracked-untouched until the user picks a swatch, after which it
|
|
1949
|
+
// sticks (colorTouchedRef). The themed-default effect lives below.
|
|
1950
|
+
const [color, setColorState] = useState<string>(() => resolveDefaultInk(theme));
|
|
1951
|
+
const colorTouchedRef = useRef(false);
|
|
1952
|
+
const setColor = useCallback((c: string) => {
|
|
1953
|
+
colorTouchedRef.current = true;
|
|
1954
|
+
setColorState(c);
|
|
1955
|
+
}, []);
|
|
1956
|
+
// While the user hasn't picked an ink swatch yet, follow the theme — so a
|
|
1957
|
+
// dark canvas arms a light-reading default and a light canvas a dark one.
|
|
1958
|
+
useEffect(() => {
|
|
1959
|
+
if (!colorTouchedRef.current) setColorState(resolveDefaultInk(theme));
|
|
1960
|
+
}, [theme]);
|
|
1961
|
+
// Shape default fill (item 2). A freshly-armed Shape tool gets a fill (not
|
|
1962
|
+
// outline-only); the index-paired tint of the active ink. Untouched until the
|
|
1963
|
+
// user picks a fill swatch (incl. "No fill"), after which it sticks.
|
|
1964
|
+
const [fill, setFillState] = useState<string | null>(null);
|
|
1965
|
+
const fillTouchedRef = useRef(false);
|
|
1966
|
+
const setFill = useCallback((f: string | null) => {
|
|
1967
|
+
fillTouchedRef.current = true;
|
|
1968
|
+
setFillState(f);
|
|
1969
|
+
}, []);
|
|
1970
|
+
// While the Shape tool is armed and the fill is untouched, follow the ink
|
|
1971
|
+
// (and theme) so a square/circle/diamond lands with a paired-tint wash. Once
|
|
1972
|
+
// the user picks a fill swatch (or "No fill"), this stops.
|
|
1973
|
+
useEffect(() => {
|
|
1974
|
+
if (tool === 'shape' && !fillTouchedRef.current) setFillState(defaultFillFor(color, theme));
|
|
1975
|
+
}, [tool, color, theme]);
|
|
1351
1976
|
const [thickness, setThickness] = useState<Thickness>(STROKE_WIDTH_THIN);
|
|
1352
1977
|
// Phase 21 — draw-time paper tint for the sticky tool (recolor-after via the
|
|
1353
1978
|
// context toolbar). Separate from `color` (ink for pen/rect/text/arrow).
|
|
1354
1979
|
const [stickyColor, setStickyColor] = useState<string>(DEFAULT_STICKY_COLOR);
|
|
1980
|
+
// Annotation polish (item 8) — draw-time highlighter marker hue + nib width.
|
|
1981
|
+
const [highlighterColor, setHighlighterColor] = useState<string>(DEFAULT_HIGHLIGHTER_COLOR);
|
|
1982
|
+
const [highlighterWidth, setHighlighterWidth] = useState<number>(DEFAULT_HIGHLIGHTER_WIDTH);
|
|
1355
1983
|
// Phase 21 — a standalone-text caret waiting for its first keystroke. No
|
|
1356
1984
|
// stroke exists yet (mirrors anchored text: the stroke is born on commit,
|
|
1357
1985
|
// so an abandoned empty caret leaves nothing behind / no undo record).
|
|
@@ -1393,7 +2021,12 @@ export function AnnotationsLayer() {
|
|
|
1393
2021
|
strokesRef.current = strokes;
|
|
1394
2022
|
|
|
1395
2023
|
const isDraw =
|
|
1396
|
-
tool === 'pen' ||
|
|
2024
|
+
tool === 'pen' ||
|
|
2025
|
+
tool === 'highlighter' ||
|
|
2026
|
+
tool === 'shape' ||
|
|
2027
|
+
tool === 'arrow' ||
|
|
2028
|
+
tool === 'sticky' ||
|
|
2029
|
+
tool === 'text';
|
|
1397
2030
|
const isErase = tool === 'eraser';
|
|
1398
2031
|
const isActive = isDraw || isErase;
|
|
1399
2032
|
// T20 / Phase 24 — every shape primitive carries stroke weight (FigJam ships
|
|
@@ -1622,6 +2255,95 @@ export function AnnotationsLayer() {
|
|
|
1622
2255
|
[vp]
|
|
1623
2256
|
);
|
|
1624
2257
|
|
|
2258
|
+
// ── Phase 23 — media intake (drag-drop / paste / file-picker / link input) ──
|
|
2259
|
+
// Image: optimistic blob: preview stroke (LOCAL only — never persisted) →
|
|
2260
|
+
// POST /_api/asset → swap href to assets/… and commit (one undo record) →
|
|
2261
|
+
// revoke the blob URL. On failure the optimistic stroke is removed + a toast.
|
|
2262
|
+
const createImageFromFile = useCallback(
|
|
2263
|
+
(file: File, world: [number, number]) => {
|
|
2264
|
+
if (typeof window === 'undefined' || !file.type.startsWith('image/')) return;
|
|
2265
|
+
const blobUrl = URL.createObjectURL(file);
|
|
2266
|
+
const probe = new Image();
|
|
2267
|
+
probe.onload = () => {
|
|
2268
|
+
const natW = probe.naturalWidth || IMAGE_MAX_DROP_SIDE;
|
|
2269
|
+
const natH = probe.naturalHeight || Math.round(IMAGE_MAX_DROP_SIDE * 0.66);
|
|
2270
|
+
const longest = Math.max(natW, natH) || 1;
|
|
2271
|
+
const scale = longest > IMAGE_MAX_DROP_SIDE ? IMAGE_MAX_DROP_SIDE / longest : 1;
|
|
2272
|
+
const w = Math.max(IMAGE_MIN_SIZE, Math.round(natW * scale));
|
|
2273
|
+
const h = Math.max(IMAGE_MIN_SIZE, Math.round(natH * scale));
|
|
2274
|
+
const id = rid();
|
|
2275
|
+
const optimistic: ImageStroke = {
|
|
2276
|
+
id,
|
|
2277
|
+
tool: 'image',
|
|
2278
|
+
x: world[0] - w / 2,
|
|
2279
|
+
y: world[1] - h / 2,
|
|
2280
|
+
w,
|
|
2281
|
+
h,
|
|
2282
|
+
href: blobUrl,
|
|
2283
|
+
};
|
|
2284
|
+
// Local-only insert — the blob: href must NOT reach the server (it's
|
|
2285
|
+
// ephemeral + would be stripped by the sanitizer); we commit only the
|
|
2286
|
+
// assets/… form once the upload lands.
|
|
2287
|
+
setStrokesState([...strokesRef.current, optimistic]);
|
|
2288
|
+
void uploadAsset(file).then((res) => {
|
|
2289
|
+
const cur = strokesRef.current;
|
|
2290
|
+
if ('path' in res) {
|
|
2291
|
+
const after = cur.map((s) => (s.id === id ? ({ ...s, href: res.path } as Stroke) : s));
|
|
2292
|
+
const beforeForUndo = cur.filter((s) => s.id !== id);
|
|
2293
|
+
commitStrokes(beforeForUndo, after, 'add image');
|
|
2294
|
+
annotSel?.replace([id]);
|
|
2295
|
+
} else {
|
|
2296
|
+
setStrokesState(cur.filter((s) => s.id !== id));
|
|
2297
|
+
showCanvasToast(`Image upload failed — ${res.error}`);
|
|
2298
|
+
}
|
|
2299
|
+
URL.revokeObjectURL(blobUrl);
|
|
2300
|
+
});
|
|
2301
|
+
};
|
|
2302
|
+
probe.onerror = () => {
|
|
2303
|
+
URL.revokeObjectURL(blobUrl);
|
|
2304
|
+
showCanvasToast('Could not read that image file');
|
|
2305
|
+
};
|
|
2306
|
+
probe.src = blobUrl;
|
|
2307
|
+
},
|
|
2308
|
+
[commitStrokes, annotSel]
|
|
2309
|
+
);
|
|
2310
|
+
|
|
2311
|
+
// Link: client-only preview chip — no upload, no server fetch. Only http(s)
|
|
2312
|
+
// URLs are accepted (the hook already gates, re-checked here defensively).
|
|
2313
|
+
const createLink = useCallback(
|
|
2314
|
+
(url: string, title: string, world: [number, number]) => {
|
|
2315
|
+
if (!isHttpUrl(url)) return;
|
|
2316
|
+
const w = LINK_DEFAULT_W;
|
|
2317
|
+
const h = LINK_DEFAULT_H;
|
|
2318
|
+
const id = rid();
|
|
2319
|
+
const link: LinkStroke = {
|
|
2320
|
+
id,
|
|
2321
|
+
tool: 'link',
|
|
2322
|
+
x: world[0] - w / 2,
|
|
2323
|
+
y: world[1] - h / 2,
|
|
2324
|
+
w,
|
|
2325
|
+
h,
|
|
2326
|
+
url,
|
|
2327
|
+
title: (title || prettifyUrl(url)).slice(0, 300),
|
|
2328
|
+
domain: linkDomain(url),
|
|
2329
|
+
};
|
|
2330
|
+
const before = strokesRef.current;
|
|
2331
|
+
commitStrokes(before, [...before, link], 'add link');
|
|
2332
|
+
annotSel?.replace([id]);
|
|
2333
|
+
},
|
|
2334
|
+
[commitStrokes, annotSel]
|
|
2335
|
+
);
|
|
2336
|
+
|
|
2337
|
+
const mediaCallbacks = useMemo(
|
|
2338
|
+
() => ({ onImage: createImageFromFile, onLink: createLink }),
|
|
2339
|
+
[createImageFromFile, createLink]
|
|
2340
|
+
);
|
|
2341
|
+
// Media intake is paste/drop only (per product steer — no toolbar buttons):
|
|
2342
|
+
// drop an image / URL or Cmd+V a clipboard image / link straight onto the
|
|
2343
|
+
// canvas. The hook owns the dragover/drop/paste wiring; the create callbacks
|
|
2344
|
+
// hold the commit/undo sink + screenToWorld.
|
|
2345
|
+
useCanvasMediaDrop({ enabled: visible, screenToWorld, callbacks: mediaCallbacks });
|
|
2346
|
+
|
|
1625
2347
|
const eraseAt = useCallback(
|
|
1626
2348
|
(wx: number, wy: number) => {
|
|
1627
2349
|
const zoom = vp?.zoom || 1;
|
|
@@ -1672,6 +2394,17 @@ export function AnnotationsLayer() {
|
|
|
1672
2394
|
const activeFill = supportsFill ? fill : null;
|
|
1673
2395
|
if (tool === 'pen') {
|
|
1674
2396
|
setDrawing({ id, tool: 'pen', color, width, points: [[wx, wy]] });
|
|
2397
|
+
} else if (tool === 'highlighter') {
|
|
2398
|
+
// Highlighter (item 8) — a pen stroke with a wide width, a translucent
|
|
2399
|
+
// marker hue, and the highlighter flag (renders multiply at draw time).
|
|
2400
|
+
setDrawing({
|
|
2401
|
+
id,
|
|
2402
|
+
tool: 'pen',
|
|
2403
|
+
color: highlighterColor,
|
|
2404
|
+
width: highlighterWidth,
|
|
2405
|
+
points: [[wx, wy]],
|
|
2406
|
+
highlighter: true,
|
|
2407
|
+
});
|
|
1675
2408
|
} else if (tool === 'shape') {
|
|
1676
2409
|
// Phase 24 — the single Shape tool maps its kind onto a stroke type:
|
|
1677
2410
|
// circle → ellipse; square/rounded → rect (cornerRadius 0 / 8);
|
|
@@ -1760,6 +2493,8 @@ export function AnnotationsLayer() {
|
|
|
1760
2493
|
fill,
|
|
1761
2494
|
thickness,
|
|
1762
2495
|
stickyColor,
|
|
2496
|
+
highlighterColor,
|
|
2497
|
+
highlighterWidth,
|
|
1763
2498
|
supportsThickness,
|
|
1764
2499
|
supportsFill,
|
|
1765
2500
|
isActive,
|
|
@@ -1862,7 +2597,9 @@ export function AnnotationsLayer() {
|
|
|
1862
2597
|
// mode is locked on this tool. Sticky lets the user draw many shapes in a
|
|
1863
2598
|
// row (canonical pattern: tldraw double-click to lock). Eraser stays
|
|
1864
2599
|
// armed by default — that tool is destructive, not constructive.
|
|
1865
|
-
|
|
2600
|
+
// Map a highlighter pen (a 'pen' stroke with the flag) back to the
|
|
2601
|
+
// 'highlighter' tool id so its sticky-lock check matches the active tool.
|
|
2602
|
+
const toolJustUsed = cur.tool === 'pen' && cur.highlighter ? 'highlighter' : cur.tool;
|
|
1866
2603
|
if (toolJustUsed !== 'eraser') {
|
|
1867
2604
|
const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
|
|
1868
2605
|
if (!stickyOnThis) setTool('move');
|
|
@@ -1993,7 +2730,9 @@ export function AnnotationsLayer() {
|
|
|
1993
2730
|
t === 'polygon' ||
|
|
1994
2731
|
t === 'arrow' ||
|
|
1995
2732
|
t === 'text' ||
|
|
1996
|
-
t === 'sticky'
|
|
2733
|
+
t === 'sticky' ||
|
|
2734
|
+
t === 'image' ||
|
|
2735
|
+
t === 'link')
|
|
1997
2736
|
) {
|
|
1998
2737
|
return id;
|
|
1999
2738
|
}
|
|
@@ -2012,21 +2751,18 @@ export function AnnotationsLayer() {
|
|
|
2012
2751
|
const target = e.target as Element | null;
|
|
2013
2752
|
if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
|
|
2014
2753
|
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
2754
|
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
2021
2755
|
const startClientX = e.clientX;
|
|
2022
2756
|
const startClientY = e.clientY;
|
|
2023
2757
|
|
|
2024
|
-
// Stroke hit
|
|
2758
|
+
// Stroke hit OR multi-selection hull hit — start a group drag. Clicking a
|
|
2759
|
+
// selected stroke, OR anywhere inside the current selection's bounding box
|
|
2760
|
+
// (FigJam parity), moves the whole group. Without the hull case, grabbing
|
|
2761
|
+
// the empty space BETWEEN selected strokes fell through to the marquee
|
|
2762
|
+
// branch below and DESELECTED them.
|
|
2763
|
+
let ids: string[] | null = null;
|
|
2025
2764
|
if (strokeId) {
|
|
2026
|
-
e.preventDefault();
|
|
2027
|
-
e.stopImmediatePropagation();
|
|
2028
2765
|
elementSel?.clear();
|
|
2029
|
-
let ids: string[];
|
|
2030
2766
|
if (e.shiftKey) {
|
|
2031
2767
|
annotSel.add(strokeId);
|
|
2032
2768
|
ids = annotSel.contains(strokeId)
|
|
@@ -2038,6 +2774,28 @@ export function AnnotationsLayer() {
|
|
|
2038
2774
|
annotSel.replace(strokeId);
|
|
2039
2775
|
ids = [strokeId];
|
|
2040
2776
|
}
|
|
2777
|
+
} else if (!e.shiftKey && annotSel.selectedIds.length > 0) {
|
|
2778
|
+
// Hull hit-test — union bbox of the currently-selected strokes.
|
|
2779
|
+
let hx1 = Infinity;
|
|
2780
|
+
let hy1 = Infinity;
|
|
2781
|
+
let hx2 = -Infinity;
|
|
2782
|
+
let hy2 = -Infinity;
|
|
2783
|
+
for (const s of strokesStoreRef.current.strokes) {
|
|
2784
|
+
if (!annotSel.contains(s.id)) continue;
|
|
2785
|
+
const bb = strokeBBox(s);
|
|
2786
|
+
if (!bb) continue;
|
|
2787
|
+
hx1 = Math.min(hx1, bb.x);
|
|
2788
|
+
hy1 = Math.min(hy1, bb.y);
|
|
2789
|
+
hx2 = Math.max(hx2, bb.x + bb.w);
|
|
2790
|
+
hy2 = Math.max(hy2, bb.y + bb.h);
|
|
2791
|
+
}
|
|
2792
|
+
if (hx2 >= hx1 && wx >= hx1 && wx <= hx2 && wy >= hy1 && wy <= hy2) {
|
|
2793
|
+
ids = annotSel.selectedIds;
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
if (ids && ids.length) {
|
|
2797
|
+
e.preventDefault();
|
|
2798
|
+
e.stopImmediatePropagation();
|
|
2041
2799
|
// Capture a snapshot of all strokes at drag start. Every pointermove
|
|
2042
2800
|
// re-translates FROM the snapshot using the cumulative cursor delta
|
|
2043
2801
|
// (NOT a delta-from-last-frame mutation), so dragging back to origin
|
|
@@ -2090,6 +2848,13 @@ export function AnnotationsLayer() {
|
|
|
2090
2848
|
return;
|
|
2091
2849
|
}
|
|
2092
2850
|
|
|
2851
|
+
// Not a stroke / hull-group drag. When pointerdown lands inside an
|
|
2852
|
+
// artboard the gesture belongs to artboard-drag / element-marquee — not
|
|
2853
|
+
// the annotation marquee (post-Wave-3 grievance G5). Checked AFTER the
|
|
2854
|
+
// group-drag decision so a multi-selection hull-drag still wins even when
|
|
2855
|
+
// the strokes sit over an artboard.
|
|
2856
|
+
if (!strokeId && target?.closest?.('[data-dc-screen]')) return;
|
|
2857
|
+
|
|
2093
2858
|
// Empty world — start a drag-select gesture. A bare click without
|
|
2094
2859
|
// moving clears annotation selection (post-Wave-3 feedback: click-to-
|
|
2095
2860
|
// deselect is back; Esc also still works).
|
|
@@ -2185,7 +2950,7 @@ export function AnnotationsLayer() {
|
|
|
2185
2950
|
}, [tool]);
|
|
2186
2951
|
|
|
2187
2952
|
const commitText = useCallback(
|
|
2188
|
-
(anchorId: string, text: string) => {
|
|
2953
|
+
(anchorId: string, text: string, fmt?: EditorFmt) => {
|
|
2189
2954
|
const trimmed = text.trim();
|
|
2190
2955
|
const prev = strokesRef.current;
|
|
2191
2956
|
const existing = prev.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
|
|
@@ -2198,8 +2963,10 @@ export function AnnotationsLayer() {
|
|
|
2198
2963
|
next = prev.filter((s) => s.id !== existing.id);
|
|
2199
2964
|
label = 'delete text';
|
|
2200
2965
|
} else if (existing) {
|
|
2201
|
-
if (existing.text === trimmed) return; // identity edit
|
|
2202
|
-
next = prev.map((s) =>
|
|
2966
|
+
if (existing.text === trimmed && fmtEqual(existing, fmt)) return; // identity edit
|
|
2967
|
+
next = prev.map((s) =>
|
|
2968
|
+
s.id === existing.id ? { ...existing, text: trimmed, ...normFmt(fmt) } : s
|
|
2969
|
+
);
|
|
2203
2970
|
} else {
|
|
2204
2971
|
next = [
|
|
2205
2972
|
...prev,
|
|
@@ -2210,6 +2977,7 @@ export function AnnotationsLayer() {
|
|
|
2210
2977
|
fontSize: DEFAULT_FONT_SIZE,
|
|
2211
2978
|
text: trimmed,
|
|
2212
2979
|
anchorId,
|
|
2980
|
+
...normFmt(fmt),
|
|
2213
2981
|
} as TextStroke,
|
|
2214
2982
|
];
|
|
2215
2983
|
label = 'add text';
|
|
@@ -2222,13 +2990,13 @@ export function AnnotationsLayer() {
|
|
|
2222
2990
|
// Phase 21 — sticky body edit. Sticky text is freeform (newlines preserved,
|
|
2223
2991
|
// no trim) and the card persists even when blank, so this only updates text.
|
|
2224
2992
|
const commitStickyText = useCallback(
|
|
2225
|
-
(id: string, text: string) => {
|
|
2993
|
+
(id: string, text: string, fmt?: EditorFmt) => {
|
|
2226
2994
|
const prev = strokesRef.current;
|
|
2227
2995
|
const existing = prev.find((s) => s.id === id && s.tool === 'sticky') as
|
|
2228
2996
|
| StickyStroke
|
|
2229
2997
|
| undefined;
|
|
2230
|
-
if (!existing || existing.text === text) return;
|
|
2231
|
-
const next = prev.map((s) => (s.id === id ? { ...existing, text } : s));
|
|
2998
|
+
if (!existing || (existing.text === text && fmtEqual(existing, fmt))) return;
|
|
2999
|
+
const next = prev.map((s) => (s.id === id ? { ...existing, text, ...normFmt(fmt) } : s));
|
|
2232
3000
|
commitStrokes(prev, next, 'edit sticky');
|
|
2233
3001
|
},
|
|
2234
3002
|
[commitStrokes]
|
|
@@ -2237,7 +3005,7 @@ export function AnnotationsLayer() {
|
|
|
2237
3005
|
// Phase 21 — re-edit an EXISTING standalone text node. Empty text deletes it
|
|
2238
3006
|
// (same rule as anchored text).
|
|
2239
3007
|
const commitStandaloneText = useCallback(
|
|
2240
|
-
(id: string, text: string) => {
|
|
3008
|
+
(id: string, text: string, fmt?: EditorFmt) => {
|
|
2241
3009
|
const trimmed = text.trim();
|
|
2242
3010
|
const prev = strokesRef.current;
|
|
2243
3011
|
const existing = prev.find((s) => s.id === id && s.tool === 'text') as TextStroke | undefined;
|
|
@@ -2250,10 +3018,10 @@ export function AnnotationsLayer() {
|
|
|
2250
3018
|
);
|
|
2251
3019
|
return;
|
|
2252
3020
|
}
|
|
2253
|
-
if (existing.text === trimmed) return;
|
|
3021
|
+
if (existing.text === trimmed && fmtEqual(existing, fmt)) return;
|
|
2254
3022
|
commitStrokes(
|
|
2255
3023
|
prev,
|
|
2256
|
-
prev.map((s) => (s.id === id ? { ...existing, text: trimmed } : s)),
|
|
3024
|
+
prev.map((s) => (s.id === id ? { ...existing, text: trimmed, ...normFmt(fmt) } : s)),
|
|
2257
3025
|
'edit text'
|
|
2258
3026
|
);
|
|
2259
3027
|
},
|
|
@@ -2263,14 +3031,23 @@ export function AnnotationsLayer() {
|
|
|
2263
3031
|
// Phase 21 — born-on-commit standalone text (from the text-tool caret). An
|
|
2264
3032
|
// empty caret persists nothing — ONE undo record only when real text lands.
|
|
2265
3033
|
const createStandaloneText = useCallback(
|
|
2266
|
-
(x: number, y: number, text: string) => {
|
|
3034
|
+
(x: number, y: number, text: string, fmt?: EditorFmt) => {
|
|
2267
3035
|
const trimmed = text.trim();
|
|
2268
3036
|
if (trimmed.length === 0) return;
|
|
2269
3037
|
const prev = strokesRef.current;
|
|
2270
3038
|
const id = rid();
|
|
2271
3039
|
const next: Stroke[] = [
|
|
2272
3040
|
...prev,
|
|
2273
|
-
{
|
|
3041
|
+
{
|
|
3042
|
+
id,
|
|
3043
|
+
tool: 'text',
|
|
3044
|
+
color,
|
|
3045
|
+
fontSize: DEFAULT_FONT_SIZE,
|
|
3046
|
+
text: trimmed,
|
|
3047
|
+
x,
|
|
3048
|
+
y,
|
|
3049
|
+
...normFmt(fmt),
|
|
3050
|
+
},
|
|
2274
3051
|
];
|
|
2275
3052
|
commitStrokes(prev, next, 'add text');
|
|
2276
3053
|
if (annotSel) annotSel.replace(id);
|
|
@@ -2298,15 +3075,15 @@ export function AnnotationsLayer() {
|
|
|
2298
3075
|
editingTargetRef.current = editingTarget;
|
|
2299
3076
|
|
|
2300
3077
|
const commitEditing = useCallback(
|
|
2301
|
-
(text: string) => {
|
|
3078
|
+
(text: string, fmt?: EditorFmt) => {
|
|
2302
3079
|
const target = editingTargetRef.current;
|
|
2303
3080
|
setEditingId(null);
|
|
2304
3081
|
setPendingText(null);
|
|
2305
3082
|
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);
|
|
3083
|
+
if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
|
|
3084
|
+
else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
|
|
3085
|
+
else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
|
|
3086
|
+
else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text, fmt);
|
|
2310
3087
|
},
|
|
2311
3088
|
[commitText, commitStickyText, commitStandaloneText, createStandaloneText]
|
|
2312
3089
|
);
|
|
@@ -2388,6 +3165,7 @@ export function AnnotationsLayer() {
|
|
|
2388
3165
|
marquee={marquee}
|
|
2389
3166
|
ghost={ghostPreview}
|
|
2390
3167
|
editingTarget={editingTarget}
|
|
3168
|
+
inkColor={color}
|
|
2391
3169
|
onCommitEdit={commitEditing}
|
|
2392
3170
|
onCancelEdit={cancelEditing}
|
|
2393
3171
|
/>
|
|
@@ -2397,10 +3175,15 @@ export function AnnotationsLayer() {
|
|
|
2397
3175
|
{isActive ? (
|
|
2398
3176
|
<AnnotationsChrome
|
|
2399
3177
|
tool={tool}
|
|
3178
|
+
theme={theme}
|
|
2400
3179
|
color={color}
|
|
2401
3180
|
setColor={setColor}
|
|
2402
3181
|
stickyColor={stickyColor}
|
|
2403
3182
|
setStickyColor={setStickyColor}
|
|
3183
|
+
highlighterColor={highlighterColor}
|
|
3184
|
+
setHighlighterColor={setHighlighterColor}
|
|
3185
|
+
highlighterWidth={highlighterWidth}
|
|
3186
|
+
setHighlighterWidth={setHighlighterWidth}
|
|
2404
3187
|
supportsFill={supportsFill}
|
|
2405
3188
|
fill={fill}
|
|
2406
3189
|
setFill={setFill}
|
|
@@ -2505,6 +3288,7 @@ function AnnotationsSvg({
|
|
|
2505
3288
|
marquee,
|
|
2506
3289
|
ghost,
|
|
2507
3290
|
editingTarget,
|
|
3291
|
+
inkColor,
|
|
2508
3292
|
onCommitEdit,
|
|
2509
3293
|
onCancelEdit,
|
|
2510
3294
|
}: {
|
|
@@ -2516,7 +3300,9 @@ function AnnotationsSvg({
|
|
|
2516
3300
|
marquee: { ax: number; ay: number; bx: number; by: number } | null;
|
|
2517
3301
|
ghost: GhostDescriptor | null;
|
|
2518
3302
|
editingTarget: EditingTarget;
|
|
2519
|
-
|
|
3303
|
+
/** Live default ink (theme-aware) for a not-yet-born pending text caret. */
|
|
3304
|
+
inkColor: string;
|
|
3305
|
+
onCommitEdit: (text: string, fmt?: EditorFmt) => void;
|
|
2520
3306
|
onCancelEdit: () => void;
|
|
2521
3307
|
}) {
|
|
2522
3308
|
const [, force] = useState({});
|
|
@@ -2578,7 +3364,7 @@ function AnnotationsSvg({
|
|
|
2578
3364
|
anchorId={editingTarget.anchorId}
|
|
2579
3365
|
host={editingTarget.host}
|
|
2580
3366
|
existing={anchoredExisting}
|
|
2581
|
-
onCommit={(_anchorId, text) => onCommitEdit(text)}
|
|
3367
|
+
onCommit={(_anchorId, text, fmt) => onCommitEdit(text, fmt)}
|
|
2582
3368
|
onCancel={onCancelEdit}
|
|
2583
3369
|
/>
|
|
2584
3370
|
) : null}
|
|
@@ -2597,8 +3383,11 @@ function AnnotationsSvg({
|
|
|
2597
3383
|
color={editingTarget.text.color}
|
|
2598
3384
|
initialText={editingTarget.text.text}
|
|
2599
3385
|
bold={editingTarget.text.bold}
|
|
3386
|
+
italic={editingTarget.text.italic}
|
|
2600
3387
|
strike={editingTarget.text.strike}
|
|
3388
|
+
underline={editingTarget.text.underline}
|
|
2601
3389
|
align={editingTarget.text.align ?? 'left'}
|
|
3390
|
+
listType={editingTarget.text.listType}
|
|
2602
3391
|
onCommit={onCommitEdit}
|
|
2603
3392
|
onCancel={onCancelEdit}
|
|
2604
3393
|
/>
|
|
@@ -2608,7 +3397,7 @@ function AnnotationsSvg({
|
|
|
2608
3397
|
x={editingTarget.x}
|
|
2609
3398
|
y={editingTarget.y}
|
|
2610
3399
|
fontSize={DEFAULT_FONT_SIZE}
|
|
2611
|
-
color={
|
|
3400
|
+
color={inkColor}
|
|
2612
3401
|
initialText=""
|
|
2613
3402
|
onCommit={onCommitEdit}
|
|
2614
3403
|
onCancel={onCancelEdit}
|
|
@@ -2629,13 +3418,26 @@ function TextEditor({
|
|
|
2629
3418
|
anchorId: string;
|
|
2630
3419
|
host: RectStroke | EllipseStroke | null;
|
|
2631
3420
|
existing: TextStroke | undefined;
|
|
2632
|
-
onCommit: (anchorId: string, text: string) => void;
|
|
3421
|
+
onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
|
|
2633
3422
|
onCancel: () => void;
|
|
2634
3423
|
}) {
|
|
2635
3424
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
2636
|
-
|
|
3425
|
+
// Show list markers WHILE editing so the read↔edit swap doesn't flicker
|
|
3426
|
+
// (item 4c) — stripped back to raw text on commit.
|
|
3427
|
+
const initial = listPrefixedBody(existing?.text ?? '', existing?.listType);
|
|
2637
3428
|
const initialRef = useRef(initial);
|
|
2638
3429
|
initialRef.current = initial;
|
|
3430
|
+
// Cmd/Ctrl+B/I/U formatting while editing (item 4d).
|
|
3431
|
+
const {
|
|
3432
|
+
fmtRef,
|
|
3433
|
+
style: fmtStyle,
|
|
3434
|
+
onFormatKey,
|
|
3435
|
+
} = useEditorFormat({
|
|
3436
|
+
bold: existing?.bold,
|
|
3437
|
+
italic: existing?.italic,
|
|
3438
|
+
underline: existing?.underline,
|
|
3439
|
+
strike: existing?.strike,
|
|
3440
|
+
});
|
|
2639
3441
|
|
|
2640
3442
|
useEffect(() => {
|
|
2641
3443
|
const el = ref.current;
|
|
@@ -2662,11 +3464,15 @@ function TextEditor({
|
|
|
2662
3464
|
const el = ref.current;
|
|
2663
3465
|
if (!el) return;
|
|
2664
3466
|
if (el.contains(e.target as Node)) return;
|
|
2665
|
-
onCommit(
|
|
3467
|
+
onCommit(
|
|
3468
|
+
anchorId,
|
|
3469
|
+
stripEditorMarkers(el.innerText || '', existing?.listType),
|
|
3470
|
+
fmtRef.current
|
|
3471
|
+
);
|
|
2666
3472
|
};
|
|
2667
3473
|
document.addEventListener('pointerdown', onDown, true);
|
|
2668
3474
|
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
2669
|
-
}, [anchorId, onCommit]);
|
|
3475
|
+
}, [anchorId, onCommit, existing?.listType, fmtRef]);
|
|
2670
3476
|
|
|
2671
3477
|
if (!host) return null;
|
|
2672
3478
|
const bbox = strokeBBox(host);
|
|
@@ -2686,24 +3492,29 @@ function TextEditor({
|
|
|
2686
3492
|
style={{
|
|
2687
3493
|
width: '100%',
|
|
2688
3494
|
height: '100%',
|
|
3495
|
+
// Column flex (NOT row) so contentEditable line breaks stack
|
|
3496
|
+
// vertically; justify-center keeps the block vertically centred in
|
|
3497
|
+
// the host. The pre-Task-5 row-flex laid lines out side-by-side
|
|
3498
|
+
// (item 4a — the mangled multi-line look).
|
|
2689
3499
|
display: 'flex',
|
|
2690
|
-
|
|
2691
|
-
justifyContent:
|
|
2692
|
-
|
|
3500
|
+
flexDirection: 'column',
|
|
3501
|
+
justifyContent: 'center',
|
|
3502
|
+
alignItems: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
|
|
2693
3503
|
padding: '0 8px',
|
|
2694
3504
|
boxSizing: 'border-box',
|
|
2695
3505
|
textAlign: align,
|
|
3506
|
+
whiteSpace: 'pre-wrap',
|
|
2696
3507
|
color: existing?.color ?? '#1a1a1a',
|
|
2697
3508
|
fontSize: `${fontSize}px`,
|
|
2698
3509
|
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
2699
|
-
|
|
2700
|
-
textDecoration: existing?.strike ? 'line-through' : undefined,
|
|
3510
|
+
...fmtStyle,
|
|
2701
3511
|
lineHeight: 1.25,
|
|
2702
3512
|
outline: 'none',
|
|
2703
3513
|
background: 'transparent',
|
|
2704
3514
|
cursor: 'text',
|
|
2705
3515
|
}}
|
|
2706
3516
|
onKeyDown={(e) => {
|
|
3517
|
+
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
2707
3518
|
if (e.key === 'Escape') {
|
|
2708
3519
|
e.preventDefault();
|
|
2709
3520
|
onCancel();
|
|
@@ -2712,7 +3523,11 @@ function TextEditor({
|
|
|
2712
3523
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
2713
3524
|
e.preventDefault();
|
|
2714
3525
|
const el = ref.current;
|
|
2715
|
-
onCommit(
|
|
3526
|
+
onCommit(
|
|
3527
|
+
anchorId,
|
|
3528
|
+
stripEditorMarkers(el?.innerText || '', existing?.listType),
|
|
3529
|
+
fmtRef.current
|
|
3530
|
+
);
|
|
2716
3531
|
}
|
|
2717
3532
|
}}
|
|
2718
3533
|
>
|
|
@@ -2731,13 +3546,30 @@ function StickyEditor({
|
|
|
2731
3546
|
onCancel,
|
|
2732
3547
|
}: {
|
|
2733
3548
|
sticky: StickyStroke;
|
|
2734
|
-
onCommit: (text: string) => void;
|
|
3549
|
+
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
2735
3550
|
onCancel: () => void;
|
|
2736
3551
|
}) {
|
|
2737
3552
|
// A flex-centered contentEditable (NOT a textarea) so the edit view matches
|
|
2738
3553
|
// the committed `.dc-sticky-body` exactly — text stays centered, no jump on
|
|
2739
|
-
// commit. Multi-line: Enter inserts a line break; Esc cancels; blur
|
|
3554
|
+
// commit. Multi-line: Enter inserts a line break; Esc cancels; blur / Cmd+Enter
|
|
3555
|
+
// commit; Cmd/Ctrl+B/I/U format (unified with the other editors — item 4d).
|
|
2740
3556
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
3557
|
+
const doneRef = useRef(false);
|
|
3558
|
+
const {
|
|
3559
|
+
fmtRef,
|
|
3560
|
+
style: fmtStyle,
|
|
3561
|
+
onFormatKey,
|
|
3562
|
+
} = useEditorFormat({
|
|
3563
|
+
bold: sticky.bold,
|
|
3564
|
+
italic: sticky.italic,
|
|
3565
|
+
underline: sticky.underline,
|
|
3566
|
+
strike: sticky.strike,
|
|
3567
|
+
});
|
|
3568
|
+
const commit = () => {
|
|
3569
|
+
if (doneRef.current) return;
|
|
3570
|
+
doneRef.current = true;
|
|
3571
|
+
onCommit(stripEditorMarkers(ref.current?.innerText ?? '', sticky.listType), fmtRef.current);
|
|
3572
|
+
};
|
|
2741
3573
|
useEffect(() => {
|
|
2742
3574
|
const el = ref.current;
|
|
2743
3575
|
if (!el) return;
|
|
@@ -2767,16 +3599,25 @@ function StickyEditor({
|
|
|
2767
3599
|
contentEditable
|
|
2768
3600
|
suppressContentEditableWarning
|
|
2769
3601
|
aria-label="Edit sticky note text"
|
|
2770
|
-
style={{ ...stickyBodyStyle(sticky), outline: 'none', cursor: 'text' }}
|
|
2771
|
-
onBlur={
|
|
3602
|
+
style={{ ...stickyBodyStyle(sticky), ...fmtStyle, outline: 'none', cursor: 'text' }}
|
|
3603
|
+
onBlur={commit}
|
|
2772
3604
|
onKeyDown={(e) => {
|
|
3605
|
+
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
2773
3606
|
if (e.key === 'Escape') {
|
|
2774
3607
|
e.preventDefault();
|
|
3608
|
+
doneRef.current = true; // suppress the unmount blur-commit
|
|
2775
3609
|
onCancel();
|
|
3610
|
+
return;
|
|
3611
|
+
}
|
|
3612
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
3613
|
+
e.preventDefault();
|
|
3614
|
+
commit();
|
|
2776
3615
|
}
|
|
2777
3616
|
}}
|
|
2778
3617
|
>
|
|
2779
|
-
{
|
|
3618
|
+
{/* Show the list markers while editing (item 4c) so the read↔edit swap
|
|
3619
|
+
doesn't flicker; stripped back to raw text on commit. */}
|
|
3620
|
+
{stickyBodyText(sticky)}
|
|
2780
3621
|
</div>
|
|
2781
3622
|
</foreignObject>
|
|
2782
3623
|
);
|
|
@@ -2791,8 +3632,11 @@ function StandaloneTextEditor({
|
|
|
2791
3632
|
color,
|
|
2792
3633
|
initialText,
|
|
2793
3634
|
bold,
|
|
3635
|
+
italic,
|
|
2794
3636
|
strike,
|
|
3637
|
+
underline,
|
|
2795
3638
|
align,
|
|
3639
|
+
listType,
|
|
2796
3640
|
onCommit,
|
|
2797
3641
|
onCancel,
|
|
2798
3642
|
}: {
|
|
@@ -2802,12 +3646,38 @@ function StandaloneTextEditor({
|
|
|
2802
3646
|
color: string;
|
|
2803
3647
|
initialText: string;
|
|
2804
3648
|
bold?: boolean;
|
|
3649
|
+
italic?: boolean;
|
|
2805
3650
|
strike?: boolean;
|
|
3651
|
+
underline?: boolean;
|
|
2806
3652
|
align?: TextAlign;
|
|
2807
|
-
|
|
3653
|
+
listType?: ListType;
|
|
3654
|
+
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
2808
3655
|
onCancel: () => void;
|
|
2809
3656
|
}) {
|
|
2810
3657
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
3658
|
+
// Cmd/Ctrl+B/I/U formatting while editing (item 4d).
|
|
3659
|
+
const {
|
|
3660
|
+
fmtRef,
|
|
3661
|
+
style: fmtStyle,
|
|
3662
|
+
onFormatKey,
|
|
3663
|
+
} = useEditorFormat({
|
|
3664
|
+
bold,
|
|
3665
|
+
italic,
|
|
3666
|
+
underline,
|
|
3667
|
+
strike,
|
|
3668
|
+
});
|
|
3669
|
+
// Single-fire commit guard — outside-click + blur can both fire in one tick;
|
|
3670
|
+
// without this the text would commit twice (two undo records). Markers shown
|
|
3671
|
+
// while editing (item 4c) are stripped back to raw text here on commit.
|
|
3672
|
+
const doneRef = useRef(false);
|
|
3673
|
+
const commitOnce = useCallback(
|
|
3674
|
+
(text: string) => {
|
|
3675
|
+
if (doneRef.current) return;
|
|
3676
|
+
doneRef.current = true;
|
|
3677
|
+
onCommit(stripEditorMarkers(text, listType), fmtRef.current);
|
|
3678
|
+
},
|
|
3679
|
+
[onCommit, listType, fmtRef]
|
|
3680
|
+
);
|
|
2811
3681
|
useEffect(() => {
|
|
2812
3682
|
const el = ref.current;
|
|
2813
3683
|
if (!el) return;
|
|
@@ -2831,13 +3701,16 @@ function StandaloneTextEditor({
|
|
|
2831
3701
|
const el = ref.current;
|
|
2832
3702
|
if (!el) return;
|
|
2833
3703
|
if (el.contains(e.target as Node)) return;
|
|
2834
|
-
|
|
3704
|
+
commitOnce(el.innerText || '');
|
|
2835
3705
|
};
|
|
2836
3706
|
document.addEventListener('pointerdown', onDown, true);
|
|
2837
3707
|
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
2838
|
-
}, [
|
|
3708
|
+
}, [commitOnce]);
|
|
2839
3709
|
return (
|
|
2840
|
-
|
|
3710
|
+
// Generous box so multi-line text isn't clipped while typing (item 4a). The
|
|
3711
|
+
// empty area is transparent + pointer-pass-through (the SVG root is
|
|
3712
|
+
// pointer-events:none), so outside-click still commits.
|
|
3713
|
+
<foreignObject x={x} y={y} width={640} height={480}>
|
|
2841
3714
|
<div
|
|
2842
3715
|
xmlns="http://www.w3.org/1999/xhtml"
|
|
2843
3716
|
ref={ref}
|
|
@@ -2848,32 +3721,38 @@ function StandaloneTextEditor({
|
|
|
2848
3721
|
style={{
|
|
2849
3722
|
display: 'inline-block',
|
|
2850
3723
|
minWidth: '8px',
|
|
2851
|
-
|
|
3724
|
+
// pre-wrap so Enter inserts a real newline (multi-line text), not a
|
|
3725
|
+
// commit; long lines also wrap within the box.
|
|
3726
|
+
whiteSpace: 'pre-wrap',
|
|
2852
3727
|
padding: '0 2px',
|
|
2853
3728
|
color,
|
|
2854
3729
|
fontSize: `${fontSize}px`,
|
|
2855
3730
|
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
2856
|
-
|
|
2857
|
-
textDecoration: strike ? 'line-through' : undefined,
|
|
3731
|
+
...fmtStyle,
|
|
2858
3732
|
textAlign: align ?? 'left',
|
|
2859
|
-
lineHeight:
|
|
3733
|
+
lineHeight: TEXT_LINE_HEIGHT,
|
|
2860
3734
|
outline: 'none',
|
|
2861
3735
|
background: 'transparent',
|
|
2862
3736
|
cursor: 'text',
|
|
2863
3737
|
}}
|
|
3738
|
+
onBlur={() => commitOnce(ref.current?.innerText || '')}
|
|
2864
3739
|
onKeyDown={(e) => {
|
|
3740
|
+
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
2865
3741
|
if (e.key === 'Escape') {
|
|
2866
3742
|
e.preventDefault();
|
|
3743
|
+
// Mark done so the unmount blur that follows doesn't commit.
|
|
3744
|
+
doneRef.current = true;
|
|
2867
3745
|
onCancel();
|
|
2868
3746
|
return;
|
|
2869
3747
|
}
|
|
2870
|
-
|
|
3748
|
+
// Cmd/Ctrl+Enter commits; plain Enter inserts a newline (item 4a).
|
|
3749
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
2871
3750
|
e.preventDefault();
|
|
2872
|
-
|
|
3751
|
+
commitOnce(ref.current?.innerText || '');
|
|
2873
3752
|
}
|
|
2874
3753
|
}}
|
|
2875
3754
|
>
|
|
2876
|
-
{initialText}
|
|
3755
|
+
{listPrefixedBody(initialText, listType)}
|
|
2877
3756
|
</div>
|
|
2878
3757
|
</foreignObject>
|
|
2879
3758
|
);
|
|
@@ -3002,12 +3881,46 @@ function stickyBodyStyle(s: StickyStroke): CSSProperties {
|
|
|
3002
3881
|
return {
|
|
3003
3882
|
fontSize: `${s.fontSize}px`,
|
|
3004
3883
|
fontWeight: s.bold ? 700 : undefined,
|
|
3005
|
-
|
|
3884
|
+
fontStyle: s.italic ? 'italic' : undefined,
|
|
3885
|
+
textDecoration: textDecoCss(s.strike, s.underline),
|
|
3006
3886
|
textAlign: align,
|
|
3007
3887
|
justifyContent: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
|
|
3008
3888
|
};
|
|
3009
3889
|
}
|
|
3010
3890
|
|
|
3891
|
+
/** Sticky body content: raw text for a plain card, else per-line with list
|
|
3892
|
+
* markers prepended (item 4c — markers are render-only). */
|
|
3893
|
+
function stickyBodyText(s: StickyStroke): string {
|
|
3894
|
+
if (!s.listType) return s.text;
|
|
3895
|
+
return splitTextLines(s.text)
|
|
3896
|
+
.map((line, i) => listPrefixedLine(line, i, s.listType))
|
|
3897
|
+
.join('\n');
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
/**
|
|
3901
|
+
* Render the inner content of a `<text>` stroke: a single string for single-
|
|
3902
|
+
* line unstyled text (item 4a parity with the legacy form), else one `<tspan>`
|
|
3903
|
+
* per line with list markers prepended (item 4c). `tx` is the per-line origin;
|
|
3904
|
+
* `centered` lifts the block half its height for vertically-centred anchored
|
|
3905
|
+
* text. Mirrors `textInnerSvg` so the live + persisted geometry agree.
|
|
3906
|
+
*/
|
|
3907
|
+
function renderTextLines(
|
|
3908
|
+
text: string,
|
|
3909
|
+
fontSize: number,
|
|
3910
|
+
tx: number,
|
|
3911
|
+
centered: boolean,
|
|
3912
|
+
list?: ListType
|
|
3913
|
+
) {
|
|
3914
|
+
if (!list && !text.includes('\n')) return text;
|
|
3915
|
+
const lines = splitTextLines(text);
|
|
3916
|
+
return lines.map((line, i) => (
|
|
3917
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional + immutable per render
|
|
3918
|
+
<tspan key={i} x={tx} dy={textLineDy(i, fontSize, lines.length, centered)}>
|
|
3919
|
+
{listPrefixedLine(line, i, list)}
|
|
3920
|
+
</tspan>
|
|
3921
|
+
));
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3011
3924
|
/**
|
|
3012
3925
|
* Phase 24 — the translucent cursor-following ghost placeholder. Pure chrome:
|
|
3013
3926
|
* `pointer-events:none`, never added to `strokes`, so it can't be selected,
|
|
@@ -3091,12 +4004,14 @@ function StrokeNode({
|
|
|
3091
4004
|
const strokeHit = interactive ? 'stroke' : ('none' as const);
|
|
3092
4005
|
if (stroke.tool === 'text') {
|
|
3093
4006
|
// Anchored text renders centered in its host; standalone (Phase 21) renders
|
|
3094
|
-
// top-left-anchored at its own world (x, y).
|
|
3095
|
-
//
|
|
4007
|
+
// top-left-anchored at its own world (x, y). bold / italic / strike /
|
|
4008
|
+
// underline applied to the rendered <text>; multi-line + list markers via
|
|
4009
|
+
// renderTextLines (one <tspan> per line).
|
|
3096
4010
|
const textStyle = {
|
|
3097
4011
|
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
3098
4012
|
fontWeight: stroke.bold ? 700 : undefined,
|
|
3099
|
-
|
|
4013
|
+
fontStyle: stroke.italic ? 'italic' : undefined,
|
|
4014
|
+
textDecoration: textDecoCss(stroke.strike, stroke.underline),
|
|
3100
4015
|
} as const;
|
|
3101
4016
|
if (stroke.anchorId != null && stroke.anchorId !== '') {
|
|
3102
4017
|
const host = anchorsById.get(stroke.anchorId);
|
|
@@ -3126,18 +4041,19 @@ function StrokeNode({
|
|
|
3126
4041
|
dominantBaseline="middle"
|
|
3127
4042
|
style={textStyle}
|
|
3128
4043
|
>
|
|
3129
|
-
{stroke.text}
|
|
4044
|
+
{renderTextLines(stroke.text, stroke.fontSize, tx, true, stroke.listType)}
|
|
3130
4045
|
</text>
|
|
3131
4046
|
);
|
|
3132
4047
|
}
|
|
3133
4048
|
const align = stroke.align ?? 'left';
|
|
3134
4049
|
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
4050
|
+
const tx = stroke.x ?? 0;
|
|
3135
4051
|
return (
|
|
3136
4052
|
<text
|
|
3137
4053
|
data-id={stroke.id}
|
|
3138
4054
|
data-tool="text"
|
|
3139
4055
|
data-font-size={stroke.fontSize}
|
|
3140
|
-
x={
|
|
4056
|
+
x={tx}
|
|
3141
4057
|
y={stroke.y ?? 0}
|
|
3142
4058
|
fill={stroke.color}
|
|
3143
4059
|
fontSize={stroke.fontSize}
|
|
@@ -3146,7 +4062,7 @@ function StrokeNode({
|
|
|
3146
4062
|
pointerEvents={interactive ? 'visiblePainted' : 'none'}
|
|
3147
4063
|
style={textStyle}
|
|
3148
4064
|
>
|
|
3149
|
-
{stroke.text}
|
|
4065
|
+
{renderTextLines(stroke.text, stroke.fontSize, tx, false, stroke.listType)}
|
|
3150
4066
|
</text>
|
|
3151
4067
|
);
|
|
3152
4068
|
}
|
|
@@ -3159,14 +4075,11 @@ function StrokeNode({
|
|
|
3159
4075
|
return (
|
|
3160
4076
|
<g data-id={stroke.id} data-tool="sticky" pointerEvents={hitMode}>
|
|
3161
4077
|
{/* Paper card: soft drop shadow + hairline edge so it reads as a
|
|
3162
|
-
lifted sticky, not a flat colored box (FigJam-style).
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
height={h}
|
|
3168
|
-
rx={r}
|
|
3169
|
-
ry={r}
|
|
4078
|
+
lifted sticky, not a flat colored box (FigJam-style). The body is a
|
|
4079
|
+
path with a SHARP bottom-right corner (item 1) — TL/TR/BL rounded.
|
|
4080
|
+
The persisted form stays a <rect> (DDR), so this is render-only. */}
|
|
4081
|
+
<path
|
|
4082
|
+
d={stickyCornerPath(x, y, w, h, r)}
|
|
3170
4083
|
fill={stroke.color}
|
|
3171
4084
|
stroke="rgba(0,0,0,0.05)"
|
|
3172
4085
|
strokeWidth={1}
|
|
@@ -3180,13 +4093,105 @@ function StrokeNode({
|
|
|
3180
4093
|
className="dc-sticky-body"
|
|
3181
4094
|
style={stickyBodyStyle(stroke)}
|
|
3182
4095
|
>
|
|
3183
|
-
{stroke
|
|
4096
|
+
{stickyBodyText(stroke)}
|
|
3184
4097
|
</div>
|
|
3185
4098
|
</foreignObject>
|
|
3186
4099
|
)}
|
|
3187
4100
|
</g>
|
|
3188
4101
|
);
|
|
3189
4102
|
}
|
|
4103
|
+
if (stroke.tool === 'image') {
|
|
4104
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
4105
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4106
|
+
const w = Math.abs(stroke.w);
|
|
4107
|
+
const h = Math.abs(stroke.h);
|
|
4108
|
+
return (
|
|
4109
|
+
<image
|
|
4110
|
+
data-id={stroke.id}
|
|
4111
|
+
data-tool="image"
|
|
4112
|
+
x={x}
|
|
4113
|
+
y={y}
|
|
4114
|
+
width={w}
|
|
4115
|
+
height={h}
|
|
4116
|
+
href={resolveAssetHref(stroke.href)}
|
|
4117
|
+
preserveAspectRatio="xMidYMid meet"
|
|
4118
|
+
aria-label={stroke.alt || undefined}
|
|
4119
|
+
pointerEvents={hitMode}
|
|
4120
|
+
/>
|
|
4121
|
+
);
|
|
4122
|
+
}
|
|
4123
|
+
if (stroke.tool === 'link') {
|
|
4124
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
4125
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4126
|
+
const w = Math.abs(stroke.w);
|
|
4127
|
+
const h = Math.abs(stroke.h);
|
|
4128
|
+
const lay = linkCardLayout(x, y, w, h);
|
|
4129
|
+
const shownTitle = clampLinkTitle(stroke.title, lay.textMaxChars);
|
|
4130
|
+
const textFont = {
|
|
4131
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
4132
|
+
} as const;
|
|
4133
|
+
return (
|
|
4134
|
+
<g
|
|
4135
|
+
data-id={stroke.id}
|
|
4136
|
+
data-tool="link"
|
|
4137
|
+
data-url={stroke.url}
|
|
4138
|
+
data-title={stroke.title}
|
|
4139
|
+
data-domain={stroke.domain}
|
|
4140
|
+
pointerEvents={hitMode}
|
|
4141
|
+
>
|
|
4142
|
+
<rect
|
|
4143
|
+
x={x}
|
|
4144
|
+
y={y}
|
|
4145
|
+
width={w}
|
|
4146
|
+
height={h}
|
|
4147
|
+
rx={8}
|
|
4148
|
+
ry={8}
|
|
4149
|
+
fill={LINK_CARD_FILL}
|
|
4150
|
+
stroke={LINK_CARD_STROKE}
|
|
4151
|
+
strokeWidth={1}
|
|
4152
|
+
vectorEffect="non-scaling-stroke"
|
|
4153
|
+
filter="url(#dc-sticky-shadow)"
|
|
4154
|
+
/>
|
|
4155
|
+
<svg
|
|
4156
|
+
x={lay.glyph.x}
|
|
4157
|
+
y={lay.glyph.y}
|
|
4158
|
+
width={lay.glyph.size}
|
|
4159
|
+
height={lay.glyph.size}
|
|
4160
|
+
viewBox="0 0 24 24"
|
|
4161
|
+
fill="none"
|
|
4162
|
+
stroke={LINK_GLYPH_STROKE}
|
|
4163
|
+
strokeWidth={2}
|
|
4164
|
+
strokeLinecap="round"
|
|
4165
|
+
strokeLinejoin="round"
|
|
4166
|
+
aria-hidden="true"
|
|
4167
|
+
>
|
|
4168
|
+
<path d={LINK_GLYPH_D1} />
|
|
4169
|
+
<path d={LINK_GLYPH_D2} />
|
|
4170
|
+
</svg>
|
|
4171
|
+
<text
|
|
4172
|
+
x={lay.textX}
|
|
4173
|
+
y={lay.domain.y}
|
|
4174
|
+
fontSize={lay.domain.fontSize}
|
|
4175
|
+
fill={LINK_DOMAIN_FILL}
|
|
4176
|
+
dominantBaseline="hanging"
|
|
4177
|
+
style={textFont}
|
|
4178
|
+
>
|
|
4179
|
+
{stroke.domain}
|
|
4180
|
+
</text>
|
|
4181
|
+
<text
|
|
4182
|
+
x={lay.textX}
|
|
4183
|
+
y={lay.title.y}
|
|
4184
|
+
fontSize={lay.title.fontSize}
|
|
4185
|
+
fill={LINK_TITLE_FILL}
|
|
4186
|
+
fontWeight={600}
|
|
4187
|
+
dominantBaseline="hanging"
|
|
4188
|
+
style={textFont}
|
|
4189
|
+
>
|
|
4190
|
+
{shownTitle}
|
|
4191
|
+
</text>
|
|
4192
|
+
</g>
|
|
4193
|
+
);
|
|
4194
|
+
}
|
|
3190
4195
|
const common = {
|
|
3191
4196
|
'data-id': stroke.id,
|
|
3192
4197
|
'data-tool': stroke.tool,
|
|
@@ -3197,7 +4202,17 @@ function StrokeNode({
|
|
|
3197
4202
|
vectorEffect: 'non-scaling-stroke' as const,
|
|
3198
4203
|
};
|
|
3199
4204
|
if (stroke.tool === 'pen') {
|
|
3200
|
-
|
|
4205
|
+
// Highlighter (item 8) — overlaps darken via multiply; the translucent hue
|
|
4206
|
+
// lives in `stroke.color`, the wide nib in `stroke.width`.
|
|
4207
|
+
return (
|
|
4208
|
+
<path
|
|
4209
|
+
{...common}
|
|
4210
|
+
fill="none"
|
|
4211
|
+
d={penPathD(stroke.points)}
|
|
4212
|
+
style={stroke.highlighter ? { mixBlendMode: 'multiply' } : undefined}
|
|
4213
|
+
pointerEvents={strokeHit}
|
|
4214
|
+
/>
|
|
4215
|
+
);
|
|
3201
4216
|
}
|
|
3202
4217
|
if (stroke.tool === 'rect') {
|
|
3203
4218
|
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
@@ -3213,6 +4228,7 @@ function StrokeNode({
|
|
|
3213
4228
|
height={Math.abs(stroke.h)}
|
|
3214
4229
|
rx={r}
|
|
3215
4230
|
ry={r}
|
|
4231
|
+
strokeDasharray={stroke.dashed ? '6 4' : undefined}
|
|
3216
4232
|
pointerEvents={hitMode}
|
|
3217
4233
|
/>
|
|
3218
4234
|
);
|
|
@@ -3226,6 +4242,7 @@ function StrokeNode({
|
|
|
3226
4242
|
cy={stroke.cy}
|
|
3227
4243
|
rx={Math.max(0, stroke.rx)}
|
|
3228
4244
|
ry={Math.max(0, stroke.ry)}
|
|
4245
|
+
strokeDasharray={stroke.dashed ? '6 4' : undefined}
|
|
3229
4246
|
pointerEvents={hitMode}
|
|
3230
4247
|
/>
|
|
3231
4248
|
);
|
|
@@ -3284,10 +4301,15 @@ function renderArrowPrimitive(p: SvgPrimitive, key: number): JSX.Element {
|
|
|
3284
4301
|
|
|
3285
4302
|
function AnnotationsChrome({
|
|
3286
4303
|
tool,
|
|
4304
|
+
theme,
|
|
3287
4305
|
color,
|
|
3288
4306
|
setColor,
|
|
3289
4307
|
stickyColor,
|
|
3290
4308
|
setStickyColor,
|
|
4309
|
+
highlighterColor,
|
|
4310
|
+
setHighlighterColor,
|
|
4311
|
+
highlighterWidth,
|
|
4312
|
+
setHighlighterWidth,
|
|
3291
4313
|
supportsFill,
|
|
3292
4314
|
fill,
|
|
3293
4315
|
setFill,
|
|
@@ -3296,10 +4318,17 @@ function AnnotationsChrome({
|
|
|
3296
4318
|
setThickness,
|
|
3297
4319
|
}: {
|
|
3298
4320
|
tool: Tool;
|
|
4321
|
+
/** Canvas theme — the ink swatch (slot 8) renders the themed default so the
|
|
4322
|
+
* active default reads true on dark canvases. */
|
|
4323
|
+
theme: string;
|
|
3299
4324
|
color: string;
|
|
3300
4325
|
setColor: (c: string) => void;
|
|
3301
4326
|
stickyColor: string;
|
|
3302
4327
|
setStickyColor: (c: string) => void;
|
|
4328
|
+
highlighterColor: string;
|
|
4329
|
+
setHighlighterColor: (c: string) => void;
|
|
4330
|
+
highlighterWidth: number;
|
|
4331
|
+
setHighlighterWidth: (w: number) => void;
|
|
3303
4332
|
supportsFill: boolean;
|
|
3304
4333
|
fill: string | null;
|
|
3305
4334
|
setFill: (f: string | null) => void;
|
|
@@ -3329,21 +4358,79 @@ function AnnotationsChrome({
|
|
|
3329
4358
|
</div>
|
|
3330
4359
|
);
|
|
3331
4360
|
}
|
|
4361
|
+
// Highlighter picks a translucent marker hue (item 8) — its own palette,
|
|
4362
|
+
// mirroring sticky. Each swatch previews on a dark chip via its alpha.
|
|
4363
|
+
if (tool === 'highlighter') {
|
|
4364
|
+
return (
|
|
4365
|
+
<div className="dc-annot-chrome" role="toolbar" aria-label="Highlighter tools">
|
|
4366
|
+
<div className="dc-annot-swatches" role="radiogroup" aria-label="Highlighter color">
|
|
4367
|
+
{HIGHLIGHTER_PALETTE.map((c) => (
|
|
4368
|
+
<button
|
|
4369
|
+
key={c}
|
|
4370
|
+
type="button"
|
|
4371
|
+
className="dc-annot-sw"
|
|
4372
|
+
aria-pressed={c === highlighterColor}
|
|
4373
|
+
aria-label={`Highlighter color ${c}`}
|
|
4374
|
+
title={`Highlighter color ${c}`}
|
|
4375
|
+
style={{ background: c }}
|
|
4376
|
+
onClick={() => setHighlighterColor(c)}
|
|
4377
|
+
/>
|
|
4378
|
+
))}
|
|
4379
|
+
</div>
|
|
4380
|
+
<div className="dc-annot-sep" />
|
|
4381
|
+
{/* Nib width — three filled dots of increasing size (item 8). */}
|
|
4382
|
+
<div className="dc-annot-swatches" role="radiogroup" aria-label="Highlighter width">
|
|
4383
|
+
{HIGHLIGHTER_WIDTHS.map((w) => {
|
|
4384
|
+
const dot = Math.round(6 + (w / 28) * 8); // 8–14 px preview dot
|
|
4385
|
+
return (
|
|
4386
|
+
<button
|
|
4387
|
+
key={w}
|
|
4388
|
+
type="button"
|
|
4389
|
+
className="dc-annot-ibtn"
|
|
4390
|
+
aria-pressed={w === highlighterWidth}
|
|
4391
|
+
aria-label={`Highlighter width ${w}`}
|
|
4392
|
+
title={`Width ${w}px`}
|
|
4393
|
+
onClick={() => setHighlighterWidth(w)}
|
|
4394
|
+
>
|
|
4395
|
+
<span
|
|
4396
|
+
aria-hidden="true"
|
|
4397
|
+
style={{
|
|
4398
|
+
width: dot,
|
|
4399
|
+
height: dot,
|
|
4400
|
+
borderRadius: '50%',
|
|
4401
|
+
background: 'currentColor',
|
|
4402
|
+
display: 'inline-block',
|
|
4403
|
+
}}
|
|
4404
|
+
/>
|
|
4405
|
+
</button>
|
|
4406
|
+
);
|
|
4407
|
+
})}
|
|
4408
|
+
</div>
|
|
4409
|
+
</div>
|
|
4410
|
+
);
|
|
4411
|
+
}
|
|
3332
4412
|
return (
|
|
3333
4413
|
<div className="dc-annot-chrome" role="toolbar" aria-label="Annotation tools">
|
|
3334
4414
|
<div className="dc-annot-swatches" role="radiogroup" aria-label="Stroke color">
|
|
3335
|
-
{STROKE_PALETTE.map((
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
4415
|
+
{STROKE_PALETTE.map((base, i) => {
|
|
4416
|
+
// The last slot is the default ink — render it themed so the active
|
|
4417
|
+
// default reads true on dark canvases (white-ish ink) without
|
|
4418
|
+
// touching the other hues. Key by the immutable base hex so a theme
|
|
4419
|
+
// flip recolours the swatch in place rather than remounting.
|
|
4420
|
+
const c = i === STROKE_PALETTE.length - 1 ? resolveDefaultInk(theme) : base;
|
|
4421
|
+
return (
|
|
4422
|
+
<button
|
|
4423
|
+
key={base}
|
|
4424
|
+
type="button"
|
|
4425
|
+
className="dc-annot-sw"
|
|
4426
|
+
aria-pressed={c === color}
|
|
4427
|
+
aria-label={`Color ${c}`}
|
|
4428
|
+
title={`Color ${c}`}
|
|
4429
|
+
style={{ background: c }}
|
|
4430
|
+
onClick={() => setColor(c)}
|
|
4431
|
+
/>
|
|
4432
|
+
);
|
|
4433
|
+
})}
|
|
3347
4434
|
</div>
|
|
3348
4435
|
{supportsFill ? (
|
|
3349
4436
|
<>
|