@1agh/maude 0.42.0 → 0.44.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/apps/studio/.ai/cache/_stats.json +7 -0
- package/apps/studio/acp/bootstrap-brief.ts +12 -2
- package/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +1451 -324
- package/apps/studio/annotations-model.ts +58 -0
- package/apps/studio/api.ts +568 -24
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_ingest-footage.mjs +386 -0
- package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/_video-playwright.mjs +141 -71
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/ingest-footage.sh +27 -0
- package/apps/studio/bin/photo-adjust.sh +163 -0
- package/apps/studio/bin/photo-bg-remove.sh +258 -0
- package/apps/studio/bin/probe-footage.sh +28 -0
- package/apps/studio/bin/read-annotations.mjs +125 -2
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +656 -12
- package/apps/studio/canvas-lib.tsx +637 -10
- package/apps/studio/canvas-pipeline.ts +65 -7
- package/apps/studio/canvas-shell.tsx +156 -16
- package/apps/studio/client/app.jsx +2385 -699
- package/apps/studio/client/export-center.jsx +35 -1
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +18 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/GitPanel.jsx +8 -0
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/panels/StickerPicker.jsx +160 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
- package/apps/studio/client/photo-knobs.jsx +432 -0
- package/apps/studio/client/styles/3-shell-maude.css +376 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/commands/edit-source-command.ts +31 -4
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +42 -8
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +5572 -21
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
- package/apps/studio/dist/runtime/pixi-js.js +519 -519
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +25 -0
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/exporters/jobs.ts +47 -5
- package/apps/studio/exporters/video.ts +24 -4
- package/apps/studio/footage/schema.test.ts +287 -0
- package/apps/studio/footage/schema.ts +662 -0
- package/apps/studio/footage-store.ts +235 -0
- package/apps/studio/fs-watch.ts +10 -0
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/endpoints.ts +68 -7
- package/apps/studio/git/service.ts +199 -21
- package/apps/studio/github/service.ts +67 -0
- package/apps/studio/handoff.ts +3 -2
- package/apps/studio/http.ts +1039 -22
- package/apps/studio/input-router.tsx +10 -0
- package/apps/studio/inspect.ts +53 -1
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +20 -0
- package/apps/studio/photo/filters.ts +285 -0
- package/apps/studio/photo/pipeline.ts +607 -0
- package/apps/studio/photo/schema.ts +367 -0
- package/apps/studio/photo-store.ts +154 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/runtime-bundle.ts +29 -6
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +18 -2
- package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
- package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
- package/apps/studio/stickers/life-style/best.png +0 -0
- package/apps/studio/stickers/life-style/come-on.png +0 -0
- package/apps/studio/stickers/life-style/cut.png +0 -0
- package/apps/studio/stickers/life-style/detox.png +0 -0
- package/apps/studio/stickers/life-style/just.png +0 -0
- package/apps/studio/stickers/life-style/manifest.json +68 -0
- package/apps/studio/stickers/life-style/mirror.png +0 -0
- package/apps/studio/stickers/life-style/nice.png +0 -0
- package/apps/studio/stickers/life-style/objects.png +0 -0
- package/apps/studio/stickers/life-style/ok.png +0 -0
- package/apps/studio/stickers/life-style/queen.png +0 -0
- package/apps/studio/stickers/life-style/star.png +0 -0
- package/apps/studio/stickers/life-style/sun.png +0 -0
- package/apps/studio/stickers/life-style/water.png +0 -0
- package/apps/studio/stickers/life-style/yeah.png +0 -0
- package/apps/studio/stickers/life-style/you-can.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
- package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
- package/apps/studio/stickers/project-status/group-100.png +0 -0
- package/apps/studio/stickers/project-status/group-101.png +0 -0
- package/apps/studio/stickers/project-status/group-102.png +0 -0
- package/apps/studio/stickers/project-status/group-103.png +0 -0
- package/apps/studio/stickers/project-status/group-104.png +0 -0
- package/apps/studio/stickers/project-status/group-105.png +0 -0
- package/apps/studio/stickers/project-status/group-106.png +0 -0
- package/apps/studio/stickers/project-status/group-107.png +0 -0
- package/apps/studio/stickers/project-status/group-108.png +0 -0
- package/apps/studio/stickers/project-status/group-109.png +0 -0
- package/apps/studio/stickers/project-status/group-110.png +0 -0
- package/apps/studio/stickers/project-status/group-111.png +0 -0
- package/apps/studio/stickers/project-status/group-112.png +0 -0
- package/apps/studio/stickers/project-status/group-113.png +0 -0
- package/apps/studio/stickers/project-status/group-114.png +0 -0
- package/apps/studio/stickers/project-status/group-115.png +0 -0
- package/apps/studio/stickers/project-status/group-117.png +0 -0
- package/apps/studio/stickers/project-status/group-118.png +0 -0
- package/apps/studio/stickers/project-status/group-119.png +0 -0
- package/apps/studio/stickers/project-status/group-120.png +0 -0
- package/apps/studio/stickers/project-status/group-121.png +0 -0
- package/apps/studio/stickers/project-status/group-122.png +0 -0
- package/apps/studio/stickers/project-status/group-41.png +0 -0
- package/apps/studio/stickers/project-status/group-42.png +0 -0
- package/apps/studio/stickers/project-status/group-43.png +0 -0
- package/apps/studio/stickers/project-status/group-44.png +0 -0
- package/apps/studio/stickers/project-status/group-45.png +0 -0
- package/apps/studio/stickers/project-status/group-46.png +0 -0
- package/apps/studio/stickers/project-status/group-47.png +0 -0
- package/apps/studio/stickers/project-status/group-48.png +0 -0
- package/apps/studio/stickers/project-status/group-49.png +0 -0
- package/apps/studio/stickers/project-status/group-50.png +0 -0
- package/apps/studio/stickers/project-status/group-51.png +0 -0
- package/apps/studio/stickers/project-status/group-52.png +0 -0
- package/apps/studio/stickers/project-status/group-53.png +0 -0
- package/apps/studio/stickers/project-status/group-54.png +0 -0
- package/apps/studio/stickers/project-status/group-55.png +0 -0
- package/apps/studio/stickers/project-status/group-56.png +0 -0
- package/apps/studio/stickers/project-status/group-57.png +0 -0
- package/apps/studio/stickers/project-status/group-58.png +0 -0
- package/apps/studio/stickers/project-status/group-59.png +0 -0
- package/apps/studio/stickers/project-status/group-60.png +0 -0
- package/apps/studio/stickers/project-status/group-61.png +0 -0
- package/apps/studio/stickers/project-status/group-62.png +0 -0
- package/apps/studio/stickers/project-status/group-63.png +0 -0
- package/apps/studio/stickers/project-status/group-64.png +0 -0
- package/apps/studio/stickers/project-status/group-65.png +0 -0
- package/apps/studio/stickers/project-status/group-66.png +0 -0
- package/apps/studio/stickers/project-status/group-67.png +0 -0
- package/apps/studio/stickers/project-status/group-68.png +0 -0
- package/apps/studio/stickers/project-status/group-69.png +0 -0
- package/apps/studio/stickers/project-status/group-70.png +0 -0
- package/apps/studio/stickers/project-status/group-71.png +0 -0
- package/apps/studio/stickers/project-status/group-72.png +0 -0
- package/apps/studio/stickers/project-status/group-73.png +0 -0
- package/apps/studio/stickers/project-status/group-74.png +0 -0
- package/apps/studio/stickers/project-status/group-75.png +0 -0
- package/apps/studio/stickers/project-status/group-76.png +0 -0
- package/apps/studio/stickers/project-status/group-77.png +0 -0
- package/apps/studio/stickers/project-status/group-78.png +0 -0
- package/apps/studio/stickers/project-status/group-79.png +0 -0
- package/apps/studio/stickers/project-status/group-80.png +0 -0
- package/apps/studio/stickers/project-status/group-81.png +0 -0
- package/apps/studio/stickers/project-status/group-82.png +0 -0
- package/apps/studio/stickers/project-status/group-83.png +0 -0
- package/apps/studio/stickers/project-status/group-84.png +0 -0
- package/apps/studio/stickers/project-status/group-85.png +0 -0
- package/apps/studio/stickers/project-status/group-86.png +0 -0
- package/apps/studio/stickers/project-status/group-87.png +0 -0
- package/apps/studio/stickers/project-status/group-88.png +0 -0
- package/apps/studio/stickers/project-status/group-89.png +0 -0
- package/apps/studio/stickers/project-status/group-90.png +0 -0
- package/apps/studio/stickers/project-status/group-91.png +0 -0
- package/apps/studio/stickers/project-status/group-92.png +0 -0
- package/apps/studio/stickers/project-status/group-93.png +0 -0
- package/apps/studio/stickers/project-status/group-94.png +0 -0
- package/apps/studio/stickers/project-status/group-96.png +0 -0
- package/apps/studio/stickers/project-status/group-97.png +0 -0
- package/apps/studio/stickers/project-status/group-98.png +0 -0
- package/apps/studio/stickers/project-status/group-99.png +0 -0
- package/apps/studio/stickers/project-status/manifest.json +328 -0
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-commands.test.ts +2 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/asset-api.test.ts +3 -3
- package/apps/studio/test/canvas-edit.test.ts +17 -6
- package/apps/studio/test/canvas-origin-gate.test.ts +50 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
- package/apps/studio/test/edit-source-command.test.ts +23 -0
- package/apps/studio/test/element-structural-edit.test.ts +154 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/footage-store.test.ts +100 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/git-branches.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +56 -1
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/input-router.test.ts +33 -0
- package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
- package/apps/studio/test/photo-edit-api.test.ts +201 -0
- package/apps/studio/test/photo-filters.test.ts +149 -0
- package/apps/studio/test/photo-pipeline.test.ts +106 -0
- package/apps/studio/test/photo-taxonomy.test.ts +96 -0
- package/apps/studio/test/read-annotations.test.ts +164 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/shell-importmap.test.ts +49 -0
- package/apps/studio/test/text-editability-stamp.test.ts +131 -0
- package/apps/studio/test/timeline-parse.test.ts +57 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/test/undo-stack.test.ts +33 -0
- package/apps/studio/test/video-asset.test.ts +5 -5
- package/apps/studio/test/video-render-bridge.test.ts +23 -1
- package/apps/studio/text-caret.ts +239 -0
- package/apps/studio/tool-palette.tsx +49 -21
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/undo-stack.ts +94 -2
- package/apps/studio/use-annotation-resize.tsx +4 -4
- package/apps/studio/use-canvas-media-drop.tsx +40 -1
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/use-selection-set.tsx +21 -0
- package/apps/studio/video-comp.tsx +48 -7
- package/apps/studio/whats-new.json +149 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +67 -3
- package/cli/lib/gitignore-block.mjs +2 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +13 -11
- package/plugins/design/dependencies.json +18 -0
- package/plugins/design/templates/_shell.html +1 -0
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
createContext,
|
|
27
27
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
28
28
|
type PointerEvent as ReactPointerEvent,
|
|
29
|
+
type RefObject,
|
|
29
30
|
useCallback,
|
|
30
31
|
useContext,
|
|
31
32
|
useEffect,
|
|
@@ -115,6 +116,7 @@ import {
|
|
|
115
116
|
SECTION_MIN_SIZE,
|
|
116
117
|
type SectionStroke,
|
|
117
118
|
SHAPE_DEFAULT_SIZE,
|
|
119
|
+
STICKER_DROP_SIZE,
|
|
118
120
|
STICKY_CORNER_RADIUS,
|
|
119
121
|
STICKY_DEFAULT_H,
|
|
120
122
|
STICKY_DEFAULT_W,
|
|
@@ -156,10 +158,20 @@ import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.t
|
|
|
156
158
|
import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
|
|
157
159
|
import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
|
|
158
160
|
import { crossedDragThreshold, type Tool } from './input-router.tsx';
|
|
159
|
-
import {
|
|
161
|
+
import { createMediaCommitChain, type MediaCommitResult } from './media-commit-chain.ts';
|
|
162
|
+
import { mountCaret, placeCaretAt } from './text-caret.ts';
|
|
163
|
+
import {
|
|
164
|
+
AnnotationResizeOverlay,
|
|
165
|
+
bboxResize,
|
|
166
|
+
type Corner,
|
|
167
|
+
padDX,
|
|
168
|
+
padDY,
|
|
169
|
+
type ResizeMods,
|
|
170
|
+
} from './use-annotation-resize.tsx';
|
|
160
171
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
161
172
|
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
162
173
|
import {
|
|
174
|
+
BATCH_DROP_CASCADE_PX,
|
|
163
175
|
isHttpUrl,
|
|
164
176
|
linkDomain,
|
|
165
177
|
prettifyUrl,
|
|
@@ -168,7 +180,7 @@ import {
|
|
|
168
180
|
useCanvasMediaDrop,
|
|
169
181
|
} from './use-canvas-media-drop.tsx';
|
|
170
182
|
import { useChromeVisibility } from './use-chrome-visibility.tsx';
|
|
171
|
-
import { useCollab } from './use-collab.tsx';
|
|
183
|
+
import { colorForName, useCollab } from './use-collab.tsx';
|
|
172
184
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
173
185
|
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
174
186
|
import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
@@ -386,6 +398,123 @@ function resolveAssetHref(href: string): string {
|
|
|
386
398
|
return /^assets\//.test(href) ? `/${canvasDesignRel()}/${href}` : href;
|
|
387
399
|
}
|
|
388
400
|
|
|
401
|
+
/**
|
|
402
|
+
* An optimistic ImageStroke's `blob:`/`data:` href must never be PUT to the
|
|
403
|
+
* server. `sanitizeAnnotationSvg` (api.ts) strips an href it doesn't
|
|
404
|
+
* recognize — keeping the `<image>` element, dropping only the attribute —
|
|
405
|
+
* so the server's STORED + broadcast SVG silently diverges from whatever the
|
|
406
|
+
* client just sent. That divergence defeats the collab-echo self-suppression
|
|
407
|
+
* guard (the `recentSelfSvgsRef` history, near `putStrokes` below): the
|
|
408
|
+
* echo's content no longer matches anything we recorded as "already
|
|
409
|
+
* applied", so a real `setStrokesState` fires from the (href-stripped)
|
|
410
|
+
* server copy — which can wipe out a SIBLING stroke's still-in-flight
|
|
411
|
+
* optimistic insert that was never itself part of that PUT. This is the
|
|
412
|
+
* concrete failure a 3-file concurrent drop hit: one
|
|
413
|
+
* image ended up href-stripped (a blank frame) and another was dropped
|
|
414
|
+
* entirely once an unrelated commit's echo round-tripped while both were
|
|
415
|
+
* still uploading. See media-commit-chain.ts for the sibling accumulator fix
|
|
416
|
+
* — this closes the other half, at the persistence layer.
|
|
417
|
+
*/
|
|
418
|
+
function isEphemeralHref(s: Stroke): boolean {
|
|
419
|
+
return s.tool === 'image' && /^(?:blob|data):/i.test(s.href);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Fold a local mutation's `next` against the live-rendered `prev`, restoring
|
|
424
|
+
* only strokes that are genuinely concurrent additions from ANOTHER
|
|
425
|
+
* in-flight commit — never reverting THIS mutation's own deletions.
|
|
426
|
+
* Disambiguated via `opBefore`, the baseline this mutation's `next` was
|
|
427
|
+
* itself computed from: an id present in `prev` but absent from BOTH
|
|
428
|
+
* `opBefore` and `next` was added by someone else after `opBefore` was
|
|
429
|
+
* captured (fold it in — this is the sibling-insert race this helper was
|
|
430
|
+
* originally written for). An id present in `opBefore` but absent from
|
|
431
|
+
* `next` was deliberately removed BY THIS MUTATION and must stay removed
|
|
432
|
+
* even though `prev` (React's rendered state) hasn't caught up to that
|
|
433
|
+
* removal yet.
|
|
434
|
+
*
|
|
435
|
+
* The prior version of this helper compared only `prev` against `next` —
|
|
436
|
+
* exactly the shape of every delete (an id in `prev`, absent from `next`) —
|
|
437
|
+
* so it silently folded every erased stroke straight back in locally, while
|
|
438
|
+
* the smaller, correct set still went out over PUT. Backspace looked like a
|
|
439
|
+
* no-op until a reload picked up the server's already-correct copy.
|
|
440
|
+
*/
|
|
441
|
+
export function reconcileCommit(
|
|
442
|
+
prev: readonly Stroke[],
|
|
443
|
+
opBefore: readonly Stroke[],
|
|
444
|
+
next: readonly Stroke[]
|
|
445
|
+
): Stroke[] {
|
|
446
|
+
const beforeIds = new Set(opBefore.map((s) => s.id));
|
|
447
|
+
const nextIds = new Set(next.map((s) => s.id));
|
|
448
|
+
const extra = prev.filter((s) => !beforeIds.has(s.id) && !nextIds.has(s.id));
|
|
449
|
+
return extra.length ? [...next, ...extra] : (next as Stroke[]);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Fold a FOREIGN collab-echo snapshot against local state. A foreign
|
|
454
|
+
* broadcast legitimately omits an id that was deleted (by us or a peer) —
|
|
455
|
+
* reviving it just because local `prev` hasn't caught up would make deletes
|
|
456
|
+
* unsyncable across tabs/peers. The only strokes worth resurrecting here are
|
|
457
|
+
* ones that are still purely local, not-yet-synced optimistic previews (an
|
|
458
|
+
* ephemeral blob:/data: href image mid-upload) that a foreign broadcast could
|
|
459
|
+
* never have known about in the first place.
|
|
460
|
+
*/
|
|
461
|
+
export function reconcileForeignEcho(
|
|
462
|
+
prev: readonly Stroke[],
|
|
463
|
+
incoming: readonly Stroke[]
|
|
464
|
+
): Stroke[] {
|
|
465
|
+
const incomingIds = new Set(incoming.map((s) => s.id));
|
|
466
|
+
const extra = prev.filter((s) => !incomingIds.has(s.id) && isEphemeralHref(s));
|
|
467
|
+
return extra.length ? [...incoming, ...extra] : (incoming as Stroke[]);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Decide what `createImageFromFile`'s post-upload swap should do with a
|
|
472
|
+
* given `before` chain link. Extracted as a pure function (no React) so the
|
|
473
|
+
* delete-during-upload race is unit-testable: `wasDeleted` reflects whether
|
|
474
|
+
* `deleteStrokes` recorded this id (via `deletedStrokeIdsRef`) before the
|
|
475
|
+
* upload resolved — checking that FIRST, rather than inferring intent from
|
|
476
|
+
* `before`'s membership alone, is what stops a Backspace-then-still-
|
|
477
|
+
* uploading image from resurrecting once the upload lands (and syncing the
|
|
478
|
+
* resurrection to every collab peer). When not deleted, an id absent from
|
|
479
|
+
* `before` is assumed to be render lag (this same file's optimistic insert
|
|
480
|
+
* hasn't rendered yet) rather than a delete, and gets folded back in.
|
|
481
|
+
*/
|
|
482
|
+
export function resolveImageUploadSwap(
|
|
483
|
+
before: readonly Stroke[],
|
|
484
|
+
id: string,
|
|
485
|
+
optimistic: Stroke,
|
|
486
|
+
realHref: string,
|
|
487
|
+
wasDeleted: boolean
|
|
488
|
+
): MediaCommitResult<Stroke> | null {
|
|
489
|
+
if (wasDeleted) {
|
|
490
|
+
if (!before.some((s) => s.id === id)) return null;
|
|
491
|
+
return { after: before.filter((s) => s.id !== id), label: 'add image' };
|
|
492
|
+
}
|
|
493
|
+
const base = before.some((s) => s.id === id) ? before : [...before, optimistic];
|
|
494
|
+
const after = base.map((s) => (s.id === id ? ({ ...s, href: realHref } as Stroke) : s));
|
|
495
|
+
// The optimistic blob: entry was never itself committed, so the undo
|
|
496
|
+
// "before" excludes it — undo should remove the image outright, not
|
|
497
|
+
// restore a revoked blob:.
|
|
498
|
+
const commitBefore = base.filter((s) => s.id !== id);
|
|
499
|
+
return { after, commitBefore, label: 'add image' };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// feature-bulk-media-insert Task 11 — classify an already-uploaded
|
|
503
|
+
// `assets/<sha8>.<ext>` path client-side, mirroring the same extension sets
|
|
504
|
+
// `listAssets` uses server-side (api.ts). The picker only ever surfaces paths
|
|
505
|
+
// it already listed via /_api/assets, so every path resolves to one of these;
|
|
506
|
+
// `null` (unrecognized) is treated as "skip" by the caller.
|
|
507
|
+
const ANNOTATION_IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg']);
|
|
508
|
+
const ANNOTATION_VIDEO_EXT = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogg']);
|
|
509
|
+
const ANNOTATION_AUDIO_EXT = new Set(['mp3', 'wav', 'm4a', 'aac', 'flac', 'oga', 'opus']);
|
|
510
|
+
function classifyAssetPathKind(path: string): 'image' | 'video' | 'audio' | null {
|
|
511
|
+
const ext = (path.split('.').pop() || '').toLowerCase();
|
|
512
|
+
if (ANNOTATION_IMAGE_EXT.has(ext)) return 'image';
|
|
513
|
+
if (ANNOTATION_VIDEO_EXT.has(ext)) return 'video';
|
|
514
|
+
if (ANNOTATION_AUDIO_EXT.has(ext)) return 'audio';
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
389
518
|
/**
|
|
390
519
|
* DDR-150 dogfood #8 — true when a pointer/click event targets the inline
|
|
391
520
|
* media player inside a mediaref chip. Every document-capture annotation
|
|
@@ -399,6 +528,70 @@ function isMediaPlayerTarget(e: Event): boolean {
|
|
|
399
528
|
return !!(t && typeof t.closest === 'function' && t.closest('[data-mediaref-player]'));
|
|
400
529
|
}
|
|
401
530
|
|
|
531
|
+
/**
|
|
532
|
+
* Dogfood fix — the Text tool's click-through target for ARTBOARD text. This
|
|
533
|
+
* layer's own input-capture div sits above the artboard content (z-index), so
|
|
534
|
+
* `elementsFromPoint` (the full z-stack at that point, topmost first) is
|
|
535
|
+
* needed to see past it. Mirrors canvas-shell.tsx's own `isLeafText` check
|
|
536
|
+
* exactly (all children are text nodes) so "would this be a leaf-text edit
|
|
537
|
+
* target" agrees between the Text-tool click-through and the native
|
|
538
|
+
* double-click path it delegates to.
|
|
539
|
+
*/
|
|
540
|
+
function findEditableElementAt(clientX: number, clientY: number): HTMLElement | null {
|
|
541
|
+
if (typeof document === 'undefined' || typeof document.elementsFromPoint !== 'function') {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
const stack = document.elementsFromPoint(clientX, clientY);
|
|
545
|
+
for (const el of stack) {
|
|
546
|
+
const stamped = el.closest?.('[data-cd-id]') as HTMLElement | null;
|
|
547
|
+
if (!stamped) continue;
|
|
548
|
+
const kids = Array.from(stamped.childNodes);
|
|
549
|
+
const isLeafText = kids.length > 0 && kids.every((n) => n.nodeType === 3);
|
|
550
|
+
return isLeafText ? stamped : null;
|
|
551
|
+
}
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Phase 4 (unified-text-editing) — the Text tool's click-through target for
|
|
557
|
+
* ANNOTATION strokes. Deliberately GEOMETRIC (world-coord bbox containment),
|
|
558
|
+
* not DOM elementsFromPoint: while a draw tool is armed, every stroke node
|
|
559
|
+
* renders with pointer-events:none (`hitMode`), which makes it invisible to
|
|
560
|
+
* DOM hit-testing — measured in the WKWebView harness. Walks strokes topmost
|
|
561
|
+
* (last-rendered) first. An anchored text is skipped — its HOST shape's bbox
|
|
562
|
+
* already covers it and resolves to the same editor. A section matches only
|
|
563
|
+
* on its label CHIP (geometry mirrored from SectionLabelChip) so a click
|
|
564
|
+
* inside the region body still drops a NEW text there.
|
|
565
|
+
*/
|
|
566
|
+
const TEXT_EDITABLE_TOOLS = new Set(['text', 'sticky', 'rect', 'ellipse', 'polygon', 'section']);
|
|
567
|
+
|
|
568
|
+
function findTextStrokeAt(
|
|
569
|
+
wx: number,
|
|
570
|
+
wy: number,
|
|
571
|
+
strokes: readonly Stroke[],
|
|
572
|
+
zoom: number
|
|
573
|
+
): string | null {
|
|
574
|
+
for (let i = strokes.length - 1; i >= 0; i--) {
|
|
575
|
+
const s = strokes[i];
|
|
576
|
+
if (!s || !TEXT_EDITABLE_TOOLS.has(s.tool)) continue;
|
|
577
|
+
if (s.tool === 'section') {
|
|
578
|
+
const x = Math.min(s.x, s.x + s.w);
|
|
579
|
+
const y = Math.min(s.y, s.y + s.h);
|
|
580
|
+
const fontSize = SECTION_LABEL_FONT / zoom;
|
|
581
|
+
const chipH = SECTION_LABEL_H / zoom;
|
|
582
|
+
const gap = 4 / zoom;
|
|
583
|
+
const chipW = Math.max(56 / zoom, s.label.length * fontSize * 0.62 + 18 / zoom);
|
|
584
|
+
if (wx >= x && wx <= x + chipW && wy >= y - chipH - gap && wy <= y - gap) return s.id;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') continue;
|
|
588
|
+
const bb = strokeBBox(s);
|
|
589
|
+
if (!bb) continue;
|
|
590
|
+
if (wx >= bb.x && wx <= bb.x + bb.w && wy >= bb.y && wy <= bb.y + bb.h) return s.id;
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
402
595
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
403
596
|
// Styles
|
|
404
597
|
|
|
@@ -573,6 +766,12 @@ const ANNOT_CSS = `
|
|
|
573
766
|
See DDR-067. (No backticks in this comment: the whole block is a JS template
|
|
574
767
|
literal, so a backtick here would terminate the string.) */
|
|
575
768
|
.dc-annot-editor, .dc-annot-editor * { cursor: text !important; }
|
|
769
|
+
/* Phase 7 (unified-text-editing) — hover affordance parity with artboard leaf
|
|
770
|
+
text: a standalone text stroke invites editing with the I-beam in Move mode
|
|
771
|
+
(double-click / Text-tool click enters its editor in place). Shapes and
|
|
772
|
+
stickies keep the selection arrow — their whole body is a move/select
|
|
773
|
+
target first. */
|
|
774
|
+
.dc-annot-svg text[data-tool="text"] { cursor: text; }
|
|
576
775
|
/* FigJam v3 — connection dots on a selected bindable shape. The important flag
|
|
577
776
|
beats use-tool-mode's blanket star-cursor rule (same fight as the editor +
|
|
578
777
|
resize handles — DDR-067). */
|
|
@@ -645,7 +844,7 @@ export function useStrokesStore(): StrokesStoreValue | null {
|
|
|
645
844
|
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
646
845
|
// right-click menu. Clicks on these route to their own handlers.
|
|
647
846
|
const CHROME_SELECTOR =
|
|
648
|
-
'.dc-annot-conn-dot, .dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle, .dc-annot-rotate-zone, .dc-annot-editor';
|
|
847
|
+
'.dc-annot-conn-dot, .dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle, .dc-annot-rotate-zone, .dc-annot-editor, [data-group-resize-corner]';
|
|
649
848
|
|
|
650
849
|
const HINTS_KEY = 'maude-annot-hints-v1';
|
|
651
850
|
|
|
@@ -670,6 +869,81 @@ function showOnceHint(key: string, msg: string): void {
|
|
|
670
869
|
// can read the same state. Re-exported here for back-compat.
|
|
671
870
|
export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
672
871
|
|
|
872
|
+
/**
|
|
873
|
+
* Phase 2 (whiteboard-improvements) — proportional group resize. Maps one
|
|
874
|
+
* stroke's geometry from its position inside the group's START bbox (`b0`)
|
|
875
|
+
* to the equivalent position inside the RESIZED bbox (`b1`) — the same
|
|
876
|
+
* affine transform applied to every selected member, so the whole selection
|
|
877
|
+
* scales as one rigid composition anchored at whichever corner `bboxResize`
|
|
878
|
+
* derived `b1` from (reused verbatim for the group's own outer bbox — see
|
|
879
|
+
* the pointerdown handler below). Returns null for a stroke with nothing to
|
|
880
|
+
* scale: anchored text inherits its host's bbox at render time already, so
|
|
881
|
+
* scaling it too would double-transform it.
|
|
882
|
+
*/
|
|
883
|
+
function scaleStrokeInGroup(
|
|
884
|
+
s: Stroke,
|
|
885
|
+
b0: { x: number; y: number; w: number; h: number },
|
|
886
|
+
b1: { x: number; y: number; w: number; h: number }
|
|
887
|
+
): Partial<Stroke> | null {
|
|
888
|
+
if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') return null;
|
|
889
|
+
const sx = b0.w === 0 ? 1 : b1.w / b0.w;
|
|
890
|
+
const sy = b0.h === 0 ? 1 : b1.h / b0.h;
|
|
891
|
+
// Non-uniform group stretches (no Shift held) still need ONE scalar for
|
|
892
|
+
// things that don't have an independent width/height, like font size.
|
|
893
|
+
const avgScale = (sx + sy) / 2;
|
|
894
|
+
const tp = (px: number, py: number): [number, number] => [
|
|
895
|
+
b1.x + (px - b0.x) * sx,
|
|
896
|
+
b1.y + (py - b0.y) * sy,
|
|
897
|
+
];
|
|
898
|
+
if (s.tool === 'pen') {
|
|
899
|
+
return { points: s.points.map(([px, py]) => tp(px, py)) } as Partial<PenStroke>;
|
|
900
|
+
}
|
|
901
|
+
if (s.tool === 'arrow') {
|
|
902
|
+
const [x1, y1] = tp(s.x1, s.y1);
|
|
903
|
+
const [x2, y2] = tp(s.x2, s.y2);
|
|
904
|
+
return { x1, y1, x2, y2 } as Partial<ArrowStroke>;
|
|
905
|
+
}
|
|
906
|
+
if (s.tool === 'ellipse') {
|
|
907
|
+
const [cx, cy] = tp(s.cx, s.cy);
|
|
908
|
+
return {
|
|
909
|
+
cx,
|
|
910
|
+
cy,
|
|
911
|
+
rx: Math.max(1, s.rx * sx),
|
|
912
|
+
ry: Math.max(1, s.ry * sy),
|
|
913
|
+
} as Partial<EllipseStroke>;
|
|
914
|
+
}
|
|
915
|
+
if (s.tool === 'text') {
|
|
916
|
+
// Standalone (unanchored) text — its own origin scales with the group;
|
|
917
|
+
// font size scales by the average factor so a non-uniform stretch keeps
|
|
918
|
+
// the text legible instead of only stretching its box.
|
|
919
|
+
const [x, y] = tp(s.x ?? 0, s.y ?? 0);
|
|
920
|
+
return {
|
|
921
|
+
x,
|
|
922
|
+
y,
|
|
923
|
+
fontSize: Math.max(6, Math.round(s.fontSize * avgScale)),
|
|
924
|
+
} as Partial<TextStroke>;
|
|
925
|
+
}
|
|
926
|
+
if (
|
|
927
|
+
s.tool === 'rect' ||
|
|
928
|
+
s.tool === 'polygon' ||
|
|
929
|
+
s.tool === 'link' ||
|
|
930
|
+
s.tool === 'mediaref' ||
|
|
931
|
+
s.tool === 'section' ||
|
|
932
|
+
s.tool === 'image' ||
|
|
933
|
+
s.tool === 'sticky'
|
|
934
|
+
) {
|
|
935
|
+
const bb = strokeBBox(s);
|
|
936
|
+
if (!bb) return null;
|
|
937
|
+
const [x, y] = tp(bb.x, bb.y);
|
|
938
|
+
const patch: Partial<Stroke> = { x, y, w: Math.max(1, bb.w * sx), h: Math.max(1, bb.h * sy) };
|
|
939
|
+
if (s.tool === 'sticky') {
|
|
940
|
+
(patch as Partial<StickyStroke>).fontSize = Math.max(6, Math.round(s.fontSize * avgScale));
|
|
941
|
+
}
|
|
942
|
+
return patch;
|
|
943
|
+
}
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
|
|
673
947
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
674
948
|
// Component
|
|
675
949
|
|
|
@@ -757,6 +1031,12 @@ export function AnnotationsLayer() {
|
|
|
757
1031
|
[visibilityCtx]
|
|
758
1032
|
);
|
|
759
1033
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
1034
|
+
// Phase 3 (unified-text-editing) — the click that OPENED the editor, so the
|
|
1035
|
+
// editor can place a collapsed caret at that exact character on mount
|
|
1036
|
+
// instead of select-all. Set only by pointer entry paths (double-click /
|
|
1037
|
+
// text-tool click-through); keyboard entries (Enter, fresh-create,
|
|
1038
|
+
// ⌘Enter chain) leave it null → select-all, the rename convention.
|
|
1039
|
+
const [editCaretPoint, setEditCaretPoint] = useState<{ x: number; y: number } | null>(null);
|
|
760
1040
|
|
|
761
1041
|
const fileRef = useRef<string | undefined>(undefined);
|
|
762
1042
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -774,6 +1054,43 @@ export function AnnotationsLayer() {
|
|
|
774
1054
|
*/
|
|
775
1055
|
const strokesRef = useRef<Stroke[]>(strokes);
|
|
776
1056
|
strokesRef.current = strokes;
|
|
1057
|
+
// Phase 23 batch-drop fix — see media-commit-chain.ts. Every async media
|
|
1058
|
+
// completion (image upload swap, video/audio upload commit) that can be
|
|
1059
|
+
// triggered concurrently (N Finder files dropped at once) enqueues onto
|
|
1060
|
+
// this instead of calling commitStrokes directly off a `strokesRef.current`
|
|
1061
|
+
// read, so two completions landing before a render commit don't clobber
|
|
1062
|
+
// each other.
|
|
1063
|
+
const mediaCommitChainRef = useRef(
|
|
1064
|
+
createMediaCommitChain<Stroke>(
|
|
1065
|
+
() => strokesRef.current,
|
|
1066
|
+
(s) => s.id
|
|
1067
|
+
)
|
|
1068
|
+
);
|
|
1069
|
+
// Live-security-review finding (feature-bulk-media-insert follow-up) —
|
|
1070
|
+
// `createImageFromFile`'s upload-completion swap folds its optimistic
|
|
1071
|
+
// stroke back in whenever the chain's `before` doesn't have it yet,
|
|
1072
|
+
// reasoning "render lag, not a delete" (see the comment there). That
|
|
1073
|
+
// reasoning has no way to tell a genuine delete apart from render lag by
|
|
1074
|
+
// array membership alone — if the user drops an image then hits
|
|
1075
|
+
// Backspace before the upload resolves, the id is legitimately gone, and
|
|
1076
|
+
// the old code resurrected it anyway once the upload landed (and synced
|
|
1077
|
+
// the resurrection to every collab peer). Tracked explicitly here instead
|
|
1078
|
+
// of inferred: `deleteStrokes` records every id it removes; the swap
|
|
1079
|
+
// checks (and consumes) this before assuming absence means lag. Bounded
|
|
1080
|
+
// like `recentSelfSvgsRef` below — only ever holds ids a delete has
|
|
1081
|
+
// touched, which is small in practice, but capped for safety.
|
|
1082
|
+
const DELETED_STROKE_IDS_CAP = 128;
|
|
1083
|
+
const deletedStrokeIdsRef = useRef<Set<string>>(new Set());
|
|
1084
|
+
const rememberDeletedIds = useCallback((ids: readonly string[]) => {
|
|
1085
|
+
const set = deletedStrokeIdsRef.current;
|
|
1086
|
+
for (const id of ids) {
|
|
1087
|
+
set.add(id);
|
|
1088
|
+
if (set.size > DELETED_STROKE_IDS_CAP) {
|
|
1089
|
+
const oldest = set.values().next().value;
|
|
1090
|
+
if (oldest !== undefined) set.delete(oldest);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}, []);
|
|
777
1094
|
|
|
778
1095
|
const isDraw =
|
|
779
1096
|
tool === 'pen' ||
|
|
@@ -800,10 +1117,28 @@ export function AnnotationsLayer() {
|
|
|
800
1117
|
}, [ghostCapable, visible]);
|
|
801
1118
|
|
|
802
1119
|
// Load existing annotations on mount.
|
|
803
|
-
// Phase 8 Task 5 —
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
|
|
1120
|
+
// Self-echo suppression (Phase 8 Task 5, hardened — feature-bulk-media-
|
|
1121
|
+
// insert follow-up). A single "last applied" string only catches the MOST
|
|
1122
|
+
// RECENT self-write: when the chain fires several rapid commits (a batch
|
|
1123
|
+
// drop), the server's broadcast of an EARLIER commit can arrive after a
|
|
1124
|
+
// LATER local commit has already moved "last applied" on — so the earlier
|
|
1125
|
+
// echo no longer matches, gets misread as a foreign change, and rolls
|
|
1126
|
+
// local state BACK to that stale snapshot. This is the confirmed cause of
|
|
1127
|
+
// images silently vanishing after a multi-file drop (live-tested: 12
|
|
1128
|
+
// mismatched/misapplied echoes correlated exactly with lost strokes across
|
|
1129
|
+
// a 30-batch stress run). Track a bounded HISTORY of our own recent writes
|
|
1130
|
+
// instead of just the latest one, so an out-of-order echo of any recent
|
|
1131
|
+
// self-write is still recognized and suppressed.
|
|
1132
|
+
const RECENT_SELF_SVG_CAP = 64;
|
|
1133
|
+
const recentSelfSvgsRef = useRef<Set<string>>(new Set());
|
|
1134
|
+
const rememberSelfSvg = useCallback((svg: string) => {
|
|
1135
|
+
const set = recentSelfSvgsRef.current;
|
|
1136
|
+
set.add(svg);
|
|
1137
|
+
if (set.size > RECENT_SELF_SVG_CAP) {
|
|
1138
|
+
const oldest = set.values().next().value;
|
|
1139
|
+
if (oldest !== undefined) set.delete(oldest);
|
|
1140
|
+
}
|
|
1141
|
+
}, []);
|
|
807
1142
|
useEffect(() => {
|
|
808
1143
|
const file = deriveFile();
|
|
809
1144
|
fileRef.current = file;
|
|
@@ -818,7 +1153,7 @@ export function AnnotationsLayer() {
|
|
|
818
1153
|
const loaded = svgToStrokes(text);
|
|
819
1154
|
if (loaded.length) {
|
|
820
1155
|
setStrokesState(loaded);
|
|
821
|
-
|
|
1156
|
+
rememberSelfSvg(text);
|
|
822
1157
|
}
|
|
823
1158
|
})
|
|
824
1159
|
.catch(() => {
|
|
@@ -827,14 +1162,15 @@ export function AnnotationsLayer() {
|
|
|
827
1162
|
return () => {
|
|
828
1163
|
cancelled = true;
|
|
829
1164
|
};
|
|
830
|
-
}, []);
|
|
1165
|
+
}, [rememberSelfSvg]);
|
|
831
1166
|
|
|
832
1167
|
// Phase 8 Task 5 — observe the Y.Map.annotations for live updates from
|
|
833
|
-
// other tabs. Bail when the incoming SVG STRING
|
|
834
|
-
//
|
|
835
|
-
// foreign changes). The prior
|
|
836
|
-
// resize / move keeps the same id
|
|
837
|
-
// even though geometry changed —
|
|
1168
|
+
// other tabs. Bail when the incoming SVG STRING matches any RECENT
|
|
1169
|
+
// self-write (covers the local echo round-trip, including an
|
|
1170
|
+
// out-of-order one, without missing real foreign changes). The prior
|
|
1171
|
+
// length+first/last-id check was wrong: a resize / move keeps the same id
|
|
1172
|
+
// list, so all three predicates matched even though geometry changed —
|
|
1173
|
+
// foreign edits silently disappeared.
|
|
838
1174
|
const collab = useCollab();
|
|
839
1175
|
useEffect(() => {
|
|
840
1176
|
if (!collab) return;
|
|
@@ -842,9 +1178,10 @@ export function AnnotationsLayer() {
|
|
|
842
1178
|
const apply = () => {
|
|
843
1179
|
const svg = map.get('svg');
|
|
844
1180
|
if (typeof svg !== 'string' || !svg) return;
|
|
845
|
-
if (
|
|
846
|
-
|
|
847
|
-
|
|
1181
|
+
if (recentSelfSvgsRef.current.has(svg)) return;
|
|
1182
|
+
rememberSelfSvg(svg);
|
|
1183
|
+
const incoming = svgToStrokes(svg);
|
|
1184
|
+
setStrokesState((prev) => reconcileForeignEcho(prev, incoming));
|
|
848
1185
|
};
|
|
849
1186
|
apply();
|
|
850
1187
|
map.observe(apply);
|
|
@@ -855,13 +1192,29 @@ export function AnnotationsLayer() {
|
|
|
855
1192
|
/* doc destroyed before unmount */
|
|
856
1193
|
}
|
|
857
1194
|
};
|
|
858
|
-
}, [collab]);
|
|
1195
|
+
}, [collab, rememberSelfSvg]);
|
|
859
1196
|
|
|
860
1197
|
const undoStack = useUndoStackOptional();
|
|
861
1198
|
const undoSinks = useUndoSinks();
|
|
862
1199
|
const undoStackRef = useRef(undoStack);
|
|
863
1200
|
undoStackRef.current = undoStack;
|
|
864
1201
|
|
|
1202
|
+
// feature-bulk-media-insert follow-up — dedicated PUT dispatch queue.
|
|
1203
|
+
// The undo-stack's own `inFlightRef` serializes its PUSH TASKS (each
|
|
1204
|
+
// awaits `cmd.do()` before the next task starts), but live network
|
|
1205
|
+
// capture during a rapid-fire batch drop showed the actual PUT REQUESTS
|
|
1206
|
+
// still overlapping (request N+1 starting before request N's response
|
|
1207
|
+
// arrived) — some other async hop between "task starts" and "fetch
|
|
1208
|
+
// fires" lets them interleave. Since every PUT body here is a strictly
|
|
1209
|
+
// growing superset (the media-commit-chain guarantees each commit is at
|
|
1210
|
+
// least as complete as the last), overlap is dangerous: if an EARLIER
|
|
1211
|
+
// (smaller) request's write lands on disk AFTER a LATER (larger) one's,
|
|
1212
|
+
// the smaller snapshot wins and silently erases the larger one's extra
|
|
1213
|
+
// strokes. A dedicated chain — mirroring `editApplyChainRef` in
|
|
1214
|
+
// client/app.jsx — makes the fetch DISPATCH itself wait for the
|
|
1215
|
+
// previous one's response, independent of whatever the undo-stack does.
|
|
1216
|
+
const putChainRef = useRef<Promise<void>>(Promise.resolve());
|
|
1217
|
+
|
|
865
1218
|
/**
|
|
866
1219
|
* Apply a `Stroke[]` snapshot: update local React state AND fire-and-forget
|
|
867
1220
|
* PUT to the server. Used as the `putFn` injected into the
|
|
@@ -875,25 +1228,40 @@ export function AnnotationsLayer() {
|
|
|
875
1228
|
* we push a command, so the server only sees one PUT per edit instead
|
|
876
1229
|
* of two-step racing.
|
|
877
1230
|
*/
|
|
878
|
-
const putStrokes = useCallback(
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1231
|
+
const putStrokes = useCallback(
|
|
1232
|
+
(next: readonly Stroke[], before: readonly Stroke[]) => {
|
|
1233
|
+
// See reconcileCommit — a direct setStrokesState(next) here can
|
|
1234
|
+
// clobber a sibling file's concurrent optimistic insert; folding
|
|
1235
|
+
// blindly against `prev` (no baseline) can just as easily revert this
|
|
1236
|
+
// very mutation's own delete. `before` (this command's own baseline)
|
|
1237
|
+
// disambiguates the two.
|
|
1238
|
+
setStrokesState((prev) => reconcileCommit(prev, before, next));
|
|
1239
|
+
const file = fileRef.current;
|
|
1240
|
+
if (!file) return Promise.resolve();
|
|
1241
|
+
const persistable = next.some(isEphemeralHref)
|
|
1242
|
+
? next.filter((s) => !isEphemeralHref(s))
|
|
1243
|
+
: next;
|
|
1244
|
+
const svg = strokesToSvg(persistable);
|
|
1245
|
+
// Phase 8 Task 5 — record the SVG we just authored locally so the
|
|
1246
|
+
// server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
|
|
1247
|
+
// Y.Map.observe) doesn't trigger a redundant setStrokesState.
|
|
1248
|
+
rememberSelfSvg(svg);
|
|
1249
|
+
const dispatch = () =>
|
|
1250
|
+
fetch('/_api/annotations', {
|
|
1251
|
+
method: 'PUT',
|
|
1252
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1253
|
+
body: JSON.stringify({ file, svg }),
|
|
1254
|
+
})
|
|
1255
|
+
.then(() => undefined)
|
|
1256
|
+
.catch(() => {
|
|
1257
|
+
/* swallow — user sees uncommitted state until the next stroke */
|
|
1258
|
+
});
|
|
1259
|
+
const chained = putChainRef.current.then(dispatch, dispatch);
|
|
1260
|
+
putChainRef.current = chained;
|
|
1261
|
+
return chained;
|
|
1262
|
+
},
|
|
1263
|
+
[rememberSelfSvg]
|
|
1264
|
+
);
|
|
897
1265
|
|
|
898
1266
|
// Register the strokes put sink with the undo provider so the rebuilt
|
|
899
1267
|
// AnnotationStrokesCommand (after a canvas switch + return) routes through
|
|
@@ -952,6 +1320,12 @@ export function AnnotationsLayer() {
|
|
|
952
1320
|
(s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
|
|
953
1321
|
);
|
|
954
1322
|
if (filtered.length === prev.length) return;
|
|
1323
|
+
// Record every id actually removed (not just the requested `ids` —
|
|
1324
|
+
// bound text anchors get swept too) so an in-flight image upload's
|
|
1325
|
+
// swap (see deletedStrokeIdsRef above) knows this id is a genuine
|
|
1326
|
+
// delete, not render lag, if it resolves after this point.
|
|
1327
|
+
const filteredIds = new Set(filtered.map((s) => s.id));
|
|
1328
|
+
rememberDeletedIds(prev.filter((s) => !filteredIds.has(s.id)).map((s) => s.id));
|
|
955
1329
|
// FigJam v3 — deleting a bind host strips the bind (endpoint frozen,
|
|
956
1330
|
// arrow survives); singleton/empty groups dissolve (tldraw lifecycle).
|
|
957
1331
|
commitStrokes(prev, recomputeBoundArrows(normalizeGroups(filtered)));
|
|
@@ -1059,7 +1433,100 @@ export function AnnotationsLayer() {
|
|
|
1059
1433
|
previewStroke,
|
|
1060
1434
|
commitGesture,
|
|
1061
1435
|
};
|
|
1062
|
-
}, [strokes, setStrokes, commitStrokes]);
|
|
1436
|
+
}, [strokes, setStrokes, commitStrokes, rememberDeletedIds]);
|
|
1437
|
+
|
|
1438
|
+
// feature-bulk-media-insert Task 11 — the picker's "Add as annotation"
|
|
1439
|
+
// confirm posts N already-uploaded asset paths in ONE message; unlike
|
|
1440
|
+
// createImageFromFile/createMediaReference (which each race an independent
|
|
1441
|
+
// upload), every path here is already on disk, so this resolves every
|
|
1442
|
+
// image's natural size up front (Promise.all) and performs exactly ONE
|
|
1443
|
+
// commitStrokes for the whole batch — correct by construction, no chain
|
|
1444
|
+
// needed since there's no per-item async race once all N are resolved.
|
|
1445
|
+
const createMediaFromAssetPaths = useCallback(
|
|
1446
|
+
(paths: readonly string[], world: [number, number]) => {
|
|
1447
|
+
if (typeof window === 'undefined' || !paths.length) return;
|
|
1448
|
+
const classified = paths
|
|
1449
|
+
.map((path) => ({ path, kind: classifyAssetPathKind(path) }))
|
|
1450
|
+
.filter((it): it is { path: string; kind: 'image' | 'video' | 'audio' } => it.kind != null);
|
|
1451
|
+
if (!classified.length) return;
|
|
1452
|
+
|
|
1453
|
+
type Resolved = { path: string; kind: 'image' | 'video' | 'audio'; w: number; h: number };
|
|
1454
|
+
const probeOne = (path: string, kind: 'image' | 'video' | 'audio'): Promise<Resolved> => {
|
|
1455
|
+
if (kind !== 'image') {
|
|
1456
|
+
const h = kind === 'video' ? MEDIAREF_VIDEO_H : MEDIAREF_DEFAULT_H;
|
|
1457
|
+
return Promise.resolve({ path, kind, w: MEDIAREF_DEFAULT_W, h });
|
|
1458
|
+
}
|
|
1459
|
+
return new Promise<Resolved>((resolve) => {
|
|
1460
|
+
const probe = new Image();
|
|
1461
|
+
probe.onload = () => {
|
|
1462
|
+
const natW = probe.naturalWidth || IMAGE_MAX_DROP_SIDE;
|
|
1463
|
+
const natH = probe.naturalHeight || Math.round(IMAGE_MAX_DROP_SIDE * 0.66);
|
|
1464
|
+
const longest = Math.max(natW, natH) || 1;
|
|
1465
|
+
const scale = longest > IMAGE_MAX_DROP_SIDE ? IMAGE_MAX_DROP_SIDE / longest : 1;
|
|
1466
|
+
resolve({
|
|
1467
|
+
path,
|
|
1468
|
+
kind: 'image',
|
|
1469
|
+
w: Math.max(IMAGE_MIN_SIZE, Math.round(natW * scale)),
|
|
1470
|
+
h: Math.max(IMAGE_MIN_SIZE, Math.round(natH * scale)),
|
|
1471
|
+
});
|
|
1472
|
+
};
|
|
1473
|
+
// Couldn't decode — fall back to a small square rather than
|
|
1474
|
+
// dropping this item from the batch (the file is already on disk).
|
|
1475
|
+
probe.onerror = () => resolve({ path, kind: 'image', w: 64, h: 64 });
|
|
1476
|
+
probe.src = resolveAssetHref(path);
|
|
1477
|
+
});
|
|
1478
|
+
};
|
|
1479
|
+
|
|
1480
|
+
void Promise.all(classified.map(({ path, kind }) => probeOne(path, kind))).then(
|
|
1481
|
+
(resolved) => {
|
|
1482
|
+
// Ids generated up front (stable regardless of chain timing) so
|
|
1483
|
+
// they're available for the selection call below without waiting
|
|
1484
|
+
// on the chain to settle.
|
|
1485
|
+
const withIds = resolved.map((item, i) => ({ ...item, id: rid(), i }));
|
|
1486
|
+
const label = `add ${withIds.length} item${withIds.length === 1 ? '' : 's'}`;
|
|
1487
|
+
// Batch-drop fix (see media-commit-chain.ts) — every path here is
|
|
1488
|
+
// already resolved, so THIS call alone is race-free by
|
|
1489
|
+
// construction, but a SECOND overlapping bulk-insert (or a
|
|
1490
|
+
// concurrent drag-drop via createImageFromFile/createMediaReference)
|
|
1491
|
+
// can still land while this one's Promise.all was in flight (large
|
|
1492
|
+
// photos take real, non-trivial decode time) — both read/write the
|
|
1493
|
+
// same `strokes` state, so this must enqueue onto the shared chain
|
|
1494
|
+
// too, not read `strokesRef.current` + commit directly.
|
|
1495
|
+
void mediaCommitChainRef.current.enqueue((before) => {
|
|
1496
|
+
const added: Stroke[] = withIds.map((item) => {
|
|
1497
|
+
const cx = world[0] + item.i * BATCH_DROP_CASCADE_PX;
|
|
1498
|
+
const cy = world[1] + item.i * BATCH_DROP_CASCADE_PX;
|
|
1499
|
+
if (item.kind === 'image') {
|
|
1500
|
+
return {
|
|
1501
|
+
id: item.id,
|
|
1502
|
+
tool: 'image',
|
|
1503
|
+
x: cx - item.w / 2,
|
|
1504
|
+
y: cy - item.h / 2,
|
|
1505
|
+
w: item.w,
|
|
1506
|
+
h: item.h,
|
|
1507
|
+
href: item.path,
|
|
1508
|
+
} as ImageStroke;
|
|
1509
|
+
}
|
|
1510
|
+
return {
|
|
1511
|
+
id: item.id,
|
|
1512
|
+
tool: 'mediaref',
|
|
1513
|
+
x: cx - item.w / 2,
|
|
1514
|
+
y: cy - item.h / 2,
|
|
1515
|
+
w: item.w,
|
|
1516
|
+
h: item.h,
|
|
1517
|
+
src: item.path,
|
|
1518
|
+
mediaKind: item.kind,
|
|
1519
|
+
title: item.path.split('/').pop() || item.path,
|
|
1520
|
+
} as MediaRefStroke;
|
|
1521
|
+
});
|
|
1522
|
+
return { after: [...before, ...added], label };
|
|
1523
|
+
}, commitStrokes);
|
|
1524
|
+
annotSel?.replace(withIds.map((item) => item.id));
|
|
1525
|
+
}
|
|
1526
|
+
);
|
|
1527
|
+
},
|
|
1528
|
+
[commitStrokes, annotSel]
|
|
1529
|
+
);
|
|
1063
1530
|
|
|
1064
1531
|
// Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
|
|
1065
1532
|
// dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
|
|
@@ -1068,6 +1535,7 @@ export function AnnotationsLayer() {
|
|
|
1068
1535
|
useEffect(() => {
|
|
1069
1536
|
if (typeof window === 'undefined') return;
|
|
1070
1537
|
const onMessage = (e: MessageEvent) => {
|
|
1538
|
+
if (e.source !== window.parent) return;
|
|
1071
1539
|
const m = e.data as { dgn?: string; visible?: boolean } | null;
|
|
1072
1540
|
if (!m || typeof m !== 'object' || !m.dgn) return;
|
|
1073
1541
|
if (m.dgn === 'view-annotations') {
|
|
@@ -1101,11 +1569,63 @@ export function AnnotationsLayer() {
|
|
|
1101
1569
|
: ({ ...s, src: path } as MediaRefStroke);
|
|
1102
1570
|
});
|
|
1103
1571
|
commitStrokes(before, after, 'replace media');
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
// Phase 4 (whiteboard-improvements) — the shell's StickerPicker (main-
|
|
1575
|
+
// origin) already uploaded the picked bundled sticker to a PROJECT asset
|
|
1576
|
+
// path via /_api/asset (canvas-origin-allowlisted — the round-trip
|
|
1577
|
+
// through the shell exists only because /_stickers/* itself is main-
|
|
1578
|
+
// origin-only, same posture as AssetPicker/replace-annotation-media
|
|
1579
|
+
// above). Drop it at the current viewport center — no cross-origin
|
|
1580
|
+
// cursor position to reuse. Inlines the screenToWorld formula (that
|
|
1581
|
+
// callback isn't declared until below this effect) against vpRef, which
|
|
1582
|
+
// is.
|
|
1583
|
+
if (m.dgn === 'insert-sticker' && typeof (m as { path?: unknown }).path === 'string') {
|
|
1584
|
+
const path = (m as { path: string }).path;
|
|
1585
|
+
const v = vpRef.current ?? { x: 0, y: 0, zoom: 1 };
|
|
1586
|
+
const z = v.zoom || 1;
|
|
1587
|
+
const cx = typeof window !== 'undefined' ? window.innerWidth / 2 : 0;
|
|
1588
|
+
const cy = typeof window !== 'undefined' ? window.innerHeight / 2 : 0;
|
|
1589
|
+
const wx = (cx - v.x) / z;
|
|
1590
|
+
const wy = (cy - v.y) / z;
|
|
1591
|
+
const size = STICKER_DROP_SIZE;
|
|
1592
|
+
const id = rid();
|
|
1593
|
+
const stroke: ImageStroke = {
|
|
1594
|
+
id,
|
|
1595
|
+
tool: 'image',
|
|
1596
|
+
x: wx - size / 2,
|
|
1597
|
+
y: wy - size / 2,
|
|
1598
|
+
w: size,
|
|
1599
|
+
h: size,
|
|
1600
|
+
href: path,
|
|
1601
|
+
};
|
|
1602
|
+
const before = strokesRef.current;
|
|
1603
|
+
commitStrokes(before, [...before, stroke], 'add sticker');
|
|
1604
|
+
annotSel?.replace([id]);
|
|
1605
|
+
}
|
|
1606
|
+
// feature-bulk-media-insert Task 11 — the shell's AssetPicker (main-
|
|
1607
|
+
// origin) multi-select "Add as annotation" confirm. Every path is
|
|
1608
|
+
// already an uploaded assets/… path (no upload step here); same
|
|
1609
|
+
// viewport-center placement as insert-sticker above (no cross-origin
|
|
1610
|
+
// cursor position to reuse).
|
|
1611
|
+
if (m.dgn === 'insert-annotation-media' && Array.isArray((m as { paths?: unknown }).paths)) {
|
|
1612
|
+
const paths = (m as { paths: unknown[] }).paths.filter(
|
|
1613
|
+
(p): p is string => typeof p === 'string'
|
|
1614
|
+
);
|
|
1615
|
+
if (paths.length) {
|
|
1616
|
+
const v = vpRef.current ?? { x: 0, y: 0, zoom: 1 };
|
|
1617
|
+
const z = v.zoom || 1;
|
|
1618
|
+
const cx = typeof window !== 'undefined' ? window.innerWidth / 2 : 0;
|
|
1619
|
+
const cy = typeof window !== 'undefined' ? window.innerHeight / 2 : 0;
|
|
1620
|
+
const wx = (cx - v.x) / z;
|
|
1621
|
+
const wy = (cy - v.y) / z;
|
|
1622
|
+
createMediaFromAssetPaths(paths, [wx, wy]);
|
|
1623
|
+
}
|
|
1104
1624
|
}
|
|
1105
1625
|
};
|
|
1106
1626
|
window.addEventListener('message', onMessage);
|
|
1107
1627
|
return () => window.removeEventListener('message', onMessage);
|
|
1108
|
-
}, [annotSel, strokes, setVisible, commitStrokes]);
|
|
1628
|
+
}, [annotSel, strokes, setVisible, commitStrokes, createMediaFromAssetPaths]);
|
|
1109
1629
|
|
|
1110
1630
|
// Document-level toggle: Shift+P (presentation). Annotation-shortcut help is
|
|
1111
1631
|
// owned by the dev-server menubar (Help button); we no longer ship an
|
|
@@ -1160,17 +1680,35 @@ export function AnnotationsLayer() {
|
|
|
1160
1680
|
};
|
|
1161
1681
|
// Local-only insert — the blob: href must NOT reach the server (it's
|
|
1162
1682
|
// ephemeral + would be stripped by the sanitizer); we commit only the
|
|
1163
|
-
// assets/… form once the upload lands.
|
|
1164
|
-
|
|
1683
|
+
// assets/… form once the upload lands. Functional updater — a batch
|
|
1684
|
+
// drop fires this for every file independently, so two optimistic
|
|
1685
|
+
// inserts landing before a render commit must compose against each
|
|
1686
|
+
// other rather than each overwriting the other's array snapshot.
|
|
1687
|
+
setStrokesState((prev) => [...prev, optimistic]);
|
|
1165
1688
|
void uploadAsset(file).then((res) => {
|
|
1166
|
-
const cur = strokesRef.current;
|
|
1167
1689
|
if ('path' in res) {
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1690
|
+
// Batch-drop fix (see media-commit-chain.ts) — N concurrent
|
|
1691
|
+
// uploads resolve at unpredictable relative speed; enqueue the
|
|
1692
|
+
// swap instead of reading strokesRef.current directly, so this
|
|
1693
|
+
// completion always builds on the latest accumulated state
|
|
1694
|
+
// rather than a stale pre-render snapshot another completion
|
|
1695
|
+
// already moved past.
|
|
1696
|
+
void mediaCommitChainRef.current
|
|
1697
|
+
.enqueue((before) => {
|
|
1698
|
+
const wasDeleted = deletedStrokeIdsRef.current.has(id);
|
|
1699
|
+
if (wasDeleted) deletedStrokeIdsRef.current.delete(id);
|
|
1700
|
+
return resolveImageUploadSwap(before, id, optimistic, res.path, wasDeleted);
|
|
1701
|
+
}, commitStrokes)
|
|
1702
|
+
.then((after) => {
|
|
1703
|
+
if (after.some((s) => s.id === id)) annotSel?.replace([id]);
|
|
1704
|
+
});
|
|
1172
1705
|
} else {
|
|
1173
|
-
|
|
1706
|
+
// Failure cleanup — no undo record (the optimistic stroke was
|
|
1707
|
+
// never committed), so a plain functional updater (not the
|
|
1708
|
+
// chain) is enough: it still composes correctly against any
|
|
1709
|
+
// chain-driven setStrokesState queued in the same batch.
|
|
1710
|
+
setStrokesState((prev) => prev.filter((s) => s.id !== id));
|
|
1711
|
+
deletedStrokeIdsRef.current.delete(id); // never reached the chain — nothing to consume there
|
|
1174
1712
|
showCanvasToast(`Image upload failed — ${res.error}`);
|
|
1175
1713
|
}
|
|
1176
1714
|
URL.revokeObjectURL(blobUrl);
|
|
@@ -1238,8 +1776,15 @@ export function AnnotationsLayer() {
|
|
|
1238
1776
|
mediaKind,
|
|
1239
1777
|
title: (file.name || res.path).slice(0, 300),
|
|
1240
1778
|
};
|
|
1241
|
-
|
|
1242
|
-
|
|
1779
|
+
// Batch-drop fix (see media-commit-chain.ts) — same accumulator
|
|
1780
|
+
// chain as createImageFromFile's swap, so a video/audio upload
|
|
1781
|
+
// resolving interleaved with concurrent image uploads (or other
|
|
1782
|
+
// media uploads) in the same batch never commits against a stale
|
|
1783
|
+
// pre-render snapshot.
|
|
1784
|
+
void mediaCommitChainRef.current.enqueue(
|
|
1785
|
+
(before) => ({ after: [...before, ref], label: `add ${mediaKind} reference` }),
|
|
1786
|
+
commitStrokes
|
|
1787
|
+
);
|
|
1243
1788
|
annotSel?.replace([id]);
|
|
1244
1789
|
const sizeMb = file.size / (1024 * 1024);
|
|
1245
1790
|
showCanvasToast(
|
|
@@ -1387,6 +1932,9 @@ export function AnnotationsLayer() {
|
|
|
1387
1932
|
} else if (tool === 'sticky') {
|
|
1388
1933
|
// Phase 21 — drag-create a paper card. Default size if the user just
|
|
1389
1934
|
// taps (no drag) is applied in endStroke.
|
|
1935
|
+
// Phase 3 (whiteboard-improvements) — stamp who drew it from presence
|
|
1936
|
+
// identity (git user.name via useCollab, else its anonymous-* / no-op
|
|
1937
|
+
// fallback); collab is optional (test harnesses without a provider).
|
|
1390
1938
|
setDrawing({
|
|
1391
1939
|
id,
|
|
1392
1940
|
tool: 'sticky',
|
|
@@ -1398,6 +1946,7 @@ export function AnnotationsLayer() {
|
|
|
1398
1946
|
text: '',
|
|
1399
1947
|
fontSize: DEFAULT_FONT_SIZE,
|
|
1400
1948
|
cornerRadius: STICKY_CORNER_RADIUS,
|
|
1949
|
+
...(collab?.myName ? { authorName: collab.myName, authorId: collab.myConnId } : null),
|
|
1401
1950
|
});
|
|
1402
1951
|
} else if (tool === 'section') {
|
|
1403
1952
|
// FigJam v3 — drag-create a labelled container; a bare tap drops the
|
|
@@ -1413,6 +1962,42 @@ export function AnnotationsLayer() {
|
|
|
1413
1962
|
color: DEFAULT_SECTION_COLOR,
|
|
1414
1963
|
});
|
|
1415
1964
|
} else if (tool === 'text') {
|
|
1965
|
+
// Dogfood fix + Phase 4 (unified-text-editing) — the Text tool
|
|
1966
|
+
// clicking EXISTING editable text edits it in place rather than
|
|
1967
|
+
// dropping a new standalone annotation on top of it. Precedence is
|
|
1968
|
+
// z-order: annotation strokes paint above artboard content, so the
|
|
1969
|
+
// geometric stroke hit-test (findTextStrokeAt — DOM hit-testing can't
|
|
1970
|
+
// see pointer-events:none stroke nodes while a tool is armed) runs
|
|
1971
|
+
// first; then artboard leaf-text (delegated to canvas-shell.tsx,
|
|
1972
|
+
// which owns the DOM/contentEditable side, via maude:enter-text-edit).
|
|
1973
|
+
const strokeId = findTextStrokeAt(wx, wy, strokesRef.current, vpRef.current?.zoom || 1);
|
|
1974
|
+
if (strokeId) {
|
|
1975
|
+
setEditCaretPoint({ x: e.clientX, y: e.clientY });
|
|
1976
|
+
setEditingId(strokeId);
|
|
1977
|
+
if (annotSel) annotSel.replace(strokeId);
|
|
1978
|
+
setTool('move');
|
|
1979
|
+
return true;
|
|
1980
|
+
}
|
|
1981
|
+
const editableTarget = findEditableElementAt(e.clientX, e.clientY);
|
|
1982
|
+
if (editableTarget) {
|
|
1983
|
+
// Phase 6 — leaf-looking text the engine would refuse (mixed /
|
|
1984
|
+
// expression source; no build-time data-cd-editable marker) gets an
|
|
1985
|
+
// honest hint, not a new annotation dropped on top of it.
|
|
1986
|
+
if (!editableTarget.hasAttribute('data-cd-editable')) {
|
|
1987
|
+
showCanvasToast(
|
|
1988
|
+
'This text is filled in from code (a variable) — edit it via chat or /design:edit.'
|
|
1989
|
+
);
|
|
1990
|
+
setTool('move');
|
|
1991
|
+
return true;
|
|
1992
|
+
}
|
|
1993
|
+
document.dispatchEvent(
|
|
1994
|
+
new CustomEvent('maude:enter-text-edit', {
|
|
1995
|
+
detail: { el: editableTarget, clientX: e.clientX, clientY: e.clientY },
|
|
1996
|
+
})
|
|
1997
|
+
);
|
|
1998
|
+
setTool('move');
|
|
1999
|
+
return true;
|
|
2000
|
+
}
|
|
1416
2001
|
// Phase 21 — single click drops an editable caret at the click point.
|
|
1417
2002
|
// No stroke is created until the user commits real text (mirrors the
|
|
1418
2003
|
// anchored double-click flow), so an empty caret leaves nothing behind.
|
|
@@ -1577,10 +2162,18 @@ export function AnnotationsLayer() {
|
|
|
1577
2162
|
// AnnotationsLayer without the provider), so guard the call.
|
|
1578
2163
|
if (annotSel) annotSel.replace(committed.id);
|
|
1579
2164
|
// Phase 21 — a fresh sticky opens in edit mode (FigJam parity: drop a
|
|
1580
|
-
// note, type immediately).
|
|
1581
|
-
|
|
2165
|
+
// note, type immediately). Phase 1 whiteboard-improvements — shapes get
|
|
2166
|
+
// the same treatment (rect/ellipse/polygon are all AnchorHost, so
|
|
2167
|
+
// setEditingId resolves them via editingTarget's 'anchored' branch same
|
|
2168
|
+
// as a double-click would).
|
|
2169
|
+
if (
|
|
2170
|
+
committed.tool === 'sticky' ||
|
|
2171
|
+
committed.tool === 'rect' ||
|
|
2172
|
+
committed.tool === 'ellipse' ||
|
|
2173
|
+
committed.tool === 'polygon'
|
|
2174
|
+
) {
|
|
1582
2175
|
setEditingId(committed.id);
|
|
1583
|
-
showOnceHint('chain', '⌘Enter commits and creates the next
|
|
2176
|
+
showOnceHint('chain', '⌘Enter commits and creates the next one beside it.');
|
|
1584
2177
|
}
|
|
1585
2178
|
}
|
|
1586
2179
|
// T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
|
|
@@ -1697,6 +2290,20 @@ export function AnnotationsLayer() {
|
|
|
1697
2290
|
altDup: boolean;
|
|
1698
2291
|
} | null>(null);
|
|
1699
2292
|
|
|
2293
|
+
// Phase 2 (whiteboard-improvements) — proportional group resize drag state.
|
|
2294
|
+
// See the gesture effect below (registered after strokesStoreRef).
|
|
2295
|
+
const groupResizeRef = useRef<{
|
|
2296
|
+
pointerId: number;
|
|
2297
|
+
corner: Corner;
|
|
2298
|
+
/** The selection's own union bbox at drag start — unpadded (AnnotGroupBbox
|
|
2299
|
+
* renders it with a screen-constant pad; the resize math works in the raw
|
|
2300
|
+
* content bbox, same convention single-resize's padDX/padDY correct for). */
|
|
2301
|
+
groupB0: { x: number; y: number; w: number; h: number };
|
|
2302
|
+
ids: string[];
|
|
2303
|
+
/** Full-strokes undo baseline (the drag-start snapshot). */
|
|
2304
|
+
undoBase: Stroke[];
|
|
2305
|
+
} | null>(null);
|
|
2306
|
+
|
|
1700
2307
|
// Drag-select marquee state. World-coord rectangle (anchor + cursor); the
|
|
1701
2308
|
// cursor end animates with pointermove. `null` = no marquee active.
|
|
1702
2309
|
const [marquee, setMarquee] = useState<{
|
|
@@ -1754,7 +2361,18 @@ export function AnnotationsLayer() {
|
|
|
1754
2361
|
// branch below and DESELECTED them.
|
|
1755
2362
|
let ids: string[] | null = null;
|
|
1756
2363
|
if (strokeId) {
|
|
1757
|
-
|
|
2364
|
+
// feature-photo-editor — an image-stroke hit routes to the Photo-only
|
|
2365
|
+
// Inspector tab (`edit-annotation-photo-request`, the singleSelectedImageId
|
|
2366
|
+
// effect below), which the shell shows via `photoSel` taking priority over
|
|
2367
|
+
// the DOM `selected` channel. `elementSel.clear()` posts a DEBOUNCED
|
|
2368
|
+
// (50ms, use-selection-set.tsx POST_DEBOUNCE_MS) `select-set: null` — that
|
|
2369
|
+
// lands AFTER the (undebounced) photo-request and the shell's `select-set`
|
|
2370
|
+
// handler unconditionally nulls `photoSel` back out, so the Photo tab
|
|
2371
|
+
// flickers open then goes blank. Skip the clear for an image stroke: it
|
|
2372
|
+
// isn't needed (photoSel already takes precedence in tab resolution) and
|
|
2373
|
+
// is the only case that races with a still-in-flight photo-tab request.
|
|
2374
|
+
const strokeTool = target?.closest?.('[data-id][data-tool]')?.getAttribute('data-tool');
|
|
2375
|
+
if (strokeTool !== 'image') elementSel?.clear();
|
|
1758
2376
|
// FigJam v3 — clicking a group member acts on the WHOLE outermost
|
|
1759
2377
|
// group. Double-click deep-selects the member (see the dblclick
|
|
1760
2378
|
// handler); an already-selected stroke keeps the current selection, so
|
|
@@ -2024,6 +2642,114 @@ export function AnnotationsLayer() {
|
|
|
2024
2642
|
const strokesStoreRef = useRef(strokesStore);
|
|
2025
2643
|
strokesStoreRef.current = strokesStore;
|
|
2026
2644
|
|
|
2645
|
+
// Phase 2 (whiteboard-improvements) — proportional group resize. A grabbed
|
|
2646
|
+
// corner (AnnotGroupBbox's `[data-group-resize-corner]` handles) scales the
|
|
2647
|
+
// SELECTION's own outer bbox via bboxResize — the exact single-shape corner-
|
|
2648
|
+
// anchor / Shift-aspect-lock / Alt-center-scale math, applied here to the
|
|
2649
|
+
// group's union bbox instead of one stroke's — then propagates that affine
|
|
2650
|
+
// transform to every selected stroke via scaleStrokeInGroup. One local
|
|
2651
|
+
// preview per pointermove, one commitStrokes on pointerup: mirrors the
|
|
2652
|
+
// hull-drag gesture above. Registering pointermove/up unconditionally
|
|
2653
|
+
// (no-op via the groupResizeRef null-check) rather than adding them inside
|
|
2654
|
+
// onDown matches AnnotationResizeOverlay's own pattern in
|
|
2655
|
+
// use-annotation-resize.tsx.
|
|
2656
|
+
useEffect(() => {
|
|
2657
|
+
if (typeof document === 'undefined') return;
|
|
2658
|
+
if (tool !== 'move') return;
|
|
2659
|
+
if (!annotSel) return;
|
|
2660
|
+
|
|
2661
|
+
const onDown = (e: PointerEvent) => {
|
|
2662
|
+
const target = e.target as Element | null;
|
|
2663
|
+
const cornerEl = target?.closest?.('[data-group-resize-corner]') ?? null;
|
|
2664
|
+
if (!cornerEl) return;
|
|
2665
|
+
const corner = cornerEl.getAttribute('data-group-resize-corner') as Corner | null;
|
|
2666
|
+
if (!corner) return;
|
|
2667
|
+
const ids = annotSel.selectedIds;
|
|
2668
|
+
if (ids.length < 2) return;
|
|
2669
|
+
const idSet = new Set(ids);
|
|
2670
|
+
const strokesNow = strokesStoreRef.current.strokes;
|
|
2671
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
2672
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
2673
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
2674
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
2675
|
+
let any = false;
|
|
2676
|
+
for (const s of strokesNow) {
|
|
2677
|
+
if (!idSet.has(s.id)) continue;
|
|
2678
|
+
const b = strokeBBox(s, anchorsById);
|
|
2679
|
+
if (!b) continue;
|
|
2680
|
+
any = true;
|
|
2681
|
+
if (b.x < xMin) xMin = b.x;
|
|
2682
|
+
if (b.y < yMin) yMin = b.y;
|
|
2683
|
+
if (b.x + b.w > xMax) xMax = b.x + b.w;
|
|
2684
|
+
if (b.y + b.h > yMax) yMax = b.y + b.h;
|
|
2685
|
+
}
|
|
2686
|
+
if (!any) return;
|
|
2687
|
+
e.preventDefault();
|
|
2688
|
+
e.stopPropagation();
|
|
2689
|
+
groupResizeRef.current = {
|
|
2690
|
+
pointerId: e.pointerId,
|
|
2691
|
+
corner,
|
|
2692
|
+
groupB0: { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin },
|
|
2693
|
+
ids,
|
|
2694
|
+
undoBase: strokesNow,
|
|
2695
|
+
};
|
|
2696
|
+
try {
|
|
2697
|
+
(cornerEl as Element & { setPointerCapture?: (id: number) => void }).setPointerCapture?.(
|
|
2698
|
+
e.pointerId
|
|
2699
|
+
);
|
|
2700
|
+
} catch {
|
|
2701
|
+
/* some browsers reject capture on synthetic events */
|
|
2702
|
+
}
|
|
2703
|
+
};
|
|
2704
|
+
const onMove = (e: PointerEvent) => {
|
|
2705
|
+
const d = groupResizeRef.current;
|
|
2706
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
2707
|
+
const [wxRaw, wyRaw] = screenToWorld(e.clientX, e.clientY);
|
|
2708
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
2709
|
+
// Handles render padded OFF the group bbox (screen-constant, same as
|
|
2710
|
+
// AnnotGroupBbox's `pad`) — shift the cursor back onto the true corner
|
|
2711
|
+
// so the first move doesn't jump-grow by the pad amount (mirrors
|
|
2712
|
+
// single-resize applyResize's padDX/padDY correction).
|
|
2713
|
+
const padWorld = HALO_PAD_PX / zoom;
|
|
2714
|
+
const wx = wxRaw + padDX(d.corner) * padWorld;
|
|
2715
|
+
const wy = wyRaw + padDY(d.corner) * padWorld;
|
|
2716
|
+
const mods: ResizeMods = { shift: e.shiftKey, alt: e.altKey };
|
|
2717
|
+
const groupB1 = bboxResize(d.groupB0, d.corner, wx, wy, mods, false);
|
|
2718
|
+
const idSet = new Set(d.ids);
|
|
2719
|
+
// Local React state only — no commitStrokes, no PUT, matching the
|
|
2720
|
+
// hull-drag / single-resize preview convention (one undo record lands
|
|
2721
|
+
// on pointerup, not one per pixel moved).
|
|
2722
|
+
const next = recomputeBoundArrows(
|
|
2723
|
+
d.undoBase.map((s) => {
|
|
2724
|
+
if (!idSet.has(s.id)) return s;
|
|
2725
|
+
const patch = scaleStrokeInGroup(s, d.groupB0, groupB1);
|
|
2726
|
+
return patch ? ({ ...s, ...patch } as Stroke) : s;
|
|
2727
|
+
})
|
|
2728
|
+
);
|
|
2729
|
+
setStrokesState(next);
|
|
2730
|
+
};
|
|
2731
|
+
const onUp = (e: PointerEvent) => {
|
|
2732
|
+
const d = groupResizeRef.current;
|
|
2733
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
2734
|
+
groupResizeRef.current = null;
|
|
2735
|
+
const final = strokesRef.current;
|
|
2736
|
+
// No-op drag (grabbed a handle, released without moving past the
|
|
2737
|
+
// resize's own resolution) skips the undo record.
|
|
2738
|
+
if (strokesShallowEqual(d.undoBase, final)) return;
|
|
2739
|
+
commitStrokes(d.undoBase, final, `resize ${d.ids.length} strokes`);
|
|
2740
|
+
};
|
|
2741
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
2742
|
+
document.addEventListener('pointermove', onMove, true);
|
|
2743
|
+
document.addEventListener('pointerup', onUp, true);
|
|
2744
|
+
document.addEventListener('pointercancel', onUp, true);
|
|
2745
|
+
return () => {
|
|
2746
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
2747
|
+
document.removeEventListener('pointermove', onMove, true);
|
|
2748
|
+
document.removeEventListener('pointerup', onUp, true);
|
|
2749
|
+
document.removeEventListener('pointercancel', onUp, true);
|
|
2750
|
+
};
|
|
2751
|
+
}, [tool, annotSel, screenToWorld, anchorsById, commitStrokes]);
|
|
2752
|
+
|
|
2027
2753
|
// Double-click enters text-edit mode: rect/ellipse (anchored text), sticky
|
|
2028
2754
|
// (its own body), or a standalone text node (re-edit in place). Anchored text
|
|
2029
2755
|
// nodes are edited via their host, so a data-anchor-id text node is skipped.
|
|
@@ -2045,15 +2771,24 @@ export function AnnotationsLayer() {
|
|
|
2045
2771
|
const stroke = strokesRef.current.find((s) => s.id === id);
|
|
2046
2772
|
if (stroke && outermostGroupOf(stroke) && annotSel) {
|
|
2047
2773
|
e.preventDefault();
|
|
2774
|
+
e.stopPropagation();
|
|
2048
2775
|
annotSel.replace(id);
|
|
2049
2776
|
}
|
|
2050
2777
|
if (t === 'rect' || t === 'ellipse' || t === 'polygon' || t === 'sticky' || t === 'section') {
|
|
2778
|
+
// A double-click we've claimed for editing must not also reach the
|
|
2779
|
+
// canvas-shell dblclick-empty→fit() handler (bubble phase, below us) —
|
|
2780
|
+
// without this it fired right after, so renaming a section or editing
|
|
2781
|
+
// a shape's text jumped the viewport to fit().
|
|
2051
2782
|
e.preventDefault();
|
|
2783
|
+
e.stopPropagation();
|
|
2784
|
+
setEditCaretPoint({ x: e.clientX, y: e.clientY });
|
|
2052
2785
|
setEditingId(id);
|
|
2053
2786
|
return;
|
|
2054
2787
|
}
|
|
2055
2788
|
if (t === 'text' && !node.getAttribute('data-anchor-id')) {
|
|
2056
2789
|
e.preventDefault();
|
|
2790
|
+
e.stopPropagation();
|
|
2791
|
+
setEditCaretPoint({ x: e.clientX, y: e.clientY });
|
|
2057
2792
|
setEditingId(id);
|
|
2058
2793
|
}
|
|
2059
2794
|
};
|
|
@@ -2192,6 +2927,7 @@ export function AnnotationsLayer() {
|
|
|
2192
2927
|
const target = editingTargetRef.current;
|
|
2193
2928
|
setEditingId(null);
|
|
2194
2929
|
setPendingText(null);
|
|
2930
|
+
setEditCaretPoint(null);
|
|
2195
2931
|
if (!target) return;
|
|
2196
2932
|
if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
|
|
2197
2933
|
else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
|
|
@@ -2211,6 +2947,7 @@ export function AnnotationsLayer() {
|
|
|
2211
2947
|
const cancelEditing = useCallback(() => {
|
|
2212
2948
|
setEditingId(null);
|
|
2213
2949
|
setPendingText(null);
|
|
2950
|
+
setEditCaretPoint(null);
|
|
2214
2951
|
}, []);
|
|
2215
2952
|
|
|
2216
2953
|
/**
|
|
@@ -2780,6 +3517,21 @@ export function AnnotationsLayer() {
|
|
|
2780
3517
|
} catch {
|
|
2781
3518
|
/* detached / cross-origin teardown */
|
|
2782
3519
|
}
|
|
3520
|
+
} else if (action === 'edit-photo') {
|
|
3521
|
+
// feature-photo-editor (Task 17) — "Edit Photo…" on an ImageStroke. Same
|
|
3522
|
+
// untrusted-origin REQUEST shape as Replace (DDR-054); the shell opens the
|
|
3523
|
+
// Photo-only Inspector tab on this stroke's content-addressed asset.
|
|
3524
|
+
if (sel.length !== 1) return;
|
|
3525
|
+
const target = strokesById.get(sel[0] as string);
|
|
3526
|
+
if (target?.tool !== 'image') return;
|
|
3527
|
+
try {
|
|
3528
|
+
window.parent.postMessage(
|
|
3529
|
+
{ dgn: 'edit-annotation-photo-request', id: target.id, asset: target.href },
|
|
3530
|
+
'*'
|
|
3531
|
+
);
|
|
3532
|
+
} catch {
|
|
3533
|
+
/* detached / cross-origin teardown */
|
|
3534
|
+
}
|
|
2783
3535
|
}
|
|
2784
3536
|
},
|
|
2785
3537
|
[annotSel, copySelection, pasteStrokesText, strokesById]
|
|
@@ -2797,6 +3549,34 @@ export function AnnotationsLayer() {
|
|
|
2797
3549
|
}
|
|
2798
3550
|
}, [selCount]);
|
|
2799
3551
|
|
|
3552
|
+
// feature-photo-editor — auto-open the Inspector's Photo tab whenever the
|
|
3553
|
+
// annotation selection becomes exactly one ImageStroke. Previously this
|
|
3554
|
+
// only fired from the "Edit Photo…" context-menu action; a plain click/
|
|
3555
|
+
// select left the Inspector showing its no-selection placeholder, since an
|
|
3556
|
+
// annotation stroke has no data-cd-id / DOM selection to ride the normal
|
|
3557
|
+
// `select`/`select-set` path. An ImageStroke has no OTHER inspectable
|
|
3558
|
+
// surface (Task 13: Photo is the only tab for this content type), so
|
|
3559
|
+
// requiring a right-click detour to reach it was pure friction. Same
|
|
3560
|
+
// `edit-annotation-photo-request` message + confused-deputy gate (DDR-054)
|
|
3561
|
+
// the context-menu action already used.
|
|
3562
|
+
const singleSelectedImageId =
|
|
3563
|
+
selCount === 1 && strokesById.get(annotSel?.selectedIds[0] as string)?.tool === 'image'
|
|
3564
|
+
? (annotSel?.selectedIds[0] as string)
|
|
3565
|
+
: null;
|
|
3566
|
+
useEffect(() => {
|
|
3567
|
+
if (!singleSelectedImageId) return;
|
|
3568
|
+
const s = strokesById.get(singleSelectedImageId);
|
|
3569
|
+
if (s?.tool !== 'image') return;
|
|
3570
|
+
try {
|
|
3571
|
+
window.parent.postMessage(
|
|
3572
|
+
{ dgn: 'edit-annotation-photo-request', id: s.id, asset: s.href },
|
|
3573
|
+
'*'
|
|
3574
|
+
);
|
|
3575
|
+
} catch {
|
|
3576
|
+
/* detached / cross-origin teardown */
|
|
3577
|
+
}
|
|
3578
|
+
}, [singleSelectedImageId, strokesById]);
|
|
3579
|
+
|
|
2800
3580
|
// Selected stroke halos — bboxes in world coords, vector-effect non-scaling-stroke.
|
|
2801
3581
|
const selectedStrokes = useMemo(() => {
|
|
2802
3582
|
if (!annotSel || annotSel.selectedIds.length === 0) return [] as Stroke[];
|
|
@@ -2834,6 +3614,7 @@ export function AnnotationsLayer() {
|
|
|
2834
3614
|
addTextHintId={editingTarget ? null : addTextHintId}
|
|
2835
3615
|
ghost={ghostPreview}
|
|
2836
3616
|
editingTarget={editingTarget}
|
|
3617
|
+
editCaretPoint={editCaretPoint}
|
|
2837
3618
|
inkColor={color}
|
|
2838
3619
|
onCommitEdit={commitEditing}
|
|
2839
3620
|
onCancelEdit={cancelEditing}
|
|
@@ -2863,6 +3644,11 @@ export function AnnotationsLayer() {
|
|
|
2863
3644
|
selectedStrokes.length === 1 &&
|
|
2864
3645
|
(selectedStrokes[0]?.tool === 'image' || selectedStrokes[0]?.tool === 'mediaref')
|
|
2865
3646
|
}
|
|
3647
|
+
canEditPhoto={
|
|
3648
|
+
selectedStrokes.length === 1 &&
|
|
3649
|
+
selectedStrokes[0]?.tool === 'image' &&
|
|
3650
|
+
/assets\/[0-9a-f]{8}\.[a-z0-9]+/i.test((selectedStrokes[0] as ImageStroke).href || '')
|
|
3651
|
+
}
|
|
2866
3652
|
onAction={onMenuAction}
|
|
2867
3653
|
onClose={() => setCtxMenu(null)}
|
|
2868
3654
|
/>
|
|
@@ -2988,6 +3774,7 @@ function AnnotationsSvg({
|
|
|
2988
3774
|
addTextHintId,
|
|
2989
3775
|
ghost,
|
|
2990
3776
|
editingTarget,
|
|
3777
|
+
editCaretPoint,
|
|
2991
3778
|
inkColor,
|
|
2992
3779
|
onCommitEdit,
|
|
2993
3780
|
onCancelEdit,
|
|
@@ -3020,6 +3807,8 @@ function AnnotationsSvg({
|
|
|
3020
3807
|
addTextHintId: string | null;
|
|
3021
3808
|
ghost: GhostDescriptor | null;
|
|
3022
3809
|
editingTarget: EditingTarget;
|
|
3810
|
+
/** The click that opened the editor — caret-at-click on mount (Phase 3). */
|
|
3811
|
+
editCaretPoint: { x: number; y: number } | null;
|
|
3023
3812
|
/** Live default ink (theme-aware) for a not-yet-born pending text caret. */
|
|
3024
3813
|
inkColor: string;
|
|
3025
3814
|
onCommitEdit: (text: string, fmt?: EditorFmt) => void;
|
|
@@ -3036,154 +3825,234 @@ function AnnotationsSvg({
|
|
|
3036
3825
|
// A sticky whose body is being edited hides its read-only text so the
|
|
3037
3826
|
// editor textarea (rendered below at the same bbox) isn't double-painted.
|
|
3038
3827
|
const editingStickyId = editingTarget?.kind === 'sticky' ? editingTarget.sticky.id : null;
|
|
3828
|
+
// Same double-paint issue for a section being renamed — its label chip must
|
|
3829
|
+
// hide while the StandaloneTextEditor sits at the same spot, else the old
|
|
3830
|
+
// label reads through behind the new text.
|
|
3831
|
+
const editingSectionId = editingTarget?.kind === 'section' ? editingTarget.section.id : null;
|
|
3039
3832
|
const anchoredExisting =
|
|
3040
3833
|
editingTarget?.kind === 'anchored'
|
|
3041
3834
|
? (strokes.find((s) => s.tool === 'text' && s.anchorId === editingTarget.anchorId) as
|
|
3042
3835
|
| TextStroke
|
|
3043
3836
|
| undefined)
|
|
3044
3837
|
: undefined;
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3838
|
+
// Same double-paint issue for a shape's anchored text — hide the read-only
|
|
3839
|
+
// <text> stroke while TextEditor sits at the same bbox (Phase 1 jump-fix
|
|
3840
|
+
// companion). Only applies once the TextStroke exists; a not-yet-created
|
|
3841
|
+
// one has nothing to hide.
|
|
3842
|
+
const editingAnchoredTextId = anchoredExisting?.id ?? null;
|
|
3843
|
+
// A standalone text being re-edited hides its read-only <text> too — the
|
|
3844
|
+
// editor paints at the same x/y, so leaving it visible double-paints (the
|
|
3845
|
+
// pre-Phase-2 "ghost" under the editor).
|
|
3846
|
+
const editingStandaloneTextId =
|
|
3847
|
+
editingTarget?.kind === 'standalone' ? editingTarget.text.id : null;
|
|
3848
|
+
return (
|
|
3849
|
+
<>
|
|
3850
|
+
{createPortal(
|
|
3851
|
+
<svg className="dc-annot-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
|
3852
|
+
<defs>
|
|
3853
|
+
{/* Phase 21 — soft "lifted paper" drop shadow for sticky notes. */}
|
|
3854
|
+
<filter id="dc-sticky-shadow" x="-25%" y="-25%" width="150%" height="170%">
|
|
3855
|
+
<feDropShadow
|
|
3856
|
+
dx="0"
|
|
3857
|
+
dy="4"
|
|
3858
|
+
stdDeviation="8"
|
|
3859
|
+
floodColor="#000000"
|
|
3860
|
+
floodOpacity="0.28"
|
|
3861
|
+
/>
|
|
3862
|
+
</filter>
|
|
3863
|
+
</defs>
|
|
3864
|
+
{strokes.map((s) => (
|
|
3865
|
+
<StrokeNode
|
|
3866
|
+
key={s.id}
|
|
3867
|
+
stroke={s}
|
|
3868
|
+
anchorsById={anchorsById}
|
|
3869
|
+
interactive={selectMode}
|
|
3870
|
+
editing={
|
|
3871
|
+
s.id === editingStickyId ||
|
|
3872
|
+
s.id === editingSectionId ||
|
|
3873
|
+
s.id === editingAnchoredTextId ||
|
|
3874
|
+
s.id === editingStandaloneTextId
|
|
3875
|
+
}
|
|
3876
|
+
/>
|
|
3877
|
+
))}
|
|
3878
|
+
{selectedStrokes.map((s) => (
|
|
3879
|
+
<SelectionHalo
|
|
3880
|
+
key={`halo-${s.id}`}
|
|
3881
|
+
stroke={s}
|
|
3882
|
+
anchorsById={anchorsById}
|
|
3883
|
+
multi={selectedStrokes.length > 1}
|
|
3884
|
+
/>
|
|
3885
|
+
))}
|
|
3886
|
+
<AnnotGroupBbox selectedStrokes={selectedStrokes} anchorsById={anchorsById} />
|
|
3887
|
+
{marquee ? (
|
|
3888
|
+
<rect
|
|
3889
|
+
className="dc-annot-marquee"
|
|
3890
|
+
x={Math.min(marquee.ax, marquee.bx)}
|
|
3891
|
+
y={Math.min(marquee.ay, marquee.by)}
|
|
3892
|
+
width={Math.abs(marquee.bx - marquee.ax)}
|
|
3893
|
+
height={Math.abs(marquee.by - marquee.ay)}
|
|
3894
|
+
vectorEffect="non-scaling-stroke"
|
|
3895
|
+
/>
|
|
3896
|
+
) : null}
|
|
3897
|
+
{/* FigJam v3 — smart guides: solid 1px accent lines at the snapped edge/
|
|
3082
3898
|
center, painted only while a drag is actively snapping. */}
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3899
|
+
{snapGuides?.map((g, i) => (
|
|
3900
|
+
<line
|
|
3901
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: guides are positional + rebuilt per move tick
|
|
3902
|
+
key={`guide-${i}`}
|
|
3903
|
+
x1={g.axis === 'x' ? g.at : g.from}
|
|
3904
|
+
y1={g.axis === 'x' ? g.from : g.at}
|
|
3905
|
+
x2={g.axis === 'x' ? g.at : g.to}
|
|
3906
|
+
y2={g.axis === 'x' ? g.to : g.at}
|
|
3907
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
3908
|
+
strokeWidth={1}
|
|
3909
|
+
vectorEffect="non-scaling-stroke"
|
|
3910
|
+
pointerEvents="none"
|
|
3911
|
+
/>
|
|
3912
|
+
))}
|
|
3913
|
+
{/* FigJam v3 — bind hint: halo the host a dragged arrow endpoint would
|
|
3098
3914
|
magnetically attach to. */}
|
|
3099
|
-
|
|
3100
|
-
|
|
3915
|
+
<BindHintHalo strokes={strokes} bindHintId={bindHintId} />
|
|
3916
|
+
{/* FigJam v3 — connection dots on a single selected bindable shape;
|
|
3101
3917
|
dragging one draws a bound connector (rendered below as a draft). */}
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3918
|
+
{selectMode && !connDraft && selectedStrokes.length === 1 && selectedStrokes[0] ? (
|
|
3919
|
+
<ConnectorDots stroke={selectedStrokes[0]} />
|
|
3920
|
+
) : null}
|
|
3921
|
+
{connDraft ? (
|
|
3922
|
+
<g
|
|
3923
|
+
stroke={inkColor}
|
|
3924
|
+
strokeWidth={2.5}
|
|
3925
|
+
strokeLinecap="round"
|
|
3926
|
+
strokeLinejoin="round"
|
|
3927
|
+
vectorEffect="non-scaling-stroke"
|
|
3928
|
+
fill="none"
|
|
3929
|
+
pointerEvents="none"
|
|
3930
|
+
>
|
|
3931
|
+
{arrowPrimitives({
|
|
3932
|
+
x1: connDraft.x1,
|
|
3933
|
+
y1: connDraft.y1,
|
|
3934
|
+
x2: connDraft.x2,
|
|
3935
|
+
y2: connDraft.y2,
|
|
3936
|
+
width: 2.5,
|
|
3937
|
+
color: inkColor,
|
|
3938
|
+
lineType: 'curved',
|
|
3939
|
+
startBind: connDraft.startBind,
|
|
3940
|
+
...(connDraft.endBind ? { endBind: connDraft.endBind } : {}),
|
|
3941
|
+
}).map((prim, i) => renderArrowPrimitive(prim, i))}
|
|
3942
|
+
</g>
|
|
3943
|
+
) : null}
|
|
3944
|
+
{/* FigJam v3 — hover affordance: an empty shape invites text. */}
|
|
3945
|
+
<AddTextHint strokes={strokes} hintId={addTextHintId} />
|
|
3946
|
+
{/* FigJam v3 — resize chrome: live W × H label at the box corner plus a
|
|
3131
3947
|
dashed halo on any neighbour whose dimension the resize just matched
|
|
3132
3948
|
(the "same size as that one" quota). */}
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3949
|
+
{resizeInfo?.box ? (
|
|
3950
|
+
<g pointerEvents="none">
|
|
3951
|
+
{resizeInfo.matchIds.map((id) => {
|
|
3952
|
+
const m = strokes.find((s) => s.id === id);
|
|
3953
|
+
const bb = m ? strokeBBox(m) : null;
|
|
3954
|
+
if (!bb) return null;
|
|
3955
|
+
return (
|
|
3956
|
+
<rect
|
|
3957
|
+
key={`dim-${id}`}
|
|
3958
|
+
x={bb.x - 2}
|
|
3959
|
+
y={bb.y - 2}
|
|
3960
|
+
width={bb.w + 4}
|
|
3961
|
+
height={bb.h + 4}
|
|
3962
|
+
fill="none"
|
|
3963
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
3964
|
+
strokeWidth={1.5}
|
|
3965
|
+
strokeDasharray="5 3"
|
|
3966
|
+
vectorEffect="non-scaling-stroke"
|
|
3967
|
+
rx={2}
|
|
3968
|
+
/>
|
|
3969
|
+
);
|
|
3970
|
+
})}
|
|
3971
|
+
<text
|
|
3972
|
+
x={resizeInfo.box.x + resizeInfo.box.w / 2}
|
|
3973
|
+
y={resizeInfo.box.y + resizeInfo.box.h + 18}
|
|
3974
|
+
textAnchor="middle"
|
|
3975
|
+
fontSize={11}
|
|
3976
|
+
fill="var(--maude-hud-accent, #d63b1f)"
|
|
3977
|
+
style={{
|
|
3978
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
3979
|
+
}}
|
|
3980
|
+
>
|
|
3981
|
+
{`${Math.round(resizeInfo.box.w)} × ${Math.round(resizeInfo.box.h)}`}
|
|
3982
|
+
</text>
|
|
3983
|
+
</g>
|
|
3984
|
+
) : null}
|
|
3985
|
+
{ghost ? <GhostPreview ghost={ghost} /> : null}
|
|
3986
|
+
</svg>,
|
|
3987
|
+
target
|
|
3988
|
+
)}
|
|
3989
|
+
<AnnotEditors
|
|
3990
|
+
worldRef={worldRef}
|
|
3991
|
+
editingTarget={editingTarget}
|
|
3992
|
+
anchoredExisting={anchoredExisting}
|
|
3993
|
+
caretPoint={editCaretPoint}
|
|
3994
|
+
inkColor={inkColor}
|
|
3995
|
+
onCommitEdit={onCommitEdit}
|
|
3996
|
+
onCancelEdit={onCancelEdit}
|
|
3997
|
+
/>
|
|
3998
|
+
</>
|
|
3999
|
+
);
|
|
4000
|
+
}
|
|
4001
|
+
|
|
4002
|
+
/**
|
|
4003
|
+
* The active annotation text editor, rendered as PLAIN HTML absolutely
|
|
4004
|
+
* positioned in the world div — NOT as SVG foreignObject. Same architectural
|
|
4005
|
+
* move as MediaRefPlayers below (read its docblock): foreignObject content
|
|
4006
|
+
* under the transformed `.dc-world` mis-hit-tests clicks at most zoom levels
|
|
4007
|
+
* (WebKit + Chromium), and the `.dc-annot-svg` root's pointer-events:none
|
|
4008
|
+
* additionally swallowed in-editor clicks — so caret-at-click could never
|
|
4009
|
+
* work. HTML children of the transformed div hit-test correctly by
|
|
4010
|
+
* construction. World coords map 1:1 (the div carries the pan/zoom
|
|
4011
|
+
* transform), so each editor's old foreignObject x/y/w/h becomes left/top/
|
|
4012
|
+
* width/height verbatim. The [data-annot-editor] attr keeps document-capture
|
|
4013
|
+
* annotation handlers out (isAnnotEditorTarget guard, mirroring
|
|
4014
|
+
* [data-mediaref-player]).
|
|
4015
|
+
*/
|
|
4016
|
+
function AnnotEditors({
|
|
4017
|
+
worldRef,
|
|
4018
|
+
editingTarget,
|
|
4019
|
+
anchoredExisting,
|
|
4020
|
+
caretPoint,
|
|
4021
|
+
inkColor,
|
|
4022
|
+
onCommitEdit,
|
|
4023
|
+
onCancelEdit,
|
|
4024
|
+
}: {
|
|
4025
|
+
worldRef: ReturnType<typeof useWorldRefContext>;
|
|
4026
|
+
editingTarget: EditingTarget;
|
|
4027
|
+
anchoredExisting: TextStroke | undefined;
|
|
4028
|
+
caretPoint: { x: number; y: number } | null;
|
|
4029
|
+
inkColor: string;
|
|
4030
|
+
onCommitEdit: (text: string, fmt?: EditorFmt) => void;
|
|
4031
|
+
onCancelEdit: () => void;
|
|
4032
|
+
}) {
|
|
4033
|
+
const target = worldRef?.current ?? null;
|
|
4034
|
+
if (!target || !editingTarget) return null;
|
|
4035
|
+
return createPortal(
|
|
4036
|
+
<>
|
|
4037
|
+
{editingTarget.kind === 'anchored' ? (
|
|
3171
4038
|
<TextEditor
|
|
3172
4039
|
anchorId={editingTarget.anchorId}
|
|
3173
4040
|
host={editingTarget.host}
|
|
3174
4041
|
existing={anchoredExisting}
|
|
4042
|
+
caretPoint={caretPoint}
|
|
3175
4043
|
onCommit={(_anchorId, text, fmt) => onCommitEdit(text, fmt)}
|
|
3176
4044
|
onCancel={onCancelEdit}
|
|
3177
4045
|
/>
|
|
3178
4046
|
) : null}
|
|
3179
|
-
{editingTarget
|
|
4047
|
+
{editingTarget.kind === 'sticky' ? (
|
|
3180
4048
|
<StickyEditor
|
|
3181
4049
|
sticky={editingTarget.sticky}
|
|
4050
|
+
caretPoint={caretPoint}
|
|
3182
4051
|
onCommit={onCommitEdit}
|
|
3183
4052
|
onCancel={onCancelEdit}
|
|
3184
4053
|
/>
|
|
3185
4054
|
) : null}
|
|
3186
|
-
{editingTarget
|
|
4055
|
+
{editingTarget.kind === 'standalone' ? (
|
|
3187
4056
|
<StandaloneTextEditor
|
|
3188
4057
|
x={editingTarget.text.x ?? 0}
|
|
3189
4058
|
y={editingTarget.text.y ?? 0}
|
|
@@ -3196,11 +4065,12 @@ function AnnotationsSvg({
|
|
|
3196
4065
|
underline={editingTarget.text.underline}
|
|
3197
4066
|
align={editingTarget.text.align ?? 'left'}
|
|
3198
4067
|
listType={editingTarget.text.listType}
|
|
4068
|
+
caretPoint={caretPoint}
|
|
3199
4069
|
onCommit={onCommitEdit}
|
|
3200
4070
|
onCancel={onCancelEdit}
|
|
3201
4071
|
/>
|
|
3202
4072
|
) : null}
|
|
3203
|
-
{editingTarget
|
|
4073
|
+
{editingTarget.kind === 'pending' ? (
|
|
3204
4074
|
<StandaloneTextEditor
|
|
3205
4075
|
x={editingTarget.x}
|
|
3206
4076
|
y={editingTarget.y}
|
|
@@ -3211,24 +4081,15 @@ function AnnotationsSvg({
|
|
|
3211
4081
|
onCancel={onCancelEdit}
|
|
3212
4082
|
/>
|
|
3213
4083
|
) : null}
|
|
3214
|
-
{editingTarget
|
|
3215
|
-
<
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
}
|
|
3219
|
-
y={
|
|
3220
|
-
Math.min(editingTarget.section.y, editingTarget.section.y + editingTarget.section.h) -
|
|
3221
|
-
SECTION_LABEL_H -
|
|
3222
|
-
2
|
|
3223
|
-
}
|
|
3224
|
-
fontSize={SECTION_LABEL_FONT}
|
|
3225
|
-
color={editingTarget.section.color}
|
|
3226
|
-
initialText={editingTarget.section.label}
|
|
4084
|
+
{editingTarget.kind === 'section' ? (
|
|
4085
|
+
<SectionTitleEditor
|
|
4086
|
+
section={editingTarget.section}
|
|
4087
|
+
caretPoint={caretPoint}
|
|
3227
4088
|
onCommit={onCommitEdit}
|
|
3228
4089
|
onCancel={onCancelEdit}
|
|
3229
4090
|
/>
|
|
3230
4091
|
) : null}
|
|
3231
|
-
|
|
4092
|
+
</>,
|
|
3232
4093
|
target
|
|
3233
4094
|
);
|
|
3234
4095
|
}
|
|
@@ -3319,16 +4180,89 @@ function MediaRefPlayers({
|
|
|
3319
4180
|
);
|
|
3320
4181
|
}
|
|
3321
4182
|
|
|
4183
|
+
// An empty flex-centered contentEditable has no line box for `justifyContent:
|
|
4184
|
+
// 'center'` to center — WebKit parks the caret at the box's top, then jumps it
|
|
4185
|
+
// to the true centered position the instant a real character exists. A
|
|
4186
|
+
// zero-width space gives the box real (invisible) content from mount, so the
|
|
4187
|
+
// caret starts centered and never jumps. Stripped back off on commit.
|
|
4188
|
+
const JUMP_SENTINEL = '\u200B';
|
|
4189
|
+
function stripJumpSentinel(text: string): string {
|
|
4190
|
+
return text.startsWith(JUMP_SENTINEL) ? text.slice(JUMP_SENTINEL.length) : text;
|
|
4191
|
+
}
|
|
4192
|
+
|
|
4193
|
+
// Unified caret style across every contentEditable text surface (annotation
|
|
4194
|
+
// editors here + the artboard inline editor's `.dc-text-editing` CSS in
|
|
4195
|
+
// canvas-shell.tsx use the SAME `--maude-hud-accent` so the caret reads the
|
|
4196
|
+
// same everywhere). An explicit caretColor makes the caret visible against any
|
|
4197
|
+
// background. NOTE: deliberately NO `transform: translateZ(0)` / `will-change`
|
|
4198
|
+
// here \u2014 promoting a contentEditable onto its own compositing layer is a known
|
|
4199
|
+
// WebKit caret-BLINK killer (the compositor caches the layer and never repaints
|
|
4200
|
+
// the blink, so the caret shows as a static line). A prior dogfood pass added
|
|
4201
|
+
// translateZ(0) to "restore" the caret and instead froze its blink; removing it
|
|
4202
|
+
// lets WebKit run the native blink. The editor already lives inside the
|
|
4203
|
+
// transformed `.dc-world`, but that ancestor transform alone does not stop the
|
|
4204
|
+
// blink \u2014 only a compositing trigger ON the editable does.
|
|
4205
|
+
const CARET_FIX_STYLE = {
|
|
4206
|
+
caretColor: 'var(--maude-hud-accent, #4a63e7)',
|
|
4207
|
+
} as const;
|
|
4208
|
+
|
|
4209
|
+
/**
|
|
4210
|
+
* Phase 3 (unified-text-editing) — shared caret behavior for every annotation
|
|
4211
|
+
* editor. On mount: focus, place a collapsed caret at the entry click point
|
|
4212
|
+
* (`placeCaretAt`, the SAME chain the artboard's enterEditModeAt uses;
|
|
4213
|
+
* keyboard entry has no point → select-all, the rename convention), and mount
|
|
4214
|
+
* the custom blinking caret (text-caret.ts) for the session. Afterwards every
|
|
4215
|
+
* plain in-editor click re-places the caret from its coordinates on pointerup
|
|
4216
|
+
* so repositioning never depends on native hit-testing (synthetic e2e clicks
|
|
4217
|
+
* take the same path — untrusted events get no UA caret action at all).
|
|
4218
|
+
* Shift-clicks and drag-selections keep native behavior; ⌘A stays native.
|
|
4219
|
+
*/
|
|
4220
|
+
function useEditorCaret(
|
|
4221
|
+
ref: RefObject<HTMLDivElement | null>,
|
|
4222
|
+
caretPoint: { x: number; y: number } | null | undefined
|
|
4223
|
+
) {
|
|
4224
|
+
const entryPointRef = useRef(caretPoint ?? null);
|
|
4225
|
+
useEffect(() => {
|
|
4226
|
+
const el = ref.current;
|
|
4227
|
+
if (!el) return;
|
|
4228
|
+
el.focus();
|
|
4229
|
+
placeCaretAt(el, window, entryPointRef.current ?? undefined);
|
|
4230
|
+
return mountCaret(el, window);
|
|
4231
|
+
}, [ref]);
|
|
4232
|
+
const downRef = useRef<{ x: number; y: number } | null>(null);
|
|
4233
|
+
const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
|
4234
|
+
downRef.current = e.button === 0 && !e.shiftKey ? { x: e.clientX, y: e.clientY } : null;
|
|
4235
|
+
}, []);
|
|
4236
|
+
const onPointerUp = useCallback(
|
|
4237
|
+
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
4238
|
+
const d = downRef.current;
|
|
4239
|
+
downRef.current = null;
|
|
4240
|
+
if (!d || e.shiftKey) return;
|
|
4241
|
+
// A real drag is a range-selection gesture — leave it to the engine.
|
|
4242
|
+
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) > 3) return;
|
|
4243
|
+
const el = ref.current;
|
|
4244
|
+
if (!el) return;
|
|
4245
|
+
const sel = window.getSelection();
|
|
4246
|
+
if (sel && !sel.isCollapsed) return; // double-click word-select etc.
|
|
4247
|
+
placeCaretAt(el, window, { x: e.clientX, y: e.clientY }, false);
|
|
4248
|
+
},
|
|
4249
|
+
[ref]
|
|
4250
|
+
);
|
|
4251
|
+
return { onPointerDown, onPointerUp };
|
|
4252
|
+
}
|
|
4253
|
+
|
|
3322
4254
|
function TextEditor({
|
|
3323
4255
|
anchorId,
|
|
3324
4256
|
host,
|
|
3325
4257
|
existing,
|
|
4258
|
+
caretPoint,
|
|
3326
4259
|
onCommit,
|
|
3327
4260
|
onCancel,
|
|
3328
4261
|
}: {
|
|
3329
4262
|
anchorId: string;
|
|
3330
4263
|
host: AnchorHost | null;
|
|
3331
4264
|
existing: TextStroke | undefined;
|
|
4265
|
+
caretPoint?: { x: number; y: number } | null;
|
|
3332
4266
|
onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
|
|
3333
4267
|
onCancel: () => void;
|
|
3334
4268
|
}) {
|
|
@@ -3351,24 +4285,18 @@ function TextEditor({
|
|
|
3351
4285
|
fontSize: existing?.fontSize ?? DEFAULT_FONT_SIZE,
|
|
3352
4286
|
align: existing?.align ?? 'center',
|
|
3353
4287
|
});
|
|
4288
|
+
// Both commit sites below (outside-click + Cmd/Ctrl+Enter) need the same
|
|
4289
|
+
// sentinel-strip + marker-strip pipeline. Memoized so the outside-click
|
|
4290
|
+
// effect below can depend on it directly instead of its own copy of
|
|
4291
|
+
// existing?.listType (lint/correctness/useExhaustiveDependencies).
|
|
4292
|
+
const toCommittedText = useCallback(
|
|
4293
|
+
(raw: string) => stripEditorMarkers(stripJumpSentinel(raw), existing?.listType),
|
|
4294
|
+
[existing?.listType]
|
|
4295
|
+
);
|
|
3354
4296
|
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
el.focus();
|
|
3359
|
-
// Select all so a re-edit replaces existing text easily.
|
|
3360
|
-
try {
|
|
3361
|
-
const r = document.createRange();
|
|
3362
|
-
r.selectNodeContents(el);
|
|
3363
|
-
const sel = window.getSelection();
|
|
3364
|
-
if (sel) {
|
|
3365
|
-
sel.removeAllRanges();
|
|
3366
|
-
sel.addRange(r);
|
|
3367
|
-
}
|
|
3368
|
-
} catch {
|
|
3369
|
-
/* selection API blocked */
|
|
3370
|
-
}
|
|
3371
|
-
}, []);
|
|
4297
|
+
// Caret-at-click on entry + custom blinking caret + click re-placement
|
|
4298
|
+
// (select-all only for keyboard entry — see useEditorCaret).
|
|
4299
|
+
const caretHandlers = useEditorCaret(ref, caretPoint);
|
|
3372
4300
|
|
|
3373
4301
|
// Commit on outside click; cancel-on-Esc handled in onKeyDown below.
|
|
3374
4302
|
useEffect(() => {
|
|
@@ -3381,15 +4309,11 @@ function TextEditor({
|
|
|
3381
4309
|
// FigJam v3 — the edit-mode text toolbar drives THIS editor; clicking
|
|
3382
4310
|
// it must not commit-and-close the session.
|
|
3383
4311
|
if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
|
|
3384
|
-
onCommit(
|
|
3385
|
-
anchorId,
|
|
3386
|
-
stripEditorMarkers(el.innerText || '', existing?.listType),
|
|
3387
|
-
fmtRef.current
|
|
3388
|
-
);
|
|
4312
|
+
onCommit(anchorId, toCommittedText(el.innerText || ''), fmtRef.current);
|
|
3389
4313
|
};
|
|
3390
4314
|
document.addEventListener('pointerdown', onDown, true);
|
|
3391
4315
|
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
3392
|
-
}, [anchorId, onCommit,
|
|
4316
|
+
}, [anchorId, onCommit, fmtRef, toCommittedText]);
|
|
3393
4317
|
|
|
3394
4318
|
if (!host) return null;
|
|
3395
4319
|
const bbox = strokeBBox(host);
|
|
@@ -3399,7 +4323,19 @@ function TextEditor({
|
|
|
3399
4323
|
// default align = centre).
|
|
3400
4324
|
const align = existing?.align ?? 'center';
|
|
3401
4325
|
return (
|
|
3402
|
-
|
|
4326
|
+
// Plain HTML host in the world div (NOT foreignObject — see AnnotEditors'
|
|
4327
|
+
// docblock): world coords map 1:1 to left/top, clicks hit-test correctly.
|
|
4328
|
+
<div
|
|
4329
|
+
data-annot-editor="1"
|
|
4330
|
+
style={{
|
|
4331
|
+
position: 'absolute',
|
|
4332
|
+
left: bbox.x,
|
|
4333
|
+
top: bbox.y,
|
|
4334
|
+
width: Math.max(20, bbox.w),
|
|
4335
|
+
height: Math.max(20, bbox.h),
|
|
4336
|
+
zIndex: 5,
|
|
4337
|
+
}}
|
|
4338
|
+
>
|
|
3403
4339
|
<div
|
|
3404
4340
|
ref={ref}
|
|
3405
4341
|
className="dc-annot-editor"
|
|
@@ -3429,7 +4365,10 @@ function TextEditor({
|
|
|
3429
4365
|
outline: 'none',
|
|
3430
4366
|
background: 'transparent',
|
|
3431
4367
|
cursor: 'text',
|
|
4368
|
+
...CARET_FIX_STYLE,
|
|
3432
4369
|
}}
|
|
4370
|
+
onPointerDown={caretHandlers.onPointerDown}
|
|
4371
|
+
onPointerUp={caretHandlers.onPointerUp}
|
|
3433
4372
|
onKeyDown={(e) => {
|
|
3434
4373
|
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
3435
4374
|
if (e.key === 'Escape') {
|
|
@@ -3437,44 +4376,46 @@ function TextEditor({
|
|
|
3437
4376
|
onCancel();
|
|
3438
4377
|
return;
|
|
3439
4378
|
}
|
|
3440
|
-
|
|
4379
|
+
// Unified across every text surface: plain Enter commits,
|
|
4380
|
+
// Shift+Enter inserts a newline (falls through untouched). ⌘/Ctrl
|
|
4381
|
+
// +Enter also commits AND chains a connected sibling (quick-create).
|
|
4382
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
3441
4383
|
e.preventDefault();
|
|
3442
4384
|
const el = ref.current;
|
|
3443
|
-
onCommit(
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
// sibling shape (quick-create) from the host.
|
|
3450
|
-
document.dispatchEvent(
|
|
3451
|
-
new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
|
|
3452
|
-
);
|
|
4385
|
+
onCommit(anchorId, toCommittedText(el?.innerText || ''), fmtRef.current);
|
|
4386
|
+
if (e.metaKey || e.ctrlKey) {
|
|
4387
|
+
document.dispatchEvent(
|
|
4388
|
+
new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
|
|
4389
|
+
);
|
|
4390
|
+
}
|
|
3453
4391
|
}
|
|
3454
4392
|
}}
|
|
3455
4393
|
>
|
|
3456
|
-
{initial}
|
|
4394
|
+
{initial || JUMP_SENTINEL}
|
|
3457
4395
|
</div>
|
|
3458
|
-
</
|
|
4396
|
+
</div>
|
|
3459
4397
|
);
|
|
3460
4398
|
}
|
|
3461
4399
|
|
|
3462
|
-
// Phase 21 — sticky body editor
|
|
3463
|
-
//
|
|
3464
|
-
// cancel on Esc; Enter
|
|
4400
|
+
// Phase 21 — sticky body editor, hosted as plain HTML at the card's bbox in
|
|
4401
|
+
// the world div (word-wrap + zoom come from the div's own box + the world
|
|
4402
|
+
// transform). Commit on blur, cancel on Esc; Enter commits, Shift+Enter
|
|
4403
|
+
// inserts a newline.
|
|
3465
4404
|
function StickyEditor({
|
|
3466
4405
|
sticky,
|
|
4406
|
+
caretPoint,
|
|
3467
4407
|
onCommit,
|
|
3468
4408
|
onCancel,
|
|
3469
4409
|
}: {
|
|
3470
4410
|
sticky: StickyStroke;
|
|
4411
|
+
caretPoint?: { x: number; y: number } | null;
|
|
3471
4412
|
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
3472
4413
|
onCancel: () => void;
|
|
3473
4414
|
}) {
|
|
3474
4415
|
// A flex-centered contentEditable (NOT a textarea) so the edit view matches
|
|
3475
4416
|
// the committed `.dc-sticky-body` exactly — text stays centered, no jump on
|
|
3476
|
-
// commit. Multi-line: Enter inserts a line break
|
|
3477
|
-
//
|
|
4417
|
+
// commit. Multi-line: Shift+Enter inserts a line break, plain Enter commits;
|
|
4418
|
+
// Esc cancels; blur commits; Cmd/Ctrl+B/I/U format (unified with the others).
|
|
3478
4419
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
3479
4420
|
const doneRef = useRef(false);
|
|
3480
4421
|
const {
|
|
@@ -3502,37 +4443,33 @@ function StickyEditor({
|
|
|
3502
4443
|
if (to?.closest?.('.dc-annot-ctx')) return;
|
|
3503
4444
|
commit();
|
|
3504
4445
|
};
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
if (!el) return;
|
|
3508
|
-
el.focus();
|
|
3509
|
-
try {
|
|
3510
|
-
const r = document.createRange();
|
|
3511
|
-
r.selectNodeContents(el);
|
|
3512
|
-
const sel = window.getSelection();
|
|
3513
|
-
if (sel) {
|
|
3514
|
-
sel.removeAllRanges();
|
|
3515
|
-
sel.addRange(r);
|
|
3516
|
-
}
|
|
3517
|
-
} catch {
|
|
3518
|
-
/* selection API blocked */
|
|
3519
|
-
}
|
|
3520
|
-
}, []);
|
|
4446
|
+
// Caret-at-click on entry + custom blinking caret + click re-placement.
|
|
4447
|
+
const caretHandlers = useEditorCaret(ref, caretPoint);
|
|
3521
4448
|
const x = Math.min(sticky.x, sticky.x + sticky.w);
|
|
3522
4449
|
const y = Math.min(sticky.y, sticky.y + sticky.h);
|
|
3523
4450
|
const w = Math.abs(sticky.w);
|
|
3524
4451
|
const h = Math.abs(sticky.h);
|
|
3525
4452
|
return (
|
|
3526
|
-
<
|
|
4453
|
+
<div
|
|
4454
|
+
data-annot-editor="1"
|
|
4455
|
+
style={{ position: 'absolute', left: x, top: y, width: w, height: h, zIndex: 5 }}
|
|
4456
|
+
>
|
|
3527
4457
|
<div
|
|
3528
|
-
xmlns="http://www.w3.org/1999/xhtml"
|
|
3529
4458
|
ref={ref}
|
|
3530
4459
|
className="dc-annot-editor dc-sticky-body"
|
|
3531
4460
|
contentEditable
|
|
3532
4461
|
suppressContentEditableWarning
|
|
3533
4462
|
aria-label="Edit sticky note text"
|
|
3534
|
-
style={{
|
|
4463
|
+
style={{
|
|
4464
|
+
...stickyBodyStyle(sticky),
|
|
4465
|
+
...fmtStyle,
|
|
4466
|
+
outline: 'none',
|
|
4467
|
+
cursor: 'text',
|
|
4468
|
+
...CARET_FIX_STYLE,
|
|
4469
|
+
}}
|
|
3535
4470
|
onBlur={onBlur}
|
|
4471
|
+
onPointerDown={caretHandlers.onPointerDown}
|
|
4472
|
+
onPointerUp={caretHandlers.onPointerUp}
|
|
3536
4473
|
onKeyDown={(e) => {
|
|
3537
4474
|
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
3538
4475
|
if (e.key === 'Escape') {
|
|
@@ -3541,14 +4478,16 @@ function StickyEditor({
|
|
|
3541
4478
|
onCancel();
|
|
3542
4479
|
return;
|
|
3543
4480
|
}
|
|
3544
|
-
|
|
4481
|
+
// Unified: plain Enter commits, Shift+Enter inserts a newline.
|
|
4482
|
+
// ⌘/Ctrl+Enter also commits AND chains the next sticky beside it.
|
|
4483
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
3545
4484
|
e.preventDefault();
|
|
3546
4485
|
commit();
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
4486
|
+
if (e.metaKey || e.ctrlKey) {
|
|
4487
|
+
document.dispatchEvent(
|
|
4488
|
+
new CustomEvent('maude:chain-create', { detail: { id: sticky.id } })
|
|
4489
|
+
);
|
|
4490
|
+
}
|
|
3552
4491
|
}
|
|
3553
4492
|
}}
|
|
3554
4493
|
>
|
|
@@ -3556,7 +4495,7 @@ function StickyEditor({
|
|
|
3556
4495
|
doesn't flicker; stripped back to raw text on commit. */}
|
|
3557
4496
|
{stickyBodyText(sticky)}
|
|
3558
4497
|
</div>
|
|
3559
|
-
</
|
|
4498
|
+
</div>
|
|
3560
4499
|
);
|
|
3561
4500
|
}
|
|
3562
4501
|
|
|
@@ -3574,6 +4513,9 @@ function StandaloneTextEditor({
|
|
|
3574
4513
|
underline,
|
|
3575
4514
|
align,
|
|
3576
4515
|
listType,
|
|
4516
|
+
singleLine,
|
|
4517
|
+
boxStyle,
|
|
4518
|
+
caretPoint,
|
|
3577
4519
|
onCommit,
|
|
3578
4520
|
onCancel,
|
|
3579
4521
|
}: {
|
|
@@ -3588,6 +4530,14 @@ function StandaloneTextEditor({
|
|
|
3588
4530
|
underline?: boolean;
|
|
3589
4531
|
align?: TextAlign;
|
|
3590
4532
|
listType?: ListType;
|
|
4533
|
+
/** The click that opened the editor — caret lands there (Phase 3). */
|
|
4534
|
+
caretPoint?: { x: number; y: number } | null;
|
|
4535
|
+
/** A one-line field (e.g. a section title rename) — plain Enter commits
|
|
4536
|
+
* instead of inserting a newline, matching a native text-input's Enter. */
|
|
4537
|
+
singleLine?: boolean;
|
|
4538
|
+
/** Extra style merged onto the editable box — e.g. a section rename wants
|
|
4539
|
+
* the same chip background/padding/radius the read-only label chip has. */
|
|
4540
|
+
boxStyle?: CSSProperties;
|
|
3591
4541
|
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
3592
4542
|
onCancel: () => void;
|
|
3593
4543
|
}) {
|
|
@@ -3617,22 +4567,8 @@ function StandaloneTextEditor({
|
|
|
3617
4567
|
},
|
|
3618
4568
|
[onCommit, listType, fmtRef]
|
|
3619
4569
|
);
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
if (!el) return;
|
|
3623
|
-
el.focus();
|
|
3624
|
-
try {
|
|
3625
|
-
const r = document.createRange();
|
|
3626
|
-
r.selectNodeContents(el);
|
|
3627
|
-
const sel = window.getSelection();
|
|
3628
|
-
if (sel) {
|
|
3629
|
-
sel.removeAllRanges();
|
|
3630
|
-
sel.addRange(r);
|
|
3631
|
-
}
|
|
3632
|
-
} catch {
|
|
3633
|
-
/* selection API blocked */
|
|
3634
|
-
}
|
|
3635
|
-
}, []);
|
|
4570
|
+
// Caret-at-click on entry + custom blinking caret + click re-placement.
|
|
4571
|
+
const caretHandlers = useEditorCaret(ref, caretPoint);
|
|
3636
4572
|
// Commit on outside click.
|
|
3637
4573
|
useEffect(() => {
|
|
3638
4574
|
if (typeof document === 'undefined') return;
|
|
@@ -3649,12 +4585,23 @@ function StandaloneTextEditor({
|
|
|
3649
4585
|
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
3650
4586
|
}, [commitOnce]);
|
|
3651
4587
|
return (
|
|
3652
|
-
// Generous box so multi-line text isn't clipped while typing (item 4a).
|
|
3653
|
-
//
|
|
3654
|
-
//
|
|
3655
|
-
|
|
4588
|
+
// Generous box so multi-line text isn't clipped while typing (item 4a).
|
|
4589
|
+
// The host passes pointer events through (empty area is not the editor —
|
|
4590
|
+
// outside-click must still commit); only the editable itself is
|
|
4591
|
+
// interactive, so clicks in it place the caret.
|
|
4592
|
+
<div
|
|
4593
|
+
data-annot-editor="1"
|
|
4594
|
+
style={{
|
|
4595
|
+
position: 'absolute',
|
|
4596
|
+
left: x,
|
|
4597
|
+
top: y,
|
|
4598
|
+
width: 640,
|
|
4599
|
+
height: 480,
|
|
4600
|
+
zIndex: 5,
|
|
4601
|
+
pointerEvents: 'none',
|
|
4602
|
+
}}
|
|
4603
|
+
>
|
|
3656
4604
|
<div
|
|
3657
|
-
xmlns="http://www.w3.org/1999/xhtml"
|
|
3658
4605
|
ref={ref}
|
|
3659
4606
|
className="dc-annot-editor"
|
|
3660
4607
|
contentEditable
|
|
@@ -3663,6 +4610,9 @@ function StandaloneTextEditor({
|
|
|
3663
4610
|
style={{
|
|
3664
4611
|
display: 'inline-block',
|
|
3665
4612
|
minWidth: '8px',
|
|
4613
|
+
// The pass-through host (above) is inert — re-enable events HERE so
|
|
4614
|
+
// in-editor clicks place the caret instead of falling through.
|
|
4615
|
+
pointerEvents: 'auto',
|
|
3666
4616
|
// pre-wrap so Enter inserts a real newline (multi-line text), not a
|
|
3667
4617
|
// commit; long lines also wrap within the box.
|
|
3668
4618
|
whiteSpace: 'pre-wrap',
|
|
@@ -3676,8 +4626,12 @@ function StandaloneTextEditor({
|
|
|
3676
4626
|
outline: 'none',
|
|
3677
4627
|
background: 'transparent',
|
|
3678
4628
|
cursor: 'text',
|
|
4629
|
+
...CARET_FIX_STYLE,
|
|
4630
|
+
...boxStyle,
|
|
3679
4631
|
}}
|
|
3680
4632
|
onBlur={() => commitOnce(ref.current?.innerText || '')}
|
|
4633
|
+
onPointerDown={caretHandlers.onPointerDown}
|
|
4634
|
+
onPointerUp={caretHandlers.onPointerUp}
|
|
3681
4635
|
onKeyDown={(e) => {
|
|
3682
4636
|
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
3683
4637
|
if (e.key === 'Escape') {
|
|
@@ -3687,8 +4641,10 @@ function StandaloneTextEditor({
|
|
|
3687
4641
|
onCancel();
|
|
3688
4642
|
return;
|
|
3689
4643
|
}
|
|
3690
|
-
//
|
|
3691
|
-
|
|
4644
|
+
// Unified: plain Enter commits, Shift+Enter inserts a newline. A
|
|
4645
|
+
// singleLine field (section rename) is a title — Shift+Enter commits
|
|
4646
|
+
// too rather than adding a newline the one-line chip can't show.
|
|
4647
|
+
if (e.key === 'Enter' && (!e.shiftKey || singleLine)) {
|
|
3692
4648
|
e.preventDefault();
|
|
3693
4649
|
commitOnce(ref.current?.innerText || '');
|
|
3694
4650
|
}
|
|
@@ -3696,7 +4652,7 @@ function StandaloneTextEditor({
|
|
|
3696
4652
|
>
|
|
3697
4653
|
{listPrefixedBody(initialText, listType)}
|
|
3698
4654
|
</div>
|
|
3699
|
-
</
|
|
4655
|
+
</div>
|
|
3700
4656
|
);
|
|
3701
4657
|
}
|
|
3702
4658
|
|
|
@@ -3798,6 +4754,7 @@ function AnnotationContextMenu({
|
|
|
3798
4754
|
selCount,
|
|
3799
4755
|
canUngroup,
|
|
3800
4756
|
canReplace,
|
|
4757
|
+
canEditPhoto,
|
|
3801
4758
|
onAction,
|
|
3802
4759
|
onClose,
|
|
3803
4760
|
}: {
|
|
@@ -3806,6 +4763,8 @@ function AnnotationContextMenu({
|
|
|
3806
4763
|
canUngroup: boolean;
|
|
3807
4764
|
/** Stage F3 — exactly one ImageStroke/MediaRefStroke is selected. */
|
|
3808
4765
|
canReplace: boolean;
|
|
4766
|
+
/** feature-photo-editor (Task 17) — exactly one content-addressed ImageStroke. */
|
|
4767
|
+
canEditPhoto: boolean;
|
|
3809
4768
|
onAction: (action: string) => void;
|
|
3810
4769
|
onClose: () => void;
|
|
3811
4770
|
}) {
|
|
@@ -3873,6 +4832,7 @@ function AnnotationContextMenu({
|
|
|
3873
4832
|
{item('paste', 'Paste', '⌘V')}
|
|
3874
4833
|
{item('duplicate', 'Duplicate', '⌘D')}
|
|
3875
4834
|
{canReplace ? item('replace', 'Replace…') : null}
|
|
4835
|
+
{canEditPhoto ? item('edit-photo', 'Edit Photo…') : null}
|
|
3876
4836
|
<div className="dc-menu-sep" aria-hidden="true" />
|
|
3877
4837
|
{item('front', 'Bring to front', ']')}
|
|
3878
4838
|
{item('forward', 'Bring forward', '⌘]')}
|
|
@@ -4004,6 +4964,16 @@ function AnnotGroupBbox({
|
|
|
4004
4964
|
const h = yMax - yMin + pad * 2;
|
|
4005
4965
|
const handle = 6;
|
|
4006
4966
|
const inset = 3;
|
|
4967
|
+
// Screen-constant hit target — bigger than the visible 6×6 square (matches
|
|
4968
|
+
// the single-resize DOM handles' generous grab area) so the corner stays
|
|
4969
|
+
// easy to grab at any zoom.
|
|
4970
|
+
const hit = 16 / zoom;
|
|
4971
|
+
const cursorFor: Record<string, string> = {
|
|
4972
|
+
nw: 'nwse-resize',
|
|
4973
|
+
se: 'nwse-resize',
|
|
4974
|
+
ne: 'nesw-resize',
|
|
4975
|
+
sw: 'nesw-resize',
|
|
4976
|
+
};
|
|
4007
4977
|
const handles = [
|
|
4008
4978
|
{ corner: 'nw', x: x - inset, y: y - inset },
|
|
4009
4979
|
{ corner: 'ne', x: x + w - handle + inset, y: y - inset },
|
|
@@ -4024,20 +4994,42 @@ function AnnotGroupBbox({
|
|
|
4024
4994
|
vectorEffect="non-scaling-stroke"
|
|
4025
4995
|
rx={2}
|
|
4026
4996
|
/>
|
|
4027
|
-
{
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4997
|
+
{/* Phase 2 (whiteboard-improvements) — proportional group resize: these
|
|
4998
|
+
corners used to be decorative (pointerEvents inherited 'none' from
|
|
4999
|
+
the wrapping <g>). Each now sets its OWN pointerEvents, which SVG
|
|
5000
|
+
lets a descendant override independent of an ancestor's value, and
|
|
5001
|
+
carries `data-group-resize-corner` for the drag gesture registered
|
|
5002
|
+
in AnnotationsLayer to identify which corner was grabbed. */}
|
|
5003
|
+
{handles.map((c) => {
|
|
5004
|
+
const cx = c.x + handle / 2;
|
|
5005
|
+
const cy = c.y + handle / 2;
|
|
5006
|
+
return (
|
|
5007
|
+
<g key={c.corner}>
|
|
5008
|
+
<rect
|
|
5009
|
+
x={cx - hit / 2}
|
|
5010
|
+
y={cy - hit / 2}
|
|
5011
|
+
width={hit}
|
|
5012
|
+
height={hit}
|
|
5013
|
+
fill="transparent"
|
|
5014
|
+
pointerEvents="all"
|
|
5015
|
+
data-group-resize-corner={c.corner}
|
|
5016
|
+
style={{ cursor: cursorFor[c.corner] }}
|
|
5017
|
+
/>
|
|
5018
|
+
<rect
|
|
5019
|
+
x={c.x}
|
|
5020
|
+
y={c.y}
|
|
5021
|
+
width={handle}
|
|
5022
|
+
height={handle}
|
|
5023
|
+
fill="var(--maude-hud-accent, #d63b1f)"
|
|
5024
|
+
stroke="var(--maude-chrome-bg-0, #ffffff)"
|
|
5025
|
+
strokeWidth={1}
|
|
5026
|
+
vectorEffect="non-scaling-stroke"
|
|
5027
|
+
rx={1}
|
|
5028
|
+
pointerEvents="none"
|
|
5029
|
+
/>
|
|
5030
|
+
</g>
|
|
5031
|
+
);
|
|
5032
|
+
})}
|
|
4041
5033
|
</g>
|
|
4042
5034
|
);
|
|
4043
5035
|
}
|
|
@@ -4166,6 +5158,106 @@ function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
|
|
|
4166
5158
|
* Pointer events pass through the group, so hit-testing + the ctx-toolbar's
|
|
4167
5159
|
* getBoundingClientRect positioning keep working on the rotated form.
|
|
4168
5160
|
*/
|
|
5161
|
+
/** Section title chip — deliberately screen-size-constant. The body/border
|
|
5162
|
+
* scale with the world like everything else, but a title that shrinks to
|
|
5163
|
+
* unreadable at zoom-out defeats the point of a label (matches the `r={5/zoom}`
|
|
5164
|
+
* counter-scale convention used for halos/connector-dots elsewhere in this file). */
|
|
5165
|
+
function SectionLabelChip({
|
|
5166
|
+
stroke,
|
|
5167
|
+
x,
|
|
5168
|
+
y,
|
|
5169
|
+
hitMode,
|
|
5170
|
+
}: {
|
|
5171
|
+
stroke: SectionStroke;
|
|
5172
|
+
x: number;
|
|
5173
|
+
y: number;
|
|
5174
|
+
hitMode: 'visiblePainted' | 'none';
|
|
5175
|
+
}) {
|
|
5176
|
+
const controller = useViewportControllerContext();
|
|
5177
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
5178
|
+
const fontSize = SECTION_LABEL_FONT / zoom;
|
|
5179
|
+
const chipH = SECTION_LABEL_H / zoom;
|
|
5180
|
+
const gap = 4 / zoom;
|
|
5181
|
+
const padX = 9 / zoom;
|
|
5182
|
+
const chipW = Math.max(56 / zoom, stroke.label.length * fontSize * 0.62 + 18 / zoom);
|
|
5183
|
+
// NOTE: this chip geometry (chipW/chipH/gap vs the region's y) is mirrored
|
|
5184
|
+
// by findTextStrokeAt's section branch — the Text tool's click-through
|
|
5185
|
+
// renames a section only from its label chip. Keep the two in sync.
|
|
5186
|
+
return (
|
|
5187
|
+
<g pointerEvents={hitMode}>
|
|
5188
|
+
<rect
|
|
5189
|
+
x={x}
|
|
5190
|
+
y={y - chipH - gap}
|
|
5191
|
+
width={chipW}
|
|
5192
|
+
height={chipH}
|
|
5193
|
+
rx={5 / zoom}
|
|
5194
|
+
ry={5 / zoom}
|
|
5195
|
+
fill={stroke.color}
|
|
5196
|
+
fillOpacity={0.16}
|
|
5197
|
+
/>
|
|
5198
|
+
<text
|
|
5199
|
+
x={x + padX}
|
|
5200
|
+
y={y - chipH / 2 - gap}
|
|
5201
|
+
dominantBaseline="middle"
|
|
5202
|
+
fontSize={fontSize}
|
|
5203
|
+
fill={stroke.color}
|
|
5204
|
+
style={{
|
|
5205
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
5206
|
+
}}
|
|
5207
|
+
>
|
|
5208
|
+
{stroke.label}
|
|
5209
|
+
</text>
|
|
5210
|
+
</g>
|
|
5211
|
+
);
|
|
5212
|
+
}
|
|
5213
|
+
|
|
5214
|
+
/** Section title RENAME field — same chip visuals + zoom-invariant sizing as
|
|
5215
|
+
* SectionLabelChip (read state), so switching into edit mode doesn't swap
|
|
5216
|
+
* the pill for a bare, ambient-zoomed sliver of text (it used to: the editor
|
|
5217
|
+
* had no background and its font-size wasn't counter-scaled, so at any zoom
|
|
5218
|
+
* below 1× the chip effectively vanished mid-rename). */
|
|
5219
|
+
function SectionTitleEditor({
|
|
5220
|
+
section,
|
|
5221
|
+
caretPoint,
|
|
5222
|
+
onCommit,
|
|
5223
|
+
onCancel,
|
|
5224
|
+
}: {
|
|
5225
|
+
section: SectionStroke;
|
|
5226
|
+
caretPoint?: { x: number; y: number } | null;
|
|
5227
|
+
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
5228
|
+
onCancel: () => void;
|
|
5229
|
+
}) {
|
|
5230
|
+
const controller = useViewportControllerContext();
|
|
5231
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
5232
|
+
const fontSize = SECTION_LABEL_FONT / zoom;
|
|
5233
|
+
const chipH = SECTION_LABEL_H / zoom;
|
|
5234
|
+
const gap = 4 / zoom;
|
|
5235
|
+
const padX = 9 / zoom;
|
|
5236
|
+
const x = Math.min(section.x, section.x + section.w);
|
|
5237
|
+
const y = Math.min(section.y, section.y + section.h);
|
|
5238
|
+
return (
|
|
5239
|
+
<StandaloneTextEditor
|
|
5240
|
+
x={x}
|
|
5241
|
+
y={y - chipH - gap}
|
|
5242
|
+
fontSize={fontSize}
|
|
5243
|
+
color={section.color}
|
|
5244
|
+
initialText={section.label}
|
|
5245
|
+
caretPoint={caretPoint}
|
|
5246
|
+
singleLine
|
|
5247
|
+
boxStyle={{
|
|
5248
|
+
background: `color-mix(in oklab, ${section.color} 16%, transparent)`,
|
|
5249
|
+
borderRadius: `${5 / zoom}px`,
|
|
5250
|
+
padding: `0 ${padX}px`,
|
|
5251
|
+
minHeight: `${chipH}px`,
|
|
5252
|
+
lineHeight: `${chipH}px`,
|
|
5253
|
+
whiteSpace: 'nowrap',
|
|
5254
|
+
}}
|
|
5255
|
+
onCommit={onCommit}
|
|
5256
|
+
onCancel={onCancel}
|
|
5257
|
+
/>
|
|
5258
|
+
);
|
|
5259
|
+
}
|
|
5260
|
+
|
|
4169
5261
|
function StrokeNode(props: {
|
|
4170
5262
|
stroke: Stroke;
|
|
4171
5263
|
anchorsById: Map<string, AnchorHost>;
|
|
@@ -4194,7 +5286,8 @@ function StrokeNodeBase({
|
|
|
4194
5286
|
stroke: Stroke;
|
|
4195
5287
|
anchorsById: Map<string, AnchorHost>;
|
|
4196
5288
|
interactive: boolean;
|
|
4197
|
-
/**
|
|
5289
|
+
/** Hide the read-only body/text while its editor is up (sticky, section, or
|
|
5290
|
+
* anchored shape text — whichever this stroke is). */
|
|
4198
5291
|
editing?: boolean;
|
|
4199
5292
|
}) {
|
|
4200
5293
|
// In Move mode, individual stroke nodes claim pointer events so we can
|
|
@@ -4217,6 +5310,11 @@ function StrokeNodeBase({
|
|
|
4217
5310
|
const host = anchorsById.get(stroke.anchorId);
|
|
4218
5311
|
const bbox = host ? strokeBBox(host) : null;
|
|
4219
5312
|
if (!bbox) return null;
|
|
5313
|
+
// Its editor (TextEditor) paints the same bbox while active — skip the
|
|
5314
|
+
// read-only <text> so the two don't double-paint (Phase 1 jump-fix
|
|
5315
|
+
// companion: the editor was already exempt via editingStickyId's
|
|
5316
|
+
// sibling, this stroke type never was).
|
|
5317
|
+
if (editing) return null;
|
|
4220
5318
|
const cy = bbox.y + bbox.h / 2;
|
|
4221
5319
|
const align = stroke.align ?? 'center';
|
|
4222
5320
|
const pad = 8;
|
|
@@ -4245,6 +5343,9 @@ function StrokeNodeBase({
|
|
|
4245
5343
|
</text>
|
|
4246
5344
|
);
|
|
4247
5345
|
}
|
|
5346
|
+
// Its editor (StandaloneTextEditor) paints at the same x/y while active —
|
|
5347
|
+
// skip the read-only <text> so the two don't double-paint (the "ghost").
|
|
5348
|
+
if (editing) return null;
|
|
4248
5349
|
const align = stroke.align ?? 'left';
|
|
4249
5350
|
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
4250
5351
|
const tx = stroke.x ?? 0;
|
|
@@ -4297,6 +5398,54 @@ function StrokeNodeBase({
|
|
|
4297
5398
|
</div>
|
|
4298
5399
|
</foreignObject>
|
|
4299
5400
|
)}
|
|
5401
|
+
{/* Phase 3 (whiteboard-improvements) — author badge, bottom-right
|
|
5402
|
+
corner. A name label (not an avatar — a full name/nickname reads
|
|
5403
|
+
faster than initials and doesn't need a legend to decode). Color
|
|
5404
|
+
re-derives from the (sanitized) name via colorForName — NEVER a
|
|
5405
|
+
stored/wire color — so it matches the author's live presence hue
|
|
5406
|
+
(cursor/avatar use the same function). foreignObject width is a
|
|
5407
|
+
fixed generous box right-anchored via flex, since a name's pixel
|
|
5408
|
+
width isn't known without measuring the DOM. */}
|
|
5409
|
+
{stroke.authorName && (
|
|
5410
|
+
<foreignObject
|
|
5411
|
+
x={x + w - 160}
|
|
5412
|
+
y={y + h - 20}
|
|
5413
|
+
width={160}
|
|
5414
|
+
height={20}
|
|
5415
|
+
pointerEvents="none"
|
|
5416
|
+
>
|
|
5417
|
+
<div
|
|
5418
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
5419
|
+
style={{
|
|
5420
|
+
display: 'flex',
|
|
5421
|
+
justifyContent: 'flex-end',
|
|
5422
|
+
alignItems: 'center',
|
|
5423
|
+
height: '100%',
|
|
5424
|
+
paddingRight: 4,
|
|
5425
|
+
}}
|
|
5426
|
+
>
|
|
5427
|
+
<span
|
|
5428
|
+
title={stroke.authorName}
|
|
5429
|
+
style={{
|
|
5430
|
+
maxWidth: '100%',
|
|
5431
|
+
overflow: 'hidden',
|
|
5432
|
+
textOverflow: 'ellipsis',
|
|
5433
|
+
whiteSpace: 'nowrap',
|
|
5434
|
+
fontSize: 9,
|
|
5435
|
+
fontWeight: 600,
|
|
5436
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
5437
|
+
color: colorForName(stroke.authorName),
|
|
5438
|
+
background: 'rgba(255,255,255,0.78)',
|
|
5439
|
+
padding: '1px 5px',
|
|
5440
|
+
borderRadius: 8,
|
|
5441
|
+
lineHeight: 1.4,
|
|
5442
|
+
}}
|
|
5443
|
+
>
|
|
5444
|
+
{stroke.authorName}
|
|
5445
|
+
</span>
|
|
5446
|
+
</div>
|
|
5447
|
+
</foreignObject>
|
|
5448
|
+
)}
|
|
4300
5449
|
</g>
|
|
4301
5450
|
);
|
|
4302
5451
|
}
|
|
@@ -4482,7 +5631,6 @@ function StrokeNodeBase({
|
|
|
4482
5631
|
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4483
5632
|
const w = Math.abs(stroke.w);
|
|
4484
5633
|
const h = Math.abs(stroke.h);
|
|
4485
|
-
const chipW = Math.max(56, stroke.label.length * SECTION_LABEL_FONT * 0.62 + 18);
|
|
4486
5634
|
return (
|
|
4487
5635
|
<g data-id={stroke.id} data-tool="section">
|
|
4488
5636
|
{/* Region body — pure backdrop, CLICK-THROUGH (FigJam: content on a
|
|
@@ -4518,31 +5666,10 @@ function StrokeNodeBase({
|
|
|
4518
5666
|
pointerEvents="stroke"
|
|
4519
5667
|
/>
|
|
4520
5668
|
) : null}
|
|
4521
|
-
{/* Label chip above the top-left corner — also a grab handle.
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
y={y - SECTION_LABEL_H - 4}
|
|
4526
|
-
width={chipW}
|
|
4527
|
-
height={SECTION_LABEL_H}
|
|
4528
|
-
rx={5}
|
|
4529
|
-
ry={5}
|
|
4530
|
-
fill={stroke.color}
|
|
4531
|
-
fillOpacity={0.16}
|
|
4532
|
-
/>
|
|
4533
|
-
<text
|
|
4534
|
-
x={x + 9}
|
|
4535
|
-
y={y - SECTION_LABEL_H / 2 - 4}
|
|
4536
|
-
dominantBaseline="middle"
|
|
4537
|
-
fontSize={SECTION_LABEL_FONT}
|
|
4538
|
-
fill={stroke.color}
|
|
4539
|
-
style={{
|
|
4540
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
4541
|
-
}}
|
|
4542
|
-
>
|
|
4543
|
-
{stroke.label}
|
|
4544
|
-
</text>
|
|
4545
|
-
</g>
|
|
5669
|
+
{/* Label chip above the top-left corner — also a grab handle. Hidden
|
|
5670
|
+
while the rename editor (StandaloneTextEditor) is up at the same
|
|
5671
|
+
spot, else the old label reads through behind the new text. */}
|
|
5672
|
+
{editing ? null : <SectionLabelChip stroke={stroke} x={x} y={y} hitMode={hitMode} />}
|
|
4546
5673
|
</g>
|
|
4547
5674
|
);
|
|
4548
5675
|
}
|