@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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file text-caret.ts — shared custom blinking caret for every editable
|
|
3
|
+
* text surface (artboard inline edit + the four annotation editors)
|
|
4
|
+
* @purpose Native caret BLINK is unreliable in WebKit under the transformed
|
|
5
|
+
* `.dc-world` (a compositing trigger on/near the editable freezes
|
|
6
|
+
* the blink to a static line — see CARET_FIX_STYLE in
|
|
7
|
+
* annotations-layer.tsx for the translateZ(0) post-mortem), and
|
|
8
|
+
* blink is temporal, so no automated tool can assert it. This
|
|
9
|
+
* module replaces the native caret with a CSS-animated
|
|
10
|
+
* `[data-maude-caret]` element: engine-independent, identical on
|
|
11
|
+
* every surface, and harness-assertable (presence + animationName
|
|
12
|
+
* + position). The native caret is hidden via
|
|
13
|
+
* `caret-color: transparent` while mounted.
|
|
14
|
+
*
|
|
15
|
+
* Positioning model: the caret element is appended to the document BODY with
|
|
16
|
+
* `position: fixed` and placed from the live selection's client rects —
|
|
17
|
+
* viewport coords, already post-transform, so the world's pan/zoom needs no
|
|
18
|
+
* special-casing. Positioning is driven THREE ways, deliberately redundant:
|
|
19
|
+
* a synchronous first paint on mount, the discrete event set (selectionchange
|
|
20
|
+
* / input / keyup / focus / blur / scroll), and a rAF loop for pan/zoom
|
|
21
|
+
* (which fires no DOM event). rAF alone is NOT enough — WKWebView pauses rAF
|
|
22
|
+
* for occluded windows (measured in the desktop-e2e harness), which would
|
|
23
|
+
* leave the caret invisible. Style is only touched on an actual rect change,
|
|
24
|
+
* and a change restarts the blink cycle exactly like a native caret (solid
|
|
25
|
+
* while typing/moving).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Place a collapsed caret inside a contentEditable at a viewport point —
|
|
30
|
+
* `caretRangeFromPoint` (WebKit/Chromium), `caretPositionFromPoint`
|
|
31
|
+
* (standard) fallback — THE unified entry-placement chain for the artboard
|
|
32
|
+
* inline editor and every annotation editor. A point that resolves outside
|
|
33
|
+
* the editable (click on padding / another element) is a miss. Without a
|
|
34
|
+
* point, or on a miss with `fallbackToSelectAll`, selects the whole content
|
|
35
|
+
* (the right default for keyboard entry, where select-all-then-retype is the
|
|
36
|
+
* rename convention). Returns true when a caret/selection was applied.
|
|
37
|
+
*/
|
|
38
|
+
export function placeCaretAt(
|
|
39
|
+
editable: HTMLElement,
|
|
40
|
+
win: Window,
|
|
41
|
+
point?: { x: number; y: number },
|
|
42
|
+
fallbackToSelectAll = true
|
|
43
|
+
): boolean {
|
|
44
|
+
const doc = editable.ownerDocument as Document & {
|
|
45
|
+
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
|
46
|
+
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
let range: Range | null = null;
|
|
50
|
+
if (point) {
|
|
51
|
+
if (typeof doc.caretRangeFromPoint === 'function') {
|
|
52
|
+
range = doc.caretRangeFromPoint(point.x, point.y);
|
|
53
|
+
} else if (typeof doc.caretPositionFromPoint === 'function') {
|
|
54
|
+
const pos = doc.caretPositionFromPoint(point.x, point.y);
|
|
55
|
+
if (pos) {
|
|
56
|
+
range = doc.createRange();
|
|
57
|
+
range.setStart(pos.offsetNode, pos.offset);
|
|
58
|
+
range.collapse(true);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (range && !editable.contains(range.startContainer)) range = null;
|
|
62
|
+
}
|
|
63
|
+
if (!range) {
|
|
64
|
+
if (!fallbackToSelectAll) return false;
|
|
65
|
+
range = doc.createRange();
|
|
66
|
+
range.selectNodeContents(editable);
|
|
67
|
+
}
|
|
68
|
+
const sel = win.getSelection();
|
|
69
|
+
if (!sel) return false;
|
|
70
|
+
sel.removeAllRanges();
|
|
71
|
+
sel.addRange(range);
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
/* selection API unavailable */
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const STYLE_ID = 'maude-caret-css';
|
|
80
|
+
|
|
81
|
+
const CARET_CSS = `
|
|
82
|
+
[data-maude-caret] {
|
|
83
|
+
animation: maude-caret-blink 1s steps(1, end) infinite;
|
|
84
|
+
}
|
|
85
|
+
@keyframes maude-caret-blink {
|
|
86
|
+
0%, 49.9% { opacity: 1; }
|
|
87
|
+
50%, 99.9% { opacity: 0; }
|
|
88
|
+
100% { opacity: 1; }
|
|
89
|
+
}
|
|
90
|
+
@media (prefers-reduced-motion: reduce) {
|
|
91
|
+
/* No blink, but the caret stays visible — position is still the signal. */
|
|
92
|
+
[data-maude-caret] { animation: none; }
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
function ensureCaretStyles(doc: Document): void {
|
|
97
|
+
if (doc.getElementById(STYLE_ID)) return;
|
|
98
|
+
const style = doc.createElement('style');
|
|
99
|
+
style.id = STYLE_ID;
|
|
100
|
+
style.textContent = CARET_CSS;
|
|
101
|
+
doc.head.appendChild(style);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface CaretRect {
|
|
105
|
+
x: number;
|
|
106
|
+
y: number;
|
|
107
|
+
h: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Resolve where the caret should paint, in viewport coords — or null when it
|
|
111
|
+
* should hide (no collapsed selection inside the editable / editable not
|
|
112
|
+
* focused / a range selection is active, where the native highlight is the
|
|
113
|
+
* affordance). */
|
|
114
|
+
function caretRectFor(editable: HTMLElement, win: Window): CaretRect | null {
|
|
115
|
+
const doc = editable.ownerDocument;
|
|
116
|
+
const active = doc.activeElement;
|
|
117
|
+
if (active !== editable && !editable.contains(active)) return null;
|
|
118
|
+
const sel = win.getSelection();
|
|
119
|
+
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
|
120
|
+
const node = sel.anchorNode;
|
|
121
|
+
if (!node || !editable.contains(node)) return null;
|
|
122
|
+
const range = sel.getRangeAt(0).cloneRange();
|
|
123
|
+
range.collapse(true);
|
|
124
|
+
// A collapsed range usually has one zero-width client rect; WebKit sometimes
|
|
125
|
+
// returns none (empty text node / line start) — getBoundingClientRect is the
|
|
126
|
+
// second try before falling back to the editable's own box metrics.
|
|
127
|
+
let r: DOMRect | null = range.getClientRects()[0] ?? null;
|
|
128
|
+
if (!r || (r.height === 0 && r.y === 0 && r.x === 0)) {
|
|
129
|
+
const br = range.getBoundingClientRect();
|
|
130
|
+
if (br && br.height > 0) r = br;
|
|
131
|
+
}
|
|
132
|
+
if (r && r.height > 0) return { x: r.left, y: r.top, h: r.height };
|
|
133
|
+
// Empty editable — no rect exists anywhere. Derive a caret box from the
|
|
134
|
+
// editable's padding box + line metrics. (The annotation editors avoid this
|
|
135
|
+
// path by mounting with a zero-width-space JUMP_SENTINEL, but the artboard
|
|
136
|
+
// editor can reach it by deleting all text.)
|
|
137
|
+
const er = editable.getBoundingClientRect();
|
|
138
|
+
const cs = win.getComputedStyle(editable);
|
|
139
|
+
const fs = Number.parseFloat(cs.fontSize) || 14;
|
|
140
|
+
const lh = cs.lineHeight === 'normal' ? fs * 1.25 : Number.parseFloat(cs.lineHeight) || fs * 1.25;
|
|
141
|
+
const padL = Number.parseFloat(cs.paddingLeft) || 0;
|
|
142
|
+
const padT = Number.parseFloat(cs.paddingTop) || 0;
|
|
143
|
+
return { x: er.left + padL, y: er.top + padT, h: lh };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Mount the custom blinking caret onto a contentEditable. Returns a disposer
|
|
148
|
+
* that removes the caret element and restores the editable's own caret-color.
|
|
149
|
+
* One mount per editing session — enter-edit mounts, teardown disposes.
|
|
150
|
+
*/
|
|
151
|
+
export function mountCaret(editable: HTMLElement, win: Window): () => void {
|
|
152
|
+
const doc = editable.ownerDocument;
|
|
153
|
+
if (!doc.body) return () => {};
|
|
154
|
+
ensureCaretStyles(doc);
|
|
155
|
+
|
|
156
|
+
const caret = doc.createElement('span');
|
|
157
|
+
caret.setAttribute('data-maude-caret', '1');
|
|
158
|
+
caret.setAttribute('aria-hidden', 'true');
|
|
159
|
+
Object.assign(caret.style, {
|
|
160
|
+
position: 'fixed',
|
|
161
|
+
left: '0px',
|
|
162
|
+
top: '0px',
|
|
163
|
+
width: '2px',
|
|
164
|
+
height: '16px',
|
|
165
|
+
background: 'var(--maude-hud-accent, #4a63e7)',
|
|
166
|
+
borderRadius: '1px',
|
|
167
|
+
pointerEvents: 'none',
|
|
168
|
+
zIndex: '9999',
|
|
169
|
+
display: 'none',
|
|
170
|
+
} satisfies Partial<CSSStyleDeclaration>);
|
|
171
|
+
doc.body.appendChild(caret);
|
|
172
|
+
|
|
173
|
+
const prevCaretColor = editable.style.caretColor;
|
|
174
|
+
editable.style.caretColor = 'transparent';
|
|
175
|
+
|
|
176
|
+
let shown = false;
|
|
177
|
+
let last: CaretRect = { x: -1, y: -1, h: -1 };
|
|
178
|
+
|
|
179
|
+
const restartBlink = (): void => {
|
|
180
|
+
// Re-trigger the stylesheet animation from 0% so the caret is solid right
|
|
181
|
+
// after a move/keystroke — the native caret's exact behavior.
|
|
182
|
+
caret.style.animation = 'none';
|
|
183
|
+
void caret.offsetWidth; // reflow flushes the 'none'
|
|
184
|
+
caret.style.animation = '';
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const position = (): void => {
|
|
188
|
+
const rc = caretRectFor(editable, win);
|
|
189
|
+
if (!rc) {
|
|
190
|
+
if (shown) {
|
|
191
|
+
caret.style.display = 'none';
|
|
192
|
+
shown = false;
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
if (rc.x !== last.x || rc.y !== last.y || rc.h !== last.h) {
|
|
196
|
+
caret.style.left = `${rc.x - 0.5}px`;
|
|
197
|
+
caret.style.top = `${rc.y}px`;
|
|
198
|
+
caret.style.height = `${Math.max(8, rc.h)}px`;
|
|
199
|
+
last = rc;
|
|
200
|
+
restartBlink();
|
|
201
|
+
}
|
|
202
|
+
if (!shown) {
|
|
203
|
+
caret.style.display = 'block';
|
|
204
|
+
shown = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Synchronous first paint + discrete events carry the caret even when rAF
|
|
210
|
+
// is throttled (WKWebView pauses rAF for occluded windows — measured in the
|
|
211
|
+
// desktop-e2e harness); the rAF loop on top tracks pan/zoom, which fires no
|
|
212
|
+
// DOM event at all.
|
|
213
|
+
const onUpdate = (): void => position();
|
|
214
|
+
doc.addEventListener('selectionchange', onUpdate);
|
|
215
|
+
editable.addEventListener('input', onUpdate);
|
|
216
|
+
editable.addEventListener('keyup', onUpdate);
|
|
217
|
+
editable.addEventListener('focus', onUpdate);
|
|
218
|
+
editable.addEventListener('blur', onUpdate);
|
|
219
|
+
win.addEventListener('scroll', onUpdate, true);
|
|
220
|
+
let raf = 0;
|
|
221
|
+
const tick = (): void => {
|
|
222
|
+
position();
|
|
223
|
+
raf = win.requestAnimationFrame(tick);
|
|
224
|
+
};
|
|
225
|
+
position();
|
|
226
|
+
raf = win.requestAnimationFrame(tick);
|
|
227
|
+
|
|
228
|
+
return () => {
|
|
229
|
+
win.cancelAnimationFrame(raf);
|
|
230
|
+
doc.removeEventListener('selectionchange', onUpdate);
|
|
231
|
+
editable.removeEventListener('input', onUpdate);
|
|
232
|
+
editable.removeEventListener('keyup', onUpdate);
|
|
233
|
+
editable.removeEventListener('focus', onUpdate);
|
|
234
|
+
editable.removeEventListener('blur', onUpdate);
|
|
235
|
+
win.removeEventListener('scroll', onUpdate, true);
|
|
236
|
+
caret.remove();
|
|
237
|
+
editable.style.caretColor = prevCaretColor;
|
|
238
|
+
};
|
|
239
|
+
}
|
|
@@ -287,17 +287,22 @@ const SHAPE_KINDS: ReadonlyArray<{ kind: ShapeKind; label: string }> = [
|
|
|
287
287
|
* when the user hasn't right-clicked a specific element). Growth-only: an
|
|
288
288
|
* artboard's `<article data-dc-screen>` carries NO `data-cd-id` of its own
|
|
289
289
|
* (`DCArtboard` doesn't forward it to the DOM — only its SOURCE JSX tag is
|
|
290
|
-
* stamped, unresolvable from here), so
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
290
|
+
* stamped, unresolvable from here), so the primary anchor is the artboard's
|
|
291
|
+
* LAST direct child that DOES carry a `data-cd-id`, inserted `after` it — the
|
|
292
|
+
* exact relation the context-menu's element-relative insert already uses.
|
|
293
|
+
* When the artboard has NO such child yet (fresh/emptied artboard — the bug
|
|
294
|
+
* this fallback fixes, was previously a silent no-op), fall back to the
|
|
295
|
+
* artboard's own `data-dc-screen` id (its `DCArtboard id` prop verbatim) and
|
|
296
|
+
* insert `inside-end` of it directly — `applyInsertElementIntoArtboard`
|
|
297
|
+
* addresses the `<DCArtboard>` JSX node by that same id, mirroring
|
|
298
|
+
* `applyResizeArtboard`/`applyDeleteArtboard`. Returns `null` only when there
|
|
299
|
+
* is no artboard at all (or no `.dc-artboard-body` wrapper) — nothing to
|
|
300
|
+
* anchor on, artboard-less canvas. `doc` is injectable for testing without a
|
|
301
|
+
* real iframe.
|
|
297
302
|
*/
|
|
298
303
|
export function resolveInsertAnchor(
|
|
299
304
|
doc: Document = document
|
|
300
|
-
): { refId: string; position: 'after' } | null {
|
|
305
|
+
): { refId: string; position: 'after' } | { artboardId: string; position: 'inside-end' } | null {
|
|
301
306
|
const current = doc.querySelector('[data-dc-screen][aria-current="true"]');
|
|
302
307
|
const artboardEl = current ?? doc.querySelector('[data-dc-screen]');
|
|
303
308
|
if (!artboardEl) return null;
|
|
@@ -306,7 +311,9 @@ export function resolveInsertAnchor(
|
|
|
306
311
|
const kids = Array.from(body.children).reverse();
|
|
307
312
|
const last = kids.find((el) => el.hasAttribute('data-cd-id'));
|
|
308
313
|
const refId = last?.getAttribute('data-cd-id');
|
|
309
|
-
|
|
314
|
+
if (refId) return { refId, position: 'after' };
|
|
315
|
+
const artboardId = artboardEl.getAttribute('data-dc-screen');
|
|
316
|
+
return artboardId ? { artboardId, position: 'inside-end' } : null;
|
|
310
317
|
}
|
|
311
318
|
|
|
312
319
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -349,25 +356,30 @@ export function ToolPalette() {
|
|
|
349
356
|
}, [tool]);
|
|
350
357
|
|
|
351
358
|
// Stage I3 tail — insert a Div/Text/Image relative to the active artboard's
|
|
352
|
-
// last existing element
|
|
359
|
+
// last existing element, or — empty-artboard fallback — as a child of the
|
|
360
|
+
// artboard itself (`resolveInsertAnchor`). Same request verbs the
|
|
353
361
|
// context-menu's per-element "Insert ▸ …" already posts (`insert-request` /
|
|
354
|
-
// `insert-image-request`, main-origin shell WRITES — DDR-054)
|
|
355
|
-
//
|
|
362
|
+
// `insert-image-request`, main-origin shell WRITES — DDR-054), extended with
|
|
363
|
+
// an `artboardId` field for the fallback case; a null anchor is still a
|
|
364
|
+
// no-op for div/text (nothing to insert into) but NOT for image —
|
|
365
|
+
// feature-bulk-media-insert still posts `insert-image-request` with no
|
|
366
|
+
// refId/artboardId, so an artboard-less canvas opens the picker (in
|
|
367
|
+
// annotation-only mode, Task 7) instead of silently doing nothing.
|
|
356
368
|
const insertViaPalette = (kind: 'div' | 'text' | 'image') => {
|
|
357
369
|
setInsertOpen(false);
|
|
358
370
|
const anchor = resolveInsertAnchor();
|
|
359
|
-
if (!anchor) return;
|
|
371
|
+
if (!anchor && kind !== 'image') return;
|
|
372
|
+
const anchorFields =
|
|
373
|
+
anchor == null
|
|
374
|
+
? {}
|
|
375
|
+
: 'artboardId' in anchor
|
|
376
|
+
? { artboardId: anchor.artboardId, position: anchor.position }
|
|
377
|
+
: { refId: anchor.refId, position: anchor.position };
|
|
360
378
|
try {
|
|
361
379
|
if (kind === 'image') {
|
|
362
|
-
window.parent.postMessage(
|
|
363
|
-
{ dgn: 'insert-image-request', refId: anchor.refId, position: anchor.position },
|
|
364
|
-
'*'
|
|
365
|
-
);
|
|
380
|
+
window.parent.postMessage({ dgn: 'insert-image-request', ...anchorFields }, '*');
|
|
366
381
|
} else {
|
|
367
|
-
window.parent.postMessage(
|
|
368
|
-
{ dgn: 'insert-request', refId: anchor.refId, position: anchor.position, kind },
|
|
369
|
-
'*'
|
|
370
|
-
);
|
|
382
|
+
window.parent.postMessage({ dgn: 'insert-request', ...anchorFields, kind }, '*');
|
|
371
383
|
}
|
|
372
384
|
} catch {
|
|
373
385
|
/* detached / cross-origin teardown */
|
|
@@ -513,6 +525,22 @@ export function ToolPalette() {
|
|
|
513
525
|
</div>
|
|
514
526
|
) : null}
|
|
515
527
|
</span>
|
|
528
|
+
<button
|
|
529
|
+
type="button"
|
|
530
|
+
aria-label="Stickers"
|
|
531
|
+
title="Stickers — searchable sticker picker"
|
|
532
|
+
onClick={() => {
|
|
533
|
+
try {
|
|
534
|
+
window.parent.postMessage({ dgn: 'open-sticker-picker' }, '*');
|
|
535
|
+
} catch {
|
|
536
|
+
/* detached / cross-origin teardown */
|
|
537
|
+
}
|
|
538
|
+
}}
|
|
539
|
+
>
|
|
540
|
+
<span aria-hidden="true" style={{ fontSize: 16, lineHeight: 1 }}>
|
|
541
|
+
🙂
|
|
542
|
+
</span>
|
|
543
|
+
</button>
|
|
516
544
|
<button
|
|
517
545
|
type="button"
|
|
518
546
|
aria-label="Export (⌘E)"
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// ui-prefs.ts — disk-backed UI / view preferences (feature-unified-settings-modal).
|
|
2
|
+
//
|
|
3
|
+
// The Settings modal's non-secret UI prefs (theme + the Canvas & View toggles)
|
|
4
|
+
// persist here so they survive a restart AND a cleared browser localStorage
|
|
5
|
+
// (the native WKWebView shell is the case that motivated an explicit on-disk
|
|
6
|
+
// store rather than trusting localStorage alone). Stored GLOBALLY per user, not
|
|
7
|
+
// per-project — these are user preferences, not canvas state — at
|
|
8
|
+
// `~/.config/maude/prefs.json` (XDG-aware, same location discipline as
|
|
9
|
+
// generation/keys.ts's keys.json and sync/hubs-config.ts's hubs.json).
|
|
10
|
+
//
|
|
11
|
+
// NON-SECRET by construction: this file only ever holds boolean/enum view
|
|
12
|
+
// toggles. It is never versioned and never served to a canvas — the GET/POST
|
|
13
|
+
// routes are MAIN-ORIGIN ONLY (privileged), like /_api/generate/prefs.
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/** The dockable shell panels the Layout tab can move between the two sides. */
|
|
20
|
+
export const DOCK_PANEL_IDS = [
|
|
21
|
+
'tree',
|
|
22
|
+
'layers',
|
|
23
|
+
'inspector',
|
|
24
|
+
'comments',
|
|
25
|
+
'changes',
|
|
26
|
+
'assistant',
|
|
27
|
+
] as const;
|
|
28
|
+
export type DockPanelId = (typeof DOCK_PANEL_IDS)[number];
|
|
29
|
+
export type DockSide = 'left' | 'right';
|
|
30
|
+
export type PanelSides = Record<DockPanelId, DockSide>;
|
|
31
|
+
|
|
32
|
+
export interface UiPrefs {
|
|
33
|
+
theme: 'light' | 'dark';
|
|
34
|
+
minimap: boolean;
|
|
35
|
+
zoom: boolean;
|
|
36
|
+
annotations: boolean;
|
|
37
|
+
autoOpenInspector: boolean;
|
|
38
|
+
/** Which side each dockable panel lives on (feature-configurable-panel-docking). */
|
|
39
|
+
panelSides: PanelSides;
|
|
40
|
+
/** Whether Layers is its own dockable panel or a tab inside the Inspector. */
|
|
41
|
+
layersMode: 'separate' | 'in-inspector';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Defaults MUST agree with app.jsx's initial state (THEME default 'dark',
|
|
45
|
+
// MINIMAP/ZOOMCTL false, annotations on, auto-open-inspector on) and with
|
|
46
|
+
// use-chrome-visibility.tsx's provider defaults. Panel defaults mirror the
|
|
47
|
+
// pre-docking shell: tree + layers on the left, everything else on the right;
|
|
48
|
+
// Layers ships as its own panel (`separate`), docked left.
|
|
49
|
+
export const PANEL_SIDES_DEFAULTS: PanelSides = {
|
|
50
|
+
tree: 'left',
|
|
51
|
+
layers: 'left',
|
|
52
|
+
inspector: 'right',
|
|
53
|
+
comments: 'right',
|
|
54
|
+
changes: 'right',
|
|
55
|
+
assistant: 'right',
|
|
56
|
+
};
|
|
57
|
+
export const UI_PREFS_DEFAULTS: UiPrefs = {
|
|
58
|
+
theme: 'dark',
|
|
59
|
+
minimap: false,
|
|
60
|
+
zoom: false,
|
|
61
|
+
annotations: true,
|
|
62
|
+
autoOpenInspector: true,
|
|
63
|
+
panelSides: { ...PANEL_SIDES_DEFAULTS },
|
|
64
|
+
layersMode: 'separate',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Resolve the on-disk path to prefs.json (mirrors keys.ts's XDG logic). */
|
|
68
|
+
export function uiPrefsPath(): string {
|
|
69
|
+
if (process.env.MAUDE_UI_PREFS_PATH) return process.env.MAUDE_UI_PREFS_PATH;
|
|
70
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
71
|
+
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.config');
|
|
72
|
+
return join(base, 'maude', 'prefs.json');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function coercePanelSides(raw: unknown): PanelSides {
|
|
76
|
+
const o =
|
|
77
|
+
raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
|
78
|
+
const out = { ...PANEL_SIDES_DEFAULTS };
|
|
79
|
+
for (const id of DOCK_PANEL_IDS) {
|
|
80
|
+
if (o[id] === 'left' || o[id] === 'right') out[id] = o[id] as DockSide;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function coerce(raw: unknown): UiPrefs {
|
|
86
|
+
const o =
|
|
87
|
+
raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
|
88
|
+
const bool = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d);
|
|
89
|
+
return {
|
|
90
|
+
theme: o.theme === 'light' || o.theme === 'dark' ? o.theme : UI_PREFS_DEFAULTS.theme,
|
|
91
|
+
minimap: bool(o.minimap, UI_PREFS_DEFAULTS.minimap),
|
|
92
|
+
zoom: bool(o.zoom, UI_PREFS_DEFAULTS.zoom),
|
|
93
|
+
annotations: bool(o.annotations, UI_PREFS_DEFAULTS.annotations),
|
|
94
|
+
autoOpenInspector: bool(o.autoOpenInspector, UI_PREFS_DEFAULTS.autoOpenInspector),
|
|
95
|
+
panelSides: coercePanelSides(o.panelSides),
|
|
96
|
+
layersMode: o.layersMode === 'in-inspector' ? 'in-inspector' : UI_PREFS_DEFAULTS.layersMode,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Current UI prefs merged over the defaults (defaults when missing/unreadable). */
|
|
101
|
+
export function readUiPrefs(): UiPrefs {
|
|
102
|
+
const path = uiPrefsPath();
|
|
103
|
+
if (!existsSync(path)) return { ...UI_PREFS_DEFAULTS };
|
|
104
|
+
try {
|
|
105
|
+
return coerce(JSON.parse(readFileSync(path, 'utf8')));
|
|
106
|
+
} catch {
|
|
107
|
+
return { ...UI_PREFS_DEFAULTS };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Persist a partial patch over the on-disk prefs (only the provided keys change;
|
|
113
|
+
* every other stored value is preserved). Returns the merged result. Best-effort
|
|
114
|
+
* — a write failure throws so the route can surface it.
|
|
115
|
+
*/
|
|
116
|
+
export function writeUiPrefs(patch: Partial<UiPrefs>): UiPrefs {
|
|
117
|
+
const cur = readUiPrefs();
|
|
118
|
+
// panelSides is deep-merged so a partial patch (one panel moved) preserves the
|
|
119
|
+
// other panels' sides instead of resetting them to defaults via coerce.
|
|
120
|
+
const merged: Partial<UiPrefs> = {
|
|
121
|
+
...cur,
|
|
122
|
+
...patch,
|
|
123
|
+
panelSides: { ...cur.panelSides, ...(patch.panelSides ?? {}) },
|
|
124
|
+
};
|
|
125
|
+
const next = coerce(merged);
|
|
126
|
+
const path = uiPrefsPath();
|
|
127
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
128
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
129
|
+
return next;
|
|
130
|
+
}
|
|
@@ -256,17 +256,109 @@ function ensureStoreMap(): Map<string, UndoStackState> {
|
|
|
256
256
|
return host.__maude_undo_stacks;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
// ── Durable layer: sessionStorage (DDR-050 follow-up) ────────────────────────
|
|
260
|
+
// The window/window.top Map above is the fast in-memory path, but it lives on a
|
|
261
|
+
// window object — and in the segregated canvas origin (DDR-054) that IS the
|
|
262
|
+
// canvas iframe's OWN window, which a full iframe reload destroys. An artboard
|
|
263
|
+
// TEXT edit rewrites the `.tsx` → HMR reload of the canvas iframe → the Map is
|
|
264
|
+
// wiped → Cmd+Z after the edit finds an empty stack. (Annotation edits write a
|
|
265
|
+
// sidecar SVG, never reload the iframe, so their history survives — the exact
|
|
266
|
+
// asymmetry users hit.) sessionStorage is per-ORIGIN and survives a reload, so
|
|
267
|
+
// it's the durable backstop: every save mirrors here, every load falls back to
|
|
268
|
+
// it when the Map misses (i.e. right after a reload rebuilt the window).
|
|
269
|
+
const SS_PREFIX = 'maude-undo:';
|
|
270
|
+
const SS_MAX_BYTES = 512 * 1024;
|
|
271
|
+
|
|
272
|
+
function getSessionStore(): Storage | null {
|
|
273
|
+
try {
|
|
274
|
+
return typeof window !== 'undefined' && window.sessionStorage ? window.sessionStorage : null;
|
|
275
|
+
} catch {
|
|
276
|
+
return null; // sessionStorage can throw in sandboxed / disabled contexts
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// DDR-054 §2g — strip dangerous keys at parse time so a poisoned sessionStorage
|
|
281
|
+
// entry can't seed `__proto__` / `constructor` / `prototype` own-properties into
|
|
282
|
+
// a rehydrated record (mirrors the sync-lane reviver, sync/codec.ts).
|
|
283
|
+
function parseJsonSafe(s: string): unknown {
|
|
284
|
+
return JSON.parse(s, (key, value) => {
|
|
285
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return undefined;
|
|
286
|
+
return value;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A well-formed persisted record — `kind`/`label` strings, a `payload`
|
|
291
|
+
* property present. Rejects a malformed/poisoned sessionStorage entry BEFORE
|
|
292
|
+
* it can reach the command rebuilder (defence-in-depth on the durable layer —
|
|
293
|
+
* the apply boundary is already path-contained + inert-write-only). */
|
|
294
|
+
function isCommandRecord(v: unknown): v is CommandRecord {
|
|
295
|
+
return (
|
|
296
|
+
!!v &&
|
|
297
|
+
typeof v === 'object' &&
|
|
298
|
+
typeof (v as CommandRecord).kind === 'string' &&
|
|
299
|
+
typeof (v as CommandRecord).label === 'string' &&
|
|
300
|
+
'payload' in (v as object)
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isUndoStackState(v: unknown): v is UndoStackState {
|
|
305
|
+
return (
|
|
306
|
+
!!v &&
|
|
307
|
+
typeof v === 'object' &&
|
|
308
|
+
Array.isArray((v as UndoStackState).past) &&
|
|
309
|
+
Array.isArray((v as UndoStackState).future) &&
|
|
310
|
+
(v as UndoStackState).past.every(isCommandRecord) &&
|
|
311
|
+
(v as UndoStackState).future.every(isCommandRecord)
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
259
315
|
/** Load saved state for a canvas file. Returns empty state on miss. */
|
|
260
316
|
export function loadStackState(canvasFile: string): UndoStackState {
|
|
261
|
-
|
|
317
|
+
const map = ensureStoreMap();
|
|
318
|
+
const cached = map.get(canvasFile);
|
|
319
|
+
if (cached) return cached;
|
|
320
|
+
// Map missed (fresh window after an iframe reload) — restore from the durable
|
|
321
|
+
// per-origin layer, then warm the Map so subsequent reads are in-memory.
|
|
322
|
+
try {
|
|
323
|
+
const raw = getSessionStore()?.getItem(SS_PREFIX + canvasFile);
|
|
324
|
+
if (raw) {
|
|
325
|
+
const parsed = parseJsonSafe(raw);
|
|
326
|
+
if (isUndoStackState(parsed)) {
|
|
327
|
+
map.set(canvasFile, parsed);
|
|
328
|
+
return parsed;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
/* corrupt / unavailable — fall through to empty */
|
|
333
|
+
}
|
|
334
|
+
return createUndoStackState();
|
|
262
335
|
}
|
|
263
336
|
|
|
264
|
-
/** Persist state for a canvas file
|
|
337
|
+
/** Persist state for a canvas file — to the in-memory Map AND the durable
|
|
338
|
+
* sessionStorage layer, so it survives the HMR iframe reload a text edit
|
|
339
|
+
* triggers. */
|
|
265
340
|
export function saveStackState(canvasFile: string, state: UndoStackState): void {
|
|
266
341
|
ensureStoreMap().set(canvasFile, state);
|
|
342
|
+
try {
|
|
343
|
+
const s = JSON.stringify(state);
|
|
344
|
+
if (s.length <= SS_MAX_BYTES) getSessionStore()?.setItem(SS_PREFIX + canvasFile, s);
|
|
345
|
+
} catch {
|
|
346
|
+
/* quota / serialization failure — the in-memory Map still holds it */
|
|
347
|
+
}
|
|
267
348
|
}
|
|
268
349
|
|
|
269
350
|
/** Test seam — wipe the cross-iframe store between bun:test cases. */
|
|
270
351
|
export function _clearStackStore(): void {
|
|
271
352
|
getStoreHost().__maude_undo_stacks = new Map();
|
|
353
|
+
try {
|
|
354
|
+
const ss = getSessionStore();
|
|
355
|
+
if (ss) {
|
|
356
|
+
for (let i = ss.length - 1; i >= 0; i--) {
|
|
357
|
+
const k = ss.key(i);
|
|
358
|
+
if (k?.startsWith(SS_PREFIX)) ss.removeItem(k);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} catch {
|
|
362
|
+
/* no sessionStorage in this runtime */
|
|
363
|
+
}
|
|
272
364
|
}
|
|
@@ -98,7 +98,7 @@ function ensureResizeStyles(): void {
|
|
|
98
98
|
document.head.appendChild(s);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
type Corner =
|
|
101
|
+
export type Corner =
|
|
102
102
|
| 'nw'
|
|
103
103
|
| 'ne'
|
|
104
104
|
| 'sw'
|
|
@@ -132,9 +132,9 @@ function isRotCorner(c: Corner): boolean {
|
|
|
132
132
|
* `padDX/padDY` shift a dragged handle's cursor back ONTO the true bbox
|
|
133
133
|
* corner/edge so the shape doesn't grow by the pad on first move.
|
|
134
134
|
*/
|
|
135
|
-
const padDX = (c: Corner): number =>
|
|
135
|
+
export const padDX = (c: Corner): number =>
|
|
136
136
|
c === 'nw' || c === 'sw' || c === 'w' ? 1 : c === 'ne' || c === 'se' || c === 'e' ? -1 : 0;
|
|
137
|
-
const padDY = (c: Corner): number =>
|
|
137
|
+
export const padDY = (c: Corner): number =>
|
|
138
138
|
c === 'nw' || c === 'ne' || c === 'n' ? 1 : c === 'sw' || c === 'se' || c === 's' ? -1 : 0;
|
|
139
139
|
|
|
140
140
|
/** Stroke types that expose resize handles. Text inherits its anchor bbox. */
|
|
@@ -227,7 +227,7 @@ function rotatedAnchorShift(
|
|
|
227
227
|
* The no-modifier branch is algebraically identical to the previous
|
|
228
228
|
* min/max corner math (verified against the resize round-trip tests).
|
|
229
229
|
*/
|
|
230
|
-
function bboxResize(
|
|
230
|
+
export function bboxResize(
|
|
231
231
|
bbox: { x: number; y: number; w: number; h: number },
|
|
232
232
|
corner: Corner,
|
|
233
233
|
wx: number,
|
|
@@ -301,6 +301,12 @@ export async function uploadAndAnnounceMedia(
|
|
|
301
301
|
showCanvasToast(`Added ${res.path}${warn} — snippet copied: ${snippet}`);
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
/** World-px stagger between cascaded drop targets so a batch Finder drop
|
|
305
|
+
* doesn't stack every clip on the exact same point. Exported — the picker's
|
|
306
|
+
* batched "Add as annotation" insert (annotations-layer.tsx) reuses the same
|
|
307
|
+
* stagger so both entry points cascade identically. */
|
|
308
|
+
export const BATCH_DROP_CASCADE_PX = 28;
|
|
309
|
+
|
|
304
310
|
export function useCanvasMediaDrop(opts: {
|
|
305
311
|
enabled: boolean;
|
|
306
312
|
screenToWorld: (cx: number, cy: number) => [number, number];
|
|
@@ -339,7 +345,40 @@ export function useCanvasMediaDrop(opts: {
|
|
|
339
345
|
const onDrop = (e: DragEvent) => {
|
|
340
346
|
body.classList.remove('dc-media-dragover');
|
|
341
347
|
if (!e.dataTransfer) return;
|
|
342
|
-
const
|
|
348
|
+
const payload = payloadFromTransfer(e.dataTransfer);
|
|
349
|
+
// A multi-file Finder drop (select N clips → drag) previously fell through
|
|
350
|
+
// classifyMediaPayload's single-intent contract (designed for the
|
|
351
|
+
// drop-carries-both-a-file-and-a-uri-list case) and silently dropped every
|
|
352
|
+
// file past the first — forcing users to drop one at a time. Batch-dispatch
|
|
353
|
+
// every image/video/audio file here; classifyMediaPayload still owns the
|
|
354
|
+
// single-item path (including link/URL drops, which have no "files" list).
|
|
355
|
+
const mediaFiles = payload.files.filter(
|
|
356
|
+
(f) =>
|
|
357
|
+
typeof f.type === 'string' &&
|
|
358
|
+
(f.type.startsWith('image/') ||
|
|
359
|
+
f.type.startsWith('video/') ||
|
|
360
|
+
f.type.startsWith('audio/'))
|
|
361
|
+
);
|
|
362
|
+
if (mediaFiles.length > 1) {
|
|
363
|
+
e.preventDefault();
|
|
364
|
+
const [ox, oy] = screenToWorld(e.clientX, e.clientY);
|
|
365
|
+
mediaFiles.forEach((file, i) => {
|
|
366
|
+
const world: [number, number] = [
|
|
367
|
+
ox + i * BATCH_DROP_CASCADE_PX,
|
|
368
|
+
oy + i * BATCH_DROP_CASCADE_PX,
|
|
369
|
+
];
|
|
370
|
+
const intent: MediaIntent = file.type.startsWith('image/')
|
|
371
|
+
? { kind: 'image', file }
|
|
372
|
+
: {
|
|
373
|
+
kind: 'media',
|
|
374
|
+
file,
|
|
375
|
+
mediaKind: file.type.startsWith('video/') ? 'video' : 'audio',
|
|
376
|
+
};
|
|
377
|
+
dispatchIntent(intent, world);
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const intent = classifyMediaPayload(payload);
|
|
343
382
|
if (!intent) return; // not media — leave the event for other handlers
|
|
344
383
|
e.preventDefault();
|
|
345
384
|
const world = screenToWorld(e.clientX, e.clientY);
|