@1agh/maude 0.28.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/{plugins/design/dev-server → apps/studio}/ai-banner.tsx +1 -2
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/{plugins/design/dev-server → apps/studio}/annotations-context-toolbar.tsx +681 -151
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +4537 -0
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/{plugins/design/dev-server → apps/studio}/api.ts +616 -70
- package/{plugins/design/dev-server → apps/studio}/artboard-activity-overlay.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_motion-sample-playwright.mjs +129 -0
- package/{plugins/design/dev-server → apps/studio}/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/{plugins/design/dev-server → apps/studio}/bin/canvas-edit.sh +1 -1
- package/{plugins/design/dev-server → apps/studio}/bin/draw-proof.sh +37 -2
- package/{plugins/design/dev-server → apps/studio}/bin/preflight.sh +1 -1
- package/apps/studio/bin/read-annotations.mjs +573 -0
- package/apps/studio/bin/read-annotations.sh +11 -0
- package/{plugins/design/dev-server → apps/studio}/bin/runtime-health.sh +1 -1
- package/{plugins/design/dev-server → apps/studio}/bin/screenshot.sh +62 -2
- package/{plugins/design/dev-server → apps/studio}/bin/server-up.sh +9 -3
- package/apps/studio/bin/to-lottie-verify.html +68 -0
- package/apps/studio/bin/to-lottie.sh +128 -0
- package/{plugins/design/dev-server → apps/studio}/build.ts +6 -4
- package/{plugins/design/dev-server → apps/studio}/canvas-arrowheads.ts +79 -10
- package/{plugins/design/dev-server → apps/studio}/canvas-comment-mount.tsx +1 -1
- package/apps/studio/canvas-create.ts +104 -0
- package/{plugins/design/dev-server → apps/studio}/canvas-cursors.ts +48 -12
- package/{plugins/design/dev-server → apps/studio}/canvas-edit.ts +257 -7
- package/{plugins/design/dev-server → apps/studio}/canvas-icons.tsx +165 -1
- package/{plugins/design/dev-server → apps/studio}/canvas-lib-inline.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/canvas-lib-resolver.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/canvas-lib.tsx +31 -12
- package/{plugins/design/dev-server → apps/studio}/canvas-shell.tsx +360 -48
- package/apps/studio/client/app.jsx +6742 -0
- package/apps/studio/client/canvas-url.js +81 -0
- package/{plugins/design/dev-server → apps/studio}/client/comments-overlay.css +41 -27
- package/apps/studio/client/index.html +16 -0
- package/apps/studio/client/styles/1-tokens-maude.css +296 -0
- package/apps/studio/client/styles/3-shell-maude.css +1161 -0
- package/{plugins/design/dev-server → apps/studio}/client/styles/3-shell.css +129 -0
- package/apps/studio/client/styles/4-components-maude.css +74 -0
- package/{plugins/design/dev-server → apps/studio}/client/styles/4-components.css +396 -34
- package/apps/studio/client/styles/5-maude-overrides.css +137 -0
- package/{plugins/design/dev-server → apps/studio}/client/styles/_index.css +9 -0
- package/{plugins/design/dev-server → apps/studio}/client/styles.css +1 -1
- package/apps/studio/client/tour/overlay.jsx +279 -0
- package/apps/studio/client/tour/usage-tour.js +48 -0
- package/apps/studio/client/whats-new-seen.js +52 -0
- package/apps/studio/client/whats-new.jsx +250 -0
- package/{plugins/design/dev-server → apps/studio}/commands/annotation-strokes-command.ts +2 -2
- package/{plugins/design/dev-server → apps/studio}/commands/equal-spacing-command.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/commands/move-artboards-command.ts +2 -2
- package/{plugins/design/dev-server → apps/studio}/comments-overlay.tsx +32 -11
- package/{plugins/design/dev-server → apps/studio}/context-menu.tsx +5 -4
- package/{plugins/design/dev-server → apps/studio}/contextual-toolbar.tsx +18 -7
- package/{plugins/design/dev-server → apps/studio}/cursors-overlay.tsx +21 -21
- package/apps/studio/dist/client.bundle.js +25545 -0
- package/apps/studio/dist/comment-mount.js +2046 -0
- package/apps/studio/dist/styles.css +8018 -0
- package/apps/studio/dom-selection.ts +342 -0
- package/apps/studio/draw/animate.ts +333 -0
- package/{plugins/design/dev-server → apps/studio}/draw/brush.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/draw/composition.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/draw/geometry.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/draw/index.ts +4 -1
- package/{plugins/design/dev-server → apps/studio}/draw/layout.ts +1 -1
- package/apps/studio/draw/morph.ts +204 -0
- package/{plugins/design/dev-server → apps/studio}/draw/optimize.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/draw/palette.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/draw/primitives.ts +1 -1
- package/apps/studio/draw/serialize-animate.ts +324 -0
- package/{plugins/design/dev-server → apps/studio}/draw/serialize.ts +9 -3
- package/apps/studio/draw/test/animate.test.ts +172 -0
- package/apps/studio/draw/test/morph.test.ts +107 -0
- package/apps/studio/draw/test/serialize-animate.test.ts +142 -0
- package/{plugins/design/dev-server → apps/studio}/equal-spacing-detector.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/equal-spacing-handles.tsx +7 -29
- package/{plugins/design/dev-server → apps/studio}/export-dialog.tsx +16 -4
- package/{plugins/design/dev-server → apps/studio}/history.ts +20 -3
- package/{plugins/design/dev-server → apps/studio}/http.ts +226 -6
- package/{plugins/design/dev-server → apps/studio}/input-router.tsx +15 -1
- package/{plugins/design/dev-server → apps/studio}/inspect.ts +50 -4
- package/{plugins/design/dev-server → apps/studio}/marquee-overlay.tsx +5 -2
- package/{plugins/design/dev-server → apps/studio}/participants-chrome.tsx +2 -8
- package/{plugins/design/dev-server → apps/studio}/paths.ts +15 -11
- package/{plugins/design/dev-server → apps/studio}/server.ts +23 -2
- package/{plugins/design/dev-server → apps/studio}/sync/agent.ts +231 -57
- package/{plugins/design/dev-server → apps/studio}/sync/codec.ts +45 -0
- package/apps/studio/sync/cold-start.ts +158 -0
- package/{plugins/design/dev-server → apps/studio}/sync/connection-state.ts +58 -2
- package/{plugins/design/dev-server → apps/studio}/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +321 -0
- package/{plugins/design/dev-server → apps/studio}/sync/projection.ts +22 -3
- package/{plugins/design/dev-server → apps/studio}/sync/status.ts +15 -4
- package/{plugins/design/dev-server → apps/studio}/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/{plugins/design/dev-server → apps/studio}/test/annotations-layer.test.ts +63 -0
- package/{plugins/design/dev-server → apps/studio}/test/annotations-roundtrip.test.ts +97 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/annotations-text-format.test.ts +396 -0
- package/apps/studio/test/asset-api.test.ts +149 -0
- package/apps/studio/test/canvas-create-api.test.ts +361 -0
- package/{plugins/design/dev-server → apps/studio}/test/canvas-cursors.test.ts +2 -0
- package/apps/studio/test/canvas-edit.test.ts +319 -0
- package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-resolver.test.ts +1 -1
- package/apps/studio/test/canvas-media-drop.test.ts +158 -0
- package/{plugins/design/dev-server → apps/studio}/test/canvas-origin-gate.test.ts +25 -1
- package/apps/studio/test/canvas-url.test.ts +99 -0
- package/{plugins/design/dev-server → apps/studio}/test/collab-bridge.test.ts +0 -2
- package/{plugins/design/dev-server → apps/studio}/test/collab-room.test.ts +2 -7
- package/{plugins/design/dev-server → apps/studio}/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +52 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/index-data-canvas-ds.test.ts +97 -0
- package/apps/studio/test/motion-sample.test.ts +35 -0
- package/{plugins/design/dev-server → apps/studio}/test/perf-harness.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/test/phase-3.6-smoke.test.ts +3 -1
- package/apps/studio/test/read-annotations.test.ts +409 -0
- package/{plugins/design/dev-server → apps/studio}/test/sanitize-annotation-svg.test.ts +108 -2
- package/{plugins/design/dev-server → apps/studio}/test/shared-doc-convergence.test.ts +1 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +369 -0
- package/apps/studio/test/sync-agent.test.ts +575 -0
- package/{plugins/design/dev-server → apps/studio}/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-start.test.ts +244 -0
- package/{plugins/design/dev-server → apps/studio}/test/sync-connection-state.test.ts +68 -0
- package/{plugins/design/dev-server → apps/studio}/test/sync-hardening.test.ts +0 -8
- package/{plugins/design/dev-server → apps/studio}/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/{plugins/design/dev-server → apps/studio}/test/sync-runtime.test.ts +360 -5
- package/{plugins/design/dev-server → apps/studio}/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +56 -0
- package/apps/studio/test/use-annotation-resize.test.ts +402 -0
- package/{plugins/design/dev-server → apps/studio}/test/use-artboard-drag.test.ts +0 -1
- package/{plugins/design/dev-server → apps/studio}/test/use-collab.test.ts +0 -0
- package/{plugins/design/dev-server → apps/studio}/test/use-tool-mode.test.tsx +8 -2
- package/apps/studio/test/whats-new.test.ts +101 -0
- package/apps/studio/text-imports.d.ts +9 -0
- package/{plugins/design/dev-server → apps/studio}/tool-palette.tsx +15 -70
- package/{plugins/design/dev-server → apps/studio}/undo-hud.tsx +1 -2
- package/{plugins/design/dev-server → apps/studio}/undo-stack.ts +1 -1
- package/{plugins/design/dev-server → apps/studio}/use-agent-presence.tsx +5 -3
- package/apps/studio/use-annotation-resize.tsx +912 -0
- package/{plugins/design/dev-server → apps/studio}/use-annotation-selection.tsx +17 -5
- package/{plugins/design/dev-server → apps/studio}/use-annotations-visibility.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/use-artboard-drag.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/use-canvas-activity.tsx +3 -3
- package/apps/studio/use-canvas-media-drop.tsx +343 -0
- package/{plugins/design/dev-server → apps/studio}/use-collab.tsx +21 -16
- package/{plugins/design/dev-server → apps/studio}/use-cursor-modifiers.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/use-keyboard-discipline.tsx +5 -2
- package/{plugins/design/dev-server → apps/studio}/use-selection-set.tsx +18 -1
- package/{plugins/design/dev-server → apps/studio}/use-snap-guides.tsx +1 -1
- package/{plugins/design/dev-server → apps/studio}/use-tool-mode.tsx +8 -2
- package/{plugins/design/dev-server → apps/studio}/use-undo-stack.tsx +1 -1
- package/apps/studio/whats-new.json +264 -0
- package/apps/studio/whats-new.schema.json +86 -0
- package/apps/studio/whats-new.ts +87 -0
- package/cli/bin/maude.mjs +10 -0
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +16 -7
- package/cli/commands/design.test.mjs +40 -0
- package/cli/commands/help.mjs +8 -2
- package/cli/commands/hub.mjs +3 -3
- package/cli/commands/hub.test.mjs +1 -1
- package/cli/commands/scenario-report.mjs +2 -9
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/flow-design-integration.test.mjs +1 -1
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/plugin-cli-reachability.test.mjs +8 -7
- package/cli/lib/preflight.mjs +1 -1
- package/package.json +15 -15
- package/plugins/design/templates/_shell.html +28 -4
- package/plugins/design/templates/brief-board.tsx.template +61 -0
- package/plugins/design/templates/canvas.tsx.template +1 -1
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/plugins/flow/.claude-plugin/config.schema.json +16 -0
- package/plugins/design/dev-server/annotations-layer.tsx +0 -3402
- package/plugins/design/dev-server/client/app.jsx +0 -2985
- package/plugins/design/dev-server/client/index.html +0 -15
- package/plugins/design/dev-server/dist/client.bundle.js +0 -20692
- package/plugins/design/dev-server/dist/comment-mount.js +0 -1924
- package/plugins/design/dev-server/dist/styles.css +0 -2710
- package/plugins/design/dev-server/dom-selection.ts +0 -156
- package/plugins/design/dev-server/sync/migrate-seed.ts +0 -163
- package/plugins/design/dev-server/test/canvas-edit.test.ts +0 -139
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +0 -159
- package/plugins/design/dev-server/test/sync-agent.test.ts +0 -305
- package/plugins/design/dev-server/test/use-annotation-resize.test.ts +0 -200
- package/plugins/design/dev-server/use-annotation-resize.tsx +0 -449
- /package/{plugins/design/dev-server → apps/studio}/activity.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_enumerate-artboards-playwright.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_html-playwright.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_pdf-playwright.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_pptx-playwright.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_pw-launch.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_screenshot-playwright.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_svg-optimize.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/_svg-playwright.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/asset-sweep.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/bootstrap-check.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/check-runtime-bundles.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/draw-build.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/handoff.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/prep.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/scenario-report.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/slug.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/smoke.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/svg-optimize.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/bin/visual-sanity.sh +0 -0
- /package/{plugins/design/dev-server → apps/studio}/boot-self-heal.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/canvas-build.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/canvas-header.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/canvas-meta.schema.json +0 -0
- /package/{plugins/design/dev-server → apps/studio}/canvas-pipeline.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/client/hmr.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/client/iframe-lazy.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/client/styles/0-reset.css +0 -0
- /package/{plugins/design/dev-server → apps/studio}/client/styles/1-tokens.css +0 -0
- /package/{plugins/design/dev-server → apps/studio}/client/styles/2-layout.css +0 -0
- /package/{plugins/design/dev-server → apps/studio}/client/styles/5-utilities.css +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/ai-activity.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/awareness-bridge.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/git-lifecycle.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/index.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/persistence.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/protocol.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/registry.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/collab/room.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/config.schema.json +0 -0
- /package/{plugins/design/dev-server → apps/studio}/context.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/.min-sizes.json +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/lib0_decoding.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/lib0_encoding.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/motion.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/motion_react.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/pixi-js.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react-dom.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react-dom_client.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react_jsx-dev-runtime.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react_jsx-runtime.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/y-protocols_awareness.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/y-protocols_sync.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/dist/runtime/yjs.js +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/brush.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/composition.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/geometry.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/gradient.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/layout.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/optimize.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/palette.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/primitives.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/draw/test/serialize.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/examples/README.md +0 -0
- /package/{plugins/design/dev-server → apps/studio}/examples/perf-100-artboards.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/_browser-bundles.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/canva-handoff-prompt.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/canva.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/html.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/index.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/pdf.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/png.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/pptx.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/scope.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/svg.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/exporters/zip.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/fs-watch.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/handoff.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/hmr-broadcast.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/locator.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/mem.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/runtime-bundle.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/server.mjs +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/atomic-write.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/echo-guard.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/fs-mirror.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/hubs-config.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/materialize.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/origins.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/sync/untrusted.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/_helpers.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/active-state.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/ai-activity.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/annotation-strokes-command.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/annotations-api.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/annotations-draw-modifiers.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/artboard-activity-overlay.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/binary-smoke.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/boot-self-heal.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/bundle-smoke.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-build.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-header.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-hmr-runtime.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-inline.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-motion.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-meta-api.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-pipeline.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/canvas-route.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/collab-annotations-bridge.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/collab-awareness-bridge.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/collab-loopback.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/collab-protocol.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/collab-reseed-guard.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/collab-stress.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/comment-mount.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/comments-api.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/compile-entry.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/context-resolve-tokens.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/equal-spacing-detector.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/canva.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/endpoint.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/history.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/html.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/pdf.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/png.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/pptx-deck.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/pptx.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/pw-launch.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/scope.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/svg.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/exporters/zip.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/fixtures/phase-20-annotations.svg +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/fixtures/phase-21-annotations.svg +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/fs-watch.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/git-lifecycle.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/handoff-static-frames.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/handoff.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/history-rollback.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/hmr-broadcast.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/hmr-classify.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/input-router.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/locator.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/marquee-overlay.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/move-artboards-command.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/participants-chrome.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/paths.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/runtime-bundle-error-mapping.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/runtime-bundle.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/server-lifecycle.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-foundation.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-projection.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/snap-distance-pill.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-atomic-write.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-echo-guard.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-fs-mirror.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-meta-codec.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/sync-untrusted.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/system-endpoint.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/tool-palette.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/undo-stack.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-agent-presence.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-annotation-selection.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-canvas-activity.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-cursor-modifiers.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-keyboard-discipline.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-selection-set.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-snap-guides.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/use-undo-stack.test.tsx +0 -0
- /package/{plugins/design/dev-server → apps/studio}/test/ws-handshake.test.ts +0 -0
- /package/{plugins/design/dev-server → apps/studio}/tsconfig.json +0 -0
- /package/{plugins/design/dev-server → apps/studio}/ws.ts +0 -0
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-annotation-resize.tsx — Task 23 (Wave 2, G4)
|
|
3
|
+
* @scope apps/studio/use-annotation-resize.tsx
|
|
4
|
+
* @purpose Screen-space corner / endpoint handles for the selected
|
|
5
|
+
* annotation. Per-tool resize math with FigJam resize modifiers:
|
|
6
|
+
* Shift = lock aspect ratio (45° angle snap for arrows), Alt =
|
|
7
|
+
* scale from center, Shift+Alt = both. Modifiers update live on
|
|
8
|
+
* keydown/keyup mid-drag (re-applied at the last pointer position).
|
|
9
|
+
*
|
|
10
|
+
* Handles are `position: fixed` DOM siblings of the canvas (the
|
|
11
|
+
* same pattern element selection uses) — they stay 8 × 8 CSS px
|
|
12
|
+
* at any zoom and never get caught in the SVG `vector-effect`
|
|
13
|
+
* gymnastics.
|
|
14
|
+
*
|
|
15
|
+
* Mounts the overlay element exactly once via `AnnotationResizeOverlay`.
|
|
16
|
+
* The hook does the math + persistence; the overlay component
|
|
17
|
+
* owns the rAF loop that follows pan/zoom + pointer drags.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
21
|
+
import { anchorPoint, BIND_THRESHOLD_PX, bindCandidate } from './annotations-bindings.ts';
|
|
22
|
+
import {
|
|
23
|
+
type ArrowStroke,
|
|
24
|
+
canRotate,
|
|
25
|
+
type EllipseStroke,
|
|
26
|
+
HALO_PAD_PX,
|
|
27
|
+
type ImageStroke,
|
|
28
|
+
type LinkStroke,
|
|
29
|
+
normalizeRotation,
|
|
30
|
+
type PenStroke,
|
|
31
|
+
type PolygonStroke,
|
|
32
|
+
type RectStroke,
|
|
33
|
+
rotatePoint,
|
|
34
|
+
type SectionStroke,
|
|
35
|
+
type StickyStroke,
|
|
36
|
+
type Stroke,
|
|
37
|
+
type StrokesStoreValue,
|
|
38
|
+
strokeBBox,
|
|
39
|
+
strokeCenter,
|
|
40
|
+
strokeRotation,
|
|
41
|
+
} from './annotations-layer.tsx';
|
|
42
|
+
import { useViewportControllerContext } from './canvas-lib.tsx';
|
|
43
|
+
import { useAnnotationSelection } from './use-annotation-selection.tsx';
|
|
44
|
+
|
|
45
|
+
const RESIZE_CSS = `
|
|
46
|
+
/* DS selection specimen .sel-handle recipe — accent square, 1px accent-fg
|
|
47
|
+
* border, --radius-xs corners. (8px box kept: the JS centers handles on the
|
|
48
|
+
* half-size offset; the specimen's 7px differs by a hairline only.) Comment
|
|
49
|
+
* must stay backtick-free — it lives inside the RESIZE_CSS template literal. */
|
|
50
|
+
.dc-annot-resize-handle {
|
|
51
|
+
position: fixed;
|
|
52
|
+
width: 8px;
|
|
53
|
+
height: 8px;
|
|
54
|
+
background: var(--maude-hud-accent, oklch(0.680 0.180 268));
|
|
55
|
+
border: 1px solid var(--maude-hud-accent-fg, oklch(0.180 0.030 268));
|
|
56
|
+
border-radius: 3px;
|
|
57
|
+
z-index: 6;
|
|
58
|
+
pointer-events: auto;
|
|
59
|
+
touch-action: none;
|
|
60
|
+
}
|
|
61
|
+
/* Phase 24 — '!important' so the scale/move affordance beats use-tool-mode's
|
|
62
|
+
blanket '* { cursor: <tool> !important }' (move mode). Without it the move
|
|
63
|
+
glyph clobbered the resize cursors and the user saw no scale affordance over
|
|
64
|
+
a handle. Specificity already wins ('.class[attr]' > '*'); the '!important'
|
|
65
|
+
is what lets it through against the other '!important' rule. See DDR-067.
|
|
66
|
+
NOTE: keep this comment backtick-free — it lives inside the RESIZE_CSS
|
|
67
|
+
template literal and a stray backtick closes it (bun parse fail, §6). */
|
|
68
|
+
.dc-annot-resize-handle[data-corner="nw"], .dc-annot-resize-handle[data-corner="se"] { cursor: nwse-resize !important; }
|
|
69
|
+
.dc-annot-resize-handle[data-corner="ne"], .dc-annot-resize-handle[data-corner="sw"] { cursor: nesw-resize !important; }
|
|
70
|
+
.dc-annot-resize-handle[data-corner="ep1"], .dc-annot-resize-handle[data-corner="ep2"] { cursor: move !important; }
|
|
71
|
+
/* FigJam v3 — mid-edge handles: single-axis resize. Slightly smaller pills so
|
|
72
|
+
the corner squares stay the primary affordance. */
|
|
73
|
+
.dc-annot-resize-handle[data-corner="n"], .dc-annot-resize-handle[data-corner="s"] { cursor: ns-resize !important; width: 14px; height: 6px; }
|
|
74
|
+
.dc-annot-resize-handle[data-corner="e"], .dc-annot-resize-handle[data-corner="w"] { cursor: ew-resize !important; width: 6px; height: 14px; }
|
|
75
|
+
/* Wave G — rotation lives in INVISIBLE hover zones just outside each corner
|
|
76
|
+
(FigJam): the cursor flips to a rotate glyph there and dragging turns the
|
|
77
|
+
stroke. No knob — the old below-the-bbox knob read as clutter and collided
|
|
78
|
+
with the connector dots. z-index below the handles so a corner square wins
|
|
79
|
+
where they overlap. */
|
|
80
|
+
.dc-annot-rotate-zone {
|
|
81
|
+
position: fixed;
|
|
82
|
+
width: 18px;
|
|
83
|
+
height: 18px;
|
|
84
|
+
background: transparent;
|
|
85
|
+
z-index: 5;
|
|
86
|
+
pointer-events: auto;
|
|
87
|
+
touch-action: none;
|
|
88
|
+
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='white' stroke-width='4' d='M4.8 13 A6 6 0 1 1 15.2 13 M2.2 10.8 L4.8 13 L7.4 10.9 M12.6 10.9 L15.2 13 L17.8 10.8'/%3E%3Cpath stroke='black' stroke-width='1.8' d='M4.8 13 A6 6 0 1 1 15.2 13 M2.2 10.8 L4.8 13 L7.4 10.9 M12.6 10.9 L15.2 13 L17.8 10.8'/%3E%3C/g%3E%3C/svg%3E") 10 10, alias !important;
|
|
89
|
+
}
|
|
90
|
+
`.trim();
|
|
91
|
+
|
|
92
|
+
function ensureResizeStyles(): void {
|
|
93
|
+
if (typeof document === 'undefined') return;
|
|
94
|
+
if (document.getElementById('dc-annot-resize-css')) return;
|
|
95
|
+
const s = document.createElement('style');
|
|
96
|
+
s.id = 'dc-annot-resize-css';
|
|
97
|
+
s.textContent = RESIZE_CSS;
|
|
98
|
+
document.head.appendChild(s);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type Corner =
|
|
102
|
+
| 'nw'
|
|
103
|
+
| 'ne'
|
|
104
|
+
| 'sw'
|
|
105
|
+
| 'se'
|
|
106
|
+
| 'n'
|
|
107
|
+
| 'e'
|
|
108
|
+
| 's'
|
|
109
|
+
| 'w'
|
|
110
|
+
| 'rot-nw'
|
|
111
|
+
| 'rot-ne'
|
|
112
|
+
| 'rot-sw'
|
|
113
|
+
| 'rot-se'
|
|
114
|
+
| 'ep1'
|
|
115
|
+
| 'ep2';
|
|
116
|
+
|
|
117
|
+
/** FigJam v3 — mid-edge handles resize a single axis. */
|
|
118
|
+
function isEdgeCorner(c: Corner): boolean {
|
|
119
|
+
return c === 'n' || c === 'e' || c === 's' || c === 'w';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Wave G — the four invisible rotate zones outside the corners. */
|
|
123
|
+
function isRotCorner(c: Corner): boolean {
|
|
124
|
+
return c === 'rot-nw' || c === 'rot-ne' || c === 'rot-sw' || c === 'rot-se';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Wave G/H — handles float a step OFF the stroke (matching the halo's pad —
|
|
129
|
+
* `HALO_PAD_PX` lives in annotations-model as the single source) instead of
|
|
130
|
+
* sitting glued to the bbox. The pad is SCREEN-constant (`HALO_PAD_PX / zoom`
|
|
131
|
+
* world units) so the breathing room reads the same at every zoom.
|
|
132
|
+
* `padDX/padDY` shift a dragged handle's cursor back ONTO the true bbox
|
|
133
|
+
* corner/edge so the shape doesn't grow by the pad on first move.
|
|
134
|
+
*/
|
|
135
|
+
const padDX = (c: Corner): number =>
|
|
136
|
+
c === 'nw' || c === 'sw' || c === 'w' ? 1 : c === 'ne' || c === 'se' || c === 'e' ? -1 : 0;
|
|
137
|
+
const padDY = (c: Corner): number =>
|
|
138
|
+
c === 'nw' || c === 'ne' || c === 'n' ? 1 : c === 'sw' || c === 'se' || c === 's' ? -1 : 0;
|
|
139
|
+
|
|
140
|
+
/** Stroke types that expose resize handles. Text inherits its anchor bbox. */
|
|
141
|
+
function isResizable(
|
|
142
|
+
s: Stroke
|
|
143
|
+
): s is
|
|
144
|
+
| RectStroke
|
|
145
|
+
| EllipseStroke
|
|
146
|
+
| PolygonStroke
|
|
147
|
+
| ArrowStroke
|
|
148
|
+
| PenStroke
|
|
149
|
+
| StickyStroke
|
|
150
|
+
| ImageStroke
|
|
151
|
+
| LinkStroke
|
|
152
|
+
| SectionStroke {
|
|
153
|
+
return (
|
|
154
|
+
s.tool === 'rect' ||
|
|
155
|
+
s.tool === 'ellipse' ||
|
|
156
|
+
s.tool === 'polygon' ||
|
|
157
|
+
s.tool === 'arrow' ||
|
|
158
|
+
s.tool === 'pen' ||
|
|
159
|
+
s.tool === 'sticky' ||
|
|
160
|
+
s.tool === 'image' ||
|
|
161
|
+
s.tool === 'link' ||
|
|
162
|
+
s.tool === 'section'
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resize modifiers (FigJam-parity). Held during a handle drag:
|
|
168
|
+
* • `shift` — lock to the start aspect ratio (45° angle snap for arrows;
|
|
169
|
+
* always-1:1 stickies ignore it — they're square regardless).
|
|
170
|
+
* • `alt` — scale symmetrically around the stroke's center / midpoint.
|
|
171
|
+
* Both together combine (ratio-locked AND center-anchored). With neither held
|
|
172
|
+
* the result is byte-identical to the pre-modifier behaviour.
|
|
173
|
+
*/
|
|
174
|
+
export interface ResizeMods {
|
|
175
|
+
shift: boolean;
|
|
176
|
+
alt: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const NO_MODS: ResizeMods = { shift: false, alt: false };
|
|
180
|
+
|
|
181
|
+
const isWestCorner = (c: Corner): boolean => c === 'nw' || c === 'sw';
|
|
182
|
+
const isNorthCorner = (c: Corner): boolean => c === 'nw' || c === 'ne';
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Wave H — resizing a ROTATED stroke must keep the anchored corner/edge fixed
|
|
186
|
+
* in WORLD space. The axis-aligned math anchors it in the LOCAL frame, but the
|
|
187
|
+
* rotation pivot is the bbox CENTER — when w/h change, the center moves, so
|
|
188
|
+
* the whole rotated image drifts on screen. The anchor's local point is
|
|
189
|
+
* preserved by `bboxResize` (non-Alt), so its world drift is exactly the
|
|
190
|
+
* difference of rotating that same local point around the OLD vs NEW center.
|
|
191
|
+
* Returns the [dx, dy] world shift to add to the new box's x/y (translation
|
|
192
|
+
* commutes with center-rotation: rotate(p+t, c+t, θ) = rotate(p, c, θ) + t).
|
|
193
|
+
* Alt-resizes keep the center fixed, so there is nothing to compensate.
|
|
194
|
+
*/
|
|
195
|
+
function rotatedAnchorShift(
|
|
196
|
+
b0: { x: number; y: number; w: number; h: number },
|
|
197
|
+
b1: { x: number; y: number; w: number; h: number },
|
|
198
|
+
corner: Corner,
|
|
199
|
+
rot: number
|
|
200
|
+
): [number, number] {
|
|
201
|
+
// Anchor identity: the opposite corner (corner drags) / the opposite edge
|
|
202
|
+
// midpoint (edge drags), as a point of the START box in local coords.
|
|
203
|
+
let ux: number;
|
|
204
|
+
let uy: number;
|
|
205
|
+
if (isEdgeCorner(corner)) {
|
|
206
|
+
ux = corner === 'e' ? 0 : corner === 'w' ? 1 : 0.5;
|
|
207
|
+
uy = corner === 's' ? 0 : corner === 'n' ? 1 : 0.5;
|
|
208
|
+
} else {
|
|
209
|
+
ux = isWestCorner(corner) ? 1 : 0;
|
|
210
|
+
uy = isNorthCorner(corner) ? 1 : 0;
|
|
211
|
+
}
|
|
212
|
+
const px = b0.x + ux * b0.w;
|
|
213
|
+
const py = b0.y + uy * b0.h;
|
|
214
|
+
const [w0x, w0y] = rotatePoint(px, py, b0.x + b0.w / 2, b0.y + b0.h / 2, rot);
|
|
215
|
+
const [w1x, w1y] = rotatePoint(px, py, b1.x + b1.w / 2, b1.y + b1.h / 2, rot);
|
|
216
|
+
return [w0x - w1x, w0y - w1y];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Shared bbox resize for rect / polygon / sticky / ellipse. Returns the new
|
|
221
|
+
* axis-aligned box for the dragged `corner` moving to world (wx, wy):
|
|
222
|
+
* • normal — the diagonally-opposite corner is the fixed anchor.
|
|
223
|
+
* • Alt — the box's center is the fixed anchor (symmetric scale).
|
|
224
|
+
* • Shift — keep the start aspect ratio (the dominant axis drives scale).
|
|
225
|
+
* • square — force 1:1 regardless of Shift (sticky notes).
|
|
226
|
+
* The no-modifier branch is algebraically identical to the previous
|
|
227
|
+
* min/max corner math (verified against the resize round-trip tests).
|
|
228
|
+
*/
|
|
229
|
+
function bboxResize(
|
|
230
|
+
bbox: { x: number; y: number; w: number; h: number },
|
|
231
|
+
corner: Corner,
|
|
232
|
+
wx: number,
|
|
233
|
+
wy: number,
|
|
234
|
+
mods: ResizeMods,
|
|
235
|
+
square: boolean
|
|
236
|
+
): { x: number; y: number; w: number; h: number } {
|
|
237
|
+
const { x, y, w, h } = bbox;
|
|
238
|
+
const cx = x + w / 2;
|
|
239
|
+
const cy = y + h / 2;
|
|
240
|
+
// FigJam v3 — mid-edge handles move ONE axis: the opposite edge anchors
|
|
241
|
+
// (Alt = symmetric around the center). Always-square strokes (sticky) let
|
|
242
|
+
// the dragged axis drive the side, re-centered on the cross axis.
|
|
243
|
+
if (isEdgeCorner(corner)) {
|
|
244
|
+
const horizontal = corner === 'e' || corner === 'w';
|
|
245
|
+
let nx = x;
|
|
246
|
+
let ny = y;
|
|
247
|
+
let nw = w;
|
|
248
|
+
let nh = h;
|
|
249
|
+
if (horizontal) {
|
|
250
|
+
const anchorX = corner === 'w' ? x + w : x;
|
|
251
|
+
nw = Math.max(1, mods.alt ? 2 * Math.abs(wx - cx) : Math.abs(wx - anchorX));
|
|
252
|
+
nx = mods.alt ? cx - nw / 2 : wx < anchorX ? anchorX - nw : anchorX;
|
|
253
|
+
} else {
|
|
254
|
+
const anchorY = corner === 'n' ? y + h : y;
|
|
255
|
+
nh = Math.max(1, mods.alt ? 2 * Math.abs(wy - cy) : Math.abs(wy - anchorY));
|
|
256
|
+
ny = mods.alt ? cy - nh / 2 : wy < anchorY ? anchorY - nh : anchorY;
|
|
257
|
+
}
|
|
258
|
+
if (square) {
|
|
259
|
+
const side = horizontal ? nw : nh;
|
|
260
|
+
if (horizontal) {
|
|
261
|
+
nh = side;
|
|
262
|
+
ny = cy - side / 2;
|
|
263
|
+
} else {
|
|
264
|
+
nw = side;
|
|
265
|
+
nx = cx - side / 2;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { x: nx, y: ny, w: nw, h: nh };
|
|
269
|
+
}
|
|
270
|
+
const isW = isWestCorner(corner);
|
|
271
|
+
const isN = isNorthCorner(corner);
|
|
272
|
+
const anchorX = isW ? x + w : x;
|
|
273
|
+
const anchorY = isN ? y + h : y;
|
|
274
|
+
|
|
275
|
+
// Raw extents from the dragged corner — center-relative under Alt.
|
|
276
|
+
let nw = mods.alt ? 2 * Math.abs(wx - cx) : Math.abs(wx - anchorX);
|
|
277
|
+
let nh = mods.alt ? 2 * Math.abs(wy - cy) : Math.abs(wy - anchorY);
|
|
278
|
+
|
|
279
|
+
if (square) {
|
|
280
|
+
const side = Math.max(nw, nh, 1);
|
|
281
|
+
nw = side;
|
|
282
|
+
nh = side;
|
|
283
|
+
} else if (mods.shift && w > 0 && h > 0) {
|
|
284
|
+
const s = Math.max(nw / w, nh / h);
|
|
285
|
+
nw = w * s;
|
|
286
|
+
nh = h * s;
|
|
287
|
+
}
|
|
288
|
+
nw = Math.max(1, nw);
|
|
289
|
+
nh = Math.max(1, nh);
|
|
290
|
+
|
|
291
|
+
let nx: number;
|
|
292
|
+
let ny: number;
|
|
293
|
+
if (mods.alt) {
|
|
294
|
+
nx = cx - nw / 2;
|
|
295
|
+
ny = cy - nh / 2;
|
|
296
|
+
} else {
|
|
297
|
+
// Grow away from the anchor toward the cursor's side (handles flips).
|
|
298
|
+
nx = wx < anchorX ? anchorX - nw : anchorX;
|
|
299
|
+
ny = wy < anchorY ? anchorY - nh : anchorY;
|
|
300
|
+
}
|
|
301
|
+
return { x: nx, y: ny, w: nw, h: nh };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Wave H — `bboxResize` plus the rotated-anchor compensation in one step. The
|
|
306
|
+
* box-shaped tools (rect family / image / ellipse) all need the same pair:
|
|
307
|
+
* run the axis-aligned math, then shift the result so the anchored corner /
|
|
308
|
+
* edge stays fixed in WORLD space (Alt keeps the center — nothing to fix).
|
|
309
|
+
*/
|
|
310
|
+
function bboxResizeRotAware(
|
|
311
|
+
start: Stroke,
|
|
312
|
+
b0: { x: number; y: number; w: number; h: number },
|
|
313
|
+
corner: Corner,
|
|
314
|
+
wx: number,
|
|
315
|
+
wy: number,
|
|
316
|
+
mods: ResizeMods,
|
|
317
|
+
square: boolean
|
|
318
|
+
): { x: number; y: number; w: number; h: number } {
|
|
319
|
+
const box = bboxResize(b0, corner, wx, wy, mods, square);
|
|
320
|
+
const rot = strokeRotation(start);
|
|
321
|
+
if (rot !== 0 && !mods.alt) {
|
|
322
|
+
const [dx, dy] = rotatedAnchorShift(b0, box, corner, rot);
|
|
323
|
+
box.x += dx;
|
|
324
|
+
box.y += dy;
|
|
325
|
+
}
|
|
326
|
+
return box;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Per-tool resize math. Given a stroke + the moved corner + the new world
|
|
331
|
+
* coords for that corner, returns a patched stroke. `start` is the stroke at
|
|
332
|
+
* the moment the drag began (used as the source-of-truth for scaling math —
|
|
333
|
+
* avoids drift from rounding successive deltas). `mods` carries the live
|
|
334
|
+
* FigJam resize modifiers (Shift aspect-lock, Alt scale-from-center).
|
|
335
|
+
*/
|
|
336
|
+
export function resizeStroke(
|
|
337
|
+
start: Stroke,
|
|
338
|
+
corner: Corner,
|
|
339
|
+
wx: number,
|
|
340
|
+
wy: number,
|
|
341
|
+
mods: ResizeMods = NO_MODS,
|
|
342
|
+
/**
|
|
343
|
+
* Wave G — rotation reference for the corner rotate zones: the pointer
|
|
344
|
+
* angle (deg, around the stroke center) at drag start + the stroke's
|
|
345
|
+
* rotation at drag start. Rotation is RELATIVE — the grab point is the
|
|
346
|
+
* zero reference, so the shape follows the hand with no jump (FigJam).
|
|
347
|
+
* Without it, the raw pointer angle is used as-is.
|
|
348
|
+
*/
|
|
349
|
+
rotRef?: { angle0: number; rot0: number }
|
|
350
|
+
): Partial<Stroke> | null {
|
|
351
|
+
if (isRotCorner(corner)) {
|
|
352
|
+
if (!canRotate(start)) return null;
|
|
353
|
+
const c = strokeCenter(start);
|
|
354
|
+
if (!c) return null;
|
|
355
|
+
const cur = (Math.atan2(wy - c[1], wx - c[0]) * 180) / Math.PI;
|
|
356
|
+
let deg = normalizeRotation(rotRef ? rotRef.rot0 + (cur - rotRef.angle0) : cur);
|
|
357
|
+
if (mods.shift) {
|
|
358
|
+
deg = normalizeRotation(Math.round(deg / 15) * 15);
|
|
359
|
+
} else {
|
|
360
|
+
// Magnetic cardinals (FigJam): drifting within 2° of 0/±90/180 locks on,
|
|
361
|
+
// so "straighten it back out" doesn't need pixel-perfect aim.
|
|
362
|
+
for (const cand of [0, 90, 180, -90, -180]) {
|
|
363
|
+
if (Math.abs(deg - cand) <= 2) {
|
|
364
|
+
deg = cand;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const norm = normalizeRotation(deg);
|
|
370
|
+
return { rotation: norm === 0 ? undefined : norm } as Partial<Stroke>;
|
|
371
|
+
}
|
|
372
|
+
if (
|
|
373
|
+
start.tool === 'rect' ||
|
|
374
|
+
start.tool === 'sticky' ||
|
|
375
|
+
start.tool === 'polygon' ||
|
|
376
|
+
start.tool === 'link' ||
|
|
377
|
+
start.tool === 'section'
|
|
378
|
+
) {
|
|
379
|
+
// Rect / polygon / sticky / link all resize via their shared x / y / w / h
|
|
380
|
+
// bbox. Text re-wraps inside the foreignObject automatically. Sticky stays
|
|
381
|
+
// 1:1; the link card free-resizes (Shift still locks its current ratio).
|
|
382
|
+
const b0 = { x: start.x, y: start.y, w: start.w, h: start.h };
|
|
383
|
+
const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, start.tool === 'sticky');
|
|
384
|
+
return box as Partial<RectStroke | StickyStroke | PolygonStroke | LinkStroke>;
|
|
385
|
+
}
|
|
386
|
+
if (start.tool === 'image') {
|
|
387
|
+
// Phase 23 — images aspect-LOCK by default and free-resize with Shift held
|
|
388
|
+
// (the inverse of the shape tools — Figma/FigJam image behaviour). Invert
|
|
389
|
+
// the Shift flag into bboxResize, which keeps the START ratio when shift is
|
|
390
|
+
// set; the start ratio IS the image's intrinsic aspect.
|
|
391
|
+
const b0 = { x: start.x, y: start.y, w: start.w, h: start.h };
|
|
392
|
+
const box = bboxResizeRotAware(
|
|
393
|
+
start,
|
|
394
|
+
b0,
|
|
395
|
+
corner,
|
|
396
|
+
wx,
|
|
397
|
+
wy,
|
|
398
|
+
{ shift: !mods.shift, alt: mods.alt },
|
|
399
|
+
false
|
|
400
|
+
);
|
|
401
|
+
return box as Partial<ImageStroke>;
|
|
402
|
+
}
|
|
403
|
+
if (start.tool === 'ellipse') {
|
|
404
|
+
// Treat the four corners as the bbox of the ellipse, then derive cx/cy/rx/ry.
|
|
405
|
+
const b0 = { x: start.cx - start.rx, y: start.cy - start.ry, w: start.rx * 2, h: start.ry * 2 };
|
|
406
|
+
const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, false);
|
|
407
|
+
return {
|
|
408
|
+
cx: box.x + box.w / 2,
|
|
409
|
+
cy: box.y + box.h / 2,
|
|
410
|
+
rx: Math.max(1, box.w / 2),
|
|
411
|
+
ry: Math.max(1, box.h / 2),
|
|
412
|
+
} as Partial<EllipseStroke>;
|
|
413
|
+
}
|
|
414
|
+
if (start.tool === 'arrow') {
|
|
415
|
+
if (corner !== 'ep1' && corner !== 'ep2') return null;
|
|
416
|
+
const otherX = corner === 'ep1' ? start.x2 : start.x1;
|
|
417
|
+
const otherY = corner === 'ep1' ? start.y2 : start.y1;
|
|
418
|
+
const midX = (start.x1 + start.x2) / 2;
|
|
419
|
+
const midY = (start.y1 + start.y2) / 2;
|
|
420
|
+
// Alt pins the midpoint (both ends mirror); otherwise the far end is fixed.
|
|
421
|
+
const refX = mods.alt ? midX : otherX;
|
|
422
|
+
const refY = mods.alt ? midY : otherY;
|
|
423
|
+
let dragX = wx;
|
|
424
|
+
let dragY = wy;
|
|
425
|
+
if (mods.shift) {
|
|
426
|
+
// Snap the shaft angle (relative to the reference) to 45° increments.
|
|
427
|
+
const dx = wx - refX;
|
|
428
|
+
const dy = wy - refY;
|
|
429
|
+
const dist = Math.hypot(dx, dy);
|
|
430
|
+
const step = Math.PI / 4;
|
|
431
|
+
const ang = Math.round(Math.atan2(dy, dx) / step) * step;
|
|
432
|
+
dragX = refX + Math.cos(ang) * dist;
|
|
433
|
+
dragY = refY + Math.sin(ang) * dist;
|
|
434
|
+
}
|
|
435
|
+
if (mods.alt) {
|
|
436
|
+
const mirrorX = 2 * midX - dragX;
|
|
437
|
+
const mirrorY = 2 * midY - dragY;
|
|
438
|
+
return corner === 'ep1'
|
|
439
|
+
? ({ x1: dragX, y1: dragY, x2: mirrorX, y2: mirrorY } as Partial<ArrowStroke>)
|
|
440
|
+
: ({ x2: dragX, y2: dragY, x1: mirrorX, y1: mirrorY } as Partial<ArrowStroke>);
|
|
441
|
+
}
|
|
442
|
+
return corner === 'ep1'
|
|
443
|
+
? ({ x1: dragX, y1: dragY } as Partial<ArrowStroke>)
|
|
444
|
+
: ({ x2: dragX, y2: dragY } as Partial<ArrowStroke>);
|
|
445
|
+
}
|
|
446
|
+
if (start.tool === 'pen') {
|
|
447
|
+
// Scale all points around an anchor — the opposite corner (normal) or the
|
|
448
|
+
// bbox center (Alt). Shift forces a uniform scale (dominant axis wins).
|
|
449
|
+
// A 0-extent axis (single-point pen stroke) keeps scale 1 (no div-by-zero).
|
|
450
|
+
// Edge handles aren't offered for ink (corner scale only).
|
|
451
|
+
if (isEdgeCorner(corner)) return null;
|
|
452
|
+
const bb = strokeBBox(start);
|
|
453
|
+
if (!bb) return null;
|
|
454
|
+
const cx = bb.x + bb.w / 2;
|
|
455
|
+
const cy = bb.y + bb.h / 2;
|
|
456
|
+
const isW = isWestCorner(corner);
|
|
457
|
+
const isN = isNorthCorner(corner);
|
|
458
|
+
let anchorX: number;
|
|
459
|
+
let anchorY: number;
|
|
460
|
+
let sx: number;
|
|
461
|
+
let sy: number;
|
|
462
|
+
if (mods.alt) {
|
|
463
|
+
anchorX = cx;
|
|
464
|
+
anchorY = cy;
|
|
465
|
+
sx = bb.w === 0 ? 1 : (wx - cx) / (isW ? -bb.w / 2 : bb.w / 2);
|
|
466
|
+
sy = bb.h === 0 ? 1 : (wy - cy) / (isN ? -bb.h / 2 : bb.h / 2);
|
|
467
|
+
} else {
|
|
468
|
+
anchorX = isW ? bb.x + bb.w : bb.x;
|
|
469
|
+
anchorY = isN ? bb.y + bb.h : bb.y;
|
|
470
|
+
const newLeft = isW ? wx : bb.x;
|
|
471
|
+
const newTop = isN ? wy : bb.y;
|
|
472
|
+
const newRight = isW ? bb.x + bb.w : wx;
|
|
473
|
+
const newBottom = isN ? bb.y + bb.h : wy;
|
|
474
|
+
sx = bb.w === 0 ? 1 : (newRight - newLeft) / bb.w;
|
|
475
|
+
sy = bb.h === 0 ? 1 : (newBottom - newTop) / bb.h;
|
|
476
|
+
}
|
|
477
|
+
if (mods.shift) {
|
|
478
|
+
const s = Math.max(Math.abs(sx), Math.abs(sy));
|
|
479
|
+
sx = (sx < 0 ? -1 : 1) * s;
|
|
480
|
+
sy = (sy < 0 ? -1 : 1) * s;
|
|
481
|
+
}
|
|
482
|
+
const scaled = start.points.map(
|
|
483
|
+
([px, py]) =>
|
|
484
|
+
[anchorX + (px - anchorX) * sx, anchorY + (py - anchorY) * sy] as [number, number]
|
|
485
|
+
);
|
|
486
|
+
return { points: scaled } as Partial<PenStroke>;
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Returns the four corners (or two endpoints, for arrow) of the selected
|
|
493
|
+
* stroke in world coordinates. Used by the overlay component to position
|
|
494
|
+
* the screen-space handle divs each rAF tick. `pad` is the halo offset in
|
|
495
|
+
* WORLD units (callers pass `HALO_PAD_PX / zoom` for a screen-constant gap).
|
|
496
|
+
*/
|
|
497
|
+
function handlePositions(s: Stroke, pad: number): Array<{ corner: Corner; x: number; y: number }> {
|
|
498
|
+
if (s.tool === 'arrow') {
|
|
499
|
+
return [
|
|
500
|
+
{ corner: 'ep1', x: s.x1, y: s.y1 },
|
|
501
|
+
{ corner: 'ep2', x: s.x2, y: s.y2 },
|
|
502
|
+
];
|
|
503
|
+
}
|
|
504
|
+
const bb0 = strokeBBox(s);
|
|
505
|
+
if (!bb0) return [];
|
|
506
|
+
// Wave G — handles sit on the HALO (bbox + pad), not glued to the stroke
|
|
507
|
+
// itself; applyResize shifts the cursor back by the same pad.
|
|
508
|
+
const bb = {
|
|
509
|
+
x: bb0.x - pad,
|
|
510
|
+
y: bb0.y - pad,
|
|
511
|
+
w: bb0.w + pad * 2,
|
|
512
|
+
h: bb0.h + pad * 2,
|
|
513
|
+
};
|
|
514
|
+
const corners: Array<{ corner: Corner; x: number; y: number }> = [
|
|
515
|
+
{ corner: 'nw', x: bb.x, y: bb.y },
|
|
516
|
+
{ corner: 'ne', x: bb.x + bb.w, y: bb.y },
|
|
517
|
+
{ corner: 'sw', x: bb.x, y: bb.y + bb.h },
|
|
518
|
+
{ corner: 'se', x: bb.x + bb.w, y: bb.y + bb.h },
|
|
519
|
+
];
|
|
520
|
+
// FigJam v3 — mid-edge handles (single-axis resize) for the box-shaped
|
|
521
|
+
// strokes; ink (pen) keeps corner-scale only.
|
|
522
|
+
if (s.tool !== 'pen') {
|
|
523
|
+
corners.push(
|
|
524
|
+
{ corner: 'n', x: bb.x + bb.w / 2, y: bb.y },
|
|
525
|
+
{ corner: 'e', x: bb.x + bb.w, y: bb.y + bb.h / 2 },
|
|
526
|
+
{ corner: 's', x: bb.x + bb.w / 2, y: bb.y + bb.h },
|
|
527
|
+
{ corner: 'w', x: bb.x, y: bb.y + bb.h / 2 }
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
// Wave G — invisible rotate zones AT the padded corners; the rAF tick adds
|
|
531
|
+
// a screen-constant outward offset (along center→corner) so they hover just
|
|
532
|
+
// beyond the corner squares at every zoom + rotation.
|
|
533
|
+
if (canRotate(s)) {
|
|
534
|
+
corners.push(
|
|
535
|
+
{ corner: 'rot-nw', x: bb.x, y: bb.y },
|
|
536
|
+
{ corner: 'rot-ne', x: bb.x + bb.w, y: bb.y },
|
|
537
|
+
{ corner: 'rot-sw', x: bb.x, y: bb.y + bb.h },
|
|
538
|
+
{ corner: 'rot-se', x: bb.x + bb.w, y: bb.y + bb.h }
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
// FigJam v3 — a rotated stroke carries its handles with it: every handle
|
|
542
|
+
// point turns around the bbox center, so they sit on the visible outline.
|
|
543
|
+
const rot = strokeRotation(s);
|
|
544
|
+
if (rot !== 0) {
|
|
545
|
+
const cx = bb.x + bb.w / 2;
|
|
546
|
+
const cy = bb.y + bb.h / 2;
|
|
547
|
+
return corners.map((c) => {
|
|
548
|
+
const [rx, ry] = rotatePoint(c.x, c.y, cx, cy, rot);
|
|
549
|
+
return { corner: c.corner, x: rx, y: ry };
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return corners;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue | null }): ReactNode {
|
|
556
|
+
ensureResizeStyles();
|
|
557
|
+
const annotSel = useAnnotationSelection();
|
|
558
|
+
const controller = useViewportControllerContext();
|
|
559
|
+
const vp = controller?.viewport ?? null;
|
|
560
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
561
|
+
const rafRef = useRef<number | null>(null);
|
|
562
|
+
const dragRef = useRef<{
|
|
563
|
+
pointerId: number;
|
|
564
|
+
startStroke: Stroke;
|
|
565
|
+
/** Wave H — full strokes snapshot at drag start (the undo `before`). */
|
|
566
|
+
startStrokes: Stroke[];
|
|
567
|
+
corner: Corner;
|
|
568
|
+
/** FigJam v3 — neighbour bbox dims for the dimension-match snap. */
|
|
569
|
+
dims: Array<{ id: string; w: number; h: number }>;
|
|
570
|
+
/** Wave G — rotation drag reference (corner rotate zones only). */
|
|
571
|
+
rotRef?: { angle0: number; rot0: number };
|
|
572
|
+
} | null>(null);
|
|
573
|
+
// Last pointer position (client coords) during a drag — lets a mid-drag
|
|
574
|
+
// Shift/Alt keydown re-apply the resize without waiting for a pointermove.
|
|
575
|
+
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
|
|
576
|
+
|
|
577
|
+
const selectedId = annotSel.selectedIds.length === 1 ? (annotSel.selectedIds[0] ?? null) : null;
|
|
578
|
+
const selectedStroke: Stroke | null = useMemo(() => {
|
|
579
|
+
if (!selectedId || !store) return null;
|
|
580
|
+
return store.strokes.find((s) => s.id === selectedId) ?? null;
|
|
581
|
+
}, [selectedId, store]);
|
|
582
|
+
|
|
583
|
+
const screenToWorld = useCallback(
|
|
584
|
+
(cx: number, cy: number): [number, number] => {
|
|
585
|
+
const v = vp ?? { x: 0, y: 0, zoom: 1 };
|
|
586
|
+
const z = v.zoom || 1;
|
|
587
|
+
return [(cx - v.x) / z, (cy - v.y) / z];
|
|
588
|
+
},
|
|
589
|
+
[vp]
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
// rAF loop — repositions handles on every frame while a single resizable
|
|
593
|
+
// stroke is selected. Cheaper than wiring pan/zoom observers because the
|
|
594
|
+
// halo overlays already follow the same pattern.
|
|
595
|
+
useEffect(() => {
|
|
596
|
+
if (!selectedStroke || !isResizable(selectedStroke)) {
|
|
597
|
+
const c = containerRef.current;
|
|
598
|
+
if (c) {
|
|
599
|
+
for (const child of Array.from(c.children)) {
|
|
600
|
+
(child as HTMLElement).style.display = 'none';
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const tick = () => {
|
|
606
|
+
rafRef.current = null;
|
|
607
|
+
const c = containerRef.current;
|
|
608
|
+
if (!c) return;
|
|
609
|
+
const v = vp ?? { x: 0, y: 0, zoom: 1 };
|
|
610
|
+
const z = v.zoom || 1;
|
|
611
|
+
// Wave H — screen-constant halo offset (HALO_PAD_PX at any zoom).
|
|
612
|
+
const positions = handlePositions(selectedStroke, HALO_PAD_PX / z);
|
|
613
|
+
// Ensure enough handle children exist (each corner = one absolutely-
|
|
614
|
+
// positioned div); the per-kind class is assigned below.
|
|
615
|
+
while (c.children.length < positions.length) {
|
|
616
|
+
c.appendChild(document.createElement('div'));
|
|
617
|
+
}
|
|
618
|
+
while (c.children.length > positions.length) {
|
|
619
|
+
c.lastChild && c.removeChild(c.lastChild);
|
|
620
|
+
}
|
|
621
|
+
// Wave G — rotate zones push a screen-constant step OUTWARD from the
|
|
622
|
+
// stroke center so they hover diagonally beyond the corner squares.
|
|
623
|
+
const wc = strokeCenter(selectedStroke);
|
|
624
|
+
const csx = wc ? wc[0] * z + v.x : 0;
|
|
625
|
+
const csy = wc ? wc[1] * z + v.y : 0;
|
|
626
|
+
for (let i = 0; i < positions.length; i++) {
|
|
627
|
+
const pos = positions[i];
|
|
628
|
+
const handle = c.children[i] as HTMLElement | undefined;
|
|
629
|
+
if (!pos || !handle) continue;
|
|
630
|
+
let sx = pos.x * z + v.x;
|
|
631
|
+
let sy = pos.y * z + v.y;
|
|
632
|
+
const isRot = isRotCorner(pos.corner);
|
|
633
|
+
if (isRot && wc) {
|
|
634
|
+
const dx = sx - csx;
|
|
635
|
+
const dy = sy - csy;
|
|
636
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
637
|
+
const reach = 13;
|
|
638
|
+
sx += (dx / len) * reach;
|
|
639
|
+
sy += (dy / len) * reach;
|
|
640
|
+
}
|
|
641
|
+
// Edge pills are 14×6 / 6×14, rotate zones 18×18 (corners 8×8) —
|
|
642
|
+
// center each on its point.
|
|
643
|
+
const ns = pos.corner === 'n' || pos.corner === 's';
|
|
644
|
+
const ew = pos.corner === 'e' || pos.corner === 'w';
|
|
645
|
+
const halfW = isRot ? 9 : ns ? 7 : ew ? 3 : 4;
|
|
646
|
+
const halfH = isRot ? 9 : ns ? 3 : ew ? 7 : 4;
|
|
647
|
+
handle.className = isRot ? 'dc-annot-rotate-zone' : 'dc-annot-resize-handle';
|
|
648
|
+
handle.style.display = 'block';
|
|
649
|
+
handle.style.left = `${Math.round(sx - halfW)}px`;
|
|
650
|
+
handle.style.top = `${Math.round(sy - halfH)}px`;
|
|
651
|
+
handle.dataset.corner = pos.corner;
|
|
652
|
+
}
|
|
653
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
654
|
+
};
|
|
655
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
656
|
+
return () => {
|
|
657
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
658
|
+
};
|
|
659
|
+
}, [selectedStroke, vp]);
|
|
660
|
+
|
|
661
|
+
// Pointer handling — pointerdown on a handle starts a drag; pointermove
|
|
662
|
+
// patches the stroke via `store.updateStroke`; pointerup commits.
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
const c = containerRef.current;
|
|
665
|
+
if (!c) return;
|
|
666
|
+
const onDown = (e: PointerEvent) => {
|
|
667
|
+
const t = e.target as HTMLElement | null;
|
|
668
|
+
if (
|
|
669
|
+
!t?.classList.contains('dc-annot-resize-handle') &&
|
|
670
|
+
!t?.classList.contains('dc-annot-rotate-zone')
|
|
671
|
+
)
|
|
672
|
+
return;
|
|
673
|
+
if (!t || !selectedStroke || !store) return;
|
|
674
|
+
const corner = t.dataset.corner as Corner | undefined;
|
|
675
|
+
if (!corner) return;
|
|
676
|
+
e.preventDefault();
|
|
677
|
+
e.stopPropagation();
|
|
678
|
+
// Wave G — corner rotation is relative to the grab angle (no jump).
|
|
679
|
+
let rotRef: { angle0: number; rot0: number } | undefined;
|
|
680
|
+
if (isRotCorner(corner)) {
|
|
681
|
+
const cw = strokeCenter(selectedStroke);
|
|
682
|
+
if (cw) {
|
|
683
|
+
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
684
|
+
rotRef = {
|
|
685
|
+
angle0: (Math.atan2(wy - cw[1], wx - cw[0]) * 180) / Math.PI,
|
|
686
|
+
rot0: strokeRotation(selectedStroke),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// FigJam v3 — dimension-match candidates, captured once per gesture: the
|
|
691
|
+
// bbox dims of every OTHER box-shaped stroke. While resizing, a width /
|
|
692
|
+
// height within the threshold of a neighbour's snaps to it exactly and
|
|
693
|
+
// the neighbour gets a match halo (the "make it the same size" quota).
|
|
694
|
+
const dims: Array<{ id: string; w: number; h: number }> = [];
|
|
695
|
+
for (const s of store.strokes) {
|
|
696
|
+
if (s.id === selectedStroke.id) continue;
|
|
697
|
+
if (s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'text') continue;
|
|
698
|
+
const bb = strokeBBox(s);
|
|
699
|
+
if (bb && bb.w > 1 && bb.h > 1) dims.push({ id: s.id, w: bb.w, h: bb.h });
|
|
700
|
+
}
|
|
701
|
+
dragRef.current = {
|
|
702
|
+
pointerId: e.pointerId,
|
|
703
|
+
startStroke: selectedStroke,
|
|
704
|
+
startStrokes: store.strokes,
|
|
705
|
+
corner,
|
|
706
|
+
dims,
|
|
707
|
+
rotRef,
|
|
708
|
+
};
|
|
709
|
+
try {
|
|
710
|
+
t.setPointerCapture(e.pointerId);
|
|
711
|
+
} catch {
|
|
712
|
+
/* some browsers reject capture on synthetic events */
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
const applyResize = (
|
|
716
|
+
clientX: number,
|
|
717
|
+
clientY: number,
|
|
718
|
+
mods: ResizeMods,
|
|
719
|
+
suppressBind: boolean
|
|
720
|
+
) => {
|
|
721
|
+
const d = dragRef.current;
|
|
722
|
+
if (!d || !store) return;
|
|
723
|
+
const [wx, wy] = screenToWorld(clientX, clientY);
|
|
724
|
+
// FigJam v3 — a rotated stroke resizes in its LOCAL frame: inverse-
|
|
725
|
+
// rotate the cursor around the start pivot, run the axis-aligned math,
|
|
726
|
+
// keep the rotation. (The rotation knob itself wants the RAW cursor.)
|
|
727
|
+
let lwx = wx;
|
|
728
|
+
let lwy = wy;
|
|
729
|
+
const startRot = strokeRotation(d.startStroke);
|
|
730
|
+
if (startRot !== 0 && !isRotCorner(d.corner)) {
|
|
731
|
+
const c = strokeCenter(d.startStroke);
|
|
732
|
+
if (c) {
|
|
733
|
+
const [ux, uy] = rotatePoint(wx, wy, c[0], c[1], -startRot);
|
|
734
|
+
lwx = ux;
|
|
735
|
+
lwy = uy;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// Wave G/H — handles render padded OFF the bbox (screen-constant); shift
|
|
739
|
+
// the cursor back onto the true corner/edge so the first move doesn't
|
|
740
|
+
// grow the shape.
|
|
741
|
+
const padWorld = HALO_PAD_PX / (vp?.zoom || 1);
|
|
742
|
+
lwx += padDX(d.corner) * padWorld;
|
|
743
|
+
lwy += padDY(d.corner) * padWorld;
|
|
744
|
+
let patch = resizeStroke(d.startStroke, d.corner, lwx, lwy, mods, d.rotRef);
|
|
745
|
+
if (!patch) return;
|
|
746
|
+
// FigJam v3 — dimension match + live size label for box resizes. ⌘
|
|
747
|
+
// suppresses matching (same convention as drag snapping).
|
|
748
|
+
if (
|
|
749
|
+
d.startStroke.tool !== 'arrow' &&
|
|
750
|
+
d.startStroke.tool !== 'pen' &&
|
|
751
|
+
'w' in patch &&
|
|
752
|
+
'h' in patch &&
|
|
753
|
+
typeof patch.w === 'number' &&
|
|
754
|
+
typeof patch.h === 'number' &&
|
|
755
|
+
typeof patch.x === 'number' &&
|
|
756
|
+
typeof patch.y === 'number'
|
|
757
|
+
) {
|
|
758
|
+
const zoom = vp?.zoom || 1;
|
|
759
|
+
const thr = 6 / zoom;
|
|
760
|
+
const matchIds: string[] = [];
|
|
761
|
+
if (!suppressBind && d.dims.length) {
|
|
762
|
+
// Only the axes this handle actually drags participate — an east-
|
|
763
|
+
// edge drag must never quietly re-snap the HEIGHT to a neighbour.
|
|
764
|
+
const edge = isEdgeCorner(d.corner);
|
|
765
|
+
const affectsW = !edge || d.corner === 'e' || d.corner === 'w';
|
|
766
|
+
const affectsH = !edge || d.corner === 'n' || d.corner === 's';
|
|
767
|
+
let bestW: { id: string; w: number } | null = null;
|
|
768
|
+
let bestH: { id: string; h: number } | null = null;
|
|
769
|
+
for (const cand of d.dims) {
|
|
770
|
+
if (
|
|
771
|
+
affectsW &&
|
|
772
|
+
Math.abs(cand.w - patch.w) <= thr &&
|
|
773
|
+
(!bestW || Math.abs(cand.w - patch.w) < Math.abs(bestW.w - patch.w))
|
|
774
|
+
) {
|
|
775
|
+
bestW = { id: cand.id, w: cand.w };
|
|
776
|
+
}
|
|
777
|
+
if (
|
|
778
|
+
affectsH &&
|
|
779
|
+
Math.abs(cand.h - patch.h) <= thr &&
|
|
780
|
+
(!bestH || Math.abs(cand.h - patch.h) < Math.abs(bestH.h - patch.h))
|
|
781
|
+
) {
|
|
782
|
+
bestH = { id: cand.id, h: cand.h };
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
// Snap the box to the matched dim, keeping the anchored edge fixed
|
|
786
|
+
// (the cursor-side edge moves; x/y only shift when the west/north
|
|
787
|
+
// edge was the dragged one).
|
|
788
|
+
if (bestW) {
|
|
789
|
+
if (wx < patch.x + patch.w / 2) patch.x = patch.x + patch.w - bestW.w;
|
|
790
|
+
patch.w = bestW.w;
|
|
791
|
+
matchIds.push(bestW.id);
|
|
792
|
+
}
|
|
793
|
+
if (bestH) {
|
|
794
|
+
if (wy < patch.y + patch.h / 2) patch.y = patch.y + patch.h - bestH.h;
|
|
795
|
+
patch.h = bestH.h;
|
|
796
|
+
matchIds.push(bestH.id);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
document.dispatchEvent(
|
|
800
|
+
new CustomEvent('maude:resize-info', {
|
|
801
|
+
detail: {
|
|
802
|
+
box: { x: patch.x, y: patch.y, w: patch.w, h: patch.h },
|
|
803
|
+
matchIds,
|
|
804
|
+
},
|
|
805
|
+
})
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
// FigJam v3 — dragging an arrow ENDPOINT re-anchors its bind: within the
|
|
809
|
+
// magnet threshold of a bindable host the endpoint snaps + binds; free
|
|
810
|
+
// space (or ⌘ held — Figma "suppress snap") clears the bind. The layer
|
|
811
|
+
// paints the candidate halo via the maude:bind-hint broadcast.
|
|
812
|
+
if (d.startStroke.tool === 'arrow' && (d.corner === 'ep1' || d.corner === 'ep2')) {
|
|
813
|
+
const zoom = vp?.zoom || 1;
|
|
814
|
+
const cand = suppressBind
|
|
815
|
+
? null
|
|
816
|
+
: bindCandidate(
|
|
817
|
+
wx,
|
|
818
|
+
wy,
|
|
819
|
+
store.strokes,
|
|
820
|
+
BIND_THRESHOLD_PX / zoom,
|
|
821
|
+
new Set([d.startStroke.id])
|
|
822
|
+
);
|
|
823
|
+
if (cand) {
|
|
824
|
+
const host = store.strokes.find((s) => s.id === cand.hostId);
|
|
825
|
+
const pt = host ? anchorPoint(host, cand.nx, cand.ny) : null;
|
|
826
|
+
if (pt) {
|
|
827
|
+
// An explicit endpoint re-anchor PINS the magnet — auto-routing
|
|
828
|
+
// must not override a side the user chose by hand.
|
|
829
|
+
const pinned = { ...cand, pinned: true };
|
|
830
|
+
patch =
|
|
831
|
+
d.corner === 'ep1'
|
|
832
|
+
? ({ ...patch, startBind: pinned, x1: pt[0], y1: pt[1] } as Partial<Stroke>)
|
|
833
|
+
: ({ ...patch, endBind: pinned, x2: pt[0], y2: pt[1] } as Partial<Stroke>);
|
|
834
|
+
}
|
|
835
|
+
} else {
|
|
836
|
+
patch = {
|
|
837
|
+
...patch,
|
|
838
|
+
[d.corner === 'ep1' ? 'startBind' : 'endBind']: undefined,
|
|
839
|
+
} as Partial<Stroke>;
|
|
840
|
+
}
|
|
841
|
+
document.dispatchEvent(
|
|
842
|
+
new CustomEvent('maude:bind-hint', { detail: { hostId: cand?.hostId ?? null } })
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
// Wave H — transient preview tick: no undo record, no PUT. The gesture
|
|
846
|
+
// commits ONCE on pointerup (undo used to walk every resize pixel).
|
|
847
|
+
store.previewStroke(d.startStroke.id, patch);
|
|
848
|
+
};
|
|
849
|
+
const onMove = (e: PointerEvent) => {
|
|
850
|
+
const d = dragRef.current;
|
|
851
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
852
|
+
lastPointRef.current = { x: e.clientX, y: e.clientY };
|
|
853
|
+
applyResize(
|
|
854
|
+
e.clientX,
|
|
855
|
+
e.clientY,
|
|
856
|
+
{ shift: e.shiftKey, alt: e.altKey },
|
|
857
|
+
e.metaKey || e.ctrlKey
|
|
858
|
+
);
|
|
859
|
+
};
|
|
860
|
+
// Holding/releasing Shift / Alt / ⌘ mid-drag re-runs the resize at the
|
|
861
|
+
// last known pointer position so the constraint flips live, FigJam-style.
|
|
862
|
+
const onKey = (e: KeyboardEvent) => {
|
|
863
|
+
if (!dragRef.current) return;
|
|
864
|
+
if (e.key !== 'Shift' && e.key !== 'Alt' && e.key !== 'Meta' && e.key !== 'Control') return;
|
|
865
|
+
const p = lastPointRef.current;
|
|
866
|
+
if (!p) return;
|
|
867
|
+
e.preventDefault();
|
|
868
|
+
applyResize(p.x, p.y, { shift: e.shiftKey, alt: e.altKey }, e.metaKey || e.ctrlKey);
|
|
869
|
+
};
|
|
870
|
+
const onUp = (e: PointerEvent) => {
|
|
871
|
+
const d = dragRef.current;
|
|
872
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
873
|
+
dragRef.current = null;
|
|
874
|
+
lastPointRef.current = null;
|
|
875
|
+
// Wave H — close the preview gesture as ONE undo record (no-op when
|
|
876
|
+
// the drag ended exactly where it started).
|
|
877
|
+
store?.commitGesture(
|
|
878
|
+
d.startStrokes,
|
|
879
|
+
isRotCorner(d.corner)
|
|
880
|
+
? 'rotate'
|
|
881
|
+
: d.corner === 'ep1' || d.corner === 'ep2'
|
|
882
|
+
? 'move endpoint'
|
|
883
|
+
: 'resize'
|
|
884
|
+
);
|
|
885
|
+
// Clear the bind-hint halo + size label regardless of what was dragged
|
|
886
|
+
// (no-ops when nothing was hinted).
|
|
887
|
+
document.dispatchEvent(new CustomEvent('maude:bind-hint', { detail: { hostId: null } }));
|
|
888
|
+
document.dispatchEvent(
|
|
889
|
+
new CustomEvent('maude:resize-info', { detail: { box: null, matchIds: [] } })
|
|
890
|
+
);
|
|
891
|
+
};
|
|
892
|
+
c.addEventListener('pointerdown', onDown);
|
|
893
|
+
document.addEventListener('pointermove', onMove);
|
|
894
|
+
document.addEventListener('pointerup', onUp);
|
|
895
|
+
document.addEventListener('pointercancel', onUp);
|
|
896
|
+
document.addEventListener('keydown', onKey, true);
|
|
897
|
+
document.addEventListener('keyup', onKey, true);
|
|
898
|
+
return () => {
|
|
899
|
+
c.removeEventListener('pointerdown', onDown);
|
|
900
|
+
document.removeEventListener('pointermove', onMove);
|
|
901
|
+
document.removeEventListener('pointerup', onUp);
|
|
902
|
+
document.removeEventListener('pointercancel', onUp);
|
|
903
|
+
document.removeEventListener('keydown', onKey, true);
|
|
904
|
+
document.removeEventListener('keyup', onKey, true);
|
|
905
|
+
};
|
|
906
|
+
}, [selectedStroke, store, screenToWorld, vp]);
|
|
907
|
+
|
|
908
|
+
// Only render when there's exactly one resizable stroke selected. Multi
|
|
909
|
+
// resize is undefined for v1 (no canonical UX); text inherits anchor bbox.
|
|
910
|
+
if (!selectedStroke || !isResizable(selectedStroke)) return null;
|
|
911
|
+
return <div ref={containerRef} aria-hidden="true" />;
|
|
912
|
+
}
|