@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
|
@@ -20,18 +20,61 @@ import GitPanel from './panels/GitPanel.jsx';
|
|
|
20
20
|
import IdentityBar from './panels/IdentityBar.jsx';
|
|
21
21
|
import OnboardingWizard from './panels/OnboardingWizard.jsx';
|
|
22
22
|
import { ReadinessDialog } from './panels/ReadinessList.jsx';
|
|
23
|
+
import IntroVideoDialog from './panels/IntroVideoDialog.jsx';
|
|
24
|
+
import BrandUploadPanel from './panels/BrandUploadPanel.jsx';
|
|
25
|
+
import SetupChecklistDialog, { useSetupReadiness } from './panels/SetupChecklist.jsx';
|
|
23
26
|
import TimelinePanel from './panels/TimelinePanel.jsx';
|
|
24
27
|
import { parseCompTimeline } from './panels/timeline-parse.js';
|
|
28
|
+
import GenerateDialog from './generate-dialog.jsx';
|
|
25
29
|
import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
|
|
30
|
+
import SettingsPanel from './panels/SettingsPanel.jsx';
|
|
31
|
+
import StickerPicker from './panels/StickerPicker.jsx';
|
|
32
|
+
import { AlignPad, AngleDial, ColorField, IconButtonGroup, IconToggleGroup, makeScrubHandler, NumberField, RadiusControl, Segmented, SliderField, Toggle, UnitSelect, ValueTokenField } from './inspector-controls.jsx';
|
|
33
|
+
import {
|
|
34
|
+
ALargeSmall as LuALargeSmall,
|
|
35
|
+
AlignCenter as LuAlignCenter,
|
|
36
|
+
AlignHorizontalJustifyCenter as LuJustifyCenter,
|
|
37
|
+
AlignHorizontalJustifyEnd as LuJustifyEnd,
|
|
38
|
+
AlignHorizontalJustifyStart as LuJustifyStart,
|
|
39
|
+
AlignHorizontalSpaceBetween as LuSpaceBetween,
|
|
40
|
+
AlignJustify as LuAlignJustify,
|
|
41
|
+
AlignLeft as LuAlignLeft,
|
|
42
|
+
AlignRight as LuAlignRight,
|
|
43
|
+
AlignVerticalJustifyCenter as LuVJustifyCenter,
|
|
44
|
+
AlignVerticalJustifyEnd as LuVJustifyEnd,
|
|
45
|
+
AlignVerticalJustifyStart as LuVJustifyStart,
|
|
46
|
+
Baseline as LuBaseline,
|
|
47
|
+
Bold as LuBold,
|
|
48
|
+
Columns3 as LuColumns3,
|
|
49
|
+
Eye as LuEye,
|
|
50
|
+
Italic as LuItalic,
|
|
51
|
+
Minus as LuMinus,
|
|
52
|
+
MoveHorizontal as LuMoveH,
|
|
53
|
+
RotateCw as LuRotateCw,
|
|
54
|
+
Rows3 as LuRows3,
|
|
55
|
+
Scissors as LuScissors,
|
|
56
|
+
ScrollText as LuScrollText,
|
|
57
|
+
Spline as LuSpline,
|
|
58
|
+
StretchHorizontal as LuStretch,
|
|
59
|
+
Underline as LuUnderline,
|
|
60
|
+
Braces as LuBraces,
|
|
61
|
+
Wand2 as LuWand2,
|
|
62
|
+
} from 'lucide-react';
|
|
63
|
+
|
|
64
|
+
// lucide wrapper — hairline stroke to match the shell's icon weight (handoff).
|
|
65
|
+
const Lu = ({ as: C, size = 14 }) => <C size={size} strokeWidth={1.75} style={{ display: 'block' }} />;
|
|
66
|
+
import { PhotoKnobs } from './photo-knobs.jsx';
|
|
26
67
|
import {
|
|
27
68
|
appIsFirstRun,
|
|
28
69
|
isNativeApp,
|
|
29
70
|
onUpdateReady,
|
|
30
71
|
pickMediaFile,
|
|
72
|
+
pickMediaFiles,
|
|
31
73
|
restartToUpdate,
|
|
32
74
|
} from './github.js';
|
|
33
75
|
import { COLLAB_TOUR } from './tour/collab-tour.js';
|
|
34
76
|
import { TourOverlay } from './tour/overlay.jsx';
|
|
77
|
+
import { QUICK_SETUP_TOUR } from './tour/quick-setup-tour.js';
|
|
35
78
|
import { USAGE_TOUR } from './tour/usage-tour.js';
|
|
36
79
|
import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
|
|
37
80
|
import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
|
|
@@ -44,7 +87,83 @@ const SYSTEM_TAB = '__system__';
|
|
|
44
87
|
const THEME_STORE = 'mdcc-theme';
|
|
45
88
|
const SHOW_HIDDEN_STORE = 'mdcc-show-hidden';
|
|
46
89
|
const SECTIONS_STORE = 'mdcc-sections-expanded';
|
|
90
|
+
// DDR-171 — CSS panel vocabulary mode ('advanced' | 'designer'), read inside
|
|
91
|
+
// CssKnobs.
|
|
92
|
+
const CP_MODE_STORE = 'maude-cp-mode';
|
|
47
93
|
const SIDEBAR_STORE = 'mdcc-sidebar-open';
|
|
94
|
+
const MINIMAP_STORE = 'mdcc-minimap-visible';
|
|
95
|
+
const ZOOMCTL_STORE = 'mdcc-zoomctl-visible';
|
|
96
|
+
const ANNOT_STORE = 'mdcc-annotations-visible';
|
|
97
|
+
const AUTOOPEN_STORE = 'maude-auto-open-inspector';
|
|
98
|
+
|
|
99
|
+
// feature-unified-settings-modal — the Settings modal's view prefs are ALSO
|
|
100
|
+
// persisted to disk (~/.config/maude/prefs.json via /_api/ui-prefs), so they
|
|
101
|
+
// survive a restart even if localStorage is cleared (the native WKWebView
|
|
102
|
+
// case). localStorage stays the synchronous init source (no boot flash); disk
|
|
103
|
+
// is the durable, cross-restart source of truth reconciled on mount.
|
|
104
|
+
function persistUiPrefs(patch) {
|
|
105
|
+
try {
|
|
106
|
+
fetch('/_api/ui-prefs', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'text/plain' },
|
|
109
|
+
body: JSON.stringify(patch),
|
|
110
|
+
}).catch(() => {});
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// feature-configurable-panel-docking — the dockable shell panels. Each can live
|
|
115
|
+
// in the LEFT or RIGHT slot (persisted in UiPrefs.panelSides); each slot is a
|
|
116
|
+
// single-panel-at-a-time surface with a tab strip over the panels assigned to
|
|
117
|
+
// it. Order here = tab order within a slot. `assistant` is native-only.
|
|
118
|
+
const DOCK_PANELS = [
|
|
119
|
+
{ id: 'tree', label: 'Files' },
|
|
120
|
+
{ id: 'layers', label: 'Layers' },
|
|
121
|
+
{ id: 'inspector', label: 'Inspector' },
|
|
122
|
+
{ id: 'comments', label: 'Comments' },
|
|
123
|
+
{ id: 'changes', label: 'Changes' },
|
|
124
|
+
{ id: 'assistant', label: 'Assistant' },
|
|
125
|
+
];
|
|
126
|
+
const PANEL_SIDES_DEFAULTS = {
|
|
127
|
+
tree: 'left',
|
|
128
|
+
layers: 'left',
|
|
129
|
+
inspector: 'right',
|
|
130
|
+
comments: 'right',
|
|
131
|
+
changes: 'right',
|
|
132
|
+
assistant: 'right',
|
|
133
|
+
};
|
|
134
|
+
const PANEL_SIDES_STORE = 'mdcc-panel-sides';
|
|
135
|
+
const LAYERS_MODE_STORE = 'mdcc-layers-mode';
|
|
136
|
+
|
|
137
|
+
// feature-configurable-panel-docking — one dock slot (left or right). Owns the
|
|
138
|
+
// resizable width + a tab strip over the panels assigned to the slot; renders
|
|
139
|
+
// the active panel (passed as children). Collapses to 0 width when nothing is
|
|
140
|
+
// visibly open (it may still host an always-mounted hidden ChatPanel).
|
|
141
|
+
function DockSlot({ side, width, open, ids, activeId, onPick, children }) {
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
className={'st-dockslot st-dockslot--' + side + (open ? '' : ' is-collapsed')}
|
|
145
|
+
style={{ width: open ? width : 0, flexBasis: open ? width : 0 }}
|
|
146
|
+
>
|
|
147
|
+
{open && ids.length > 1 && (
|
|
148
|
+
<div className="st-docktabs" role="tablist" aria-label={side + ' panels'}>
|
|
149
|
+
{ids.map((id) => (
|
|
150
|
+
<button
|
|
151
|
+
key={id}
|
|
152
|
+
type="button"
|
|
153
|
+
role="tab"
|
|
154
|
+
aria-selected={activeId === id}
|
|
155
|
+
className={'st-docktab' + (activeId === id ? ' is-active' : '')}
|
|
156
|
+
onClick={() => onPick(id)}
|
|
157
|
+
>
|
|
158
|
+
{(DOCK_PANELS.find((p) => p.id === id) || {}).label || id}
|
|
159
|
+
</button>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
{children}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
48
167
|
const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
|
|
49
168
|
// Bun's `define` substitutes this at build time (see build.ts); falls back when
|
|
50
169
|
// the bundle is consumed in a context that hasn't run the build.
|
|
@@ -841,6 +960,23 @@ const PNG_SCALES = [
|
|
|
841
960
|
{ value: 3, label: '3× (max)' },
|
|
842
961
|
];
|
|
843
962
|
|
|
963
|
+
// feature-bulk-media-insert — best-effort MIME guess from a filename extension.
|
|
964
|
+
// Only used to classify a natively-picked file (Rust returns `{name, bytes}`,
|
|
965
|
+
// no type) for the destination-toggle's image/video/audio split; the actual
|
|
966
|
+
// upload is always magic-byte-sniffed server-side, so a wrong guess here
|
|
967
|
+
// can't misrepresent what gets stored, only which picker UI state it shows.
|
|
968
|
+
const EXT_MIME = {
|
|
969
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
970
|
+
webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
|
|
971
|
+
mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/mp4', ogg: 'video/ogg',
|
|
972
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', aac: 'audio/aac',
|
|
973
|
+
flac: 'audio/flac', oga: 'audio/ogg', opus: 'audio/opus',
|
|
974
|
+
};
|
|
975
|
+
function mimeFromExt(name) {
|
|
976
|
+
const ext = String(name || '').split('.').pop()?.toLowerCase();
|
|
977
|
+
return EXT_MIME[ext] || 'application/octet-stream';
|
|
978
|
+
}
|
|
979
|
+
|
|
844
980
|
// feature-element-editing-robustness Stage F1 — AssetPicker. A shell modal that
|
|
845
981
|
// lists the versioned content-addressed media under <designRoot>/assets/ (via the
|
|
846
982
|
// main-origin-only GET /_api/assets) and lets the user pick one — or upload a new
|
|
@@ -848,16 +984,38 @@ const PNG_SCALES = [
|
|
|
848
984
|
// Insert. Thumbnails load from the main origin's designRoot static serve
|
|
849
985
|
// (/${designRel}/${asset.path}); never a remote hotlink (the CSP split origin
|
|
850
986
|
// blocks those — memory reference_canvas_images_download_first).
|
|
851
|
-
function AssetPicker({
|
|
987
|
+
function AssetPicker({
|
|
988
|
+
designRel,
|
|
989
|
+
onPick,
|
|
990
|
+
onClose,
|
|
991
|
+
multiple = false,
|
|
992
|
+
hasArtboardAnchor = false,
|
|
993
|
+
onPickMany,
|
|
994
|
+
}) {
|
|
852
995
|
const [assets, setAssets] = useState(null);
|
|
853
996
|
const [busy, setBusy] = useState(false);
|
|
854
997
|
const [err, setErr] = useState(null);
|
|
998
|
+
// feature-bulk-media-insert — multi-select state. `kindByPath` merges the
|
|
999
|
+
// fetched listing with freshly uploaded assets (the upload response carries
|
|
1000
|
+
// no `kind`, so a just-uploaded file's kind comes from its own File.type).
|
|
1001
|
+
const [selected, setSelected] = useState(() => new Set());
|
|
1002
|
+
const [kindByPath, setKindByPath] = useState({});
|
|
1003
|
+
const [destination, setDestination] = useState(hasArtboardAnchor ? 'artboard' : 'annotation');
|
|
855
1004
|
|
|
856
1005
|
useEffect(() => {
|
|
857
1006
|
let alive = true;
|
|
858
1007
|
fetch('/_api/assets')
|
|
859
1008
|
.then((r) => r.json())
|
|
860
|
-
.then((j) =>
|
|
1009
|
+
.then((j) => {
|
|
1010
|
+
if (!alive) return;
|
|
1011
|
+
const list = j.ok ? j.assets : [];
|
|
1012
|
+
setAssets(list);
|
|
1013
|
+
setKindByPath((prev) => {
|
|
1014
|
+
const next = { ...prev };
|
|
1015
|
+
for (const a of list) next[a.path] = a.kind;
|
|
1016
|
+
return next;
|
|
1017
|
+
});
|
|
1018
|
+
})
|
|
861
1019
|
.catch(() => alive && setAssets([]));
|
|
862
1020
|
return () => {
|
|
863
1021
|
alive = false;
|
|
@@ -875,28 +1033,84 @@ function AssetPicker({ designRel, onPick, onClose }) {
|
|
|
875
1033
|
return () => window.removeEventListener('keydown', onKey, true);
|
|
876
1034
|
}, [onClose]);
|
|
877
1035
|
|
|
1036
|
+
const hasNonImageSelected = Array.from(selected).some((p) => (kindByPath[p] || 'image') !== 'image');
|
|
1037
|
+
const artboardDisabled = !hasArtboardAnchor || hasNonImageSelected;
|
|
1038
|
+
const artboardDisabledReason = !hasArtboardAnchor
|
|
1039
|
+
? 'No artboard on this canvas'
|
|
1040
|
+
: hasNonImageSelected
|
|
1041
|
+
? 'Video/audio can only be added as annotations'
|
|
1042
|
+
: '';
|
|
1043
|
+
// Force off "Add to artboard" the moment it becomes unavailable (e.g. a
|
|
1044
|
+
// video gets added to an until-now all-image selection).
|
|
1045
|
+
useEffect(() => {
|
|
1046
|
+
if (artboardDisabled && destination === 'artboard') setDestination('annotation');
|
|
1047
|
+
}, [artboardDisabled, destination]);
|
|
1048
|
+
|
|
1049
|
+
const toggleSelected = (path) => {
|
|
1050
|
+
setSelected((prev) => {
|
|
1051
|
+
const next = new Set(prev);
|
|
1052
|
+
if (next.has(path)) next.delete(path);
|
|
1053
|
+
else next.add(path);
|
|
1054
|
+
return next;
|
|
1055
|
+
});
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
// Shared upload core — both the single-pick `doUpload` and the multi-file
|
|
1059
|
+
// `doUploadMany` post through here, so neither duplicates the request shape.
|
|
1060
|
+
const uploadOne = async (f) => {
|
|
1061
|
+
const res = await fetch('/_api/asset', {
|
|
1062
|
+
method: 'POST',
|
|
1063
|
+
headers: { 'content-type': f.type || 'application/octet-stream' },
|
|
1064
|
+
body: f,
|
|
1065
|
+
});
|
|
1066
|
+
const j = await res.json().catch(() => ({}));
|
|
1067
|
+
// /_api/asset returns 201 { path } on success — NO `ok` field (the bug: a
|
|
1068
|
+
// `j.ok` check always failed → "upload failed" even on a good upload).
|
|
1069
|
+
if (res.ok && j.path) return { ok: true, path: j.path };
|
|
1070
|
+
return { ok: false, error: j.error || `upload failed (HTTP ${res.status})` };
|
|
1071
|
+
};
|
|
1072
|
+
|
|
878
1073
|
const doUpload = async (f) => {
|
|
879
1074
|
if (!f) return;
|
|
880
1075
|
setBusy(true);
|
|
881
1076
|
setErr(null);
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1077
|
+
const res = await uploadOne(f);
|
|
1078
|
+
setBusy(false);
|
|
1079
|
+
if (res.ok) onPick(res.path);
|
|
1080
|
+
else setErr(res.error);
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
const kindOfMime = (mime) => {
|
|
1084
|
+
if (typeof mime === 'string' && mime.startsWith('video/')) return 'video';
|
|
1085
|
+
if (typeof mime === 'string' && mime.startsWith('audio/')) return 'audio';
|
|
1086
|
+
return 'image';
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
// Multi-file upload — each file uploads independently/concurrently (the
|
|
1090
|
+
// route is idempotent + content-addressed, so no ordering concern); every
|
|
1091
|
+
// one that succeeds joins the selection as it resolves.
|
|
1092
|
+
const doUploadMany = async (files) => {
|
|
1093
|
+
if (!files.length) return;
|
|
1094
|
+
setBusy(true);
|
|
1095
|
+
setErr(null);
|
|
1096
|
+
const results = await Promise.all(
|
|
1097
|
+
files.map((f) => uploadOne(f).then((r) => ({ ...r, file: f })))
|
|
1098
|
+
);
|
|
1099
|
+
setBusy(false);
|
|
1100
|
+
const ok = results.filter((r) => r.ok);
|
|
1101
|
+
const failed = results.length - ok.length;
|
|
1102
|
+
if (failed > 0) setErr(`${failed} of ${results.length} uploads failed`);
|
|
1103
|
+
if (ok.length) {
|
|
1104
|
+
setKindByPath((prev) => {
|
|
1105
|
+
const next = { ...prev };
|
|
1106
|
+
for (const r of ok) next[r.path] = kindOfMime(r.file.type);
|
|
1107
|
+
return next;
|
|
1108
|
+
});
|
|
1109
|
+
setSelected((prev) => {
|
|
1110
|
+
const next = new Set(prev);
|
|
1111
|
+
for (const r of ok) next.add(r.path);
|
|
1112
|
+
return next;
|
|
887
1113
|
});
|
|
888
|
-
const j = await res.json().catch(() => ({}));
|
|
889
|
-
// /_api/asset returns 201 { path } on success — NO `ok` field (the bug: a
|
|
890
|
-
// `j.ok` check always failed → "upload failed" even on a good upload).
|
|
891
|
-
if (res.ok && j.path) {
|
|
892
|
-
onPick(j.path);
|
|
893
|
-
return;
|
|
894
|
-
}
|
|
895
|
-
setErr(j.error || `upload failed (HTTP ${res.status})`);
|
|
896
|
-
} catch {
|
|
897
|
-
setErr('upload failed');
|
|
898
|
-
} finally {
|
|
899
|
-
setBusy(false);
|
|
900
1114
|
}
|
|
901
1115
|
};
|
|
902
1116
|
|
|
@@ -909,6 +1123,15 @@ function AssetPicker({ designRel, onPick, onClose }) {
|
|
|
909
1123
|
setBusy(true);
|
|
910
1124
|
setErr(null);
|
|
911
1125
|
try {
|
|
1126
|
+
if (multiple) {
|
|
1127
|
+
const picked = await pickMediaFiles();
|
|
1128
|
+
const files = (picked || []).map(
|
|
1129
|
+
(p) => new File([new Uint8Array(p.bytes)], p.name, { type: mimeFromExt(p.name) })
|
|
1130
|
+
);
|
|
1131
|
+
setBusy(false);
|
|
1132
|
+
if (files.length) await doUploadMany(files);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
912
1135
|
const picked = await pickMediaFile();
|
|
913
1136
|
if (picked?.bytes) {
|
|
914
1137
|
// Blob from the byte array; the server sniffs the type, so no content-type
|
|
@@ -919,7 +1142,7 @@ function AssetPicker({ designRel, onPick, onClose }) {
|
|
|
919
1142
|
} catch (e) {
|
|
920
1143
|
setErr(e?.message || 'open failed');
|
|
921
1144
|
}
|
|
922
|
-
setBusy(false); // only reached on cancel / error (doUpload
|
|
1145
|
+
setBusy(false); // only reached on cancel / error (doUpload/doUploadMany own the success path)
|
|
923
1146
|
};
|
|
924
1147
|
|
|
925
1148
|
// Browser — imperative <input>, mirrors the proven replaceMediaViaPicker
|
|
@@ -933,6 +1156,7 @@ function AssetPicker({ designRel, onPick, onClose }) {
|
|
|
933
1156
|
const input = document.createElement('input');
|
|
934
1157
|
input.type = 'file';
|
|
935
1158
|
input.accept = 'image/*,video/*';
|
|
1159
|
+
if (multiple) input.multiple = true;
|
|
936
1160
|
input.style.cssText =
|
|
937
1161
|
'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none';
|
|
938
1162
|
document.body.appendChild(input);
|
|
@@ -941,9 +1165,11 @@ function AssetPicker({ designRel, onPick, onClose }) {
|
|
|
941
1165
|
};
|
|
942
1166
|
window.addEventListener('focus', () => setTimeout(cleanup, 300), { once: true });
|
|
943
1167
|
input.addEventListener('change', () => {
|
|
944
|
-
const
|
|
1168
|
+
const files = input.files ? Array.from(input.files) : [];
|
|
945
1169
|
cleanup();
|
|
946
|
-
if (
|
|
1170
|
+
if (!files.length) return;
|
|
1171
|
+
if (multiple) doUploadMany(files);
|
|
1172
|
+
else doUpload(files[0]);
|
|
947
1173
|
});
|
|
948
1174
|
input.click();
|
|
949
1175
|
};
|
|
@@ -978,25 +1204,71 @@ function AssetPicker({ designRel, onPick, onClose }) {
|
|
|
978
1204
|
) : assets.length === 0 ? (
|
|
979
1205
|
<div className="st-ap-empty">No assets yet — upload one.</div>
|
|
980
1206
|
) : (
|
|
981
|
-
assets.map((a) =>
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1207
|
+
assets.map((a) => {
|
|
1208
|
+
const isSelected = multiple && selected.has(a.path);
|
|
1209
|
+
return (
|
|
1210
|
+
<button
|
|
1211
|
+
type="button"
|
|
1212
|
+
key={a.path}
|
|
1213
|
+
className="st-ap-cell"
|
|
1214
|
+
data-selected={isSelected ? 'true' : undefined}
|
|
1215
|
+
aria-pressed={multiple ? isSelected : undefined}
|
|
1216
|
+
title={`${a.name} · ${Math.max(1, Math.round(a.size / 1024))} KB`}
|
|
1217
|
+
onClick={() => (multiple ? toggleSelected(a.path) : onPick(a.path))}
|
|
1218
|
+
>
|
|
1219
|
+
{multiple && (
|
|
1220
|
+
<span className="st-ap-check" aria-hidden="true">
|
|
1221
|
+
{isSelected ? <StIcon name="check" size={12} /> : null}
|
|
1222
|
+
</span>
|
|
1223
|
+
)}
|
|
1224
|
+
{a.kind === 'video' ? (
|
|
1225
|
+
<video className="st-ap-thumb" src={assetUrl(a.path)} muted playsInline />
|
|
1226
|
+
) : (
|
|
1227
|
+
<img className="st-ap-thumb" src={assetUrl(a.path)} alt={a.name} loading="lazy" />
|
|
1228
|
+
)}
|
|
1229
|
+
<span className="st-ap-name">{a.name}</span>
|
|
1230
|
+
</button>
|
|
1231
|
+
);
|
|
1232
|
+
})
|
|
997
1233
|
)}
|
|
998
1234
|
</div>
|
|
999
1235
|
</div>
|
|
1236
|
+
{multiple && selected.size > 0 && (
|
|
1237
|
+
<div className="st-dialog-ft st-ap-confirm">
|
|
1238
|
+
<span className="st-ap-count">{selected.size} selected</span>
|
|
1239
|
+
<div className="st-ap-seg" role="radiogroup" aria-label="Insert as">
|
|
1240
|
+
<button
|
|
1241
|
+
type="button"
|
|
1242
|
+
className="st-ap-seg-btn"
|
|
1243
|
+
aria-pressed={destination === 'artboard'}
|
|
1244
|
+
disabled={artboardDisabled}
|
|
1245
|
+
onClick={() => setDestination('artboard')}
|
|
1246
|
+
>
|
|
1247
|
+
Add to artboard
|
|
1248
|
+
</button>
|
|
1249
|
+
<button
|
|
1250
|
+
type="button"
|
|
1251
|
+
className="st-ap-seg-btn"
|
|
1252
|
+
aria-pressed={destination === 'annotation'}
|
|
1253
|
+
onClick={() => setDestination('annotation')}
|
|
1254
|
+
>
|
|
1255
|
+
Add as annotation
|
|
1256
|
+
</button>
|
|
1257
|
+
</div>
|
|
1258
|
+
{artboardDisabled && <span className="st-ap-dest-note">{artboardDisabledReason}</span>}
|
|
1259
|
+
<span className="st-ap-confirm-spacer" />
|
|
1260
|
+
<button type="button" className="btn btn--ghost" onClick={onClose}>
|
|
1261
|
+
Cancel
|
|
1262
|
+
</button>
|
|
1263
|
+
<button
|
|
1264
|
+
type="button"
|
|
1265
|
+
className="btn btn--primary"
|
|
1266
|
+
onClick={() => onPickMany?.(Array.from(selected), destination)}
|
|
1267
|
+
>
|
|
1268
|
+
Insert
|
|
1269
|
+
</button>
|
|
1270
|
+
</div>
|
|
1271
|
+
)}
|
|
1000
1272
|
</div>
|
|
1001
1273
|
</div>
|
|
1002
1274
|
);
|
|
@@ -1324,7 +1596,18 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
|
|
|
1324
1596
|
);
|
|
1325
1597
|
}
|
|
1326
1598
|
|
|
1327
|
-
function FileRow({
|
|
1599
|
+
function FileRow({
|
|
1600
|
+
file,
|
|
1601
|
+
activePath,
|
|
1602
|
+
onOpen,
|
|
1603
|
+
onDelete,
|
|
1604
|
+
openCount: oc,
|
|
1605
|
+
depth,
|
|
1606
|
+
kind,
|
|
1607
|
+
sidecar,
|
|
1608
|
+
dirty,
|
|
1609
|
+
experimentalKind,
|
|
1610
|
+
}) {
|
|
1328
1611
|
const isSel = file.path === activePath;
|
|
1329
1612
|
const isCanvas = CANVAS_EXT_RE.test(file.name);
|
|
1330
1613
|
// Non-canvas rows (PROJECT *.md, RUNTIME _active.json, ...) are display-only —
|
|
@@ -1368,6 +1651,15 @@ function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kin
|
|
|
1368
1651
|
<StIcon name="file" size={13} />
|
|
1369
1652
|
</span>
|
|
1370
1653
|
<span className="st-row-name">{label}</span>
|
|
1654
|
+
{experimentalKind === 'reconstructed-experimental' && (
|
|
1655
|
+
<span
|
|
1656
|
+
className="st-row-exp-badge"
|
|
1657
|
+
title="Reconstructed from an image via /design:import --reconstruct — experimental, lossy, review before trusting (DDR-174)"
|
|
1658
|
+
aria-label="Reconstructed, experimental"
|
|
1659
|
+
>
|
|
1660
|
+
exp
|
|
1661
|
+
</span>
|
|
1662
|
+
)}
|
|
1371
1663
|
{dirty && (
|
|
1372
1664
|
<span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
|
|
1373
1665
|
{dirty}
|
|
@@ -1410,6 +1702,7 @@ function CanvasRow({
|
|
|
1410
1702
|
showHidden,
|
|
1411
1703
|
forceOpen,
|
|
1412
1704
|
dirtyByPath,
|
|
1705
|
+
experimentalKind,
|
|
1413
1706
|
}) {
|
|
1414
1707
|
const dirty = dirtyByPath?.get(primary.path);
|
|
1415
1708
|
const hasSidecars = sidecars.length > 0;
|
|
@@ -1431,6 +1724,7 @@ function CanvasRow({
|
|
|
1431
1724
|
depth={depth}
|
|
1432
1725
|
kind={kind}
|
|
1433
1726
|
dirty={dirty}
|
|
1727
|
+
experimentalKind={experimentalKind}
|
|
1434
1728
|
/>
|
|
1435
1729
|
);
|
|
1436
1730
|
}
|
|
@@ -1464,6 +1758,15 @@ function CanvasRow({
|
|
|
1464
1758
|
<StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
|
|
1465
1759
|
</span>
|
|
1466
1760
|
<span className="st-row-name">{displayName(primary.name)}</span>
|
|
1761
|
+
{experimentalKind === 'reconstructed-experimental' && (
|
|
1762
|
+
<span
|
|
1763
|
+
className="st-row-exp-badge"
|
|
1764
|
+
title="Reconstructed from an image via /design:import --reconstruct — experimental, lossy, review before trusting (DDR-174)"
|
|
1765
|
+
aria-label="Reconstructed, experimental"
|
|
1766
|
+
>
|
|
1767
|
+
exp
|
|
1768
|
+
</span>
|
|
1769
|
+
)}
|
|
1467
1770
|
{dirty && (
|
|
1468
1771
|
<span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
|
|
1469
1772
|
{dirty}
|
|
@@ -1502,6 +1805,7 @@ function Tree({
|
|
|
1502
1805
|
onOpenSystem,
|
|
1503
1806
|
onDelete,
|
|
1504
1807
|
dirtyByPath,
|
|
1808
|
+
canvasKinds,
|
|
1505
1809
|
}) {
|
|
1506
1810
|
const dirs = Object.keys(node)
|
|
1507
1811
|
.filter((k) => k !== '_files')
|
|
@@ -1544,6 +1848,7 @@ function Tree({
|
|
|
1544
1848
|
showHidden={showHidden}
|
|
1545
1849
|
forceOpen={forceOpen}
|
|
1546
1850
|
dirtyByPath={dirtyByPath}
|
|
1851
|
+
experimentalKind={canvasKinds?.[entry.primary.path]}
|
|
1547
1852
|
/>
|
|
1548
1853
|
);
|
|
1549
1854
|
})}
|
|
@@ -1576,6 +1881,7 @@ function Tree({
|
|
|
1576
1881
|
onOpenSystem={onOpenSystem}
|
|
1577
1882
|
onDelete={onDelete}
|
|
1578
1883
|
dirtyByPath={dirtyByPath}
|
|
1884
|
+
canvasKinds={canvasKinds}
|
|
1579
1885
|
/>
|
|
1580
1886
|
);
|
|
1581
1887
|
if (dsMatch && onOpenSystem) {
|
|
@@ -1648,6 +1954,9 @@ function Sidebar({
|
|
|
1648
1954
|
dirtyByPath,
|
|
1649
1955
|
project,
|
|
1650
1956
|
gitBranch,
|
|
1957
|
+
remoteSync,
|
|
1958
|
+
onGetLatest,
|
|
1959
|
+
canvasKinds,
|
|
1651
1960
|
}) {
|
|
1652
1961
|
const filteredGroups = useMemo(() => {
|
|
1653
1962
|
if (!search) return groups;
|
|
@@ -1878,6 +2187,7 @@ function Sidebar({
|
|
|
1878
2187
|
onOpenSystem={isDs ? onOpenSystem : undefined}
|
|
1879
2188
|
onDelete={isDs ? undefined : onDeleteBoard}
|
|
1880
2189
|
dirtyByPath={dirtyByPath}
|
|
2190
|
+
canvasKinds={canvasKinds}
|
|
1881
2191
|
/>
|
|
1882
2192
|
) : (
|
|
1883
2193
|
<div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
|
|
@@ -1889,7 +2199,7 @@ function Sidebar({
|
|
|
1889
2199
|
{/* Phase 29 (E4) — the project + draft switcher: a compact one-line dock that
|
|
1890
2200
|
opens UPWARD, sitting directly above the GitHub identity avatar so the two
|
|
1891
2201
|
form one bottom dock. Renders nothing until the project is a git repo. */}
|
|
1892
|
-
<RepoBranchSwitcher project={project} liveBranch={gitBranch} />
|
|
2202
|
+
<RepoBranchSwitcher project={project} liveBranch={gitBranch} remoteSync={remoteSync} onGetLatest={onGetLatest} />
|
|
1893
2203
|
{/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
|
|
1894
2204
|
sign in, connected account + New/Pull/Share, sign out. Self-contained
|
|
1895
2205
|
(owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
|
|
@@ -2427,12 +2737,9 @@ function ViewDropdown({ panels, onToggle, onClose, onZoom, hasCanvas }) {
|
|
|
2427
2737
|
<span className="st-dd-lead">
|
|
2428
2738
|
<span className="st-dd-check">{p.checked ? <StIcon name="check" size={13} /> : null}</span>
|
|
2429
2739
|
<span>{p.label}</span>
|
|
2740
|
+
{p.phase ? <span className="st-dd-phase">{p.phase}</span> : null}
|
|
2430
2741
|
</span>
|
|
2431
|
-
{p.
|
|
2432
|
-
<span className="st-dd-phase">{p.phase}</span>
|
|
2433
|
-
) : p.shortcut ? (
|
|
2434
|
-
<Kbd>{p.shortcut}</Kbd>
|
|
2435
|
-
) : null}
|
|
2742
|
+
{p.shortcut ? <Kbd>{p.shortcut}</Kbd> : null}
|
|
2436
2743
|
</button>
|
|
2437
2744
|
))}
|
|
2438
2745
|
<div className="st-dd-sep" />
|
|
@@ -2517,10 +2824,15 @@ function HelpDropdown({ onAction, onClose }) {
|
|
|
2517
2824
|
{ id: 'help', label: 'Help · commands & flows', shortcut: 'F1' },
|
|
2518
2825
|
{ sep: true },
|
|
2519
2826
|
{ id: 'tour', label: 'Take the tour' },
|
|
2827
|
+
{ id: 'watch-intro', label: 'Watch the intro' },
|
|
2520
2828
|
// The collab "how sharing works" course teaches the plain-words Save →
|
|
2521
2829
|
// Publish → Pull cycle — a non-technical, native-app concern. A web-studio
|
|
2522
2830
|
// dev already knows git, so it's hidden there (DDR-119).
|
|
2523
2831
|
...(isNativeApp() ? [{ id: 'collab-tour', label: 'How sharing works' }] : []),
|
|
2832
|
+
// DDR-166 plan, Phase 2 (T7) — the quick-setup journey (design system →
|
|
2833
|
+
// first canvas → first AI edit) is a native, no-terminal concern same as
|
|
2834
|
+
// the two tours above.
|
|
2835
|
+
...(isNativeApp() ? [{ id: 'quick-setup', label: 'Quick setup' }] : []),
|
|
2524
2836
|
...(isNativeApp() ? [{ id: 'readiness', label: 'Check AI editing readiness…' }] : []),
|
|
2525
2837
|
{ id: 'whatsnew', label: "What's new" },
|
|
2526
2838
|
]}
|
|
@@ -2588,6 +2900,10 @@ function FileDropdown({ onAction, onClose, hasCanvas }) {
|
|
|
2588
2900
|
{ id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
|
|
2589
2901
|
{ id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
|
|
2590
2902
|
{ sep: true },
|
|
2903
|
+
// feature-ai-media-generation (DDR-16x) — BYOK generate action + settings.
|
|
2904
|
+
{ id: 'generate', label: 'Generate with AI…' },
|
|
2905
|
+
{ id: 'settings', label: 'Settings…', shortcut: '⌘,' },
|
|
2906
|
+
{ sep: true },
|
|
2591
2907
|
{ id: 'reload', label: 'Reload canvas', shortcut: '⌘R', disabled: !hasCanvas },
|
|
2592
2908
|
{ id: 'close', label: 'Close canvas', disabled: !hasCanvas },
|
|
2593
2909
|
]}
|
|
@@ -2655,6 +2971,8 @@ function Menubar({
|
|
|
2655
2971
|
postToActiveCanvas,
|
|
2656
2972
|
onOpenWhatsNew,
|
|
2657
2973
|
onOpenReadiness,
|
|
2974
|
+
onOpenQuickSetup,
|
|
2975
|
+
onWatchIntro,
|
|
2658
2976
|
whatsNewCount,
|
|
2659
2977
|
exportCenter,
|
|
2660
2978
|
artboardCount = 0,
|
|
@@ -2675,6 +2993,8 @@ function Menubar({
|
|
|
2675
2993
|
onNewCanvas,
|
|
2676
2994
|
onAssembleVideo,
|
|
2677
2995
|
onOpenExport,
|
|
2996
|
+
onOpenSettings,
|
|
2997
|
+
onOpenGenerate,
|
|
2678
2998
|
onReload,
|
|
2679
2999
|
onCloseCanvas,
|
|
2680
3000
|
onInsertArtboard,
|
|
@@ -2740,6 +3060,7 @@ function Menubar({
|
|
|
2740
3060
|
{
|
|
2741
3061
|
id: 'timeline',
|
|
2742
3062
|
label: 'Timeline',
|
|
3063
|
+
shortcut: '⌘ ⇧ T',
|
|
2743
3064
|
phase: hasComps ? 'video' : undefined,
|
|
2744
3065
|
checked: timelineOpen,
|
|
2745
3066
|
disabled: false,
|
|
@@ -2881,6 +3202,8 @@ function Menubar({
|
|
|
2881
3202
|
else if (id === 'assemble') onAssembleVideo?.();
|
|
2882
3203
|
else if (id === 'export') onOpenExport?.('export');
|
|
2883
3204
|
else if (id === 'handoff') onOpenExport?.('handoff');
|
|
3205
|
+
else if (id === 'generate') onOpenGenerate?.();
|
|
3206
|
+
else if (id === 'settings') onOpenSettings?.();
|
|
2884
3207
|
else if (id === 'reload') onReload?.();
|
|
2885
3208
|
else if (id === 'close') onCloseCanvas?.();
|
|
2886
3209
|
}}
|
|
@@ -2948,8 +3271,10 @@ function Menubar({
|
|
|
2948
3271
|
else if (id === 'help') onOpenHelp?.();
|
|
2949
3272
|
else if (id === 'tour') onStartTour?.();
|
|
2950
3273
|
else if (id === 'collab-tour') onStartCollabTour?.();
|
|
3274
|
+
else if (id === 'quick-setup') onOpenQuickSetup?.();
|
|
2951
3275
|
else if (id === 'readiness') onOpenReadiness?.();
|
|
2952
3276
|
else if (id === 'whatsnew') onOpenWhatsNew?.();
|
|
3277
|
+
else if (id === 'watch-intro') onWatchIntro?.();
|
|
2953
3278
|
}}
|
|
2954
3279
|
onClose={() => setOpenMenu(null)}
|
|
2955
3280
|
/>
|
|
@@ -2960,6 +3285,7 @@ function Menubar({
|
|
|
2960
3285
|
<button
|
|
2961
3286
|
type="button"
|
|
2962
3287
|
className="st-assistant"
|
|
3288
|
+
data-testid="assistant-toggle"
|
|
2963
3289
|
data-active={assistantOpen ? 'true' : 'false'}
|
|
2964
3290
|
data-busy={assistantBusy ? 'true' : 'false'}
|
|
2965
3291
|
data-unseen={assistantUnseen ? 'true' : 'false'}
|
|
@@ -3009,6 +3335,8 @@ function Viewport({
|
|
|
3009
3335
|
cfg,
|
|
3010
3336
|
loadingPath,
|
|
3011
3337
|
onIframeLoad,
|
|
3338
|
+
showQuickSetup,
|
|
3339
|
+
onStartQuickSetup,
|
|
3012
3340
|
}) {
|
|
3013
3341
|
return (
|
|
3014
3342
|
<div className="viewport st-stage" data-tour="viewport">
|
|
@@ -3024,24 +3352,34 @@ function Viewport({
|
|
|
3024
3352
|
localhost:{typeof window !== 'undefined' ? window.location.port : '4399'}
|
|
3025
3353
|
</span>
|
|
3026
3354
|
</div>
|
|
3027
|
-
<div className="st-empty-title">
|
|
3355
|
+
<div className="st-empty-title">Nothing open yet</div>
|
|
3028
3356
|
<div className="st-empty-body">
|
|
3029
|
-
←
|
|
3030
|
-
|
|
3357
|
+
← Pick a screen from the list on the left, or open <strong>Design system</strong> above
|
|
3358
|
+
it to see your colors, type, and components.
|
|
3031
3359
|
<br />
|
|
3032
3360
|
<br />
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3361
|
+
<strong>To select something on the canvas:</strong> hold <Kbd>⌘</Kbd> and hover to
|
|
3362
|
+
preview an element, click to select it — <Kbd>⌘⇧</Kbd>+click selects more than one.
|
|
3363
|
+
Right-click for more options.
|
|
3364
|
+
{isNativeApp() ? (
|
|
3365
|
+
<>
|
|
3366
|
+
<br />
|
|
3367
|
+
<br />
|
|
3368
|
+
Claude can see whatever's selected when you ask for a change in the Assistant
|
|
3369
|
+
panel, so pointing is often faster than describing it.
|
|
3370
|
+
</>
|
|
3371
|
+
) : null}
|
|
3044
3372
|
</div>
|
|
3373
|
+
{showQuickSetup && (
|
|
3374
|
+
<button
|
|
3375
|
+
type="button"
|
|
3376
|
+
data-testid="st-empty-start-quick-setup"
|
|
3377
|
+
className="btn btn--primary st-empty-quick-setup"
|
|
3378
|
+
onClick={onStartQuickSetup}
|
|
3379
|
+
>
|
|
3380
|
+
Start quick setup
|
|
3381
|
+
</button>
|
|
3382
|
+
)}
|
|
3045
3383
|
</div>
|
|
3046
3384
|
)}
|
|
3047
3385
|
{tabs.map((t) => {
|
|
@@ -3832,16 +4170,15 @@ const CSS_UNITLESS = new Set(['line-height', 'opacity', 'font-weight', 'z-index'
|
|
|
3832
4170
|
// #2 — Figma-style property prefix inside numeric fields: a small glyph (icon) or
|
|
3833
4171
|
// a mono letter (t). Only where it reads cleanly; selects/colours keep their own.
|
|
3834
4172
|
const PROP_LEAD = {
|
|
3835
|
-
'font-size': {
|
|
3836
|
-
'line-height': {
|
|
3837
|
-
'letter-spacing': {
|
|
3838
|
-
gap: {
|
|
4173
|
+
'font-size': { node: <Lu as={LuALargeSmall} size={12} /> },
|
|
4174
|
+
'line-height': { node: <Lu as={LuBaseline} size={12} /> },
|
|
4175
|
+
'letter-spacing': { node: <Lu as={LuMoveH} size={12} /> },
|
|
4176
|
+
gap: { node: <Lu as={LuSpaceBetween} size={12} /> },
|
|
3839
4177
|
width: { t: 'W' },
|
|
3840
4178
|
height: { t: 'H' },
|
|
3841
4179
|
'max-width': { t: 'W' },
|
|
3842
|
-
'border-radius': {
|
|
3843
|
-
'border-width': {
|
|
3844
|
-
opacity: { icon: 'p-opacity' },
|
|
4180
|
+
'border-radius': { node: <Lu as={LuSpline} size={12} /> },
|
|
4181
|
+
'border-width': { node: <Lu as={LuMinus} size={12} /> },
|
|
3845
4182
|
};
|
|
3846
4183
|
const CSS_ALIGN_OPTS = ['left', 'center', 'right', 'justify'];
|
|
3847
4184
|
// feature-element-editing-robustness Stage B — enum option lists for the promoted
|
|
@@ -3853,6 +4190,26 @@ const CSS_TEXT_DECORATION = ['none', 'underline', 'line-through', 'overline'];
|
|
|
3853
4190
|
const CSS_WHITE_SPACE = ['normal', 'nowrap', 'pre', 'pre-wrap', 'pre-line', 'break-spaces'];
|
|
3854
4191
|
const CSS_OBJECT_FIT = ['fill', 'contain', 'cover', 'none', 'scale-down'];
|
|
3855
4192
|
const CSS_OVERFLOW = ['visible', 'hidden', 'auto', 'scroll'];
|
|
4193
|
+
// DDR-171 — Designer mode "Effects" cluster (blend) + the matching Advanced-mode
|
|
4194
|
+
// Appearance row. Standard CSS `mix-blend-mode` keyword list.
|
|
4195
|
+
const CSS_BLEND_MODES = [
|
|
4196
|
+
'normal',
|
|
4197
|
+
'multiply',
|
|
4198
|
+
'screen',
|
|
4199
|
+
'overlay',
|
|
4200
|
+
'darken',
|
|
4201
|
+
'lighten',
|
|
4202
|
+
'color-dodge',
|
|
4203
|
+
'color-burn',
|
|
4204
|
+
'hard-light',
|
|
4205
|
+
'soft-light',
|
|
4206
|
+
'difference',
|
|
4207
|
+
'exclusion',
|
|
4208
|
+
'hue',
|
|
4209
|
+
'saturation',
|
|
4210
|
+
'color',
|
|
4211
|
+
'luminosity',
|
|
4212
|
+
];
|
|
3856
4213
|
// Common aspect ratios for the Media dropdown (dogfood request — a select, not a
|
|
3857
4214
|
// free-text field). Canonical spaced form so a dropdown-set value round-trips.
|
|
3858
4215
|
const CSS_ASPECT_RATIO = ['auto', '1 / 1', '4 / 3', '3 / 2', '16 / 9', '21 / 9', '3 / 4', '2 / 3', '9 / 16'];
|
|
@@ -3959,6 +4316,12 @@ function cssSplitUnit(v) {
|
|
|
3959
4316
|
// is for the SAME element we already hold locally, preserve those fields instead
|
|
3960
4317
|
// of clobbering them to empty (else the server round-trip wipes the custom-CSS /
|
|
3961
4318
|
// custom-attr rows + computed readout right after selection).
|
|
4319
|
+
// feature-photo-editor — `photoKind`/`photoAsset` (dom-selection.ts) are the SAME
|
|
4320
|
+
// class of client-only DOM-derived field and belong in this list for the same
|
|
4321
|
+
// reason: without it, every server-pushed `selected`/`snapshot` restore (a canvas
|
|
4322
|
+
// switch, a reconnect, the persisted `_active.json` on boot — none of which carry
|
|
4323
|
+
// these fields) silently drops the Inspector's Photo tab until the next fresh
|
|
4324
|
+
// click re-derives it live — the "tab is there, then it's gone" flicker.
|
|
3962
4325
|
function mergeSelClientFields(incoming, prev) {
|
|
3963
4326
|
if (!incoming || Array.isArray(incoming) || Array.isArray(prev) || !prev) return incoming;
|
|
3964
4327
|
if (!incoming.id || incoming.id !== prev.id) return incoming;
|
|
@@ -3968,6 +4331,8 @@ function mergeSelClientFields(incoming, prev) {
|
|
|
3968
4331
|
computed: incoming.computed ?? prev.computed,
|
|
3969
4332
|
customStyles: incoming.customStyles ?? prev.customStyles,
|
|
3970
4333
|
attrs: incoming.attrs ?? prev.attrs,
|
|
4334
|
+
photoKind: incoming.photoKind ?? prev.photoKind,
|
|
4335
|
+
photoAsset: incoming.photoAsset ?? prev.photoAsset,
|
|
3971
4336
|
};
|
|
3972
4337
|
}
|
|
3973
4338
|
|
|
@@ -4068,7 +4433,7 @@ function useAllDsTokens(cfg, designRel, activeName) {
|
|
|
4068
4433
|
// `seed` is the resolved current colour (hex). Drag updates the picker UI live;
|
|
4069
4434
|
// commits on pointer-up (one source write per drag); the hex field commits on
|
|
4070
4435
|
// blur/Enter.
|
|
4071
|
-
function ColorPicker({ seed, onApply }) {
|
|
4436
|
+
function ColorPicker({ seed, label, onApply }) {
|
|
4072
4437
|
const [hsv, setHsv] = useState(() => rgbToHsv(hexToRgb(seed || '#000000')));
|
|
4073
4438
|
const hsvRef = useRef(hsv);
|
|
4074
4439
|
hsvRef.current = hsv;
|
|
@@ -4123,6 +4488,31 @@ function ColorPicker({ seed, onApply }) {
|
|
|
4123
4488
|
document.addEventListener('pointermove', move);
|
|
4124
4489
|
document.addEventListener('pointerup', up);
|
|
4125
4490
|
};
|
|
4491
|
+
// Keyboard equivalents for dragSV/dragHue (feature-photo-editor follow-up
|
|
4492
|
+
// debt, Task 18) — the pad + hue bar were pointer-only. Arrow keys nudge
|
|
4493
|
+
// the same `setHsv`/`onApply` path the drag handlers use; shift = a bigger
|
|
4494
|
+
// step (mirrors makeScrub's shift=×10 modifier convention elsewhere).
|
|
4495
|
+
const nudgeHsv = (patch) => {
|
|
4496
|
+
const next = { ...hsvRef.current, ...patch };
|
|
4497
|
+
setHsv(next);
|
|
4498
|
+
onApply(rgbToHex(hsvToRgb(next)));
|
|
4499
|
+
};
|
|
4500
|
+
const onSvKeyDown = (e) => {
|
|
4501
|
+
const step = e.shiftKey ? 0.1 : 0.02;
|
|
4502
|
+
const deltas = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };
|
|
4503
|
+
const d = deltas[e.key];
|
|
4504
|
+
if (!d) return;
|
|
4505
|
+
e.preventDefault();
|
|
4506
|
+
nudgeHsv({ s: clamp01(hsvRef.current.s + d[0]), v: clamp01(hsvRef.current.v + d[1]) });
|
|
4507
|
+
};
|
|
4508
|
+
const onHueKeyDown = (e) => {
|
|
4509
|
+
const step = e.shiftKey ? 20 : 2;
|
|
4510
|
+
const deltas = { ArrowLeft: -step, ArrowRight: step };
|
|
4511
|
+
const d = deltas[e.key];
|
|
4512
|
+
if (d == null) return;
|
|
4513
|
+
e.preventDefault();
|
|
4514
|
+
nudgeHsv({ h: Math.min(360, Math.max(0, hsvRef.current.h + d)) });
|
|
4515
|
+
};
|
|
4126
4516
|
const eyedrop = async () => {
|
|
4127
4517
|
try {
|
|
4128
4518
|
// EyeDropper is Chromium-only; guarded.
|
|
@@ -4138,15 +4528,43 @@ function ColorPicker({ seed, onApply }) {
|
|
|
4138
4528
|
}
|
|
4139
4529
|
};
|
|
4140
4530
|
|
|
4531
|
+
// handoff — RGB numeric fields (design parity). Editing one re-derives hsv.
|
|
4532
|
+
const rgb = hsvToRgb(hsv);
|
|
4533
|
+
const setRgb = (patch) => {
|
|
4534
|
+
const next = { ...rgb, ...patch };
|
|
4535
|
+
const h = rgbToHsv({ r: clamp01(next.r / 255) * 255, g: clamp01(next.g / 255) * 255, b: clamp01(next.b / 255) * 255 });
|
|
4536
|
+
setHsv(h);
|
|
4537
|
+
onApply(rgbToHex(hsvToRgb(h)));
|
|
4538
|
+
};
|
|
4141
4539
|
return (
|
|
4142
4540
|
<div className="st-cp-cpick">
|
|
4541
|
+
{/* hex + swatch on top (design), then the SV pad, controls, RGB */}
|
|
4542
|
+
<div className="st-cp-cpick-hexrow">
|
|
4543
|
+
<span className="st-cp-cpick-hexsw" style={{ background: hex }} />
|
|
4544
|
+
<input
|
|
4545
|
+
className="st-cp-cpick-hex"
|
|
4546
|
+
type="text"
|
|
4547
|
+
value={hex}
|
|
4548
|
+
aria-label={label ? `${label} hex value` : 'hex value'}
|
|
4549
|
+
onChange={(e) => {
|
|
4550
|
+
const v = e.target.value;
|
|
4551
|
+
if (/^#?[0-9a-f]{6}$/i.test(v)) setHsv(rgbToHsv(hexToRgb(v)));
|
|
4552
|
+
}}
|
|
4553
|
+
onKeyDown={(e) => {
|
|
4554
|
+
if (e.key === 'Enter') onApply(e.currentTarget.value);
|
|
4555
|
+
}}
|
|
4556
|
+
onBlur={(e) => onApply(e.currentTarget.value)}
|
|
4557
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
4558
|
+
/>
|
|
4559
|
+
</div>
|
|
4143
4560
|
<button
|
|
4144
4561
|
type="button"
|
|
4145
4562
|
ref={svRef}
|
|
4146
4563
|
className="st-cp-cpick-sv"
|
|
4147
|
-
aria-label=
|
|
4564
|
+
aria-label={label ? `${label} saturation and value` : 'saturation and value'}
|
|
4148
4565
|
style={{ background: `hsl(${hsv.h} 100% 50%)` }}
|
|
4149
4566
|
onPointerDown={dragSV}
|
|
4567
|
+
onKeyDown={onSvKeyDown}
|
|
4150
4568
|
>
|
|
4151
4569
|
<span className="st-cp-cpick-svwhite" />
|
|
4152
4570
|
<span className="st-cp-cpick-svblack" />
|
|
@@ -4167,30 +4585,31 @@ function ColorPicker({ seed, onApply }) {
|
|
|
4167
4585
|
<StIcon name="eyedropper" size={14} />
|
|
4168
4586
|
</button>
|
|
4169
4587
|
) : null}
|
|
4588
|
+
<span className="st-cp-cpick-preview" style={{ background: hex }} aria-hidden="true" />
|
|
4170
4589
|
<button
|
|
4171
4590
|
type="button"
|
|
4172
4591
|
ref={hueRef}
|
|
4173
4592
|
className="st-cp-cpick-hue"
|
|
4174
|
-
aria-label=
|
|
4593
|
+
aria-label={label ? `${label} hue` : 'hue'}
|
|
4175
4594
|
onPointerDown={dragHue}
|
|
4595
|
+
onKeyDown={onHueKeyDown}
|
|
4176
4596
|
>
|
|
4177
4597
|
<span className="st-cp-cpick-huethumb" style={{ left: `${(hsv.h / 360) * 100}%` }} />
|
|
4178
4598
|
</button>
|
|
4179
4599
|
</div>
|
|
4180
|
-
<
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
}
|
|
4192
|
-
|
|
4193
|
-
/>
|
|
4600
|
+
<div className="st-cp-cpick-rgb">
|
|
4601
|
+
{['r', 'g', 'b'].map((k) => (
|
|
4602
|
+
<label key={k} className="st-cp-cpick-rgbf">
|
|
4603
|
+
<input
|
|
4604
|
+
aria-label={k.toUpperCase()}
|
|
4605
|
+
value={Math.round(rgb[k])}
|
|
4606
|
+
onChange={(e) => setRgb({ [k]: clamp01((Number.parseFloat(e.target.value) || 0) / 255) * 255 })}
|
|
4607
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
4608
|
+
/>
|
|
4609
|
+
<span>{k.toUpperCase()}</span>
|
|
4610
|
+
</label>
|
|
4611
|
+
))}
|
|
4612
|
+
</div>
|
|
4194
4613
|
</div>
|
|
4195
4614
|
);
|
|
4196
4615
|
}
|
|
@@ -4200,7 +4619,7 @@ function ColorPicker({ seed, onApply }) {
|
|
|
4200
4619
|
// `kind='value'` a variable list (pretty name + resolved value, à la Figma's
|
|
4201
4620
|
// variable picker). Picking commits `var(--token)`. Portals to <body> +
|
|
4202
4621
|
// fixed-positions from the trigger rect so the panel's overflow never clips it.
|
|
4203
|
-
function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex, activeDs }) {
|
|
4622
|
+
function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex, activeDs, swatchClassName }) {
|
|
4204
4623
|
const [open, setOpen] = useState(false);
|
|
4205
4624
|
const [pos, setPos] = useState(null);
|
|
4206
4625
|
// Phase 12.3 (#4) — colour popover gets two tabs: a normal colour input
|
|
@@ -4256,26 +4675,48 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
|
|
|
4256
4675
|
const onKey = (e) => {
|
|
4257
4676
|
if (e.key === 'Escape') setOpen(false);
|
|
4258
4677
|
};
|
|
4259
|
-
|
|
4260
|
-
//
|
|
4261
|
-
//
|
|
4262
|
-
//
|
|
4678
|
+
// Task 6 (feature-inspector-controls-redesign) — RE-ANCHOR on scroll/resize
|
|
4679
|
+
// instead of dismissing. `place()` computes viewport-relative coordinates
|
|
4680
|
+
// from `getBoundingClientRect()`, but this fixed-positioned popover's actual
|
|
4681
|
+
// containing block is whichever ANCESTOR (if any) has a transform/filter/
|
|
4682
|
+
// will-change — e.g. the right panel's mount-in `st-panel-in` transform —
|
|
4683
|
+
// not necessarily the viewport. Re-running place() keeps it glued to the
|
|
4684
|
+
// trigger through any layout change instead of vanishing on the first
|
|
4685
|
+
// scroll (the old dismiss-on-scroll workaround for the same root cause).
|
|
4263
4686
|
const onScroll = (e) => {
|
|
4264
4687
|
if (popRef.current?.contains(e.target)) return;
|
|
4265
|
-
|
|
4688
|
+
place();
|
|
4266
4689
|
};
|
|
4267
4690
|
document.addEventListener('pointerdown', onDoc, true);
|
|
4268
4691
|
document.addEventListener('keydown', onKey);
|
|
4269
|
-
window.addEventListener('resize',
|
|
4692
|
+
window.addEventListener('resize', place);
|
|
4270
4693
|
document.addEventListener('scroll', onScroll, true);
|
|
4271
4694
|
return () => {
|
|
4272
4695
|
document.removeEventListener('pointerdown', onDoc, true);
|
|
4273
4696
|
document.removeEventListener('keydown', onKey);
|
|
4274
|
-
window.removeEventListener('resize',
|
|
4697
|
+
window.removeEventListener('resize', place);
|
|
4275
4698
|
document.removeEventListener('scroll', onScroll, true);
|
|
4276
4699
|
};
|
|
4277
4700
|
}, [open]);
|
|
4278
4701
|
|
|
4702
|
+
// Transform-ancestor compensation — regardless of WHICH ancestor ends up
|
|
4703
|
+
// establishing this fixed popover's containing block, measure where it
|
|
4704
|
+
// actually landed vs where `place()` intended (viewport-relative) and cancel
|
|
4705
|
+
// out the delta. This is correct independent of the cause, so it doesn't
|
|
4706
|
+
// require hunting down every transform/filter/will-change that could ever
|
|
4707
|
+
// apply between `document.body` (the portal target) and this element.
|
|
4708
|
+
// Converges in at most one correction: once the delta is compensated, the
|
|
4709
|
+
// measured rect matches the intended position and the effect is a no-op.
|
|
4710
|
+
useEffect(() => {
|
|
4711
|
+
if (!open || !pos || !popRef.current) return;
|
|
4712
|
+
const r = popRef.current.getBoundingClientRect();
|
|
4713
|
+
const dx = pos.left - r.left;
|
|
4714
|
+
const dy = pos.top - r.top;
|
|
4715
|
+
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
|
|
4716
|
+
setPos((p) => (p ? { ...p, left: p.left + dx, top: p.top + dy } : p));
|
|
4717
|
+
}
|
|
4718
|
+
}, [open, pos]);
|
|
4719
|
+
|
|
4279
4720
|
// Pick a token. #3 — apply CORRECTLY across design systems: a token from the
|
|
4280
4721
|
// canvas's OWN active DS commits `var(--token)` (round-trips + resolves right);
|
|
4281
4722
|
// a token from ANOTHER DS commits its RESOLVED value (literal), because
|
|
@@ -4373,7 +4814,7 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
|
|
|
4373
4814
|
<button
|
|
4374
4815
|
type="button"
|
|
4375
4816
|
ref={btnRef}
|
|
4376
|
-
className={
|
|
4817
|
+
className={`${swatchClassName || 'st-cp-swatch st-cp-swatch--mini st-cp-swatch--trigger'}${bound && !swatchClassName ? ' is-bound' : ''}`}
|
|
4377
4818
|
aria-haspopup="dialog"
|
|
4378
4819
|
aria-expanded={open}
|
|
4379
4820
|
aria-label={label || 'pick a colour'}
|
|
@@ -4467,7 +4908,7 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
|
|
|
4467
4908
|
);
|
|
4468
4909
|
}
|
|
4469
4910
|
|
|
4470
|
-
function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo }) {
|
|
4911
|
+
function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo, mode, onSetMode }) {
|
|
4471
4912
|
const editable = !!el.id;
|
|
4472
4913
|
const computed = el.computed || {};
|
|
4473
4914
|
// Phase 12.3 — optimistic local overlay over the selection's authored / custom
|
|
@@ -4519,7 +4960,6 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4519
4960
|
Appearance: true,
|
|
4520
4961
|
Advanced: false,
|
|
4521
4962
|
});
|
|
4522
|
-
const [split, setSplit] = useState(false);
|
|
4523
4963
|
|
|
4524
4964
|
// Phase 12.3 — auto-expand Advanced when the selected element carries custom
|
|
4525
4965
|
// CSS props / HTML attrs, so a just-added (or pre-existing) custom value is
|
|
@@ -4530,6 +4970,28 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4530
4970
|
useEffect(() => {
|
|
4531
4971
|
if (hasCustom) setOpen((o) => (o.Advanced ? o : { ...o, Advanced: true }));
|
|
4532
4972
|
}, [el.id, hasCustom]);
|
|
4973
|
+
// DDR-171 — Designer mode's Position cluster mirrors the same auto-expand
|
|
4974
|
+
// precedent: collapsed by default (position is the rare case), auto-opens
|
|
4975
|
+
// the moment the element actually has a non-static position so the inset
|
|
4976
|
+
// fields the user just set (or that came from the source) aren't hidden.
|
|
4977
|
+
const hasCustomPosition = (authored.position || cssHint(computed.position) || 'static') !== 'static';
|
|
4978
|
+
useEffect(() => {
|
|
4979
|
+
if (hasCustomPosition) setOpen((o) => (o['d:Position'] ? o : { ...o, 'd:Position': true }));
|
|
4980
|
+
}, [el.id, hasCustomPosition]);
|
|
4981
|
+
|
|
4982
|
+
// DDR-171 — the panel's vocabulary mode: 'advanced' (today's raw-CSS panel,
|
|
4983
|
+
// unchanged, still the default — zero behavior change for existing users) or
|
|
4984
|
+
// 'designer' (the Figma-vocabulary regroup). Lifted to App state (DDR-171
|
|
4985
|
+
// follow-up) so the same preference is controllable from BOTH the in-panel
|
|
4986
|
+
// corner toggle AND Settings → Appearance, and the two never diverge — the
|
|
4987
|
+
// exact single-source-of-truth pattern `theme` uses. App owns the
|
|
4988
|
+
// `maude-cp-mode` localStorage persistence; here it's a controlled prop.
|
|
4989
|
+
const setMode = onSetMode;
|
|
4990
|
+
// Designer-mode per-cluster "···" disclosure state (Auto layout/Wrap,
|
|
4991
|
+
// Size/Min-Max, Position/z-index, Text/extras) — keyed by cluster name, kept
|
|
4992
|
+
// in-memory only (unlike `open`, not persisted; matches the disclosure being
|
|
4993
|
+
// a "peek", not a durable preference).
|
|
4994
|
+
const [designerMore, setDesignerMore] = useState({});
|
|
4533
4995
|
|
|
4534
4996
|
async function post(url, payload, key) {
|
|
4535
4997
|
setStatus((s) => ({ ...s, [key]: 'saving' }));
|
|
@@ -4647,30 +5109,50 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4647
5109
|
const sizeModeSeg = (axis) => {
|
|
4648
5110
|
const cur = sizingModeOf(axis, authored, computed, parentLayout);
|
|
4649
5111
|
return (
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
{
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
aria-pressed={cur === m}
|
|
4664
|
-
disabled={!editable}
|
|
4665
|
-
onClick={() => applySizing(axis, m)}
|
|
4666
|
-
title={`${label} ${axis}`}
|
|
4667
|
-
>
|
|
4668
|
-
{label}
|
|
4669
|
-
</button>
|
|
4670
|
-
))}
|
|
5112
|
+
// handoff — a proper inspector row: "Width sizing" / "Height sizing" label
|
|
5113
|
+
// in the label column, the shared Segmented right-aligned in the control
|
|
5114
|
+
// column (aligned with the number inputs below), input-matching height.
|
|
5115
|
+
<div className="st-cp-moderow" key={`mode-${axis}`}>
|
|
5116
|
+
<span className="st-cp-modelabel">{axis === 'width' ? 'Width sizing' : 'Height sizing'}</span>
|
|
5117
|
+
<div className="st-cp-modeseg" role="group" aria-label={`${axis} sizing mode`}>
|
|
5118
|
+
<Segmented
|
|
5119
|
+
value={cur}
|
|
5120
|
+
ariaLabel={`${axis} sizing`}
|
|
5121
|
+
options={[{ value: 'fixed', label: 'fixed' }, { value: 'hug', label: 'hug' }, { value: 'fill', label: 'fill' }]}
|
|
5122
|
+
onChange={(m) => applySizing(axis, m)}
|
|
5123
|
+
/>
|
|
5124
|
+
</div>
|
|
4671
5125
|
</div>
|
|
4672
5126
|
);
|
|
4673
5127
|
};
|
|
5128
|
+
// DDR-171 — Designer mode's Auto-layout alignment: `AlignPad`'s 9-cell grid
|
|
5129
|
+
// maps to the TWO real CSS axes (`justify-content` = main axis,
|
|
5130
|
+
// `align-items` = cross axis), which axis is "horizontal" vs "vertical"
|
|
5131
|
+
// depending on `flex-direction`. Only the 3 positional align-items values
|
|
5132
|
+
// (start/center/end) round-trip through the pad — `stretch` reads as the
|
|
5133
|
+
// pad's center cell (closest visual analog) but the pad never WRITES
|
|
5134
|
+
// `stretch`; that stays an Advanced-mode-only value, same as Figma's own
|
|
5135
|
+
// alignment pad (no "stretch" cell — it's a separate control there too).
|
|
5136
|
+
const AP_JC = ['flex-start', 'center', 'flex-end'];
|
|
5137
|
+
const AP_AI = ['flex-start', 'center', 'flex-end'];
|
|
5138
|
+
const AP_ROWS = ['t', 'c', 'b'];
|
|
5139
|
+
const AP_COLS = ['l', 'c', 'r'];
|
|
5140
|
+
const alignPadCell = () => {
|
|
5141
|
+
const isRow = !(authored['flex-direction'] || cssHint(computed['flex-direction']) || 'row').startsWith('column');
|
|
5142
|
+
const jcPos = Math.max(0, AP_JC.indexOf(authored['justify-content'] || cssHint(computed['justify-content']) || 'flex-start'));
|
|
5143
|
+
const aiRaw = authored['align-items'] || cssHint(computed['align-items']) || 'stretch';
|
|
5144
|
+
const aiPos = aiRaw === 'stretch' ? 1 : Math.max(0, AP_AI.indexOf(aiRaw));
|
|
5145
|
+
const [h, v] = isRow ? [jcPos, aiPos] : [aiPos, jcPos];
|
|
5146
|
+
return AP_ROWS[v] + AP_COLS[h];
|
|
5147
|
+
};
|
|
5148
|
+
const setAlignPadCell = (cell) => {
|
|
5149
|
+
const v = AP_ROWS.indexOf(cell[0]);
|
|
5150
|
+
const h = AP_COLS.indexOf(cell[1]);
|
|
5151
|
+
const isRow = !(authored['flex-direction'] || cssHint(computed['flex-direction']) || 'row').startsWith('column');
|
|
5152
|
+
const [jcPos, aiPos] = isRow ? [h, v] : [v, h];
|
|
5153
|
+
commit('justify-content', AP_JC[jcPos]);
|
|
5154
|
+
commit('align-items', AP_AI[aiPos]);
|
|
5155
|
+
};
|
|
4674
5156
|
// Cmd+Z / Cmd+Shift+Z (or Cmd+Y) inside the inspector forwards to the canvas
|
|
4675
5157
|
// undo stack — Figma-parity: a property field reverts the last DOCUMENT edit,
|
|
4676
5158
|
// not field text. Without this, an edit committed with focus still in the
|
|
@@ -4686,69 +5168,6 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4686
5168
|
onUndoRedo?.('redo');
|
|
4687
5169
|
}
|
|
4688
5170
|
};
|
|
4689
|
-
// Phase 12.3 (W2.2) — Figma/Webflow scrub: drag a number field horizontally to
|
|
4690
|
-
// change its value. Live preview via optimistic apply on every move (no source
|
|
4691
|
-
// write); commits ONCE on release. A pointer that doesn't pass a 3px threshold
|
|
4692
|
-
// is a normal click (focus to type). `opts.step` modifiers: shift = ×10, alt =
|
|
4693
|
-
// ×0.1. `opts.sides` enables Webflow box-model modifiers: alt = symmetric pair,
|
|
4694
|
-
// alt+shift = all four (else just this side). `opts.min` clamps (default 0).
|
|
4695
|
-
const makeScrub = (prop, opts = {}) => (e) => {
|
|
4696
|
-
if (e.button !== 0) return;
|
|
4697
|
-
const input = e.currentTarget;
|
|
4698
|
-
const startX = e.clientX;
|
|
4699
|
-
const baseN =
|
|
4700
|
-
Number.parseFloat(
|
|
4701
|
-
cssSplitUnit(authored[prop] ?? cssHint(computed[prop]) ?? '0').n || '0'
|
|
4702
|
-
) || 0;
|
|
4703
|
-
const unit = opts.unitless
|
|
4704
|
-
? ''
|
|
4705
|
-
: opts.unit || cssSplitUnit(authored[prop] ?? '').unit || 'px';
|
|
4706
|
-
const min = opts.min ?? 0;
|
|
4707
|
-
const fmt = (n) => (opts.unitless ? `${n}` : `${n}${unit}`);
|
|
4708
|
-
const sidesFor = (ev) => {
|
|
4709
|
-
if (!opts.sides) return [prop];
|
|
4710
|
-
if (ev.altKey && ev.shiftKey) return opts.sides.all;
|
|
4711
|
-
if (ev.altKey) return opts.sides.pair;
|
|
4712
|
-
return [prop];
|
|
4713
|
-
};
|
|
4714
|
-
let scrubbing = false;
|
|
4715
|
-
let last = baseN;
|
|
4716
|
-
const move = (ev) => {
|
|
4717
|
-
const dx = ev.clientX - startX;
|
|
4718
|
-
if (!scrubbing && Math.abs(dx) < 3) return;
|
|
4719
|
-
if (!scrubbing) {
|
|
4720
|
-
scrubbing = true;
|
|
4721
|
-
document.body.classList.add('st-scrubbing');
|
|
4722
|
-
}
|
|
4723
|
-
ev.preventDefault();
|
|
4724
|
-
const granular = opts.sides ? 1 : ev.shiftKey ? 10 : ev.altKey ? 0.1 : 1;
|
|
4725
|
-
last = Math.round((baseN + dx * granular) * 100) / 100;
|
|
4726
|
-
if (last < min) last = min;
|
|
4727
|
-
const sides = sidesFor(ev);
|
|
4728
|
-
// Live-update the dragged field AND, for a box-model multi-side scrub, the
|
|
4729
|
-
// sibling box inputs so the whole pair / four-up move shows in the panel —
|
|
4730
|
-
// not just the one being dragged (W2.2 feedback).
|
|
4731
|
-
if (input) input.value = String(last);
|
|
4732
|
-
if (opts.sides && sides.length > 1) {
|
|
4733
|
-
const box = input?.closest('.st-cp-box');
|
|
4734
|
-
for (const p of sides) {
|
|
4735
|
-
if (p === prop) continue;
|
|
4736
|
-
const sib = box?.querySelector(`.st-cp-boxv[aria-label="${p}"]`);
|
|
4737
|
-
if (sib) sib.value = String(last);
|
|
4738
|
-
}
|
|
4739
|
-
}
|
|
4740
|
-
for (const p of sides) optimistic(p, fmt(last));
|
|
4741
|
-
};
|
|
4742
|
-
const up = (ev) => {
|
|
4743
|
-
document.removeEventListener('pointermove', move);
|
|
4744
|
-
document.removeEventListener('pointerup', up);
|
|
4745
|
-
if (!scrubbing) return;
|
|
4746
|
-
document.body.classList.remove('st-scrubbing');
|
|
4747
|
-
for (const p of sidesFor(ev)) commit(p, fmt(last));
|
|
4748
|
-
};
|
|
4749
|
-
document.addEventListener('pointermove', move);
|
|
4750
|
-
document.addEventListener('pointerup', up);
|
|
4751
|
-
};
|
|
4752
5171
|
const provOf = (prop) => {
|
|
4753
5172
|
const v = authored[prop];
|
|
4754
5173
|
if (!v) return 'inherit';
|
|
@@ -4811,16 +5230,21 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4811
5230
|
);
|
|
4812
5231
|
};
|
|
4813
5232
|
|
|
4814
|
-
|
|
5233
|
+
// `labelOverride` (DDR-171 — Designer mode) swaps BOTH the visible label text
|
|
5234
|
+
// AND the title tooltip together, so they never drift out of sync. Optional
|
|
5235
|
+
// and additive — every Advanced-mode call site omits it and renders exactly
|
|
5236
|
+
// as before (label = title = the raw CSS property name).
|
|
5237
|
+
const row = (prop, control, provKind, labelOverride) => {
|
|
4815
5238
|
// #1 bigger-bet — scannable diff: a fully-unset single-prop row is dimmed so
|
|
4816
5239
|
// the handful of overridden rows pop (Webflow/Framer model). Composite rows
|
|
4817
5240
|
// (border — they pass an explicit provKind) are never dimmed.
|
|
4818
5241
|
const unset = provKind === undefined && !authored[prop];
|
|
5242
|
+
const label = labelOverride ?? prop;
|
|
4819
5243
|
return (
|
|
4820
5244
|
<div className={`st-cp-row${unset ? ' is-unset' : ''}`} key={prop}>
|
|
4821
5245
|
{provDot(prop, provKind)}
|
|
4822
|
-
<label className="st-cp-label" title={
|
|
4823
|
-
{
|
|
5246
|
+
<label className="st-cp-label" title={label}>
|
|
5247
|
+
{label}
|
|
4824
5248
|
</label>
|
|
4825
5249
|
<div className="st-cp-ctl">{control}</div>
|
|
4826
5250
|
</div>
|
|
@@ -4882,6 +5306,8 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4882
5306
|
'border-style',
|
|
4883
5307
|
'border-color',
|
|
4884
5308
|
'box-shadow',
|
|
5309
|
+
'filter',
|
|
5310
|
+
'mix-blend-mode',
|
|
4885
5311
|
'opacity',
|
|
4886
5312
|
'transform',
|
|
4887
5313
|
'transform-origin',
|
|
@@ -4909,22 +5335,86 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4909
5335
|
</span>
|
|
4910
5336
|
{name}
|
|
4911
5337
|
</button>
|
|
4912
|
-
{
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
5338
|
+
{/* handoff — reset always present (design), dimmed when nothing to reset. */}
|
|
5339
|
+
<button
|
|
5340
|
+
type="button"
|
|
5341
|
+
className={`st-cp-secreset${dirty ? '' : ' is-quiet'}`}
|
|
5342
|
+
aria-label={`reset ${name} section to original`}
|
|
5343
|
+
title={`reset ${name}`}
|
|
5344
|
+
disabled={!dirty}
|
|
5345
|
+
onClick={() => resetSection(name)}
|
|
5346
|
+
>
|
|
5347
|
+
<Lu as={LuRotateCw} size={12} />
|
|
5348
|
+
</button>
|
|
5349
|
+
</div>
|
|
5350
|
+
{/* animated collapse — grid-rows 0fr→1fr keeps the DOM + interpolates
|
|
5351
|
+
height (feature-inspector-controls-redesign handoff). */}
|
|
5352
|
+
<div className={`st-cp-sec-anim${open[name] ? ' is-open' : ''}`}>
|
|
5353
|
+
<div className="st-cp-sec-inner">{body}</div>
|
|
5354
|
+
</div>
|
|
5355
|
+
</section>
|
|
5356
|
+
);
|
|
5357
|
+
};
|
|
5358
|
+
|
|
5359
|
+
// DDR-171 — Designer mode's cluster wrapper. Mirrors `sec()`'s exact chrome
|
|
5360
|
+
// (caret, reset, animated collapse — reuses `.st-cp-sec`/`.st-cp-sechd`/
|
|
5361
|
+
// `.st-cp-sec-anim` verbatim, no new CSS shape) but takes an explicit
|
|
5362
|
+
// `props` reset-list instead of looking one up in `SECTION_PROPS`, because
|
|
5363
|
+
// Designer clusters cut across Advanced-mode section boundaries and don't
|
|
5364
|
+
// map 1:1 onto it (e.g. "Auto layout" pulls from both Layout and Size).
|
|
5365
|
+
// Open-state lives in the SAME `open` object under a `d:`-prefixed key so no
|
|
5366
|
+
// second piece of state is needed; `defaultOpen=false` is how the Position
|
|
5367
|
+
// cluster starts collapsed (see `hasCustomPosition` auto-expand above).
|
|
5368
|
+
const dsec = (name, props, body, defaultOpen = true) => {
|
|
5369
|
+
const key = `d:${name}`;
|
|
5370
|
+
const isOpen = open[key] === undefined ? defaultOpen : open[key];
|
|
5371
|
+
const dirty = props.some((p) => authored[p]);
|
|
5372
|
+
return (
|
|
5373
|
+
<section className="st-cp-sec" key={key}>
|
|
5374
|
+
<div className="st-cp-sechd-row">
|
|
5375
|
+
<button
|
|
5376
|
+
type="button"
|
|
5377
|
+
className="st-cp-sechd"
|
|
5378
|
+
aria-expanded={isOpen}
|
|
5379
|
+
onClick={() => setOpen((o) => ({ ...o, [key]: !isOpen }))}
|
|
5380
|
+
>
|
|
5381
|
+
<span className="st-cp-caret" aria-hidden="true">
|
|
5382
|
+
{isOpen ? '▾' : '▸'}
|
|
5383
|
+
</span>
|
|
5384
|
+
{name}
|
|
5385
|
+
</button>
|
|
5386
|
+
<button
|
|
5387
|
+
type="button"
|
|
5388
|
+
className={`st-cp-secreset${dirty ? '' : ' is-quiet'}`}
|
|
5389
|
+
aria-label={`reset ${name} to original`}
|
|
5390
|
+
title={`reset ${name}`}
|
|
5391
|
+
disabled={!dirty}
|
|
5392
|
+
onClick={() => props.forEach((p) => { if (authored[p]) reset(p); })}
|
|
5393
|
+
>
|
|
5394
|
+
<Lu as={LuRotateCw} size={12} />
|
|
5395
|
+
</button>
|
|
5396
|
+
</div>
|
|
5397
|
+
<div className={`st-cp-sec-anim${isOpen ? ' is-open' : ''}`}>
|
|
5398
|
+
<div className="st-cp-sec-inner">{body}</div>
|
|
4923
5399
|
</div>
|
|
4924
|
-
{open[name] ? body : null}
|
|
4925
5400
|
</section>
|
|
4926
5401
|
);
|
|
4927
5402
|
};
|
|
5403
|
+
// A cluster's "···" disclosure toggle for its less-common rows (Figma's own
|
|
5404
|
+
// per-panel overflow affordance) — distinct from the cluster's own
|
|
5405
|
+
// open/collapse caret above. `designerMore[key]` gates the extra rows.
|
|
5406
|
+
const moreBtn = (key) => (
|
|
5407
|
+
<button
|
|
5408
|
+
type="button"
|
|
5409
|
+
className="st-cp-clustermore"
|
|
5410
|
+
aria-expanded={!!designerMore[key]}
|
|
5411
|
+
aria-label={designerMore[key] ? `${key} — fewer options` : `${key} — more options`}
|
|
5412
|
+
title={designerMore[key] ? 'fewer' : 'more'}
|
|
5413
|
+
onClick={() => setDesignerMore((m) => ({ ...m, [key]: !m[key] }))}
|
|
5414
|
+
>
|
|
5415
|
+
{designerMore[key] ? '▴' : '···'}
|
|
5416
|
+
</button>
|
|
5417
|
+
);
|
|
4928
5418
|
|
|
4929
5419
|
// native <select> committing a CSS value directly
|
|
4930
5420
|
const csel = (prop, list) => (
|
|
@@ -4945,6 +5435,165 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4945
5435
|
</select>
|
|
4946
5436
|
);
|
|
4947
5437
|
|
|
5438
|
+
// ── feature-inspector-controls-redesign handoff — the design's control set,
|
|
5439
|
+
// wired to CssKnobs' CSS-string commit lane. ────────────────────────────────
|
|
5440
|
+
const flatTokens = (familyKey) => tokenGroups(familyKey).flatMap((g) => (g.names || []).map((n) => ({ name: n, value: g.vals?.[n] || '' })));
|
|
5441
|
+
// enum → lucide icon button group (commits the raw CSS keyword)
|
|
5442
|
+
const iconseg = (prop, options) => (
|
|
5443
|
+
<IconButtonGroup value={authored[prop] || cssHint(computed[prop]) || options[0].value} ariaLabel={prop} options={options} onChange={(v) => commit(prop, v)} />
|
|
5444
|
+
);
|
|
5445
|
+
// number + unit + ◇ design-token binding (space / radius / type families)
|
|
5446
|
+
const vtok = (prop, familyKey, opts = {}) => {
|
|
5447
|
+
const cur = cssSplitUnit(authored[prop] ?? '');
|
|
5448
|
+
const unitless = CSS_UNITLESS.has(prop);
|
|
5449
|
+
const av = authored[prop] ?? '';
|
|
5450
|
+
const bound = typeof av === 'string' && /var\(\s*--/.test(av);
|
|
5451
|
+
const unit = unitless ? '' : cur.unit && cur.unit !== 'auto' ? cur.unit : 'px';
|
|
5452
|
+
const hintN = Number.parseFloat(cssSplitUnit(cssHint(computed[prop]) ?? '').n) || 0;
|
|
5453
|
+
const numVal = cur.n !== '' && cur.n != null ? Number.parseFloat(cur.n) || 0 : hintN;
|
|
5454
|
+
const lead = PROP_LEAD[prop];
|
|
5455
|
+
return (
|
|
5456
|
+
<ValueTokenField
|
|
5457
|
+
value={bound ? av : numVal}
|
|
5458
|
+
tokens={flatTokens(familyKey)}
|
|
5459
|
+
ariaLabel={prop}
|
|
5460
|
+
min={opts.min ?? 0}
|
|
5461
|
+
lead={lead ? (lead.node ?? lead.t) : undefined}
|
|
5462
|
+
unitSlot={unitless ? null : <UnitSelect units={CSS_UNITS} value={cur.unit || 'px'} ariaLabel={`${prop} unit`} onChange={(u) => commit(prop, u === 'auto' ? 'auto' : `${cur.n || '0'}${u}`)} />}
|
|
5463
|
+
onChange={(v) => commit(prop, typeof v === 'string' ? v : unitless ? `${v}` : `${v}${unit}`)}
|
|
5464
|
+
/>
|
|
5465
|
+
);
|
|
5466
|
+
};
|
|
5467
|
+
// border-radius → uniform field + ▢ detach → 2×2 corner quad
|
|
5468
|
+
const radiusControl = () => {
|
|
5469
|
+
const parse = (p) => Number.parseFloat(cssSplitUnit(authored[p] ?? authored['border-radius'] ?? cssHint(computed[p]) ?? cssHint(computed['border-radius']) ?? '0').n) || 0;
|
|
5470
|
+
const corners = { tl: parse('border-top-left-radius'), tr: parse('border-top-right-radius'), bl: parse('border-bottom-left-radius'), br: parse('border-bottom-right-radius') };
|
|
5471
|
+
const onCorners = (c) => {
|
|
5472
|
+
const uniform = c.tl === c.tr && c.tr === c.bl && c.bl === c.br;
|
|
5473
|
+
if (uniform) {
|
|
5474
|
+
['border-top-left-radius', 'border-top-right-radius', 'border-bottom-left-radius', 'border-bottom-right-radius'].forEach((p) => { if (authored[p]) reset(p); });
|
|
5475
|
+
commit('border-radius', `${c.tl}px`);
|
|
5476
|
+
} else {
|
|
5477
|
+
commit('border-top-left-radius', `${c.tl}px`);
|
|
5478
|
+
commit('border-top-right-radius', `${c.tr}px`);
|
|
5479
|
+
commit('border-bottom-left-radius', `${c.bl}px`);
|
|
5480
|
+
commit('border-bottom-right-radius', `${c.br}px`);
|
|
5481
|
+
}
|
|
5482
|
+
};
|
|
5483
|
+
const lead = PROP_LEAD['border-radius'];
|
|
5484
|
+
return <RadiusControl corners={corners} lead={lead ? (lead.node ?? lead.t) : undefined} onCorners={onCorners} />;
|
|
5485
|
+
};
|
|
5486
|
+
// border composite (width + style + colour) — DDR-171 pulled this out of the
|
|
5487
|
+
// Advanced-mode Appearance row inline JSX so Designer mode's "Stroke"
|
|
5488
|
+
// cluster (Task 6) can reuse the exact same control, not a re-implementation.
|
|
5489
|
+
const borderControl = () => (
|
|
5490
|
+
<div className="st-cp-border">
|
|
5491
|
+
{num('border-width', null, { fixedUnit: 'px' })}
|
|
5492
|
+
<select
|
|
5493
|
+
className="st-cp-nsel st-cp-nsel--mini"
|
|
5494
|
+
aria-label="border-style"
|
|
5495
|
+
value={CSS_BORDER_STYLES.includes(authored['border-style']) ? authored['border-style'] : ''}
|
|
5496
|
+
onChange={(e) => commit('border-style', e.target.value)}
|
|
5497
|
+
>
|
|
5498
|
+
<option value="" disabled>
|
|
5499
|
+
style
|
|
5500
|
+
</option>
|
|
5501
|
+
{CSS_BORDER_STYLES.map((s) => (
|
|
5502
|
+
<option key={s} value={s}>
|
|
5503
|
+
{s}
|
|
5504
|
+
</option>
|
|
5505
|
+
))}
|
|
5506
|
+
</select>
|
|
5507
|
+
<TokenPopover
|
|
5508
|
+
kind="color"
|
|
5509
|
+
groups={tokenGroups('color')}
|
|
5510
|
+
current={authored['border-color']}
|
|
5511
|
+
activeDs={_activeDs}
|
|
5512
|
+
swatchBg={computed['border-color'] || authored['border-color'] || ''}
|
|
5513
|
+
seedHex={cssColorToHex(computed['border-color'] || authored['border-color']) || '#000000'}
|
|
5514
|
+
onPick={(v) => commit('border-color', v)}
|
|
5515
|
+
label="border colour"
|
|
5516
|
+
/>
|
|
5517
|
+
</div>
|
|
5518
|
+
);
|
|
5519
|
+
// rotation dial, reading/writing the rotate() term of `transform`
|
|
5520
|
+
const rotationControl = () => {
|
|
5521
|
+
const t = authored.transform || cssHint(computed.transform) || '';
|
|
5522
|
+
const m = /rotate\(\s*(-?\d+(?:\.\d+)?)deg\s*\)/.exec(t);
|
|
5523
|
+
const deg = (((m ? Number.parseFloat(m[1]) : 0) % 360) + 360) % 360;
|
|
5524
|
+
const setDeg = (d) => {
|
|
5525
|
+
const norm = ((d % 360) + 360) % 360;
|
|
5526
|
+
const base = (authored.transform || '').replace(/\s*rotate\([^)]*\)\s*/g, ' ').trim();
|
|
5527
|
+
commit('transform', `${base ? `${base} ` : ''}rotate(${norm}deg)`.trim());
|
|
5528
|
+
};
|
|
5529
|
+
return (
|
|
5530
|
+
<div className="st-cp-num" style={{ border: 0, background: 'transparent', gap: 'var(--space-2)' }}>
|
|
5531
|
+
<AngleDial value={deg} onChange={setDeg} />
|
|
5532
|
+
<NumberField value={deg} min={0} max={360} ariaLabel="rotation" lead={<Lu as={LuRotateCw} />} steppers={false} unitSlot={<span className="st-cp-numsuffix" aria-hidden="true">°</span>} onCommit={setDeg} />
|
|
5533
|
+
</div>
|
|
5534
|
+
);
|
|
5535
|
+
};
|
|
5536
|
+
// DDR-171 — `filter: blur(Npx)`, scoped to blur-only for v1 (not a full
|
|
5537
|
+
// filter-function editor). Local parse/serialize (unlike most rows this
|
|
5538
|
+
// doesn't pass the raw string straight to `commit`) — kept as a closure here,
|
|
5539
|
+
// not a top-level utility, so it stays governed by the "no new primitives"
|
|
5540
|
+
// constraint. A non-blur `filter` value (set via Advanced's raw-CSS hatch)
|
|
5541
|
+
// reads as 0 here rather than being clobbered — only written back once the
|
|
5542
|
+
// user actually commits a blur amount.
|
|
5543
|
+
const blurControl = () => {
|
|
5544
|
+
const f = authored.filter || cssHint(computed.filter) || '';
|
|
5545
|
+
const m = /blur\(\s*(-?\d+(?:\.\d+)?)px\s*\)/.exec(f);
|
|
5546
|
+
const px = m ? Number.parseFloat(m[1]) : 0;
|
|
5547
|
+
const setBlur = (n) => {
|
|
5548
|
+
const base = (authored.filter || '').replace(/\s*blur\([^)]*\)\s*/g, ' ').trim();
|
|
5549
|
+
commit('filter', n > 0 ? `${base ? `${base} ` : ''}blur(${n}px)`.trim() : base || 'none');
|
|
5550
|
+
};
|
|
5551
|
+
return <NumberField value={px} min={0} ariaLabel="filter blur" unitSlot={<span className="st-cp-numsuffix" aria-hidden="true">px</span>} onCommit={setBlur} />;
|
|
5552
|
+
};
|
|
5553
|
+
// handoff — shown as 0–100 % (design), stored as the CSS 0–1 value. Pulled
|
|
5554
|
+
// out (DDR-171) so Designer mode's "Opacity" cluster reuses it verbatim.
|
|
5555
|
+
const opacityControl = () => {
|
|
5556
|
+
const a = authored.opacity;
|
|
5557
|
+
const raw = a != null && a !== '' ? Number.parseFloat(a) : Number.parseFloat(cssHint(computed.opacity)) || 1;
|
|
5558
|
+
const pct = Math.round((Number.isNaN(raw) ? 1 : raw) * 100);
|
|
5559
|
+
return (
|
|
5560
|
+
<SliderField
|
|
5561
|
+
key={`opacity:${a ?? ''}`}
|
|
5562
|
+
value={pct}
|
|
5563
|
+
min={0}
|
|
5564
|
+
max={100}
|
|
5565
|
+
step={1}
|
|
5566
|
+
unit="%"
|
|
5567
|
+
ariaLabel="opacity"
|
|
5568
|
+
onInput={(n) => optimistic('opacity', String(n / 100))}
|
|
5569
|
+
onCommit={(n) => commit('opacity', String(n / 100))}
|
|
5570
|
+
/>
|
|
5571
|
+
);
|
|
5572
|
+
};
|
|
5573
|
+
// B / I / U quick-style toggle group → font-weight / font-style / text-decoration
|
|
5574
|
+
const textStyleToggle = () => {
|
|
5575
|
+
const isBold = Number.parseInt(authored['font-weight'] || cssHint(computed['font-weight']) || '400', 10) >= 600;
|
|
5576
|
+
const isItalic = (authored['font-style'] || cssHint(computed['font-style'])) === 'italic';
|
|
5577
|
+
const isUnder = /underline/.test(authored['text-decoration'] || cssHint(computed['text-decoration']) || '');
|
|
5578
|
+
return (
|
|
5579
|
+
<IconToggleGroup
|
|
5580
|
+
value={{ b: isBold, i: isItalic, u: isUnder }}
|
|
5581
|
+
ariaLabel="text style"
|
|
5582
|
+
options={[{ value: 'b', node: <Lu as={LuBold} />, label: 'Bold' }, { value: 'i', node: <Lu as={LuItalic} />, label: 'Italic' }, { value: 'u', node: <Lu as={LuUnderline} />, label: 'Underline' }]}
|
|
5583
|
+
onToggle={(k) => {
|
|
5584
|
+
if (k === 'b') commit('font-weight', isBold ? '400' : '700');
|
|
5585
|
+
else if (k === 'i') commit('font-style', isItalic ? 'normal' : 'italic');
|
|
5586
|
+
else commit('text-decoration', isUnder ? 'none' : 'underline');
|
|
5587
|
+
}}
|
|
5588
|
+
/>
|
|
5589
|
+
);
|
|
5590
|
+
};
|
|
5591
|
+
const DIR_OPTS = [{ value: 'row', node: <Lu as={LuColumns3} />, label: 'Row' }, { value: 'column', node: <Lu as={LuRows3} />, label: 'Column' }];
|
|
5592
|
+
const JUSTIFY_OPTS = [{ value: 'flex-start', node: <Lu as={LuJustifyStart} />, label: 'Start' }, { value: 'center', node: <Lu as={LuJustifyCenter} />, label: 'Center' }, { value: 'flex-end', node: <Lu as={LuJustifyEnd} />, label: 'End' }, { value: 'space-between', node: <Lu as={LuSpaceBetween} />, label: 'Space between' }];
|
|
5593
|
+
const ALIGNITEMS_OPTS = [{ value: 'flex-start', node: <Lu as={LuVJustifyStart} />, label: 'Start' }, { value: 'center', node: <Lu as={LuVJustifyCenter} />, label: 'Center' }, { value: 'flex-end', node: <Lu as={LuVJustifyEnd} />, label: 'End' }, { value: 'stretch', node: <Lu as={LuStretch} />, label: 'Stretch' }];
|
|
5594
|
+
const TEXTALIGN_OPTS = [{ value: 'left', node: <Lu as={LuAlignLeft} />, label: 'Left' }, { value: 'center', node: <Lu as={LuAlignCenter} />, label: 'Center' }, { value: 'right', node: <Lu as={LuAlignRight} />, label: 'Right' }, { value: 'justify', node: <Lu as={LuAlignJustify} />, label: 'Justify' }];
|
|
5595
|
+
const OVERFLOW_OPTS = [{ value: 'visible', node: <Lu as={LuEye} />, label: 'Visible' }, { value: 'hidden', node: <Lu as={LuScissors} />, label: 'Hidden' }, { value: 'scroll', node: <Lu as={LuScrollText} />, label: 'Scroll' }];
|
|
5596
|
+
|
|
4948
5597
|
// token quick-pick — Figma-style POPOVER (W2.1) listing the DS variables for
|
|
4949
5598
|
// this property (name + resolved value), grouped per design system (W3);
|
|
4950
5599
|
// picking writes var(--token). `familyKey` selects the token family.
|
|
@@ -4962,6 +5611,31 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4962
5611
|
) : null;
|
|
4963
5612
|
};
|
|
4964
5613
|
|
|
5614
|
+
// Select-all-on-focus (deferred past the browser's own click-caret placement
|
|
5615
|
+
// so a SECOND click, already focused, places the caret instead) — the
|
|
5616
|
+
// interaction-model rule applied to the CssKnobs fields that stay bespoke
|
|
5617
|
+
// <input>s (free text, box-model cells) rather than the shared NumberField.
|
|
5618
|
+
const selectAllOnFocus = (e) => {
|
|
5619
|
+
const el = e.currentTarget;
|
|
5620
|
+
requestAnimationFrame(() => {
|
|
5621
|
+
if (document.activeElement === el) el.select();
|
|
5622
|
+
});
|
|
5623
|
+
};
|
|
5624
|
+
// Arrow-key stepping (±1, Shift ×10) for the bespoke box-model cells — same
|
|
5625
|
+
// keyboard model NumberField gives the main fields, hand-rolled here because
|
|
5626
|
+
// these stay compact <input>s (no room for NumberField's handle+stepper
|
|
5627
|
+
// chrome in a 36×24 box-model cell). Returns true if it handled the key.
|
|
5628
|
+
const stepBoxInput = (e, commitFn) => {
|
|
5629
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false;
|
|
5630
|
+
e.preventDefault();
|
|
5631
|
+
const mult = e.shiftKey ? 10 : 1;
|
|
5632
|
+
const dir = e.key === 'ArrowUp' ? 1 : -1;
|
|
5633
|
+
const n = (Number.parseFloat(e.currentTarget.value) || 0) + dir * mult;
|
|
5634
|
+
e.currentTarget.value = String(n);
|
|
5635
|
+
commitFn(n);
|
|
5636
|
+
return true;
|
|
5637
|
+
};
|
|
5638
|
+
|
|
4965
5639
|
// free text input — raw value or var(--token), commits on blur/Enter
|
|
4966
5640
|
const text = (prop) => (
|
|
4967
5641
|
<input
|
|
@@ -4970,6 +5644,7 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4970
5644
|
aria-label={prop}
|
|
4971
5645
|
defaultValue={authored[prop] ?? ''}
|
|
4972
5646
|
placeholder={cssHint(computed[prop]) || '—'}
|
|
5647
|
+
onFocus={selectAllOnFocus}
|
|
4973
5648
|
onKeyDown={(e) => {
|
|
4974
5649
|
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4975
5650
|
}}
|
|
@@ -4977,79 +5652,46 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4977
5652
|
/>
|
|
4978
5653
|
);
|
|
4979
5654
|
|
|
4980
|
-
// number + steppers + unit-select (+ optional token quick-pick after)
|
|
5655
|
+
// number + steppers + unit-select (+ optional token quick-pick after) — built
|
|
5656
|
+
// on the shared NumberField (feature-inspector-controls-redesign): the drag
|
|
5657
|
+
// handle moves to the leading icon/grip (never the input body, so click-to-
|
|
5658
|
+
// type + select-all-on-focus work), and arrow-key stepping comes for free.
|
|
4981
5659
|
const num = (prop, tokenList, opts = {}) => {
|
|
4982
5660
|
const cur = cssSplitUnit(authored[prop] ?? '');
|
|
4983
5661
|
// Unitless CSS properties — a bare number must commit WITHOUT a unit suffix
|
|
4984
5662
|
// (line-height: 1.5px ≠ 1.5 — knob-smoke finding, 2026-06-12).
|
|
4985
5663
|
const unitless = CSS_UNITLESS.has(prop);
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
};
|
|
5664
|
+
// `opts.fixedUnit` — a px-only field (border-width) skips the unit <select>
|
|
5665
|
+
// entirely so the compact border-cluster row (width + style + swatch) has
|
|
5666
|
+
// room to fit at the panel's 260-304px widths (Task 5 overflow fix).
|
|
5667
|
+
const unit = unitless ? '' : opts.fixedUnit || (cur.unit && cur.unit !== 'auto' ? cur.unit : 'px');
|
|
4991
5668
|
const lead = PROP_LEAD[prop];
|
|
5669
|
+
// Unset (no authored value) shows the computed/inherited value as the
|
|
5670
|
+
// starting number — same value the old placeholder hinted at; the row's own
|
|
5671
|
+
// dimming (`row()`'s `is-unset`) is what signals "inherited", not this field.
|
|
5672
|
+
const hintN = Number.parseFloat(cssSplitUnit(cssHint(computed[prop]) ?? '').n) || 0;
|
|
5673
|
+
const shownN = cur.n !== '' && cur.n != null ? Number.parseFloat(cur.n) || 0 : hintN;
|
|
4992
5674
|
return (
|
|
4993
5675
|
<>
|
|
4994
|
-
<
|
|
4995
|
-
{
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
commit(prop, /[a-z%(]/i.test(raw) ? raw : `${raw}${unit}`);
|
|
5014
|
-
}}
|
|
5015
|
-
/>
|
|
5016
|
-
<span className="st-cp-step">
|
|
5017
|
-
<button
|
|
5018
|
-
type="button"
|
|
5019
|
-
className="st-cp-stepb"
|
|
5020
|
-
tabIndex={-1}
|
|
5021
|
-
aria-label={`increase ${prop}`}
|
|
5022
|
-
onClick={() => bump(1)}
|
|
5023
|
-
>
|
|
5024
|
-
▲
|
|
5025
|
-
</button>
|
|
5026
|
-
<button
|
|
5027
|
-
type="button"
|
|
5028
|
-
className="st-cp-stepb"
|
|
5029
|
-
tabIndex={-1}
|
|
5030
|
-
aria-label={`decrease ${prop}`}
|
|
5031
|
-
onClick={() => bump(-1)}
|
|
5032
|
-
>
|
|
5033
|
-
▼
|
|
5034
|
-
</button>
|
|
5035
|
-
</span>
|
|
5036
|
-
{unitless ? null : (
|
|
5037
|
-
<select
|
|
5038
|
-
className="st-cp-unitsel"
|
|
5039
|
-
aria-label={`${prop} unit`}
|
|
5040
|
-
value={cur.unit || 'px'}
|
|
5041
|
-
onChange={(e) =>
|
|
5042
|
-
commit(prop, e.target.value === 'auto' ? 'auto' : `${cur.n || '0'}${e.target.value}`)
|
|
5043
|
-
}
|
|
5044
|
-
>
|
|
5045
|
-
{CSS_UNITS.map((u) => (
|
|
5046
|
-
<option key={u} value={u}>
|
|
5047
|
-
{u}
|
|
5048
|
-
</option>
|
|
5049
|
-
))}
|
|
5050
|
-
</select>
|
|
5051
|
-
)}
|
|
5052
|
-
</div>
|
|
5676
|
+
<NumberField
|
|
5677
|
+
key={`${prop}:${authored[prop] ?? ''}`}
|
|
5678
|
+
value={shownN}
|
|
5679
|
+
min={opts.min ?? 0}
|
|
5680
|
+
step={1}
|
|
5681
|
+
ariaLabel={prop}
|
|
5682
|
+
lead={lead ? (lead.node ?? lead.t) : undefined}
|
|
5683
|
+
onCommit={(n) => commit(prop, unitless ? `${n}` : `${n}${unit}`)}
|
|
5684
|
+
unitSlot={
|
|
5685
|
+
unitless || opts.fixedUnit ? null : (
|
|
5686
|
+
<UnitSelect
|
|
5687
|
+
units={CSS_UNITS}
|
|
5688
|
+
value={cur.unit || 'px'}
|
|
5689
|
+
ariaLabel={`${prop} unit`}
|
|
5690
|
+
onChange={(u) => commit(prop, u === 'auto' ? 'auto' : `${cur.n || '0'}${u}`)}
|
|
5691
|
+
/>
|
|
5692
|
+
)
|
|
5693
|
+
}
|
|
5694
|
+
/>
|
|
5053
5695
|
{tok(prop, tokenList)}
|
|
5054
5696
|
</>
|
|
5055
5697
|
);
|
|
@@ -5057,24 +5699,34 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5057
5699
|
|
|
5058
5700
|
// color swatch (native picker → hex) + raw text + token quick-pick
|
|
5059
5701
|
const color = (prop) => {
|
|
5060
|
-
// ONE colour
|
|
5061
|
-
//
|
|
5062
|
-
//
|
|
5702
|
+
// ONE compact colour field (feature-inspector-controls-redesign handoff): the
|
|
5703
|
+
// TokenPopover swatch is the FLUSH prefix (divider, no gap) inside ColorField,
|
|
5704
|
+
// then the value input — swatch + value read as one field. The popover keeps
|
|
5705
|
+
// its full HSV picker (Custom) + DS swatches (Variables) + cross-DS/security.
|
|
5063
5706
|
const resolved = computed[prop] || authored[prop] || '';
|
|
5707
|
+
const av = authored[prop] ?? '';
|
|
5708
|
+
const bound = typeof av === 'string' && /var\(\s*--/.test(av);
|
|
5709
|
+
const display = bound ? av.replace(/^var\(\s*|\s*\)$/g, '').replace(/^--/, '').replace(/-/g, ' ') : av;
|
|
5064
5710
|
return (
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5711
|
+
<ColorField
|
|
5712
|
+
swatch={
|
|
5713
|
+
<TokenPopover
|
|
5714
|
+
kind="color"
|
|
5715
|
+
swatchClassName="st-cp-cf-sw"
|
|
5716
|
+
groups={tokenGroups('color')}
|
|
5717
|
+
current={authored[prop]}
|
|
5718
|
+
activeDs={_activeDs}
|
|
5719
|
+
swatchBg={resolved}
|
|
5720
|
+
seedHex={cssColorToHex(computed[prop] || authored[prop]) || '#000000'}
|
|
5721
|
+
onPick={(v) => commit(prop, v)}
|
|
5722
|
+
label={`${prop} colour`}
|
|
5723
|
+
/>
|
|
5724
|
+
}
|
|
5725
|
+
displayValue={display}
|
|
5726
|
+
bound={bound}
|
|
5727
|
+
ariaLabel={prop}
|
|
5728
|
+
onValue={(v) => commit(prop, v)}
|
|
5729
|
+
/>
|
|
5078
5730
|
);
|
|
5079
5731
|
};
|
|
5080
5732
|
|
|
@@ -5082,6 +5734,49 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5082
5734
|
// always shows the RESOLVED value (0 instead of blank) and a faint `is-zero`
|
|
5083
5735
|
// styling for an unset/zero side. Edits the single side (the old "link all
|
|
5084
5736
|
// sides" toggle was removed — DDR-104 Phase 12.3 W1.5).
|
|
5737
|
+
// Built on the shared `makeScrubHandler` engine (feature-inspector-controls-
|
|
5738
|
+
// redesign) — a plain (non-hook) factory, since `side`/`inset` are helper
|
|
5739
|
+
// closures invoked during render, not components (can't call a hook there).
|
|
5740
|
+
// These stay compact <input>s with whole-cell scrub (no separate drag handle
|
|
5741
|
+
// — the Figma/Webflow convention for tiny box-model cells with no room for
|
|
5742
|
+
// one; the 3px dead-zone already lets a plain click through to focus). A
|
|
5743
|
+
// multi-side drag (alt = pair, alt+shift = all four) live-updates the sibling
|
|
5744
|
+
// box inputs too, so the whole move shows in the panel, not just the dragged
|
|
5745
|
+
// cell — `node` closes over the actual input DOM element via onPointerDown.
|
|
5746
|
+
const boxScrub = (prop, opts) => {
|
|
5747
|
+
let node = null;
|
|
5748
|
+
const unit = opts.unitless ? '' : 'px';
|
|
5749
|
+
const fmt = (n) => (opts.unitless ? `${n}` : `${n}${unit}`);
|
|
5750
|
+
const applyToSides = (n, activeSides, fn) => {
|
|
5751
|
+
for (const p of activeSides ?? [prop]) fn(p, fmt(n));
|
|
5752
|
+
};
|
|
5753
|
+
const scrub = makeScrubHandler({
|
|
5754
|
+
getBase: () => node?.value ?? '0',
|
|
5755
|
+
min: opts.min ?? 0,
|
|
5756
|
+
step: 1,
|
|
5757
|
+
sides: opts.sides,
|
|
5758
|
+
onInput: (n, activeSides) => {
|
|
5759
|
+
if (node) node.value = String(n);
|
|
5760
|
+
if (activeSides) {
|
|
5761
|
+
const box = node?.closest('.st-cp-box');
|
|
5762
|
+
for (const p of activeSides) {
|
|
5763
|
+
if (p === prop) continue;
|
|
5764
|
+
const sib = box?.querySelector(`.st-cp-boxv[aria-label="${p}"]`);
|
|
5765
|
+
if (sib) sib.value = String(n);
|
|
5766
|
+
}
|
|
5767
|
+
}
|
|
5768
|
+
applyToSides(n, activeSides, optimistic);
|
|
5769
|
+
},
|
|
5770
|
+
onCommit: (n, activeSides) => applyToSides(n, activeSides, commit),
|
|
5771
|
+
});
|
|
5772
|
+
return {
|
|
5773
|
+
onPointerDown: (e) => {
|
|
5774
|
+
node = e.currentTarget;
|
|
5775
|
+
scrub(e);
|
|
5776
|
+
},
|
|
5777
|
+
};
|
|
5778
|
+
};
|
|
5779
|
+
|
|
5085
5780
|
const side = (prop, group) => {
|
|
5086
5781
|
const a = authored[prop];
|
|
5087
5782
|
const shown =
|
|
@@ -5106,8 +5801,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5106
5801
|
aria-label={prop}
|
|
5107
5802
|
defaultValue={shown}
|
|
5108
5803
|
title="drag to scrub · alt = symmetric · alt+shift = all sides"
|
|
5109
|
-
|
|
5804
|
+
{...boxScrub(prop, { sides: { pair, all } })}
|
|
5805
|
+
onFocus={selectAllOnFocus}
|
|
5110
5806
|
onKeyDown={(e) => {
|
|
5807
|
+
if (stepBoxInput(e, (n) => commit(prop, `${n}px`))) return;
|
|
5111
5808
|
if (e.key === 'Enter') e.currentTarget.blur();
|
|
5112
5809
|
}}
|
|
5113
5810
|
onBlur={(e) => {
|
|
@@ -5141,8 +5838,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5141
5838
|
defaultValue={shown}
|
|
5142
5839
|
placeholder="auto"
|
|
5143
5840
|
title="drag to scrub · alt = axis pair · alt+shift = all sides · type auto"
|
|
5144
|
-
|
|
5841
|
+
{...boxScrub(prop, { sides: { pair, all }, min: -Infinity })}
|
|
5842
|
+
onFocus={selectAllOnFocus}
|
|
5145
5843
|
onKeyDown={(e) => {
|
|
5844
|
+
if (stepBoxInput(e, (n) => commit(prop, `${n}px`))) return;
|
|
5146
5845
|
if (e.key === 'Enter') e.currentTarget.blur();
|
|
5147
5846
|
}}
|
|
5148
5847
|
onBlur={(e) => {
|
|
@@ -5155,40 +5854,261 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5155
5854
|
);
|
|
5156
5855
|
};
|
|
5157
5856
|
|
|
5158
|
-
const corner = (label, prop) => (
|
|
5159
|
-
<label className="st-cp-cornerf">
|
|
5160
|
-
<span>{label}</span>
|
|
5161
|
-
<input
|
|
5162
|
-
key={`${prop}:${authored[prop] ?? ''}`}
|
|
5163
|
-
aria-label={prop}
|
|
5164
|
-
defaultValue={cssSplitUnit(authored[prop] ?? '').n || ''}
|
|
5165
|
-
placeholder={cssHint(computed[prop]) || '0'}
|
|
5166
|
-
onKeyDown={(e) => {
|
|
5167
|
-
if (e.key === 'Enter') e.currentTarget.blur();
|
|
5168
|
-
}}
|
|
5169
|
-
onBlur={(e) => {
|
|
5170
|
-
const raw = e.currentTarget.value.trim();
|
|
5171
|
-
if (raw) commit(prop, /[a-z%]/i.test(raw) ? raw : `${raw}px`);
|
|
5172
|
-
}}
|
|
5173
|
-
/>
|
|
5174
|
-
</label>
|
|
5175
|
-
);
|
|
5176
|
-
|
|
5177
5857
|
// Phase 12.3 — authored inline props with no curated row + custom HTML attrs,
|
|
5178
5858
|
// surfaced in Advanced so the user can see/edit/remove what they added.
|
|
5179
5859
|
const customStyleRows = Object.entries(customStyles);
|
|
5180
5860
|
const attrRows = Object.entries(attrs);
|
|
5181
5861
|
|
|
5862
|
+
// Stage B (Task B5) — Media framing gate + body, pulled out (DDR-171) so
|
|
5863
|
+
// Designer mode's "Media" cluster renders the identical content (just a
|
|
5864
|
+
// relabeled wrapper) instead of re-deriving `showMedia`/`canReplace`.
|
|
5865
|
+
// Rendered only for a media element (img / video / picture / svg / canvas)
|
|
5866
|
+
// or a selection that already carries a framing prop, so a plain <div>
|
|
5867
|
+
// doesn't grow object-fit rows. Media = box/framing/source (this plan); the
|
|
5868
|
+
// photo-editor plan's "Photo" tab owns pixels/look — separate DOM slots by
|
|
5869
|
+
// design.
|
|
5870
|
+
const mediaGate = () => {
|
|
5871
|
+
const t = (el.tag || '').toLowerCase();
|
|
5872
|
+
const isMediaEl = t === 'img' || t === 'video' || t === 'picture' || t === 'svg' || t === 'canvas';
|
|
5873
|
+
const showMedia =
|
|
5874
|
+
isMediaEl || !!authored['object-fit'] || !!authored['object-position'] || !!authored['aspect-ratio'];
|
|
5875
|
+
// Stage F2 — "Replace…" opens the AssetPicker to re-point src (authored
|
|
5876
|
+
// <img>/<video> only; a template-expression src can't be string-swapped,
|
|
5877
|
+
// so gate on a real src attr being present).
|
|
5878
|
+
const canReplace = (t === 'img' || t === 'video') && !!el.attrs?.src && !!onReplaceMedia;
|
|
5879
|
+
return { showMedia, canReplace };
|
|
5880
|
+
};
|
|
5881
|
+
const mediaBody = (canReplace) => (
|
|
5882
|
+
<>
|
|
5883
|
+
{canReplace && (
|
|
5884
|
+
<div className="st-cp-mediabtn">
|
|
5885
|
+
<button type="button" className="st-btn st-cp-replace" onClick={() => onReplaceMedia(el)}>
|
|
5886
|
+
Replace…
|
|
5887
|
+
</button>
|
|
5888
|
+
</div>
|
|
5889
|
+
)}
|
|
5890
|
+
{row('object-fit', csel('object-fit', CSS_OBJECT_FIT))}
|
|
5891
|
+
{row('object-position', text('object-position'))}
|
|
5892
|
+
{row(
|
|
5893
|
+
'aspect-ratio',
|
|
5894
|
+
<select
|
|
5895
|
+
className="st-cp-nsel"
|
|
5896
|
+
aria-label="aspect-ratio"
|
|
5897
|
+
value={CSS_ASPECT_RATIO.includes(authored['aspect-ratio']) ? authored['aspect-ratio'] : ''}
|
|
5898
|
+
onChange={(e) => {
|
|
5899
|
+
const v = e.target.value;
|
|
5900
|
+
commit('aspect-ratio', v);
|
|
5901
|
+
// A fixed height overrides aspect-ratio (CSS: explicit width+height
|
|
5902
|
+
// win). When applying a real ratio, release the height so the ratio
|
|
5903
|
+
// actually reshapes the box (dogfood: "nastavil jsem 16/9 a nic se
|
|
5904
|
+
// nestalo").
|
|
5905
|
+
if (v && v !== 'auto' && authored.height) reset('height');
|
|
5906
|
+
}}
|
|
5907
|
+
>
|
|
5908
|
+
<option value="" disabled>
|
|
5909
|
+
{cssHint(computed['aspect-ratio']) || '—'}
|
|
5910
|
+
</option>
|
|
5911
|
+
{CSS_ASPECT_RATIO.map((v) => (
|
|
5912
|
+
<option key={v} value={v}>
|
|
5913
|
+
{v}
|
|
5914
|
+
</option>
|
|
5915
|
+
))}
|
|
5916
|
+
</select>
|
|
5917
|
+
)}
|
|
5918
|
+
</>
|
|
5919
|
+
);
|
|
5920
|
+
|
|
5182
5921
|
return (
|
|
5183
|
-
<div className=
|
|
5922
|
+
<div className={`st-cp${mode === 'designer' ? ' st-cp--designer' : ''}`} key={el.id} data-tour="css-panel" onKeyDown={onKnobKeyDown}>
|
|
5184
5923
|
<div className="st-cp-id">
|
|
5185
5924
|
<span className="st-cp-idtag">
|
|
5186
5925
|
{el.tag || 'element'}
|
|
5187
5926
|
{el.classes ? <span className="st-cp-idcls">.{el.classes.split(/\s+/)[0]}</span> : null}
|
|
5188
5927
|
</span>
|
|
5189
|
-
|
|
5928
|
+
{/* DDR-171 — vocabulary mode toggle, tucked into the id row's corner
|
|
5929
|
+
slot (was a full-width Segmented row — read as too heavy; a
|
|
5930
|
+
two-icon IconButtonGroup matches every other compact toggle in
|
|
5931
|
+
this panel). 'advanced' is the default (today's panel, byte-
|
|
5932
|
+
identical below); 'designer' swaps in the Figma-vocabulary regroup.
|
|
5933
|
+
Named "Advanced" (not "Simple"/"Basic") so neither mode reads as
|
|
5934
|
+
the lesser fallback — see DDR-171 for the naming-collision call
|
|
5935
|
+
against the nested Advanced *section* below. */}
|
|
5936
|
+
<span className="st-cp-idmode" data-tour="cp-mode">
|
|
5937
|
+
<IconButtonGroup
|
|
5938
|
+
value={mode}
|
|
5939
|
+
ariaLabel="panel vocabulary mode"
|
|
5940
|
+
options={[
|
|
5941
|
+
{ value: 'advanced', node: <Lu as={LuBraces} size={12} />, label: 'Advanced — raw CSS' },
|
|
5942
|
+
{ value: 'designer', node: <Lu as={LuWand2} size={12} />, label: 'Designer — Figma vocabulary' },
|
|
5943
|
+
]}
|
|
5944
|
+
onChange={setMode}
|
|
5945
|
+
/>
|
|
5946
|
+
</span>
|
|
5190
5947
|
</div>
|
|
5191
5948
|
|
|
5949
|
+
{mode === 'designer' ? (
|
|
5950
|
+
<>
|
|
5951
|
+
{dsec(
|
|
5952
|
+
'Auto layout',
|
|
5953
|
+
['display', 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
|
|
5954
|
+
(() => {
|
|
5955
|
+
const disp = (authored.display || cssHint(computed.display) || '').trim();
|
|
5956
|
+
const isFlex = disp === 'flex' || disp === 'inline-flex';
|
|
5957
|
+
return isFlex ? (
|
|
5958
|
+
<>
|
|
5959
|
+
{row('flex-direction', iconseg('flex-direction', DIR_OPTS), undefined, 'Direction')}
|
|
5960
|
+
{row(
|
|
5961
|
+
'align-items',
|
|
5962
|
+
<AlignPad value={alignPadCell()} onChange={setAlignPadCell} ariaLabel="auto-layout alignment" />,
|
|
5963
|
+
provOf('align-items'),
|
|
5964
|
+
'Alignment'
|
|
5965
|
+
)}
|
|
5966
|
+
{row('gap', vtok('gap', 'space'), undefined, 'Gap')}
|
|
5967
|
+
<div className="st-cp-modes">
|
|
5968
|
+
{sizeModeSeg('width')}
|
|
5969
|
+
{sizeModeSeg('height')}
|
|
5970
|
+
</div>
|
|
5971
|
+
<div className="st-cp-box" aria-label="padding">
|
|
5972
|
+
<span className="st-cp-boxtag st-cp-boxtag--p">
|
|
5973
|
+
{prov(provOf('padding-top'))}padding
|
|
5974
|
+
</span>
|
|
5975
|
+
{side('padding-top', 'padding')}
|
|
5976
|
+
{side('padding-right', 'padding')}
|
|
5977
|
+
{side('padding-bottom', 'padding')}
|
|
5978
|
+
{side('padding-left', 'padding')}
|
|
5979
|
+
</div>
|
|
5980
|
+
<div className="st-cp-clustermore-row">{moreBtn('Auto layout')}</div>
|
|
5981
|
+
{designerMore['Auto layout']
|
|
5982
|
+
? row(
|
|
5983
|
+
'flex-wrap',
|
|
5984
|
+
<Toggle
|
|
5985
|
+
checked={/^wrap/.test(authored['flex-wrap'] || cssHint(computed['flex-wrap']) || '')}
|
|
5986
|
+
label="wrap items"
|
|
5987
|
+
ariaLabel="flex-wrap"
|
|
5988
|
+
onChange={(w) => commit('flex-wrap', w ? 'wrap' : 'nowrap')}
|
|
5989
|
+
/>,
|
|
5990
|
+
provOf('flex-wrap'),
|
|
5991
|
+
'Wrap'
|
|
5992
|
+
)
|
|
5993
|
+
: null}
|
|
5994
|
+
</>
|
|
5995
|
+
) : (
|
|
5996
|
+
<button type="button" className="st-cp-makeflex" disabled={!editable} onClick={() => commit('display', 'flex')}>
|
|
5997
|
+
+ Auto layout (flex)
|
|
5998
|
+
</button>
|
|
5999
|
+
);
|
|
6000
|
+
})()
|
|
6001
|
+
)}
|
|
6002
|
+
|
|
6003
|
+
{dsec(
|
|
6004
|
+
'Size',
|
|
6005
|
+
['width', 'height', 'min-width', 'max-width', 'min-height', 'max-height'],
|
|
6006
|
+
<>
|
|
6007
|
+
{row('width', num('width'), undefined, 'Width')}
|
|
6008
|
+
{row('height', num('height'), undefined, 'Height')}
|
|
6009
|
+
<div className="st-cp-clustermore-row">{moreBtn('Size')}</div>
|
|
6010
|
+
{designerMore.Size ? (
|
|
6011
|
+
<>
|
|
6012
|
+
{row('min-width', num('min-width'), undefined, 'Min width')}
|
|
6013
|
+
{row('max-width', num('max-width'), undefined, 'Max width')}
|
|
6014
|
+
{row('min-height', num('min-height'), undefined, 'Min height')}
|
|
6015
|
+
{row('max-height', num('max-height'), undefined, 'Max height')}
|
|
6016
|
+
</>
|
|
6017
|
+
) : null}
|
|
6018
|
+
</>
|
|
6019
|
+
)}
|
|
6020
|
+
|
|
6021
|
+
{dsec(
|
|
6022
|
+
'Position',
|
|
6023
|
+
['position', 'top', 'right', 'bottom', 'left', 'z-index'],
|
|
6024
|
+
<>
|
|
6025
|
+
{row('position', csel('position', CSS_POSITION), undefined, 'Position')}
|
|
6026
|
+
<div className="st-cp-box st-cp-box--inset" aria-label="position inset (top / right / bottom / left)">
|
|
6027
|
+
<span className="st-cp-boxtag st-cp-boxtag--i">{prov(provOf('top'))}inset</span>
|
|
6028
|
+
{inset('top')}
|
|
6029
|
+
{inset('right')}
|
|
6030
|
+
{inset('bottom')}
|
|
6031
|
+
{inset('left')}
|
|
6032
|
+
<div className="st-cp-boxcore st-cp-boxcore--pos">
|
|
6033
|
+
{authored.position || cssHint(computed.position) || 'static'}
|
|
6034
|
+
</div>
|
|
6035
|
+
</div>
|
|
6036
|
+
{(authored.position || cssHint(computed.position) || 'static') === 'static' ? (
|
|
6037
|
+
<div className="st-cp-note">
|
|
6038
|
+
top / right / bottom / left apply once position is relative, absolute, fixed, or sticky
|
|
6039
|
+
</div>
|
|
6040
|
+
) : null}
|
|
6041
|
+
<div className="st-cp-clustermore-row">{moreBtn('Position')}</div>
|
|
6042
|
+
{designerMore.Position ? row('z-index', num('z-index'), undefined, 'Layer order') : null}
|
|
6043
|
+
</>,
|
|
6044
|
+
false
|
|
6045
|
+
)}
|
|
6046
|
+
|
|
6047
|
+
{dsec('Fill', ['background-color'], row('background-color', color('background-color'), undefined, 'Fill'))}
|
|
6048
|
+
|
|
6049
|
+
{dsec('Stroke', ['border-width', 'border-style', 'border-color'], row('border', borderControl(), provOf('border-width'), 'Stroke'))}
|
|
6050
|
+
|
|
6051
|
+
{dsec(
|
|
6052
|
+
'Corner radius',
|
|
6053
|
+
['border-radius', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-left-radius', 'border-bottom-right-radius'],
|
|
6054
|
+
row('border-radius', radiusControl(), provOf('border-radius'), 'Corner radius')
|
|
6055
|
+
)}
|
|
6056
|
+
|
|
6057
|
+
{dsec(
|
|
6058
|
+
'Effects',
|
|
6059
|
+
['box-shadow', 'filter', 'mix-blend-mode'],
|
|
6060
|
+
<>
|
|
6061
|
+
{row('box-shadow', tok('box-shadow', 'shadow') || text('box-shadow'), undefined, 'Shadow')}
|
|
6062
|
+
{row('filter', blurControl(), provOf('filter'), 'Blur')}
|
|
6063
|
+
{row('mix-blend-mode', csel('mix-blend-mode', CSS_BLEND_MODES), undefined, 'Blend')}
|
|
6064
|
+
</>
|
|
6065
|
+
)}
|
|
6066
|
+
|
|
6067
|
+
{dsec('Opacity', ['opacity'], row('opacity', opacityControl(), undefined, 'Opacity'))}
|
|
6068
|
+
|
|
6069
|
+
{dsec(
|
|
6070
|
+
'Text',
|
|
6071
|
+
['font-family', 'color', 'font-size', 'font-weight', 'line-height', 'text-align', 'letter-spacing', 'font-style', 'text-transform', 'white-space'],
|
|
6072
|
+
<>
|
|
6073
|
+
{row('font-family', csel('font-family', CSS_FONTS), undefined, 'Font')}
|
|
6074
|
+
{row('color', color('color'), undefined, 'Color')}
|
|
6075
|
+
{row('font-size', vtok('font-size', 'type'), undefined, 'Size')}
|
|
6076
|
+
{row('font-weight', csel('font-weight', CSS_WEIGHTS), undefined, 'Weight')}
|
|
6077
|
+
{row('line-height', num('line-height', 'lh'), undefined, 'Line height')}
|
|
6078
|
+
{row('text-align', iconseg('text-align', TEXTALIGN_OPTS), undefined, 'Align')}
|
|
6079
|
+
<div className="st-cp-clustermore-row">{moreBtn('Text')}</div>
|
|
6080
|
+
{designerMore.Text ? (
|
|
6081
|
+
<>
|
|
6082
|
+
{row('letter-spacing', num('letter-spacing', null, { min: -Infinity }), undefined, 'Letter spacing')}
|
|
6083
|
+
{row('font-style', csel('font-style', CSS_FONT_STYLE), undefined, 'Style')}
|
|
6084
|
+
{row('text-transform', csel('text-transform', CSS_TEXT_TRANSFORM), undefined, 'Case')}
|
|
6085
|
+
{row('white-space', csel('white-space', CSS_WHITE_SPACE), undefined, 'Whitespace')}
|
|
6086
|
+
</>
|
|
6087
|
+
) : null}
|
|
6088
|
+
</>
|
|
6089
|
+
)}
|
|
6090
|
+
|
|
6091
|
+
{dsec(
|
|
6092
|
+
'Spacing',
|
|
6093
|
+
['margin-top', 'margin-right', 'margin-bottom', 'margin-left'],
|
|
6094
|
+
<div className="st-cp-box" aria-label="margin">
|
|
6095
|
+
<span className="st-cp-boxtag st-cp-boxtag--m">{prov(provOf('margin-top'))}margin</span>
|
|
6096
|
+
{side('margin-top', 'margin')}
|
|
6097
|
+
{side('margin-right', 'margin')}
|
|
6098
|
+
{side('margin-bottom', 'margin')}
|
|
6099
|
+
{side('margin-left', 'margin')}
|
|
6100
|
+
</div>
|
|
6101
|
+
)}
|
|
6102
|
+
|
|
6103
|
+
{(() => {
|
|
6104
|
+
const { showMedia, canReplace } = mediaGate();
|
|
6105
|
+
return showMedia
|
|
6106
|
+
? dsec('Media', ['object-fit', 'object-position', 'aspect-ratio'], mediaBody(canReplace))
|
|
6107
|
+
: null;
|
|
6108
|
+
})()}
|
|
6109
|
+
</>
|
|
6110
|
+
) : (
|
|
6111
|
+
<>
|
|
5192
6112
|
{sec(
|
|
5193
6113
|
'Layout',
|
|
5194
6114
|
(() => {
|
|
@@ -5206,15 +6126,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5206
6126
|
{row('display', csel('display', CSS_DISPLAYS))}
|
|
5207
6127
|
{isFlex ? (
|
|
5208
6128
|
<>
|
|
5209
|
-
{row('flex-direction',
|
|
5210
|
-
{row('flex-wrap',
|
|
6129
|
+
{row('flex-direction', iconseg('flex-direction', DIR_OPTS))}
|
|
6130
|
+
{row('flex-wrap', <Toggle checked={/^wrap/.test(authored['flex-wrap'] || cssHint(computed['flex-wrap']) || '')} label="wrap items" ariaLabel="flex-wrap" onChange={(w) => commit('flex-wrap', w ? 'wrap' : 'nowrap')} />, provOf('flex-wrap'))}
|
|
5211
6131
|
</>
|
|
5212
6132
|
) : null}
|
|
5213
6133
|
{isFlex || isGrid ? (
|
|
5214
6134
|
<>
|
|
5215
|
-
{row('align-items',
|
|
5216
|
-
{row('justify-content',
|
|
5217
|
-
{row('gap',
|
|
6135
|
+
{row('align-items', iconseg('align-items', ALIGNITEMS_OPTS))}
|
|
6136
|
+
{row('justify-content', iconseg('justify-content', JUSTIFY_OPTS))}
|
|
6137
|
+
{row('gap', vtok('gap', 'space'))}
|
|
5218
6138
|
</>
|
|
5219
6139
|
) : (
|
|
5220
6140
|
<button
|
|
@@ -5259,35 +6179,16 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5259
6179
|
<>
|
|
5260
6180
|
{row('font-family', csel('font-family', CSS_FONTS))}
|
|
5261
6181
|
{row('color', color('color'))}
|
|
5262
|
-
{row('font-size',
|
|
6182
|
+
{row('font-size', vtok('font-size', 'type'))}
|
|
5263
6183
|
{row('font-weight', csel('font-weight', CSS_WEIGHTS))}
|
|
5264
6184
|
{row('line-height', num('line-height', 'lh'))}
|
|
5265
6185
|
{row('letter-spacing', num('letter-spacing', null, { min: -Infinity }))}
|
|
5266
|
-
{
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
<button
|
|
5271
|
-
key={a}
|
|
5272
|
-
type="button"
|
|
5273
|
-
className={`st-cp-segbtn${(authored['text-align'] || computed['text-align']) === a ? ' is-active' : ''}`}
|
|
5274
|
-
aria-label={`align ${a}`}
|
|
5275
|
-
aria-pressed={(authored['text-align'] || computed['text-align']) === a}
|
|
5276
|
-
onClick={() => commit('text-align', a)}
|
|
5277
|
-
>
|
|
5278
|
-
<span className={`st-cp-bars st-cp-bars--${a === 'justify' ? 'just' : a}`} aria-hidden="true">
|
|
5279
|
-
<i />
|
|
5280
|
-
<i />
|
|
5281
|
-
<i />
|
|
5282
|
-
</span>
|
|
5283
|
-
</button>
|
|
5284
|
-
))}
|
|
5285
|
-
</div>
|
|
5286
|
-
)}
|
|
6186
|
+
{/* handoff — text-align as a lucide icon button group; B/I/U as a toggle
|
|
6187
|
+
group mapped to font-weight / font-style / text-decoration. */}
|
|
6188
|
+
{row('text-align', iconseg('text-align', TEXTALIGN_OPTS))}
|
|
6189
|
+
{row('font-style', textStyleToggle(), provOf('font-style'))}
|
|
5287
6190
|
{/* Stage B (Task B4) — promoted typography knobs (was DDR-104 OUT-list). */}
|
|
5288
|
-
{row('font-style', csel('font-style', CSS_FONT_STYLE))}
|
|
5289
6191
|
{row('text-transform', csel('text-transform', CSS_TEXT_TRANSFORM))}
|
|
5290
|
-
{row('text-decoration', csel('text-decoration', CSS_TEXT_DECORATION))}
|
|
5291
6192
|
{row('white-space', csel('white-space', CSS_WHITE_SPACE))}
|
|
5292
6193
|
</>
|
|
5293
6194
|
)}
|
|
@@ -5312,7 +6213,9 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5312
6213
|
{side('padding-bottom', 'padding')}
|
|
5313
6214
|
{side('padding-left', 'padding')}
|
|
5314
6215
|
<div className="st-cp-boxcore">
|
|
5315
|
-
{
|
|
6216
|
+
{/* handoff — prefer the AUTHORED size (updates live as you edit
|
|
6217
|
+
width/height); el.bounds is stale until the canvas re-measures. */}
|
|
6218
|
+
{Math.round(Number.parseFloat(authored.width) || el.bounds?.w || 0)} × {Math.round(Number.parseFloat(authored.height) || el.bounds?.h || 0)}
|
|
5316
6219
|
</div>
|
|
5317
6220
|
</div>
|
|
5318
6221
|
</div>
|
|
@@ -5334,7 +6237,7 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5334
6237
|
{row('max-width', num('max-width'))}
|
|
5335
6238
|
{row('min-height', num('min-height'))}
|
|
5336
6239
|
{row('max-height', num('max-height'))}
|
|
5337
|
-
{row('overflow',
|
|
6240
|
+
{row('overflow', iconseg('overflow', OVERFLOW_OPTS))}
|
|
5338
6241
|
{/* Stage M1 — flex-CHILD controls, only meaningful when the parent is a
|
|
5339
6242
|
flex container. align-self is the cross-axis override; flex-grow/shrink/
|
|
5340
6243
|
basis are the main-axis behavior the Fill mode writes for you. */}
|
|
@@ -5350,159 +6253,28 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5350
6253
|
</>
|
|
5351
6254
|
)}
|
|
5352
6255
|
|
|
5353
|
-
{/* Stage B (Task B5) — Media framing
|
|
5354
|
-
(
|
|
5355
|
-
|
|
5356
|
-
Media = box/framing/source (this plan); the photo-editor plan's "Photo"
|
|
5357
|
-
tab owns pixels/look — they are separate DOM slots by design. */}
|
|
6256
|
+
{/* Stage B (Task B5) — Media framing (gate/body pulled into mediaGate()/
|
|
6257
|
+
mediaBody() above, DDR-171, so Designer mode's "Media" cluster
|
|
6258
|
+
reuses the identical content). */}
|
|
5358
6259
|
{(() => {
|
|
5359
|
-
const
|
|
5360
|
-
|
|
5361
|
-
const showMedia =
|
|
5362
|
-
isMediaEl ||
|
|
5363
|
-
!!authored['object-fit'] ||
|
|
5364
|
-
!!authored['object-position'] ||
|
|
5365
|
-
!!authored['aspect-ratio'];
|
|
5366
|
-
// Stage F2 — "Replace…" opens the AssetPicker to re-point src (authored
|
|
5367
|
-
// <img>/<video> only; a template-expression src can't be string-swapped,
|
|
5368
|
-
// so gate on a real src attr being present).
|
|
5369
|
-
const canReplace = (t === 'img' || t === 'video') && !!el.attrs?.src && !!onReplaceMedia;
|
|
5370
|
-
return showMedia
|
|
5371
|
-
? sec(
|
|
5372
|
-
'Media',
|
|
5373
|
-
<>
|
|
5374
|
-
{canReplace && (
|
|
5375
|
-
<div className="st-cp-mediabtn">
|
|
5376
|
-
<button
|
|
5377
|
-
type="button"
|
|
5378
|
-
className="st-btn st-cp-replace"
|
|
5379
|
-
onClick={() => onReplaceMedia(el)}
|
|
5380
|
-
>
|
|
5381
|
-
Replace…
|
|
5382
|
-
</button>
|
|
5383
|
-
</div>
|
|
5384
|
-
)}
|
|
5385
|
-
{row('object-fit', csel('object-fit', CSS_OBJECT_FIT))}
|
|
5386
|
-
{row('object-position', text('object-position'))}
|
|
5387
|
-
{row(
|
|
5388
|
-
'aspect-ratio',
|
|
5389
|
-
<select
|
|
5390
|
-
className="st-cp-nsel"
|
|
5391
|
-
aria-label="aspect-ratio"
|
|
5392
|
-
value={
|
|
5393
|
-
CSS_ASPECT_RATIO.includes(authored['aspect-ratio'])
|
|
5394
|
-
? authored['aspect-ratio']
|
|
5395
|
-
: ''
|
|
5396
|
-
}
|
|
5397
|
-
onChange={(e) => {
|
|
5398
|
-
const v = e.target.value;
|
|
5399
|
-
commit('aspect-ratio', v);
|
|
5400
|
-
// A fixed height overrides aspect-ratio (CSS: explicit
|
|
5401
|
-
// width+height win). When applying a real ratio, release the
|
|
5402
|
-
// height so the ratio actually reshapes the box (dogfood:
|
|
5403
|
-
// "nastavil jsem 16/9 a nic se nestalo").
|
|
5404
|
-
if (v && v !== 'auto' && authored.height) reset('height');
|
|
5405
|
-
}}
|
|
5406
|
-
>
|
|
5407
|
-
<option value="" disabled>
|
|
5408
|
-
{cssHint(computed['aspect-ratio']) || '—'}
|
|
5409
|
-
</option>
|
|
5410
|
-
{CSS_ASPECT_RATIO.map((v) => (
|
|
5411
|
-
<option key={v} value={v}>
|
|
5412
|
-
{v}
|
|
5413
|
-
</option>
|
|
5414
|
-
))}
|
|
5415
|
-
</select>
|
|
5416
|
-
)}
|
|
5417
|
-
</>
|
|
5418
|
-
)
|
|
5419
|
-
: null;
|
|
6260
|
+
const { showMedia, canReplace } = mediaGate();
|
|
6261
|
+
return showMedia ? sec('Media', mediaBody(canReplace)) : null;
|
|
5420
6262
|
})()}
|
|
5421
6263
|
|
|
5422
6264
|
{sec(
|
|
5423
6265
|
'Appearance',
|
|
5424
6266
|
<>
|
|
5425
6267
|
{row('background-color', color('background-color'))}
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
<label className="st-cp-label" title="border-radius">
|
|
5429
|
-
border-radius
|
|
5430
|
-
</label>
|
|
5431
|
-
<div className="st-cp-ctl">
|
|
5432
|
-
{num('border-radius', 'radius')}
|
|
5433
|
-
<button
|
|
5434
|
-
type="button"
|
|
5435
|
-
className={`st-cp-split${split ? ' is-on' : ''}`}
|
|
5436
|
-
aria-pressed={split}
|
|
5437
|
-
aria-label="set each corner separately"
|
|
5438
|
-
title="set each corner separately"
|
|
5439
|
-
onClick={() => setSplit((v) => !v)}
|
|
5440
|
-
/>
|
|
5441
|
-
</div>
|
|
5442
|
-
</div>
|
|
5443
|
-
{split ? (
|
|
5444
|
-
<div className="st-cp-corners" aria-label="per-corner radius">
|
|
5445
|
-
{corner('TL', 'border-top-left-radius')}
|
|
5446
|
-
{corner('TR', 'border-top-right-radius')}
|
|
5447
|
-
{corner('BL', 'border-bottom-left-radius')}
|
|
5448
|
-
{corner('BR', 'border-bottom-right-radius')}
|
|
5449
|
-
</div>
|
|
5450
|
-
) : null}
|
|
5451
|
-
{row(
|
|
5452
|
-
'border',
|
|
5453
|
-
<div className="st-cp-border">
|
|
5454
|
-
{num('border-width')}
|
|
5455
|
-
<select
|
|
5456
|
-
className="st-cp-nsel st-cp-nsel--mini"
|
|
5457
|
-
aria-label="border-style"
|
|
5458
|
-
value={CSS_BORDER_STYLES.includes(authored['border-style']) ? authored['border-style'] : ''}
|
|
5459
|
-
onChange={(e) => commit('border-style', e.target.value)}
|
|
5460
|
-
>
|
|
5461
|
-
<option value="" disabled>
|
|
5462
|
-
style
|
|
5463
|
-
</option>
|
|
5464
|
-
{CSS_BORDER_STYLES.map((s) => (
|
|
5465
|
-
<option key={s} value={s}>
|
|
5466
|
-
{s}
|
|
5467
|
-
</option>
|
|
5468
|
-
))}
|
|
5469
|
-
</select>
|
|
5470
|
-
<TokenPopover
|
|
5471
|
-
kind="color"
|
|
5472
|
-
groups={tokenGroups('color')}
|
|
5473
|
-
current={authored['border-color']}
|
|
5474
|
-
activeDs={_activeDs}
|
|
5475
|
-
swatchBg={computed['border-color'] || authored['border-color'] || ''}
|
|
5476
|
-
seedHex={
|
|
5477
|
-
cssColorToHex(computed['border-color'] || authored['border-color']) || '#000000'
|
|
5478
|
-
}
|
|
5479
|
-
onPick={(v) => commit('border-color', v)}
|
|
5480
|
-
label="border colour"
|
|
5481
|
-
/>
|
|
5482
|
-
</div>,
|
|
5483
|
-
provOf('border-width')
|
|
5484
|
-
)}
|
|
6268
|
+
{row('border-radius', radiusControl(), provOf('border-radius'))}
|
|
6269
|
+
{row('border', borderControl(), provOf('border-width'))}
|
|
5485
6270
|
{row('box-shadow', tok('box-shadow', 'shadow') || text('box-shadow'))}
|
|
5486
|
-
{
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
</span>
|
|
5492
|
-
<input
|
|
5493
|
-
className="st-cp-numin"
|
|
5494
|
-
key={`opacity:${authored.opacity ?? ''}`}
|
|
5495
|
-
aria-label="opacity"
|
|
5496
|
-
defaultValue={authored.opacity ?? ''}
|
|
5497
|
-
placeholder={cssHint(computed.opacity) || '1'}
|
|
5498
|
-
onKeyDown={(e) => {
|
|
5499
|
-
if (e.key === 'Enter') e.currentTarget.blur();
|
|
5500
|
-
}}
|
|
5501
|
-
onBlur={(e) => commit('opacity', e.currentTarget.value)}
|
|
5502
|
-
/>
|
|
5503
|
-
</div>
|
|
5504
|
-
)}
|
|
6271
|
+
{/* DDR-171 — blur + blend, real Advanced-mode rows (not Designer-exclusive);
|
|
6272
|
+
also power Designer mode's "Effects" cluster. */}
|
|
6273
|
+
{row('filter', blurControl(), provOf('filter'))}
|
|
6274
|
+
{row('mix-blend-mode', csel('mix-blend-mode', CSS_BLEND_MODES))}
|
|
6275
|
+
{row('opacity', opacityControl())}
|
|
5505
6276
|
{/* Stage B (Task B4) — transform as a free-value row (mirrors box-shadow). */}
|
|
6277
|
+
{row('rotation', rotationControl(), provOf('transform'))}
|
|
5506
6278
|
{row('transform', text('transform'))}
|
|
5507
6279
|
{row('transform-origin', text('transform-origin'))}
|
|
5508
6280
|
</>
|
|
@@ -5601,6 +6373,8 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5601
6373
|
<AttrKnob commit={commitAttr} />
|
|
5602
6374
|
</div>
|
|
5603
6375
|
)}
|
|
6376
|
+
</>
|
|
6377
|
+
)}
|
|
5604
6378
|
|
|
5605
6379
|
<div className="st-cp-legend">
|
|
5606
6380
|
<span>
|
|
@@ -5979,7 +6753,14 @@ function resolveArtboardIdFromSelection(el) {
|
|
|
5979
6753
|
// /_api/resize-artboard, NOT edit-css — DDR-027 numeric JSX props) + the SAME
|
|
5980
6754
|
// SCREEN_PRESETS the "+ Artboard" menu uses, so picking "Tablet" resizes the
|
|
5981
6755
|
// CURRENT artboard to 834×1194 in one click instead of typing both fields.
|
|
5982
|
-
|
|
6756
|
+
const ARTBOARD_LAYOUT_OPTIONS = [
|
|
6757
|
+
['', 'Block (default)'],
|
|
6758
|
+
['flex-col', 'Flex ↓ (column)'],
|
|
6759
|
+
['flex-row', 'Flex → (row)'],
|
|
6760
|
+
['grid', 'Grid'],
|
|
6761
|
+
];
|
|
6762
|
+
|
|
6763
|
+
function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardStyle }) {
|
|
5983
6764
|
const artboardId = resolveArtboardIdFromSelection(el);
|
|
5984
6765
|
// Dogfood 2026-07-07 (round 2) — `worldW`/`worldH` (zoom-independent) are
|
|
5985
6766
|
// undefined for a selection that reached here via a code path predating
|
|
@@ -6007,6 +6788,25 @@ function ArtboardKnobs({ el, onResizeArtboard }) {
|
|
|
6007
6788
|
const activePreset = Object.entries(SCREEN_PRESETS).find(
|
|
6008
6789
|
([, p]) => p.width === w && p.height === h
|
|
6009
6790
|
)?.[0];
|
|
6791
|
+
// Hug default (artboard "hug height") — current mode + the "more settings"
|
|
6792
|
+
// (background/padding/layout/gap) read off the SAME generic `attrs` escape
|
|
6793
|
+
// hatch dom-selection.ts already scrapes for every selection (the `data-dc-*`
|
|
6794
|
+
// attributes DCArtboard stamps on the frame purely for this panel to read
|
|
6795
|
+
// its own resolved props back — see canvas-lib.tsx readBackAttrs).
|
|
6796
|
+
const fixed = el.attrs?.['data-dc-fixed'] === 'true';
|
|
6797
|
+
const bg = el.attrs?.['data-dc-bg'] ?? '';
|
|
6798
|
+
const padding = el.attrs?.['data-dc-padding'] ?? '';
|
|
6799
|
+
const layoutMode = el.attrs?.['data-dc-layout'] ?? '';
|
|
6800
|
+
const gap = el.attrs?.['data-dc-gap'] ?? '';
|
|
6801
|
+
const setHug = (nextFixed) => {
|
|
6802
|
+
if (!artboardId) return;
|
|
6803
|
+
const freezeHeight = nextFixed && Number.isFinite(h) ? Math.round(h) : undefined;
|
|
6804
|
+
onSetArtboardHug?.(artboardId, nextFixed, freezeHeight);
|
|
6805
|
+
};
|
|
6806
|
+
const commitStyle = (patch) => {
|
|
6807
|
+
if (!artboardId) return;
|
|
6808
|
+
onSetArtboardStyle?.(artboardId, patch);
|
|
6809
|
+
};
|
|
6010
6810
|
return (
|
|
6011
6811
|
<section className="st-cp-sec">
|
|
6012
6812
|
<div className="st-cp-sechd-row">
|
|
@@ -6039,12 +6839,17 @@ function ArtboardKnobs({ el, onResizeArtboard }) {
|
|
|
6039
6839
|
type="number"
|
|
6040
6840
|
min="1"
|
|
6041
6841
|
aria-label="artboard height"
|
|
6042
|
-
|
|
6842
|
+
readOnly={!fixed}
|
|
6843
|
+
title={fixed ? undefined : 'Hug — grows to fit content. Switch to Fixed to set an exact height.'}
|
|
6844
|
+
key={`h:${h ?? ''}:${fixed}`}
|
|
6043
6845
|
defaultValue={h ?? ''}
|
|
6044
6846
|
onKeyDown={(e) => {
|
|
6045
6847
|
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6046
6848
|
}}
|
|
6047
|
-
onBlur={(e) =>
|
|
6849
|
+
onBlur={(e) => {
|
|
6850
|
+
if (!fixed) return;
|
|
6851
|
+
commitSize(null, Number.parseFloat(e.currentTarget.value));
|
|
6852
|
+
}}
|
|
6048
6853
|
/>
|
|
6049
6854
|
</div>
|
|
6050
6855
|
</div>
|
|
@@ -6068,10 +6873,133 @@ function ArtboardKnobs({ el, onResizeArtboard }) {
|
|
|
6068
6873
|
))}
|
|
6069
6874
|
</select>
|
|
6070
6875
|
</div>
|
|
6876
|
+
<div style={{ padding: '0 12px 8px' }}>
|
|
6877
|
+
<div className="st-cp-modeseg" role="group" aria-label="artboard height sizing mode">
|
|
6878
|
+
<span className="st-cp-modeax" aria-hidden="true">
|
|
6879
|
+
H
|
|
6880
|
+
</span>
|
|
6881
|
+
{[
|
|
6882
|
+
[false, 'Hug'],
|
|
6883
|
+
[true, 'Fixed'],
|
|
6884
|
+
].map(([val, label]) => (
|
|
6885
|
+
<button
|
|
6886
|
+
key={label}
|
|
6887
|
+
type="button"
|
|
6888
|
+
className={`st-cp-modebtn${fixed === val ? ' is-active' : ''}`}
|
|
6889
|
+
aria-pressed={fixed === val}
|
|
6890
|
+
onClick={() => setHug(val)}
|
|
6891
|
+
title={`${label} height`}
|
|
6892
|
+
>
|
|
6893
|
+
{label}
|
|
6894
|
+
</button>
|
|
6895
|
+
))}
|
|
6896
|
+
</div>
|
|
6897
|
+
</div>
|
|
6898
|
+
<div className="st-cp-sechd-row">
|
|
6899
|
+
<span className="st-cp-sechd">Style</span>
|
|
6900
|
+
</div>
|
|
6901
|
+
<div style={{ padding: '4px 12px 8px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
6902
|
+
<div className="st-cp-num" style={{ width: '100%' }}>
|
|
6903
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
6904
|
+
Bg
|
|
6905
|
+
</span>
|
|
6906
|
+
<input
|
|
6907
|
+
className="st-cp-numin"
|
|
6908
|
+
type="text"
|
|
6909
|
+
aria-label="artboard background"
|
|
6910
|
+
placeholder="var(--bg-1)"
|
|
6911
|
+
key={`bg:${bg}`}
|
|
6912
|
+
defaultValue={bg}
|
|
6913
|
+
onKeyDown={(e) => {
|
|
6914
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6915
|
+
}}
|
|
6916
|
+
onBlur={(e) => commitStyle({ background: e.currentTarget.value.trim() || null })}
|
|
6917
|
+
/>
|
|
6918
|
+
</div>
|
|
6919
|
+
<select
|
|
6920
|
+
className="st-cp-nsel"
|
|
6921
|
+
aria-label="artboard body layout"
|
|
6922
|
+
value={layoutMode}
|
|
6923
|
+
onChange={(e) => commitStyle({ layout: e.currentTarget.value || null })}
|
|
6924
|
+
>
|
|
6925
|
+
{ARTBOARD_LAYOUT_OPTIONS.map(([val, label]) => (
|
|
6926
|
+
<option key={val} value={val}>
|
|
6927
|
+
{label}
|
|
6928
|
+
</option>
|
|
6929
|
+
))}
|
|
6930
|
+
</select>
|
|
6931
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
6932
|
+
<div className="st-cp-num">
|
|
6933
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
6934
|
+
Pad
|
|
6935
|
+
</span>
|
|
6936
|
+
<input
|
|
6937
|
+
className="st-cp-numin"
|
|
6938
|
+
type="number"
|
|
6939
|
+
min="0"
|
|
6940
|
+
aria-label="artboard padding"
|
|
6941
|
+
key={`pad:${padding}`}
|
|
6942
|
+
defaultValue={padding}
|
|
6943
|
+
onKeyDown={(e) => {
|
|
6944
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6945
|
+
}}
|
|
6946
|
+
onBlur={(e) => {
|
|
6947
|
+
const v = e.currentTarget.value;
|
|
6948
|
+
commitStyle({ padding: v === '' ? null : Number.parseFloat(v) });
|
|
6949
|
+
}}
|
|
6950
|
+
/>
|
|
6951
|
+
</div>
|
|
6952
|
+
<div className="st-cp-num">
|
|
6953
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
6954
|
+
Gap
|
|
6955
|
+
</span>
|
|
6956
|
+
<input
|
|
6957
|
+
className="st-cp-numin"
|
|
6958
|
+
type="number"
|
|
6959
|
+
min="0"
|
|
6960
|
+
aria-label="artboard gap"
|
|
6961
|
+
key={`gap:${gap}`}
|
|
6962
|
+
defaultValue={gap}
|
|
6963
|
+
onKeyDown={(e) => {
|
|
6964
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6965
|
+
}}
|
|
6966
|
+
onBlur={(e) => {
|
|
6967
|
+
const v = e.currentTarget.value;
|
|
6968
|
+
commitStyle({ gap: v === '' ? null : Number.parseFloat(v) });
|
|
6969
|
+
}}
|
|
6970
|
+
/>
|
|
6971
|
+
</div>
|
|
6972
|
+
</div>
|
|
6973
|
+
</div>
|
|
6071
6974
|
</section>
|
|
6072
6975
|
);
|
|
6073
6976
|
}
|
|
6074
6977
|
|
|
6978
|
+
// feature-photo-editor (Task 13/14) — derive the content-addressed photo asset a
|
|
6979
|
+
// DOM selection points at (an artboard `<img src="assets/<sha8>.<ext>">`). Prefers
|
|
6980
|
+
// the `photoAsset` the resolver stamped (dom-selection.ts), falling back to a scan
|
|
6981
|
+
// of the selection's src/html for a selection that reached the panel via a code
|
|
6982
|
+
// path predating that field. Returns null for a non-photo element.
|
|
6983
|
+
const PHOTO_ASSET_RE = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
|
|
6984
|
+
function photoAssetOfSelection(el) {
|
|
6985
|
+
if (!el || Array.isArray(el)) return null;
|
|
6986
|
+
if (el.photoAsset) return el.photoAsset;
|
|
6987
|
+
if ((el.tag || '').toLowerCase() !== 'img') return null;
|
|
6988
|
+
const html = el.html || '';
|
|
6989
|
+
// Once an edit is baked, the element's LIVE src is a `data:` URL (canvas-lib's
|
|
6990
|
+
// PhotoPreviewBridge swaps it in directly) — it never matches PHOTO_ASSET_RE,
|
|
6991
|
+
// so a REPLAYED/serialized selection (a WS resync, or the persisted
|
|
6992
|
+
// `_active.json` restored on boot) that arrives without the dedicated
|
|
6993
|
+
// `photoAsset` field would otherwise permanently lose the Photo tab for any
|
|
6994
|
+
// already-edited photo. The element still carries the `data-photo-asset` tag
|
|
6995
|
+
// the bridge stamped, and that DOES survive into an outerHTML snapshot — check
|
|
6996
|
+
// it before falling back to the plain assets/<sha8> src scan.
|
|
6997
|
+
const tagged = /data-photo-asset="([^"]+)"/.exec(html);
|
|
6998
|
+
if (tagged) return tagged[1];
|
|
6999
|
+
const m = PHOTO_ASSET_RE.exec(`${el.attrs?.src || ''} ${html}`);
|
|
7000
|
+
return m ? m[0] : null;
|
|
7001
|
+
}
|
|
7002
|
+
|
|
6075
7003
|
function InspectorPanel({
|
|
6076
7004
|
selected,
|
|
6077
7005
|
onClose,
|
|
@@ -6086,19 +7014,41 @@ function InspectorPanel({
|
|
|
6086
7014
|
onRecordEdit,
|
|
6087
7015
|
onReplaceMedia,
|
|
6088
7016
|
onResizeArtboard,
|
|
7017
|
+
onSetArtboardHug,
|
|
7018
|
+
onSetArtboardStyle,
|
|
6089
7019
|
onUndoRedo,
|
|
6090
7020
|
editScope,
|
|
6091
7021
|
tab: tabProp,
|
|
6092
7022
|
onTabChange,
|
|
6093
7023
|
width,
|
|
6094
7024
|
resizing,
|
|
7025
|
+
// feature-photo-editor (Task 13/15) — the Photo tab + its live-preview /
|
|
7026
|
+
// bg-removal / undo channels. `photoSel` is the annotation-image target
|
|
7027
|
+
// (threaded up separately since the annotation model has no DOM selection);
|
|
7028
|
+
// an artboard `<img>` target is derived from `selected` instead.
|
|
7029
|
+
photoSel,
|
|
7030
|
+
photoRev,
|
|
7031
|
+
onPhotoEdit,
|
|
7032
|
+
onPhotoRemoveBackground,
|
|
7033
|
+
onPhotoRecordEdit,
|
|
7034
|
+
onPhotoUndoRedo,
|
|
7035
|
+
// feature-configurable-panel-docking — when true this instance IS the standalone
|
|
7036
|
+
// Layers panel: it forces the Layers view and hides the tab bar (the tab strip
|
|
7037
|
+
// lives on the dock slot instead). `hideLayersTab` drops the inline Layers tab
|
|
7038
|
+
// when Layers has been split into its own panel.
|
|
7039
|
+
layersOnly = false,
|
|
7040
|
+
hideLayersTab = false,
|
|
7041
|
+
// DDR-171 — CSS-panel vocabulary mode ('advanced' | 'designer'), owned by App
|
|
7042
|
+
// (single source of truth shared with Settings → Appearance).
|
|
7043
|
+
cpMode,
|
|
7044
|
+
onSetCpMode,
|
|
6095
7045
|
}) {
|
|
6096
7046
|
// Tab is controllable from the parent (the guided tour drives it to 'css' /
|
|
6097
7047
|
// 'layers' so a spotlight step lands on a real row) but falls back to local
|
|
6098
7048
|
// state for normal use. A user click both updates local state and notifies the
|
|
6099
7049
|
// parent, so the two stay in lockstep whichever owns it.
|
|
6100
7050
|
const [tabState, setTabState] = useState('inspect');
|
|
6101
|
-
const tab = tabProp ?? tabState;
|
|
7051
|
+
const tab = layersOnly ? 'layers' : (tabProp ?? tabState);
|
|
6102
7052
|
const setTab = (t) => {
|
|
6103
7053
|
setTabState(t);
|
|
6104
7054
|
onTabChange?.(t);
|
|
@@ -6390,10 +7340,41 @@ function InspectorPanel({
|
|
|
6390
7340
|
};
|
|
6391
7341
|
// `selected` may be a single element, an array (multi-select), or null.
|
|
6392
7342
|
const el = Array.isArray(selected) ? selected[0] : selected;
|
|
7343
|
+
// feature-photo-editor (Task 13) — resolve the Photo-tab target. Priority: an
|
|
7344
|
+
// annotation-image threaded up (`photoSel`, no DOM selection) → a Photo-ONLY
|
|
7345
|
+
// panel; else a content-addressed artboard `<img>` selection → Photo alongside
|
|
7346
|
+
// the normal tabs.
|
|
7347
|
+
const photoTarget = photoSel
|
|
7348
|
+
? { asset: photoSel.asset, kind: 'annotation-image' }
|
|
7349
|
+
: (() => {
|
|
7350
|
+
const a = photoAssetOfSelection(el);
|
|
7351
|
+
return a ? { asset: a, kind: 'artboard-img' } : null;
|
|
7352
|
+
})();
|
|
7353
|
+
const photoOnly = photoTarget?.kind === 'annotation-image';
|
|
7354
|
+
// Cmd+Z / Cmd+Shift+Z (or Cmd+Y) while focus is still inside a Photo-tab
|
|
7355
|
+
// slider — mirrors `onKnobKeyDown` above for the same reason: the window-
|
|
7356
|
+
// level shortcut listener bails out whenever `document.activeElement` is an
|
|
7357
|
+
// `<input>` (native text-undo would otherwise win), so a commit-then-Cmd+Z
|
|
7358
|
+
// with the slider still focused needs its own forwarder here.
|
|
7359
|
+
const onPhotoKnobKeyDown = (e) => {
|
|
7360
|
+
if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
|
|
7361
|
+
const k = e.key.toLowerCase();
|
|
7362
|
+
if (k === 'z') {
|
|
7363
|
+
e.preventDefault();
|
|
7364
|
+
onPhotoUndoRedo?.(e.shiftKey ? 'redo' : 'undo');
|
|
7365
|
+
} else if (k === 'y') {
|
|
7366
|
+
e.preventDefault();
|
|
7367
|
+
onPhotoUndoRedo?.('redo');
|
|
7368
|
+
}
|
|
7369
|
+
};
|
|
7370
|
+
// An annotation-image has no element tabs; force Photo. Otherwise honor the
|
|
7371
|
+
// requested tab, but drop off a stale 'photo' tab when the new selection isn't
|
|
7372
|
+
// photo-eligible (so switching from a photo to a normal element lands sanely).
|
|
7373
|
+
const effTab = photoOnly ? 'photo' : tab === 'photo' && !photoTarget ? 'inspect' : tab;
|
|
6393
7374
|
const tabBtn = (id, label, icon) => (
|
|
6394
7375
|
<button
|
|
6395
7376
|
type="button"
|
|
6396
|
-
className={'st-rp-tab' + (
|
|
7377
|
+
className={'st-rp-tab' + (effTab === id ? ' is-active' : '')}
|
|
6397
7378
|
onClick={() => setTab(id)}
|
|
6398
7379
|
>
|
|
6399
7380
|
<StIcon name={icon} size={14} />
|
|
@@ -6408,10 +7389,21 @@ function InspectorPanel({
|
|
|
6408
7389
|
aria-label="Inspector"
|
|
6409
7390
|
data-tour="inspector"
|
|
6410
7391
|
>
|
|
6411
|
-
<div
|
|
6412
|
-
|
|
6413
|
-
|
|
6414
|
-
{
|
|
7392
|
+
<div
|
|
7393
|
+
className="st-rp-tabs"
|
|
7394
|
+
data-tour="inspector-tabs"
|
|
7395
|
+
style={layersOnly ? { display: 'none' } : undefined}
|
|
7396
|
+
>
|
|
7397
|
+
{photoOnly ? (
|
|
7398
|
+
tabBtn('photo', 'Photo', 'image')
|
|
7399
|
+
) : (
|
|
7400
|
+
<>
|
|
7401
|
+
{tabBtn('inspect', 'Inspect', 'sliders')}
|
|
7402
|
+
{!hideLayersTab && tabBtn('layers', 'Layers', 'layers')}
|
|
7403
|
+
{tabBtn('css', 'CSS', 'code')}
|
|
7404
|
+
{photoTarget ? tabBtn('photo', 'Photo', 'image') : null}
|
|
7405
|
+
</>
|
|
7406
|
+
)}
|
|
6415
7407
|
<button
|
|
6416
7408
|
type="button"
|
|
6417
7409
|
className="st-iconbtn"
|
|
@@ -6447,7 +7439,19 @@ function InspectorPanel({
|
|
|
6447
7439
|
</div>
|
|
6448
7440
|
) : null}
|
|
6449
7441
|
<div className="st-rp-body">
|
|
6450
|
-
{
|
|
7442
|
+
{effTab === 'photo' && photoTarget ? (
|
|
7443
|
+
<div onKeyDown={onPhotoKnobKeyDown}>
|
|
7444
|
+
<PhotoKnobs
|
|
7445
|
+
key={`${photoTarget.asset}:${photoRev ?? 0}`}
|
|
7446
|
+
asset={photoTarget.asset}
|
|
7447
|
+
ColorPicker={ColorPicker}
|
|
7448
|
+
StIcon={StIcon}
|
|
7449
|
+
onEdit={(edit) => onPhotoEdit?.(photoTarget.asset, edit)}
|
|
7450
|
+
onRemoveBackground={onPhotoRemoveBackground}
|
|
7451
|
+
onRecordEdit={(before, after) => onPhotoRecordEdit?.(photoTarget.asset, before, after)}
|
|
7452
|
+
/>
|
|
7453
|
+
</div>
|
|
7454
|
+
) : !el ? (
|
|
6451
7455
|
<div className="st-rp-empty">
|
|
6452
7456
|
{/* <p> wrapper — st-rp-empty is a flex column, bare text nodes +
|
|
6453
7457
|
kbd would stack as stretched flex items. */}
|
|
@@ -6455,7 +7459,7 @@ function InspectorPanel({
|
|
|
6455
7459
|
Hold <Kbd>⌘</Kbd> inside the canvas and click an element to inspect it.
|
|
6456
7460
|
</p>
|
|
6457
7461
|
</div>
|
|
6458
|
-
) :
|
|
7462
|
+
) : effTab === 'inspect' ? (
|
|
6459
7463
|
<>
|
|
6460
7464
|
<div className="st-rp-hd">{el.selector || el.tag || 'element'}</div>
|
|
6461
7465
|
<div className="st-insp-row">
|
|
@@ -6504,7 +7508,7 @@ function InspectorPanel({
|
|
|
6504
7508
|
) : null}
|
|
6505
7509
|
<InspectComputed el={el} />
|
|
6506
7510
|
</>
|
|
6507
|
-
) :
|
|
7511
|
+
) : effTab === 'layers' ? (
|
|
6508
7512
|
<>
|
|
6509
7513
|
<div className="st-rp-hd">Layers{layersTree?.nodes?.length ? '' : ' · ancestry'}</div>
|
|
6510
7514
|
{layersTree?.nodes?.length ? (
|
|
@@ -6578,7 +7582,12 @@ function InspectorPanel({
|
|
|
6578
7582
|
)}
|
|
6579
7583
|
</>
|
|
6580
7584
|
) : !el.id && resolveArtboardIdFromSelection(el) ? (
|
|
6581
|
-
<ArtboardKnobs
|
|
7585
|
+
<ArtboardKnobs
|
|
7586
|
+
el={el}
|
|
7587
|
+
onResizeArtboard={onResizeArtboard}
|
|
7588
|
+
onSetArtboardHug={onSetArtboardHug}
|
|
7589
|
+
onSetArtboardStyle={onSetArtboardStyle}
|
|
7590
|
+
/>
|
|
6582
7591
|
) : (
|
|
6583
7592
|
<CssKnobs
|
|
6584
7593
|
el={el}
|
|
@@ -6587,6 +7596,8 @@ function InspectorPanel({
|
|
|
6587
7596
|
onRecordEdit={onRecordEdit}
|
|
6588
7597
|
onReplaceMedia={onReplaceMedia}
|
|
6589
7598
|
onUndoRedo={onUndoRedo}
|
|
7599
|
+
mode={cpMode}
|
|
7600
|
+
onSetMode={onSetCpMode}
|
|
6590
7601
|
/>
|
|
6591
7602
|
)}
|
|
6592
7603
|
</div>
|
|
@@ -6834,12 +7845,31 @@ function App() {
|
|
|
6834
7845
|
const [commentsPanelOpen, setCommentsPanelOpen] = useState(false);
|
|
6835
7846
|
const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
|
|
6836
7847
|
const [theme, setTheme] = useState(readInitialTheme);
|
|
7848
|
+
// DDR-171 — CSS-panel vocabulary mode ('advanced' | 'designer'), App-owned so
|
|
7849
|
+
// the in-panel corner toggle (CssKnobs) and Settings → Appearance share one
|
|
7850
|
+
// source of truth; persisted to `maude-cp-mode` (same pattern as `theme`).
|
|
7851
|
+
const [cpMode, setCpMode] = useState(() => {
|
|
7852
|
+
const m = readJsonStore(CP_MODE_STORE, 'advanced');
|
|
7853
|
+
return m === 'designer' ? 'designer' : 'advanced';
|
|
7854
|
+
});
|
|
7855
|
+
useEffect(() => {
|
|
7856
|
+
try {
|
|
7857
|
+
localStorage.setItem(CP_MODE_STORE, JSON.stringify(cpMode));
|
|
7858
|
+
} catch {}
|
|
7859
|
+
}, [cpMode]);
|
|
6837
7860
|
const [openMenu, setOpenMenu] = useState(null);
|
|
6838
7861
|
const [sidebarOpen, setSidebarOpen] = useState(() => readBoolStore(SIDEBAR_STORE, true));
|
|
6839
7862
|
const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
|
|
6840
7863
|
const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
|
|
6841
7864
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
6842
7865
|
const [readinessOpen, setReadinessOpen] = useState(false);
|
|
7866
|
+
const [introOpen, setIntroOpen] = useState(false);
|
|
7867
|
+
const [quickSetupOpen, setQuickSetupOpen] = useState(false);
|
|
7868
|
+
const [brandUploadOpen, setBrandUploadOpen] = useState(false);
|
|
7869
|
+
// DDR-166 plan, Phase 2 (T7) — the persistent "Setup" affordance in the empty
|
|
7870
|
+
// canvas state (below) only renders while the project's own setup (design
|
|
7871
|
+
// system / first canvas / brand assets) is incomplete; native-only concern.
|
|
7872
|
+
const { report: setupReadiness } = useSetupReadiness(isNativeApp());
|
|
6843
7873
|
// ? cheat-sheet (DS components-shortcuts-overlay) — separate from the deep
|
|
6844
7874
|
// Help modal (F1), which keeps commands & flows.
|
|
6845
7875
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
@@ -6847,7 +7877,26 @@ function App() {
|
|
|
6847
7877
|
// T5/T6 (Plan C) — shell-level export/handoff dialog + inspector panel state.
|
|
6848
7878
|
// The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
|
|
6849
7879
|
const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
|
|
7880
|
+
// feature-ai-media-generation (DDR-16x) — BYOK provider-key Settings modal +
|
|
7881
|
+
// the AI generate action.
|
|
7882
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
7883
|
+
const [generateOpen, setGenerateOpen] = useState(false);
|
|
6850
7884
|
const [inspectorOpen, setInspectorOpen] = useState(false);
|
|
7885
|
+
// feature-configurable-panel-docking — Layers as its own openable panel (when
|
|
7886
|
+
// layersMode==='separate'), plus the per-panel side map + layers mode. All
|
|
7887
|
+
// three hydrate from disk (/_api/ui-prefs) on mount and persist on change,
|
|
7888
|
+
// mirroring the view-pref pattern.
|
|
7889
|
+
const [layersOpen, setLayersOpen] = useState(false);
|
|
7890
|
+
const [panelSide, setPanelSide] = useState(() =>
|
|
7891
|
+
readJsonStore(PANEL_SIDES_STORE, PANEL_SIDES_DEFAULTS)
|
|
7892
|
+
);
|
|
7893
|
+
const [layersMode, setLayersMode] = useState(() => {
|
|
7894
|
+
try {
|
|
7895
|
+
const v = localStorage.getItem(LAYERS_MODE_STORE);
|
|
7896
|
+
if (v === 'separate' || v === 'in-inspector') return v;
|
|
7897
|
+
} catch {}
|
|
7898
|
+
return 'separate';
|
|
7899
|
+
});
|
|
6851
7900
|
// DDR-148 — Timeline panel (right dock) for scrubbing a video-comp. `activeComps`
|
|
6852
7901
|
// is populated from the iframe's `timeline-comps` announce; `timelineFrame` from
|
|
6853
7902
|
// its live `timeline-frame`. Empty comps ⇒ the panel shows its empty state.
|
|
@@ -6855,7 +7904,9 @@ function App() {
|
|
|
6855
7904
|
const [activeComps, setActiveComps] = useState([]);
|
|
6856
7905
|
const [timelineFrame, setTimelineFrame] = useState(0);
|
|
6857
7906
|
const [timelinePlaying, setTimelinePlaying] = useState(false);
|
|
6858
|
-
|
|
7907
|
+
// Default off — looping is an explicit opt-in per session, not a surprise
|
|
7908
|
+
// once the user presses Play (rca/issue-video-artboard-loop-defaults-on).
|
|
7909
|
+
const [timelineLoop, setTimelineLoop] = useState(false);
|
|
6859
7910
|
const [timelineMuted, setTimelineMuted] = useState(false);
|
|
6860
7911
|
const [timelineVolume, setTimelineVolume] = useState(1);
|
|
6861
7912
|
const [timelineHeight, setTimelineHeight] = useState(216);
|
|
@@ -6878,6 +7929,15 @@ function App() {
|
|
|
6878
7929
|
useEffect(() => {
|
|
6879
7930
|
timelineCompIdRef.current = timelineCompId;
|
|
6880
7931
|
}, [timelineCompId]);
|
|
7932
|
+
// rca/issue-video-artboard-frame-reset-on-edit — last known playhead, read
|
|
7933
|
+
// from the `timeline-comps` handler below (which fires on every comp
|
|
7934
|
+
// (re)mount, incl. a ⌘R hard iframe reload) to re-seed the fresh Player
|
|
7935
|
+
// instance. A ref, not state, so the handler always sees the latest value
|
|
7936
|
+
// without pulling `timelineFrame` into that big message-listener's deps.
|
|
7937
|
+
const timelineFrameRef = useRef(0);
|
|
7938
|
+
useEffect(() => {
|
|
7939
|
+
timelineFrameRef.current = timelineFrame;
|
|
7940
|
+
}, [timelineFrame]);
|
|
6881
7941
|
// The DCArtboard id the timeline scoped to (from parseCompTimeline). Used for
|
|
6882
7942
|
// /_api/comp-clips + every clip op so the enumerator targets the SAME comp the
|
|
6883
7943
|
// rows came from — `timelineCompId` is the Player's `videocomp-N` id, which
|
|
@@ -6925,6 +7985,17 @@ function App() {
|
|
|
6925
7985
|
// Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
|
|
6926
7986
|
// tab (the menu item sat disabled as "Phase 12" long after the tab shipped).
|
|
6927
7987
|
const [inspectorTab, setInspectorTab] = useState('inspect');
|
|
7988
|
+
// feature-photo-editor (Task 13) — the annotation-image Photo-tab target,
|
|
7989
|
+
// threaded up from the annotation layer's "Edit Photo…" (the annotation model
|
|
7990
|
+
// has no DOM selection to ride, unlike an artboard `<img>`). `{ asset, strokeId }`
|
|
7991
|
+
// or null. Cleared whenever a normal DOM selection arrives so the two never
|
|
7992
|
+
// fight over the panel. The shell-side photo undo stack lives alongside it.
|
|
7993
|
+
const [photoSel, setPhotoSel] = useState(null);
|
|
7994
|
+
const photoUndoRef = useRef({ undo: [], redo: [] });
|
|
7995
|
+
// Bumped by the Cmd+Z/Cmd+Shift+Z photo-undo handler below so the mounted
|
|
7996
|
+
// PhotoKnobs (keyed on `asset:photoRev`) remounts and re-fetches the sidecar
|
|
7997
|
+
// it just PUT, instead of drifting from its own stale local `edit` state.
|
|
7998
|
+
const [photoRev, setPhotoRev] = useState(0);
|
|
6928
7999
|
// feature-element-editing-robustness Stage C — auto-open the Inspector on the
|
|
6929
8000
|
// CSS tab when a fresh single selection arrives AND no right panel is already
|
|
6930
8001
|
// open. Preference-backed (default ON); disable it in the View menu. Refs keep
|
|
@@ -6959,14 +8030,57 @@ function App() {
|
|
|
6959
8030
|
// ~13 call sites and only some closed their siblings, so a panel opened via a
|
|
6960
8031
|
// path that left a sibling `true` rendered *behind* it under the fixed
|
|
6961
8032
|
// precedence — looking like the new panel "overlapped" the old one.)
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
|
|
6965
|
-
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
8033
|
+
// feature-configurable-panel-docking — generic, SIDE-AWARE panel management.
|
|
8034
|
+
// A slot (left/right) shows one panel at a time, so opening a panel closes any
|
|
8035
|
+
// OTHER open panel ON THE SAME SIDE (panels on the other side are independent).
|
|
8036
|
+
// A live ref carries the current open + side maps so the callbacks stay stable
|
|
8037
|
+
// yet never act on stale state. (The Timeline is a BOTTOM dock, DDR-148, and is
|
|
8038
|
+
// NOT part of either slot's mutual-exclusion.)
|
|
8039
|
+
const panelStateRef = useRef({ open: {}, side: PANEL_SIDES_DEFAULTS });
|
|
8040
|
+
panelStateRef.current = {
|
|
8041
|
+
open: {
|
|
8042
|
+
tree: sidebarOpen,
|
|
8043
|
+
layers: layersOpen,
|
|
8044
|
+
inspector: inspectorOpen,
|
|
8045
|
+
comments: commentsPanelOpen,
|
|
8046
|
+
changes: changesOpen,
|
|
8047
|
+
assistant: assistantOpen,
|
|
8048
|
+
},
|
|
8049
|
+
side: panelSide,
|
|
8050
|
+
};
|
|
8051
|
+
const setPanelOpen = useCallback((id, val) => {
|
|
8052
|
+
if (id === 'tree') setSidebarOpen(val);
|
|
8053
|
+
else if (id === 'layers') setLayersOpen(val);
|
|
8054
|
+
else if (id === 'inspector') setInspectorOpen(val);
|
|
8055
|
+
else if (id === 'comments') setCommentsPanelOpen(val);
|
|
8056
|
+
else if (id === 'changes') setChangesOpen(val);
|
|
8057
|
+
else if (id === 'assistant') setAssistantOpen(val);
|
|
6969
8058
|
}, []);
|
|
8059
|
+
const sideOf = useCallback(
|
|
8060
|
+
(id) => panelStateRef.current.side?.[id] || PANEL_SIDES_DEFAULTS[id],
|
|
8061
|
+
[]
|
|
8062
|
+
);
|
|
8063
|
+
const openPanelExclusive = useCallback(
|
|
8064
|
+
(id) => {
|
|
8065
|
+
const side = sideOf(id);
|
|
8066
|
+
setPanelOpen(id, true);
|
|
8067
|
+
for (const p of DOCK_PANELS) {
|
|
8068
|
+
if (p.id !== id && sideOf(p.id) === side) setPanelOpen(p.id, false);
|
|
8069
|
+
}
|
|
8070
|
+
},
|
|
8071
|
+
[setPanelOpen, sideOf]
|
|
8072
|
+
);
|
|
8073
|
+
const togglePanel = useCallback(
|
|
8074
|
+
(id) => {
|
|
8075
|
+
if (panelStateRef.current.open?.[id]) setPanelOpen(id, false);
|
|
8076
|
+
else openPanelExclusive(id);
|
|
8077
|
+
},
|
|
8078
|
+
[openPanelExclusive, setPanelOpen]
|
|
8079
|
+
);
|
|
8080
|
+
// Legacy name kept for the many call sites; now side-aware (opens `which` and
|
|
8081
|
+
// closes only same-side siblings, so a panel moved to the left is unaffected).
|
|
8082
|
+
const openRightPanel = openPanelExclusive;
|
|
8083
|
+
const toggleRightPanel = togglePanel;
|
|
6970
8084
|
// feature-element-editing-robustness Stage C (Task C1) — open the Inspector on
|
|
6971
8085
|
// the CSS tab for a fresh SINGLE selection, but only when nothing is already
|
|
6972
8086
|
// docked on the right (never override an open Layers/Inspect/Comments panel)
|
|
@@ -6982,47 +8096,6 @@ function App() {
|
|
|
6982
8096
|
},
|
|
6983
8097
|
[openRightPanel]
|
|
6984
8098
|
);
|
|
6985
|
-
// Functional updates so this is stale-closure-safe inside the keydown /
|
|
6986
|
-
// postMessage listeners; opening always clears the sibling panels.
|
|
6987
|
-
const toggleRightPanel = useCallback((which) => {
|
|
6988
|
-
if (which === 'inspector') {
|
|
6989
|
-
setInspectorOpen((v) => {
|
|
6990
|
-
if (!v) {
|
|
6991
|
-
setChangesOpen(false);
|
|
6992
|
-
setCommentsPanelOpen(false);
|
|
6993
|
-
setAssistantOpen(false);
|
|
6994
|
-
}
|
|
6995
|
-
return !v;
|
|
6996
|
-
});
|
|
6997
|
-
} else if (which === 'comments') {
|
|
6998
|
-
setCommentsPanelOpen((v) => {
|
|
6999
|
-
if (!v) {
|
|
7000
|
-
setChangesOpen(false);
|
|
7001
|
-
setInspectorOpen(false);
|
|
7002
|
-
setAssistantOpen(false);
|
|
7003
|
-
}
|
|
7004
|
-
return !v;
|
|
7005
|
-
});
|
|
7006
|
-
} else if (which === 'changes') {
|
|
7007
|
-
setChangesOpen((v) => {
|
|
7008
|
-
if (!v) {
|
|
7009
|
-
setInspectorOpen(false);
|
|
7010
|
-
setCommentsPanelOpen(false);
|
|
7011
|
-
setAssistantOpen(false);
|
|
7012
|
-
}
|
|
7013
|
-
return !v;
|
|
7014
|
-
});
|
|
7015
|
-
} else if (which === 'assistant') {
|
|
7016
|
-
setAssistantOpen((v) => {
|
|
7017
|
-
if (!v) {
|
|
7018
|
-
setChangesOpen(false);
|
|
7019
|
-
setInspectorOpen(false);
|
|
7020
|
-
setCommentsPanelOpen(false);
|
|
7021
|
-
}
|
|
7022
|
-
return !v;
|
|
7023
|
-
});
|
|
7024
|
-
}
|
|
7025
|
-
}, []);
|
|
7026
8099
|
// DDR-148 — the Timeline is a BOTTOM dock, independent of the right rail: it
|
|
7027
8100
|
// toggles on its own and coexists with Inspector/Changes/Comments/Chat.
|
|
7028
8101
|
const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
|
|
@@ -7088,7 +8161,7 @@ function App() {
|
|
|
7088
8161
|
setup: (step) => {
|
|
7089
8162
|
if (!step) return;
|
|
7090
8163
|
if ((step.canvas || step.requireSelection) && tabs.length === 0) {
|
|
7091
|
-
|
|
8164
|
+
openPanelExclusive('tree');
|
|
7092
8165
|
setTimeout(() => {
|
|
7093
8166
|
try {
|
|
7094
8167
|
document.querySelector('.st-sidebar [role="treeitem"]')?.click();
|
|
@@ -7108,13 +8181,19 @@ function App() {
|
|
|
7108
8181
|
localStorage.setItem(USAGE_TOUR_STORE, '1');
|
|
7109
8182
|
} catch {}
|
|
7110
8183
|
}, []);
|
|
7111
|
-
const [annotationsVisible, setAnnotationsVisible] = useState(
|
|
8184
|
+
const [annotationsVisible, setAnnotationsVisible] = useState(() =>
|
|
8185
|
+
readBoolStore(ANNOT_STORE, true)
|
|
8186
|
+
);
|
|
8187
|
+
// feature-unified-settings-modal — gate the disk-write effect until the boot
|
|
8188
|
+
// GET /_api/ui-prefs has reconciled (disk-wins), so we never clobber a stored
|
|
8189
|
+
// pref with the localStorage/default value on the first render.
|
|
8190
|
+
const [uiPrefsHydrated, setUiPrefsHydrated] = useState(false);
|
|
7112
8191
|
// Canvas-chrome visibility (View menu). minimap + zoom-controls are
|
|
7113
8192
|
// persistent prefs broadcast to every open canvas iframe; presentMode is a
|
|
7114
8193
|
// non-destructive "hide ALL chrome + shell, artboards only" overlay with an
|
|
7115
8194
|
// Esc / floating-pill escape hatch back to the chrome.
|
|
7116
|
-
const [minimapVisible, setMinimapVisible] = useState(
|
|
7117
|
-
const [zoomCtlVisible, setZoomCtlVisible] = useState(
|
|
8195
|
+
const [minimapVisible, setMinimapVisible] = useState(() => readBoolStore(MINIMAP_STORE, false));
|
|
8196
|
+
const [zoomCtlVisible, setZoomCtlVisible] = useState(() => readBoolStore(ZOOMCTL_STORE, false));
|
|
7118
8197
|
const [presentMode, setPresentMode] = useState(false);
|
|
7119
8198
|
// P2/P3 (Plan C) — top-bar live state. (Zoom lives in the canvas toolbar pill,
|
|
7120
8199
|
// so the top bar no longer mirrors it.)
|
|
@@ -7145,6 +8224,96 @@ function App() {
|
|
|
7145
8224
|
[activePath]
|
|
7146
8225
|
);
|
|
7147
8226
|
|
|
8227
|
+
// ── feature-photo-editor — the Photo tab's three channels ─────────────────
|
|
8228
|
+
// (1) live preview: broadcast the edit DOWN to the active canvas iframe, whose
|
|
8229
|
+
// canvas-lib `PhotoPreviewBridge` bakes the composite and swaps it directly
|
|
8230
|
+
// into the matching `<img>`/`<image>` element's src/href (iteration 2 — see
|
|
8231
|
+
// the bridge's own header comment for why it's a direct swap, not an
|
|
8232
|
+
// overlay). (2) undo: a shell-side stack (photo edits are sidecar writes,
|
|
8233
|
+
// not the canvas-side source-edit stack). (3) background removal: the
|
|
8234
|
+
// client-side @imgly ML flow (Task 12).
|
|
8235
|
+
const onPhotoEdit = useCallback(
|
|
8236
|
+
(asset, edit) => postToActiveCanvas({ dgn: 'photo-preview', asset, edit }),
|
|
8237
|
+
[postToActiveCanvas]
|
|
8238
|
+
);
|
|
8239
|
+
const onPhotoRecordEdit = useCallback((asset, before, after) => {
|
|
8240
|
+
photoUndoRef.current.undo.push({ asset, before, after });
|
|
8241
|
+
photoUndoRef.current.redo.length = 0;
|
|
8242
|
+
}, []);
|
|
8243
|
+
// Pops the shell-side photo-undo stack, re-persists the reverted edit through
|
|
8244
|
+
// the same PUT route PhotoKnobs itself uses, re-broadcasts it to the live
|
|
8245
|
+
// preview, and bumps `photoRev` so the mounted PhotoKnobs (keyed on
|
|
8246
|
+
// `asset:photoRev`) remounts and re-fetches instead of drifting from its own
|
|
8247
|
+
// stale local state. Returns false (does nothing) when the requested stack is
|
|
8248
|
+
// empty, so callers can fall through to the canvas's own undo stack.
|
|
8249
|
+
const performPhotoUndo = useCallback(
|
|
8250
|
+
(redo) => {
|
|
8251
|
+
const stack = redo ? photoUndoRef.current.redo : photoUndoRef.current.undo;
|
|
8252
|
+
if (!stack.length) return false;
|
|
8253
|
+
const entry = stack.pop();
|
|
8254
|
+
(redo ? photoUndoRef.current.undo : photoUndoRef.current.redo).push(entry);
|
|
8255
|
+
const edit = (redo ? entry.after : entry.before) || {};
|
|
8256
|
+
onPhotoEdit(entry.asset, edit);
|
|
8257
|
+
fetch(`/_api/photo-edit?asset=${encodeURIComponent(entry.asset)}`, {
|
|
8258
|
+
method: 'PUT',
|
|
8259
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8260
|
+
body: JSON.stringify(edit),
|
|
8261
|
+
}).catch(() => {});
|
|
8262
|
+
setPhotoRev((v) => v + 1);
|
|
8263
|
+
return true;
|
|
8264
|
+
},
|
|
8265
|
+
[onPhotoEdit]
|
|
8266
|
+
);
|
|
8267
|
+
// Task 12 — magic background removal, entirely client-side (WASM/WebGPU via
|
|
8268
|
+
// @imgly/background-removal — NEVER the -node native-addon variant, DDR-070).
|
|
8269
|
+
// The lib is DYNAMICALLY imported so a session that never removes a background
|
|
8270
|
+
// pays zero bundle cost (the lazy-bundle guarantee). Fetches the source bytes,
|
|
8271
|
+
// runs the matte, uploads it content-addressed through the SAME /_api/asset lane
|
|
8272
|
+
// drag-drop uses, and returns the new `assets/<sha8>.png` for PhotoEdit.
|
|
8273
|
+
const onPhotoRemoveBackground = useCallback(
|
|
8274
|
+
async (asset) => {
|
|
8275
|
+
// Scan/shimmer reveal on the live photo while the ML pass runs, the same
|
|
8276
|
+
// "something is actively happening here" language as the agent-edit
|
|
8277
|
+
// artboard rim (artboard-activity-overlay.tsx) — but applied directly to
|
|
8278
|
+
// the photo element itself (a `data-photo-busy` attribute + injected CSS
|
|
8279
|
+
// mask sweep, inspect.ts) rather than a floating tracked overlay. A
|
|
8280
|
+
// floating decoy is exactly the architecture DDR-161's addendum tore out
|
|
8281
|
+
// for the live-edit preview (z-index/resize/hit-testing bugs); an
|
|
8282
|
+
// attribute toggle on the real element sidesteps that whole class.
|
|
8283
|
+
postToActiveCanvas({ dgn: 'photo-busy', asset, busy: true });
|
|
8284
|
+
try {
|
|
8285
|
+
const srcRes = await fetch(`/${asset.replace(/^\/+/, '')}`);
|
|
8286
|
+
if (!srcRes.ok) return null;
|
|
8287
|
+
const srcBlob = await srcRes.blob();
|
|
8288
|
+
const { removeBackground } = await import('@imgly/background-removal');
|
|
8289
|
+
// Inference is 100% CLIENT-SIDE (WASM/WebGPU) — the user's pixels NEVER
|
|
8290
|
+
// leave the browser. Only the public model weights (~40 MB, identical for
|
|
8291
|
+
// everyone) are fetched from IMG.LY's default host on first use; they're
|
|
8292
|
+
// too large to bundle in the npm tarball (`resources.json` ships empty).
|
|
8293
|
+
// Self-hosting those weights off the dev server for offline / air-gapped
|
|
8294
|
+
// parity is the flagged Task-11 follow-up (a one-time download step) — until
|
|
8295
|
+
// then the default host provides them, with no pixel-privacy exposure.
|
|
8296
|
+
const matte = await removeBackground(srcBlob);
|
|
8297
|
+
const up = await fetch('/_api/asset', {
|
|
8298
|
+
method: 'POST',
|
|
8299
|
+
headers: { 'content-type': matte.type || 'image/png' },
|
|
8300
|
+
body: matte,
|
|
8301
|
+
});
|
|
8302
|
+
const j = await up.json().catch(() => ({}));
|
|
8303
|
+
if (up.ok && j.path) return { maskAsset: j.path };
|
|
8304
|
+
return null;
|
|
8305
|
+
} catch (err) {
|
|
8306
|
+
console.error('[photo] background removal failed', err);
|
|
8307
|
+
return null;
|
|
8308
|
+
} finally {
|
|
8309
|
+
// Always clears, success or failure — an errored pass must not leave
|
|
8310
|
+
// the shimmer stuck on the photo forever.
|
|
8311
|
+
postToActiveCanvas({ dgn: 'photo-busy', asset, busy: false });
|
|
8312
|
+
}
|
|
8313
|
+
},
|
|
8314
|
+
[postToActiveCanvas]
|
|
8315
|
+
);
|
|
8316
|
+
|
|
7148
8317
|
// DDR-150 dogfood #7 — a file dragged from Finder and dropped on a shell area
|
|
7149
8318
|
// with no drop target used to make the browser NAVIGATE AWAY to the file
|
|
7150
8319
|
// (file:// replaces the app). Document-level guard: always cancel the default
|
|
@@ -7533,6 +8702,87 @@ function App() {
|
|
|
7533
8702
|
localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded));
|
|
7534
8703
|
} catch {}
|
|
7535
8704
|
}, [sectionsExpanded]);
|
|
8705
|
+
useEffect(() => {
|
|
8706
|
+
try {
|
|
8707
|
+
localStorage.setItem(MINIMAP_STORE, minimapVisible ? '1' : '0');
|
|
8708
|
+
} catch {}
|
|
8709
|
+
}, [minimapVisible]);
|
|
8710
|
+
useEffect(() => {
|
|
8711
|
+
try {
|
|
8712
|
+
localStorage.setItem(ZOOMCTL_STORE, zoomCtlVisible ? '1' : '0');
|
|
8713
|
+
} catch {}
|
|
8714
|
+
}, [zoomCtlVisible]);
|
|
8715
|
+
useEffect(() => {
|
|
8716
|
+
try {
|
|
8717
|
+
localStorage.setItem(ANNOT_STORE, annotationsVisible ? '1' : '0');
|
|
8718
|
+
} catch {}
|
|
8719
|
+
}, [annotationsVisible]);
|
|
8720
|
+
|
|
8721
|
+
// feature-unified-settings-modal — reconcile view prefs with the on-disk store
|
|
8722
|
+
// once on mount (disk wins over the localStorage/default init: it survives a
|
|
8723
|
+
// cleared localStorage). Applying a value that already matches is a no-op, so
|
|
8724
|
+
// there's no flash in the common case. On failure we still mark hydrated so
|
|
8725
|
+
// writes resume best-effort.
|
|
8726
|
+
useEffect(() => {
|
|
8727
|
+
let cancelled = false;
|
|
8728
|
+
fetch('/_api/ui-prefs')
|
|
8729
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
8730
|
+
.then((p) => {
|
|
8731
|
+
if (cancelled || !p || typeof p !== 'object') return;
|
|
8732
|
+
if (p.theme === 'light' || p.theme === 'dark') setTheme(p.theme);
|
|
8733
|
+
if (typeof p.minimap === 'boolean') setMinimapVisible(p.minimap);
|
|
8734
|
+
if (typeof p.zoom === 'boolean') setZoomCtlVisible(p.zoom);
|
|
8735
|
+
if (typeof p.annotations === 'boolean') setAnnotationsVisible(p.annotations);
|
|
8736
|
+
if (typeof p.autoOpenInspector === 'boolean') setAutoOpenInspector(p.autoOpenInspector);
|
|
8737
|
+
if (p.panelSides && typeof p.panelSides === 'object')
|
|
8738
|
+
setPanelSide({ ...PANEL_SIDES_DEFAULTS, ...p.panelSides });
|
|
8739
|
+
if (p.layersMode === 'separate' || p.layersMode === 'in-inspector')
|
|
8740
|
+
setLayersMode(p.layersMode);
|
|
8741
|
+
})
|
|
8742
|
+
.catch(() => {})
|
|
8743
|
+
.finally(() => {
|
|
8744
|
+
if (!cancelled) setUiPrefsHydrated(true);
|
|
8745
|
+
});
|
|
8746
|
+
return () => {
|
|
8747
|
+
cancelled = true;
|
|
8748
|
+
};
|
|
8749
|
+
}, []);
|
|
8750
|
+
|
|
8751
|
+
// Mirror every view pref to disk once hydrated. The barrier stops the initial
|
|
8752
|
+
// render (localStorage/default values) from overwriting the disk store before
|
|
8753
|
+
// the boot GET has reconciled.
|
|
8754
|
+
useEffect(() => {
|
|
8755
|
+
if (!uiPrefsHydrated) return;
|
|
8756
|
+
persistUiPrefs({
|
|
8757
|
+
theme,
|
|
8758
|
+
minimap: minimapVisible,
|
|
8759
|
+
zoom: zoomCtlVisible,
|
|
8760
|
+
annotations: annotationsVisible,
|
|
8761
|
+
autoOpenInspector,
|
|
8762
|
+
panelSides: panelSide,
|
|
8763
|
+
layersMode,
|
|
8764
|
+
});
|
|
8765
|
+
}, [
|
|
8766
|
+
uiPrefsHydrated,
|
|
8767
|
+
theme,
|
|
8768
|
+
minimapVisible,
|
|
8769
|
+
zoomCtlVisible,
|
|
8770
|
+
annotationsVisible,
|
|
8771
|
+
autoOpenInspector,
|
|
8772
|
+
panelSide,
|
|
8773
|
+
layersMode,
|
|
8774
|
+
]);
|
|
8775
|
+
// localStorage mirror for panel sides + layers mode (synchronous no-flash init).
|
|
8776
|
+
useEffect(() => {
|
|
8777
|
+
try {
|
|
8778
|
+
localStorage.setItem(PANEL_SIDES_STORE, JSON.stringify(panelSide));
|
|
8779
|
+
} catch {}
|
|
8780
|
+
}, [panelSide]);
|
|
8781
|
+
useEffect(() => {
|
|
8782
|
+
try {
|
|
8783
|
+
localStorage.setItem(LAYERS_MODE_STORE, layersMode);
|
|
8784
|
+
} catch {}
|
|
8785
|
+
}, [layersMode]);
|
|
7536
8786
|
|
|
7537
8787
|
const toggleSection = useCallback((label, defaultOpen) => {
|
|
7538
8788
|
setSectionsExpanded((prev) => {
|
|
@@ -7563,7 +8813,14 @@ function App() {
|
|
|
7563
8813
|
// fetch (either may land first). `?? {}` keeps older servers (no map) on
|
|
7564
8814
|
// the ds0 fallback. Re-runs on every tree reload, so adding/retargeting a
|
|
7565
8815
|
// canvas refreshes the map.
|
|
7566
|
-
setCfg((prev) => ({
|
|
8816
|
+
setCfg((prev) => ({
|
|
8817
|
+
...prev,
|
|
8818
|
+
canvasDesignSystems: data.canvasDesignSystems ?? {},
|
|
8819
|
+
// DDR-174 (T15) — per-canvas notable `.meta.json` `kind` values (today:
|
|
8820
|
+
// only `reconstructed-experimental`), folded in the same way + for the
|
|
8821
|
+
// same reason as canvasDesignSystems above.
|
|
8822
|
+
canvasKinds: data.canvasKinds ?? {},
|
|
8823
|
+
}));
|
|
7567
8824
|
} catch (e) {
|
|
7568
8825
|
console.error('failed to load tree', e);
|
|
7569
8826
|
}
|
|
@@ -7863,8 +9120,11 @@ function App() {
|
|
|
7863
9120
|
useEffect(() => {
|
|
7864
9121
|
if (gitStatus?.repo === false) return; // solo / non-git project — no remote
|
|
7865
9122
|
refreshRemoteSync();
|
|
7866
|
-
|
|
7867
|
-
|
|
9123
|
+
// Poll in the background too — not only while Changes is open — so the dock's
|
|
9124
|
+
// "Get latest" nudge surfaces a teammate's publish proactively. Slower cadence
|
|
9125
|
+
// when the panel is closed to stay network-polite (the ahead/behind probe is
|
|
9126
|
+
// TTL-cached server-side, so a missed tick is cheap to re-issue).
|
|
9127
|
+
const id = setInterval(refreshRemoteSync, changesOpen ? 60000 : 120000);
|
|
7868
9128
|
return () => clearInterval(id);
|
|
7869
9129
|
}, [gitStatus?.repo, changesOpen, refreshRemoteSync]);
|
|
7870
9130
|
|
|
@@ -8245,7 +9505,22 @@ function App() {
|
|
|
8245
9505
|
// canvas must not be able to plant a selection with no user gesture. The
|
|
8246
9506
|
// user can only click the visible active canvas, so a non-active source is
|
|
8247
9507
|
// never a legitimate selection.
|
|
8248
|
-
|
|
9508
|
+
//
|
|
9509
|
+
// The SAME gate now covers the privileged SOURCE-WRITE relays — `edit-text`
|
|
9510
|
+
// (inline text commit) and `apply-edit` (undo/redo re-application). Both
|
|
9511
|
+
// ride into a main-origin `.tsx` rewrite; a write path is strictly more
|
|
9512
|
+
// dangerous than a selection, yet was previously ungated (ethical-hacker
|
|
9513
|
+
// F-B, DDR-160 follow-up). A background/synced untrusted canvas (DDR-054)
|
|
9514
|
+
// must not drive a gestureless write into another canvas — and inline edits
|
|
9515
|
+
// + Cmd+Z always originate in the ACTIVE canvas the user is looking at, so
|
|
9516
|
+
// a non-active source is never legitimate here either.
|
|
9517
|
+
if (
|
|
9518
|
+
m.dgn === 'select' ||
|
|
9519
|
+
m.dgn === 'select-set' ||
|
|
9520
|
+
m.dgn === 'clear-select' ||
|
|
9521
|
+
m.dgn === 'edit-text' ||
|
|
9522
|
+
m.dgn === 'apply-edit'
|
|
9523
|
+
) {
|
|
8249
9524
|
const activeWin =
|
|
8250
9525
|
activePath && activePath !== SYSTEM_TAB
|
|
8251
9526
|
? iframesRef.current.get(activePath)?.contentWindow
|
|
@@ -8253,10 +9528,12 @@ function App() {
|
|
|
8253
9528
|
if (e.source !== activeWin) return;
|
|
8254
9529
|
}
|
|
8255
9530
|
if (m.dgn === 'select' && m.selection) {
|
|
9531
|
+
setPhotoSel(null); // a DOM selection supersedes an annotation-image Photo target
|
|
8256
9532
|
wsSend({ type: 'select', selection: m.selection });
|
|
8257
9533
|
setSelected(m.selection);
|
|
8258
9534
|
maybeAutoOpenInspectorOnSelect(m.selection); // Stage C
|
|
8259
9535
|
} else if (m.dgn === 'select-set') {
|
|
9536
|
+
setPhotoSel(null);
|
|
8260
9537
|
// Canvas multi-select. Payload shape:
|
|
8261
9538
|
// null → empty selection
|
|
8262
9539
|
// Selection → length-1 (back-compat with legacy single-element shape)
|
|
@@ -8280,6 +9557,7 @@ function App() {
|
|
|
8280
9557
|
maybeAutoOpenInspectorOnSelect(payload); // Stage C
|
|
8281
9558
|
}
|
|
8282
9559
|
} else if (m.dgn === 'clear-select') {
|
|
9560
|
+
setPhotoSel(null);
|
|
8283
9561
|
wsSend({ type: 'clear-select' });
|
|
8284
9562
|
setSelected(null);
|
|
8285
9563
|
} else if (m.dgn === 'edit-text' && m.id) {
|
|
@@ -8301,7 +9579,15 @@ function App() {
|
|
|
8301
9579
|
fetch('/_api/edit-text', {
|
|
8302
9580
|
method: 'POST',
|
|
8303
9581
|
headers: { 'content-type': 'application/json' },
|
|
8304
|
-
body: JSON.stringify({
|
|
9582
|
+
body: JSON.stringify({
|
|
9583
|
+
canvas: m.file,
|
|
9584
|
+
id: m.id,
|
|
9585
|
+
text: m.text ?? '',
|
|
9586
|
+
// Context for editing `{variable}` text — which rendered instance +
|
|
9587
|
+
// its pre-edit text, so the engine targets the right source string.
|
|
9588
|
+
...(typeof m.occurrence === 'number' ? { occurrence: m.occurrence } : {}),
|
|
9589
|
+
...(typeof m.before === 'string' ? { before: m.before } : {}),
|
|
9590
|
+
}),
|
|
8305
9591
|
})
|
|
8306
9592
|
.then((r) => r.json().catch(() => ({})))
|
|
8307
9593
|
.then((j) => {
|
|
@@ -8334,6 +9620,11 @@ function App() {
|
|
|
8334
9620
|
} else {
|
|
8335
9621
|
url = '/_api/edit-text';
|
|
8336
9622
|
body = { canvas: m.canvas, id: m.id, text: value ?? '' };
|
|
9623
|
+
// Undo/redo of a `{variable}` text edit needs to re-target the source
|
|
9624
|
+
// string: the occurrence + the value currently on disk (`from` — the
|
|
9625
|
+
// side we're replacing FROM). Harmless for literal text.
|
|
9626
|
+
if (typeof m.occurrence === 'number') body.occurrence = m.occurrence;
|
|
9627
|
+
if (typeof m.from === 'string') body.before = m.from;
|
|
8337
9628
|
}
|
|
8338
9629
|
editApplyChainRef.current = editApplyChainRef.current
|
|
8339
9630
|
.catch(() => {})
|
|
@@ -8510,40 +9801,66 @@ function App() {
|
|
|
8510
9801
|
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8511
9802
|
if (e.source === activeWin && typeof m.id === 'string') pasteStyleRef.current?.(m.id);
|
|
8512
9803
|
} else if (m.dgn === 'insert-request') {
|
|
8513
|
-
// Stage I3 — insert a synthesized div/text/image relative to `refId
|
|
9804
|
+
// Stage I3 — insert a synthesized div/text/image relative to `refId`,
|
|
9805
|
+
// OR — empty-artboard fallback (tool-palette "+ Element" on a fresh
|
|
9806
|
+
// artboard with no elements yet) — as a child of `artboardId`. Exactly
|
|
9807
|
+
// one of the two must be present; the artboardId variant only makes
|
|
9808
|
+
// sense inside-start/inside-end (no sibling to be before/after).
|
|
8514
9809
|
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
9810
|
+
const hasRefId = typeof m.refId === 'string';
|
|
9811
|
+
const hasArtboardId = typeof m.artboardId === 'string';
|
|
9812
|
+
const okPosition =
|
|
9813
|
+
m.position === 'before' ||
|
|
9814
|
+
m.position === 'after' ||
|
|
9815
|
+
m.position === 'inside-start' ||
|
|
9816
|
+
m.position === 'inside-end';
|
|
8515
9817
|
const okShape =
|
|
8516
|
-
|
|
9818
|
+
hasRefId !== hasArtboardId &&
|
|
8517
9819
|
(m.kind === 'div' || m.kind === 'text' || m.kind === 'image') &&
|
|
8518
|
-
|
|
8519
|
-
|
|
8520
|
-
m.position === 'inside-start' ||
|
|
8521
|
-
m.position === 'inside-end');
|
|
9820
|
+
okPosition &&
|
|
9821
|
+
(!hasArtboardId || m.position === 'inside-start' || m.position === 'inside-end');
|
|
8522
9822
|
if (e.source === activeWin && okShape) {
|
|
8523
9823
|
insertElementShellRef.current?.(m.refId, m.position, m.kind, {
|
|
9824
|
+
artboardId: hasArtboardId ? m.artboardId : undefined,
|
|
8524
9825
|
src: typeof m.src === 'string' ? m.src : undefined,
|
|
8525
9826
|
refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
|
|
8526
9827
|
});
|
|
8527
9828
|
}
|
|
8528
9829
|
} else if (m.dgn === 'insert-image-request') {
|
|
8529
|
-
// Stage F/I3 — "Insert ▸ Image" from the canvas context menu
|
|
8530
|
-
//
|
|
8531
|
-
//
|
|
8532
|
-
//
|
|
9830
|
+
// Stage F/I3 — "Insert ▸ Image" from the canvas context menu, or the
|
|
9831
|
+
// tool-palette's Image tool (`artboardId` in place of `refId` for its
|
|
9832
|
+
// empty-artboard fallback, or NEITHER when the canvas has no artboard
|
|
9833
|
+
// at all — feature-bulk-media-insert). An image needs a contained
|
|
9834
|
+
// asset src, so the shell opens the AssetPicker in bulk mode; the
|
|
9835
|
+
// confirm then drives insertElementShell(kind:'image') per path (Task
|
|
9836
|
+
// 10) or a single batched annotation insert. Confused-deputy gated +
|
|
9837
|
+
// pinned to the active canvas like the other request verbs.
|
|
8533
9838
|
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
9839
|
+
const hasRefId = typeof m.refId === 'string';
|
|
9840
|
+
const hasArtboardId = typeof m.artboardId === 'string';
|
|
9841
|
+
const noAnchor = !hasRefId && !hasArtboardId;
|
|
8534
9842
|
const okShape =
|
|
8535
|
-
|
|
8536
|
-
(
|
|
9843
|
+
(hasRefId !== hasArtboardId || noAnchor) &&
|
|
9844
|
+
(noAnchor ||
|
|
9845
|
+
m.position === 'before' ||
|
|
8537
9846
|
m.position === 'after' ||
|
|
8538
9847
|
m.position === 'inside-start' ||
|
|
8539
|
-
m.position === 'inside-end')
|
|
9848
|
+
m.position === 'inside-end') &&
|
|
9849
|
+
(!hasArtboardId || m.position === 'inside-start' || m.position === 'inside-end');
|
|
8540
9850
|
if (e.source === activeWin && okShape) {
|
|
8541
9851
|
openAssetPickerRef.current?.({
|
|
8542
9852
|
purpose: 'insert-image',
|
|
8543
9853
|
canvas: activePath,
|
|
8544
9854
|
refId: m.refId,
|
|
8545
|
-
|
|
9855
|
+
artboardId: hasArtboardId ? m.artboardId : undefined,
|
|
9856
|
+
position: noAnchor ? undefined : m.position,
|
|
8546
9857
|
refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
|
|
9858
|
+
multiple: true,
|
|
9859
|
+
// Artboard destination is possible whenever there's ANY resolvable
|
|
9860
|
+
// anchor — a refId (context-menu, or the tool-palette's normal
|
|
9861
|
+
// last-element case) inserts inside the SAME artboard that
|
|
9862
|
+
// element already lives in, not just the artboardId fallback.
|
|
9863
|
+
hasArtboardAnchor: !noAnchor,
|
|
8547
9864
|
});
|
|
8548
9865
|
}
|
|
8549
9866
|
} else if (m.dgn === 'replace-media-request') {
|
|
@@ -8573,6 +9890,27 @@ function App() {
|
|
|
8573
9890
|
before: typeof m.before === 'string' ? m.before : null,
|
|
8574
9891
|
});
|
|
8575
9892
|
}
|
|
9893
|
+
} else if (m.dgn === 'edit-annotation-photo-request') {
|
|
9894
|
+
// feature-photo-editor (Task 17) — "Edit Photo…" on an annotation
|
|
9895
|
+
// ImageStroke. The annotation model has no data-cd-id / DOM selection, so
|
|
9896
|
+
// it can't ride the normal select path; instead the canvas posts its
|
|
9897
|
+
// `assets/<sha8>.<ext>` href up and the shell opens the Photo-only tab on
|
|
9898
|
+
// it. Same confused-deputy gate (DDR-054) + active-canvas pin as F3.
|
|
9899
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
9900
|
+
const asset = PHOTO_ASSET_RE.exec(typeof m.asset === 'string' ? m.asset : '')?.[0] || null;
|
|
9901
|
+
if (e.source === activeWin && typeof m.id === 'string' && asset) {
|
|
9902
|
+
setPhotoSel({ asset, strokeId: m.id });
|
|
9903
|
+
openRightPanel('inspector');
|
|
9904
|
+
setInspectorTab('photo');
|
|
9905
|
+
}
|
|
9906
|
+
} else if (m.dgn === 'open-sticker-picker') {
|
|
9907
|
+
// Phase 4 (whiteboard-improvements) — the toolbar's Stickers button.
|
|
9908
|
+
// Confused-deputy gated + pinned to the active canvas like the other
|
|
9909
|
+
// request verbs above.
|
|
9910
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
9911
|
+
if (e.source === activeWin) {
|
|
9912
|
+
openStickerPickerRef.current?.({ canvas: activePath });
|
|
9913
|
+
}
|
|
8576
9914
|
} else if (m.dgn === 'insert-artboard-request') {
|
|
8577
9915
|
// Stage I4 — insert a new empty artboard from a screen-size preset.
|
|
8578
9916
|
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
@@ -8609,7 +9947,12 @@ function App() {
|
|
|
8609
9947
|
}
|
|
8610
9948
|
} else if (m.dgn === 'open-inspector') {
|
|
8611
9949
|
// Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
|
|
9950
|
+
// feature-photo-editor (Task 16) — an optional `tab` lands the panel on a
|
|
9951
|
+
// specific tab (the element "Edit Photo…" entry passes `tab: 'photo'`; the
|
|
9952
|
+
// img is already selected via select-set, so the Photo tab derives its
|
|
9953
|
+
// target from that selection's `photoAsset`).
|
|
8612
9954
|
openRightPanel('inspector');
|
|
9955
|
+
if (typeof m.tab === 'string') setInspectorTab(m.tab);
|
|
8613
9956
|
} else if (m.dgn === 'present-enter') {
|
|
8614
9957
|
// Canvas tool-palette "Presentation mode" button — Present Mode is a
|
|
8615
9958
|
// shell-level state (hides the menubar / sidebar / panels), so the
|
|
@@ -8703,6 +10046,23 @@ function App() {
|
|
|
8703
10046
|
height: Math.max(1, Math.round(Number(c.height) || 0)),
|
|
8704
10047
|
}));
|
|
8705
10048
|
setActiveComps(safe);
|
|
10049
|
+
// rca/issue-video-artboard-frame-reset-on-edit — the SAME comp
|
|
10050
|
+
// re-announcing means its Player just remounted (an edit-triggered
|
|
10051
|
+
// HMR remount, or a ⌘R hard iframe reload that wiped video-comp.tsx's
|
|
10052
|
+
// own module-scope frame mirror too). The shell's `timelineFrame`
|
|
10053
|
+
// survives either way (this component doesn't remount) — push it back
|
|
10054
|
+
// down so the fresh Player opens where the Timeline left off, instead
|
|
10055
|
+
// of the poster-frame default. Skip on a genuinely new comp/canvas
|
|
10056
|
+
// (no prior id, or frame still at its post-switch 0).
|
|
10057
|
+
const prevCompId = timelineCompIdRef.current;
|
|
10058
|
+
const stillPresent = prevCompId && safe.some((c) => c.id === prevCompId);
|
|
10059
|
+
if (stillPresent && timelineFrameRef.current > 0) {
|
|
10060
|
+
postToActiveCanvas({
|
|
10061
|
+
dgn: 'timeline-seek',
|
|
10062
|
+
frame: timelineFrameRef.current,
|
|
10063
|
+
id: prevCompId,
|
|
10064
|
+
});
|
|
10065
|
+
}
|
|
8706
10066
|
} else if (m.dgn === 'timeline-frame' && typeof m.frame === 'number') {
|
|
8707
10067
|
// Live playhead mirror from the Player (preview scrub/playback).
|
|
8708
10068
|
const activeWin =
|
|
@@ -8714,6 +10074,17 @@ function App() {
|
|
|
8714
10074
|
// artboard's Player must not fight it for the readout (multi-comp canvas).
|
|
8715
10075
|
if (m.id && timelineCompIdRef.current && m.id !== timelineCompIdRef.current) return;
|
|
8716
10076
|
if (Number.isFinite(m.frame)) setTimelineFrame(Math.max(0, Math.round(m.frame)));
|
|
10077
|
+
} else if (m.dgn === 'timeline-ended') {
|
|
10078
|
+
// The Player stopped itself at the last frame (loop off) — resync the
|
|
10079
|
+
// shell's Play/Pause button, which otherwise has no way to learn
|
|
10080
|
+
// playback ended on its own (rca/issue-video-artboard-loop-defaults-on).
|
|
10081
|
+
const activeWin =
|
|
10082
|
+
activePath && activePath !== SYSTEM_TAB
|
|
10083
|
+
? iframesRef.current.get(activePath)?.contentWindow
|
|
10084
|
+
: null;
|
|
10085
|
+
if (e.source !== activeWin) return;
|
|
10086
|
+
if (m.id && timelineCompIdRef.current && m.id !== timelineCompIdRef.current) return;
|
|
10087
|
+
setTimelinePlaying(false);
|
|
8717
10088
|
} else if (m.dgn === 'toggle-palette') {
|
|
8718
10089
|
// ⌘K pressed while focus was inside the canvas iframe — the injected
|
|
8719
10090
|
// inspector forwards the chord here since the iframe's keydown never
|
|
@@ -8726,6 +10097,8 @@ function App() {
|
|
|
8726
10097
|
else if (m.id === 'inspector') toggleRightPanel('inspector');
|
|
8727
10098
|
else if (m.id === 'assistant' && isNativeApp()) toggleRightPanel('assistant');
|
|
8728
10099
|
else if (m.id === 'comments') toggleRightPanel('comments');
|
|
10100
|
+
else if (m.id === 'changes') toggleRightPanel('changes');
|
|
10101
|
+
else if (m.id === 'timeline') toggleTimeline();
|
|
8729
10102
|
else if (m.id === 'export') setExportDialog({ mode: 'export' });
|
|
8730
10103
|
else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
|
|
8731
10104
|
} else if (m.dgn === 'open-export') {
|
|
@@ -8736,6 +10109,11 @@ function App() {
|
|
|
8736
10109
|
mode: 'export',
|
|
8737
10110
|
scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
|
|
8738
10111
|
});
|
|
10112
|
+
} else if (m.dgn === 'open-timeline-request') {
|
|
10113
|
+
// Artboard-chrome context menu's "Open Timeline" (video-comp artboards
|
|
10114
|
+
// only). Scope the Timeline to the right-clicked artboard, then open it.
|
|
10115
|
+
if (typeof m.artboardId === 'string') setCanvasActiveArtboard(m.artboardId.slice(0, 120));
|
|
10116
|
+
setTimelineOpen(true);
|
|
8739
10117
|
} else if (m.dgn === 'loaded' && m.file) {
|
|
8740
10118
|
// iframe finished loading — drop the compile skeleton, push current
|
|
8741
10119
|
// comments + carry over focused pin if any
|
|
@@ -8885,6 +10263,7 @@ function App() {
|
|
|
8885
10263
|
zoomCtlVisible,
|
|
8886
10264
|
broadcastChrome,
|
|
8887
10265
|
activePath,
|
|
10266
|
+
postToActiveCanvas,
|
|
8888
10267
|
]);
|
|
8889
10268
|
|
|
8890
10269
|
// Tell the active canvas iframe to drop any persistent selection (canvas
|
|
@@ -9269,6 +10648,7 @@ function App() {
|
|
|
9269
10648
|
'/_api/insert-element',
|
|
9270
10649
|
{
|
|
9271
10650
|
refId,
|
|
10651
|
+
artboardId: typeof opts.artboardId === 'string' ? opts.artboardId : undefined,
|
|
9272
10652
|
position,
|
|
9273
10653
|
kind,
|
|
9274
10654
|
src: typeof opts.src === 'string' ? opts.src : undefined,
|
|
@@ -9287,6 +10667,32 @@ function App() {
|
|
|
9287
10667
|
[structuralWrite]
|
|
9288
10668
|
);
|
|
9289
10669
|
|
|
10670
|
+
// feature-ai-media-generation Phase 1 (Task 1.1) — auto-insert a generated
|
|
10671
|
+
// image onto the canvas where the user is looking, so generation is never a
|
|
10672
|
+
// dead-end modal. The image landed in the content-addressed asset store
|
|
10673
|
+
// (assets/<sha8>.png) via the privileged /_api/generate-jobs route; splice it
|
|
10674
|
+
// into the active artboard through the SAME main-origin source-write lane the
|
|
10675
|
+
// AssetPicker uses (insertElementShell → /_api/insert-element). Returns true
|
|
10676
|
+
// when it placed the image, false when there's no target artboard (no canvas
|
|
10677
|
+
// open / no active artboard) so the caller can fall back to a manual affordance.
|
|
10678
|
+
const insertGeneratedImage = useCallback(
|
|
10679
|
+
(assetPath) => {
|
|
10680
|
+
if (typeof assetPath !== 'string' || !assetPath) return false;
|
|
10681
|
+
if (!activePath) return false;
|
|
10682
|
+
// Respect the active-canvas + selected-artboard signals (_active.json): an
|
|
10683
|
+
// explicit selection wins, else the viewport-active artboard canvas-lib
|
|
10684
|
+
// reports on pan. Without a target artboard we can't source-write.
|
|
10685
|
+
const artboardId = selectedRef.current?.artboardId ?? canvasActiveArtboard ?? null;
|
|
10686
|
+
if (!artboardId) return false;
|
|
10687
|
+
// Insert as the last child of the artboard (empty or not — the engine's
|
|
10688
|
+
// insertElementIntoArtboard handles both). Content-addressed src only; the
|
|
10689
|
+
// route contains an `image` src to assets/ (no remote hotlink / scheme).
|
|
10690
|
+
insertElementShell(undefined, 'inside-end', 'image', { artboardId, src: assetPath });
|
|
10691
|
+
return true;
|
|
10692
|
+
},
|
|
10693
|
+
[activePath, canvasActiveArtboard, insertElementShell]
|
|
10694
|
+
);
|
|
10695
|
+
|
|
9290
10696
|
const duplicateElementShell = useCallback(
|
|
9291
10697
|
(id, idIndex) => {
|
|
9292
10698
|
structuralWrite(
|
|
@@ -9424,6 +10830,28 @@ function App() {
|
|
|
9424
10830
|
},
|
|
9425
10831
|
[structuralWrite]
|
|
9426
10832
|
);
|
|
10833
|
+
|
|
10834
|
+
// Artboard "hug height" default — Hug ⇄ Fixed toggle from ArtboardKnobs
|
|
10835
|
+
// (CSS panel). Direct shell-side action (no canvas postMessage round trip,
|
|
10836
|
+
// unlike resizeArtboardShell's drag-overlay caller), so no *ShellRef needed.
|
|
10837
|
+
const setArtboardHugShell = useCallback(
|
|
10838
|
+
(artboardId, fixed, freezeHeight) => {
|
|
10839
|
+
structuralWrite(
|
|
10840
|
+
'/_api/set-artboard-hug',
|
|
10841
|
+
{ artboardId, fixed, freezeHeight },
|
|
10842
|
+
{ label: fixed ? 'pin artboard height' : 'hug artboard height' }
|
|
10843
|
+
);
|
|
10844
|
+
},
|
|
10845
|
+
[structuralWrite]
|
|
10846
|
+
);
|
|
10847
|
+
|
|
10848
|
+
// Artboard "more settings" (background/padding/layout/gap) from ArtboardKnobs.
|
|
10849
|
+
const setArtboardStyleShell = useCallback(
|
|
10850
|
+
(artboardId, patch) => {
|
|
10851
|
+
structuralWrite('/_api/set-artboard-style', { artboardId, ...patch }, { label: 'artboard style' });
|
|
10852
|
+
},
|
|
10853
|
+
[structuralWrite]
|
|
10854
|
+
);
|
|
9427
10855
|
// Refs the (stale-closure) onMessage handlers below read.
|
|
9428
10856
|
const deleteElementShellRef = useRef(null);
|
|
9429
10857
|
const insertElementShellRef = useRef(null);
|
|
@@ -9474,8 +10902,11 @@ function App() {
|
|
|
9474
10902
|
t.tagName === 'SELECT' ||
|
|
9475
10903
|
t.isContentEditable);
|
|
9476
10904
|
if (editable) return;
|
|
9477
|
-
|
|
10905
|
+
// Suppress the WKWebView back-nav unconditionally once focus isn't editable —
|
|
10906
|
+
// the activePath/SYSTEM_TAB check below is app logic, not default-action gating,
|
|
10907
|
+
// so it must not gate preventDefault (that gap caused the desktop "Starting…" hang).
|
|
9478
10908
|
e.preventDefault();
|
|
10909
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
9479
10910
|
const one = Array.isArray(selected) ? (selected.length === 1 ? selected[0] : null) : selected;
|
|
9480
10911
|
if (one?.artboardId && !one.id) deleteArtboardShell(one.artboardId);
|
|
9481
10912
|
};
|
|
@@ -9491,6 +10922,16 @@ function App() {
|
|
|
9491
10922
|
useEffect(() => {
|
|
9492
10923
|
openAssetPickerRef.current = (req) => setAssetPickerReq(req);
|
|
9493
10924
|
}, []);
|
|
10925
|
+
|
|
10926
|
+
// Phase 4 (whiteboard-improvements) — { canvas: activePath } when open, null
|
|
10927
|
+
// when closed. Mirrors assetPickerReq's ref-indirection (the message
|
|
10928
|
+
// listener below is set up with a minimal dep array, so it reaches a FRESH
|
|
10929
|
+
// opener via a ref rather than needing setStickerPickerReq in its own deps).
|
|
10930
|
+
const [stickerPickerReq, setStickerPickerReq] = useState(null);
|
|
10931
|
+
const openStickerPickerRef = useRef(null);
|
|
10932
|
+
useEffect(() => {
|
|
10933
|
+
openStickerPickerRef.current = (req) => setStickerPickerReq(req);
|
|
10934
|
+
}, []);
|
|
9494
10935
|
const onAssetPicked = useCallback(
|
|
9495
10936
|
(pickedPath) => {
|
|
9496
10937
|
const req = assetPickerReq;
|
|
@@ -9506,6 +10947,7 @@ function App() {
|
|
|
9506
10947
|
}
|
|
9507
10948
|
if (req.purpose === 'insert-image') {
|
|
9508
10949
|
insertElementShell(req.refId, req.position || 'after', 'image', {
|
|
10950
|
+
artboardId: req.artboardId,
|
|
9509
10951
|
src: pickedPath,
|
|
9510
10952
|
refIndex: req.refIndex,
|
|
9511
10953
|
});
|
|
@@ -9551,6 +10993,77 @@ function App() {
|
|
|
9551
10993
|
},
|
|
9552
10994
|
[assetPickerReq, insertElementShell, activePath, recordSourceEdit, postToActiveCanvas]
|
|
9553
10995
|
);
|
|
10996
|
+
|
|
10997
|
+
// feature-bulk-media-insert Task 10 — the picker's multi-select confirm.
|
|
10998
|
+
// `destination === 'artboard'`: loop insertElementShell once per path
|
|
10999
|
+
// (already race-safe via editApplyChainRef, no new serialization needed);
|
|
11000
|
+
// always appended `inside-end` of whichever artboard is currently active —
|
|
11001
|
+
// NOT the single-pick request's own refId/position, which only made sense
|
|
11002
|
+
// for inserting exactly one image relative to one anchor. `destination ===
|
|
11003
|
+
// 'annotation'`: one batched message so the canvas-side handler (Task 11)
|
|
11004
|
+
// performs a single atomic commit instead of N racing ones.
|
|
11005
|
+
const onPickMany = useCallback(
|
|
11006
|
+
(paths, destination) => {
|
|
11007
|
+
const req = assetPickerReq;
|
|
11008
|
+
setAssetPickerReq(null);
|
|
11009
|
+
if (!req || !Array.isArray(paths) || !paths.length) return;
|
|
11010
|
+
if (req.canvas && req.canvas !== activePath) {
|
|
11011
|
+
console.warn('[asset-picker] active canvas changed since request — aborting');
|
|
11012
|
+
return;
|
|
11013
|
+
}
|
|
11014
|
+
if (destination === 'artboard') {
|
|
11015
|
+
const artboardId = selectedRef.current?.artboardId ?? canvasActiveArtboard ?? null;
|
|
11016
|
+
if (!artboardId) return;
|
|
11017
|
+
for (const src of paths) {
|
|
11018
|
+
insertElementShell(undefined, 'inside-end', 'image', { artboardId, src });
|
|
11019
|
+
}
|
|
11020
|
+
return;
|
|
11021
|
+
}
|
|
11022
|
+
postToActiveCanvas({ dgn: 'insert-annotation-media', paths });
|
|
11023
|
+
},
|
|
11024
|
+
[assetPickerReq, activePath, insertElementShell, canvasActiveArtboard, postToActiveCanvas]
|
|
11025
|
+
);
|
|
11026
|
+
|
|
11027
|
+
// Phase 4 (whiteboard-improvements) — a bundled sticker has no project asset
|
|
11028
|
+
// path yet (it lives in MAUDE's own STICKERS_DIR, main-origin-only per
|
|
11029
|
+
// DDR-054), so re-upload its bytes through the SAME /_api/asset lane every
|
|
11030
|
+
// other image source uses (content-addressed, canvas-origin-allowlisted) —
|
|
11031
|
+
// then relay the resulting project-relative path down to the canvas exactly
|
|
11032
|
+
// like replace-annotation-media above (the annotation model owns its own
|
|
11033
|
+
// strokes; the shell performs no stroke write of its own).
|
|
11034
|
+
const onStickerPicked = useCallback(
|
|
11035
|
+
async (sticker) => {
|
|
11036
|
+
const req = stickerPickerReq;
|
|
11037
|
+
setStickerPickerReq(null);
|
|
11038
|
+
if (!req || !sticker?.url) return;
|
|
11039
|
+
// G3 security (DDR-152) — same re-check as onAssetPicked: the request
|
|
11040
|
+
// captured the active canvas when the picker opened; abort rather than
|
|
11041
|
+
// insert into whatever canvas happens to be active now.
|
|
11042
|
+
if (req.canvas && req.canvas !== activePath) {
|
|
11043
|
+
console.warn('[sticker-picker] active canvas changed since request — aborting');
|
|
11044
|
+
return;
|
|
11045
|
+
}
|
|
11046
|
+
try {
|
|
11047
|
+
const blob = await fetch(sticker.url).then((r) =>
|
|
11048
|
+
r.ok ? r.blob() : Promise.reject(new Error(`HTTP ${r.status}`))
|
|
11049
|
+
);
|
|
11050
|
+
const res = await fetch('/_api/asset', {
|
|
11051
|
+
method: 'POST',
|
|
11052
|
+
headers: { 'content-type': blob.type || 'image/png' },
|
|
11053
|
+
body: blob,
|
|
11054
|
+
});
|
|
11055
|
+
const j = await res.json().catch(() => ({}));
|
|
11056
|
+
if (!res.ok || !j.path) {
|
|
11057
|
+
console.warn('[sticker-picker]', j.error || `upload failed (HTTP ${res.status})`);
|
|
11058
|
+
return;
|
|
11059
|
+
}
|
|
11060
|
+
postToActiveCanvas({ dgn: 'insert-sticker', path: j.path });
|
|
11061
|
+
} catch {
|
|
11062
|
+
console.warn('[sticker-picker] could not load or upload that sticker');
|
|
11063
|
+
}
|
|
11064
|
+
},
|
|
11065
|
+
[stickerPickerReq, activePath, postToActiveCanvas]
|
|
11066
|
+
);
|
|
9554
11067
|
// Media-section "Replace…" (CssKnobs) → open the picker in replace mode with
|
|
9555
11068
|
// the element's current src as the undo before-value (captured from the
|
|
9556
11069
|
// Selection's `attrs.src`, not the resolved URL).
|
|
@@ -9654,8 +11167,19 @@ function App() {
|
|
|
9654
11167
|
) {
|
|
9655
11168
|
return; // the timeline shortcuts effect claims it
|
|
9656
11169
|
}
|
|
9657
|
-
e.preventDefault();
|
|
9658
11170
|
const redo = e.key === 'y' || e.key === 'Y' || e.shiftKey;
|
|
11171
|
+
// feature-photo-editor — photo edits are sidecar writes (`/_api/photo-edit`),
|
|
11172
|
+
// invisible to the canvas's own source-edit undo stack, so they need
|
|
11173
|
+
// their own owner here. Claims the keypress only while the Photo tab
|
|
11174
|
+
// is the one on screen AND it actually has something to undo/redo —
|
|
11175
|
+
// otherwise falls through to the canvas stack as before. (Focus still
|
|
11176
|
+
// inside a Photo-tab slider is handled separately by
|
|
11177
|
+
// `onPhotoKnobKeyDown`, mirroring `onKnobKeyDown` for CSS knobs.)
|
|
11178
|
+
if (inspectorTab === 'photo' && performPhotoUndo(redo)) {
|
|
11179
|
+
e.preventDefault();
|
|
11180
|
+
return;
|
|
11181
|
+
}
|
|
11182
|
+
e.preventDefault();
|
|
9659
11183
|
postToActiveCanvas({ dgn: redo ? 'redo' : 'undo' });
|
|
9660
11184
|
return;
|
|
9661
11185
|
}
|
|
@@ -9713,6 +11237,19 @@ function App() {
|
|
|
9713
11237
|
setExportDialog({ mode: 'handoff' });
|
|
9714
11238
|
return;
|
|
9715
11239
|
}
|
|
11240
|
+
// Cmd+, — open Settings (AI generation keys). The platform convention for
|
|
11241
|
+
// a settings/preferences surface (feature-ai-media-generation, DDR-16x).
|
|
11242
|
+
if (meta && !e.shiftKey && !e.altKey && e.key === ',') {
|
|
11243
|
+
e.preventDefault();
|
|
11244
|
+
setSettingsOpen(true);
|
|
11245
|
+
return;
|
|
11246
|
+
}
|
|
11247
|
+
// Cmd+Shift+T — toggle the Timeline (video-comp scrub) dock.
|
|
11248
|
+
if (meta && e.shiftKey && (e.key === 't' || e.key === 'T')) {
|
|
11249
|
+
e.preventDefault();
|
|
11250
|
+
toggleTimeline();
|
|
11251
|
+
return;
|
|
11252
|
+
}
|
|
9716
11253
|
// Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
|
|
9717
11254
|
// Canvas comment-drop is the `C` tool letter (press C in the canvas,
|
|
9718
11255
|
// then click the element) or right-click "Add comment". Cmd+C now
|
|
@@ -9743,7 +11280,7 @@ function App() {
|
|
|
9743
11280
|
}
|
|
9744
11281
|
if (meta && (e.key === 'f' || e.key === 'F')) {
|
|
9745
11282
|
e.preventDefault();
|
|
9746
|
-
if (!sidebarOpen)
|
|
11283
|
+
if (!sidebarOpen) openPanelExclusive('tree');
|
|
9747
11284
|
setTimeout(() => {
|
|
9748
11285
|
const inp = document.querySelector('.st-search input');
|
|
9749
11286
|
if (inp) inp.focus();
|
|
@@ -9762,7 +11299,7 @@ function App() {
|
|
|
9762
11299
|
if (e.key === 't' || e.key === 'T') {
|
|
9763
11300
|
if (e.shiftKey || meta) return;
|
|
9764
11301
|
e.preventDefault();
|
|
9765
|
-
|
|
11302
|
+
togglePanel('tree');
|
|
9766
11303
|
return;
|
|
9767
11304
|
}
|
|
9768
11305
|
// H — toggle show-hidden (sidecars + project/runtime orphans)
|
|
@@ -9786,7 +11323,7 @@ function App() {
|
|
|
9786
11323
|
// which the browser reserves for New Window and never delivers).
|
|
9787
11324
|
if ((e.key === 'n' || e.key === 'N') && !meta && !e.shiftKey) {
|
|
9788
11325
|
e.preventDefault();
|
|
9789
|
-
|
|
11326
|
+
openPanelExclusive('tree');
|
|
9790
11327
|
setTimeout(
|
|
9791
11328
|
() => document.querySelector('[aria-label="New blank brief board"]')?.click(),
|
|
9792
11329
|
60
|
|
@@ -9828,6 +11365,9 @@ function App() {
|
|
|
9828
11365
|
clearActiveCanvasSelection,
|
|
9829
11366
|
presentMode,
|
|
9830
11367
|
exitPresent,
|
|
11368
|
+
toggleTimeline,
|
|
11369
|
+
inspectorTab,
|
|
11370
|
+
performPhotoUndo,
|
|
9831
11371
|
]);
|
|
9832
11372
|
|
|
9833
11373
|
const registerIframe = useCallback((path, el) => {
|
|
@@ -9864,7 +11404,7 @@ function App() {
|
|
|
9864
11404
|
icon: 'plus',
|
|
9865
11405
|
kbd: 'N',
|
|
9866
11406
|
run: () => {
|
|
9867
|
-
|
|
11407
|
+
openPanelExclusive('tree');
|
|
9868
11408
|
setTimeout(
|
|
9869
11409
|
() => document.querySelector('[aria-label="New blank brief board"]')?.click(),
|
|
9870
11410
|
60
|
|
@@ -9887,6 +11427,21 @@ function App() {
|
|
|
9887
11427
|
kbd: '⇧⌘H',
|
|
9888
11428
|
run: () => setExportDialog({ mode: 'handoff' }),
|
|
9889
11429
|
},
|
|
11430
|
+
{
|
|
11431
|
+
id: 'generate',
|
|
11432
|
+
group: 'Canvas',
|
|
11433
|
+
label: 'Generate with AI…',
|
|
11434
|
+
icon: 'sparkle',
|
|
11435
|
+
run: () => setGenerateOpen(true),
|
|
11436
|
+
},
|
|
11437
|
+
{
|
|
11438
|
+
id: 'settings',
|
|
11439
|
+
group: 'Canvas',
|
|
11440
|
+
label: 'Settings…',
|
|
11441
|
+
icon: 'sliders',
|
|
11442
|
+
kbd: '⌘,',
|
|
11443
|
+
run: () => setSettingsOpen(true),
|
|
11444
|
+
},
|
|
9890
11445
|
// ── View ────────────────────────────────────────────────────────────
|
|
9891
11446
|
{
|
|
9892
11447
|
id: 'system',
|
|
@@ -9969,6 +11524,155 @@ function App() {
|
|
|
9969
11524
|
[openSystem, toggleTheme, reloadActive, whatsNew]
|
|
9970
11525
|
);
|
|
9971
11526
|
|
|
11527
|
+
// feature-configurable-panel-docking — resolve, for each slot, the panels
|
|
11528
|
+
// assigned to it and which one is active (the open one). Layers is only a
|
|
11529
|
+
// dockable panel in `separate` mode; Assistant is native-only.
|
|
11530
|
+
const panelAvailable = (id) =>
|
|
11531
|
+
id === 'assistant' ? isNativeApp() : id === 'layers' ? layersMode === 'separate' : true;
|
|
11532
|
+
const idsForSide = (side) =>
|
|
11533
|
+
DOCK_PANELS.filter(
|
|
11534
|
+
(p) => panelAvailable(p.id) && (panelSide[p.id] || PANEL_SIDES_DEFAULTS[p.id]) === side
|
|
11535
|
+
).map((p) => p.id);
|
|
11536
|
+
const panelIsOpen = {
|
|
11537
|
+
tree: sidebarOpen,
|
|
11538
|
+
layers: layersOpen,
|
|
11539
|
+
inspector: inspectorOpen,
|
|
11540
|
+
comments: commentsPanelOpen,
|
|
11541
|
+
changes: changesOpen,
|
|
11542
|
+
assistant: assistantOpen,
|
|
11543
|
+
};
|
|
11544
|
+
const leftIds = idsForSide('left');
|
|
11545
|
+
const rightIds = idsForSide('right');
|
|
11546
|
+
const leftActive = leftIds.find((id) => panelIsOpen[id]) || null;
|
|
11547
|
+
const rightActive = rightIds.find((id) => panelIsOpen[id]) || null;
|
|
11548
|
+
const leftHostsAssistant = isNativeApp() && (panelSide.assistant || 'right') === 'left';
|
|
11549
|
+
const rightHostsAssistant = isNativeApp() && (panelSide.assistant || 'right') === 'right';
|
|
11550
|
+
const resizingFor = (id) =>
|
|
11551
|
+
(panelSide[id] || PANEL_SIDES_DEFAULTS[id]) === 'left' ? dragSide === 'sb' : dragSide === 'rp';
|
|
11552
|
+
const activeCanvasFile =
|
|
11553
|
+
activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath) ? activePath : null;
|
|
11554
|
+
|
|
11555
|
+
// Render a panel body by id (width undefined ⇒ fills the .st-dockslot wrapper,
|
|
11556
|
+
// which owns the resizable width). Assistant is handled separately below as an
|
|
11557
|
+
// always-mounted ChatPanel so its stream survives a tab switch.
|
|
11558
|
+
const renderPanelBody = (id) => {
|
|
11559
|
+
if (id === 'tree')
|
|
11560
|
+
return (
|
|
11561
|
+
<Sidebar
|
|
11562
|
+
groups={groups}
|
|
11563
|
+
activePath={activePath}
|
|
11564
|
+
activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
|
|
11565
|
+
onOpen={openTab}
|
|
11566
|
+
onOpenSystem={openSystem}
|
|
11567
|
+
wsConnected={wsConnected}
|
|
11568
|
+
search={search}
|
|
11569
|
+
setSearch={setSearch}
|
|
11570
|
+
commentsByFile={commentsByFile}
|
|
11571
|
+
showHidden={showHidden}
|
|
11572
|
+
sectionsExpanded={sectionsExpanded}
|
|
11573
|
+
onToggleSection={toggleSection}
|
|
11574
|
+
onNewBoard={createBoard}
|
|
11575
|
+
onDeleteBoard={deleteBoard}
|
|
11576
|
+
onRefresh={refreshTree}
|
|
11577
|
+
refreshing={treeRefreshing}
|
|
11578
|
+
collapsed={false}
|
|
11579
|
+
onCollapse={() => togglePanel('tree')}
|
|
11580
|
+
resizing={resizingFor('tree')}
|
|
11581
|
+
dirtyByPath={dirtyByPath}
|
|
11582
|
+
project={project}
|
|
11583
|
+
gitBranch={gitStatus?.branch}
|
|
11584
|
+
remoteSync={remoteSync}
|
|
11585
|
+
onGetLatest={gitGetLatest}
|
|
11586
|
+
canvasKinds={cfg?.canvasKinds}
|
|
11587
|
+
/>
|
|
11588
|
+
);
|
|
11589
|
+
if (id === 'changes')
|
|
11590
|
+
return (
|
|
11591
|
+
<GitPanel
|
|
11592
|
+
status={gitStatus && remoteSync ? { ...gitStatus, ...remoteSync } : gitStatus}
|
|
11593
|
+
project={project}
|
|
11594
|
+
readOnly={!isNativeApp()}
|
|
11595
|
+
resizing={resizingFor('changes')}
|
|
11596
|
+
onClose={() => setChangesOpen(false)}
|
|
11597
|
+
onCommit={gitCommit}
|
|
11598
|
+
onDiscard={gitDiscard}
|
|
11599
|
+
onPublish={gitPublish}
|
|
11600
|
+
onGetLatest={gitGetLatest}
|
|
11601
|
+
loadLog={gitLoadLog}
|
|
11602
|
+
onOpenCanvas={(p) => openTab(p)}
|
|
11603
|
+
onOpenDiff={(file) => setDiffTarget({ file, beforeSha: 'HEAD', conflict: false })}
|
|
11604
|
+
activeCanvas={activeCanvasFile}
|
|
11605
|
+
onPreviewVersion={(sha) => setDiffTarget({ file: activePath, beforeSha: sha, conflict: false })}
|
|
11606
|
+
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
11607
|
+
/>
|
|
11608
|
+
);
|
|
11609
|
+
if (id === 'inspector' || id === 'layers')
|
|
11610
|
+
return (
|
|
11611
|
+
<InspectorPanel
|
|
11612
|
+
layersOnly={id === 'layers'}
|
|
11613
|
+
hideLayersTab={layersMode === 'separate'}
|
|
11614
|
+
cpMode={cpMode}
|
|
11615
|
+
onSetCpMode={setCpMode}
|
|
11616
|
+
selected={selected}
|
|
11617
|
+
cfg={cfg}
|
|
11618
|
+
tab={id === 'layers' ? 'layers' : inspectorTab}
|
|
11619
|
+
onTabChange={setInspectorTab}
|
|
11620
|
+
onClose={() => (id === 'layers' ? setLayersOpen(false) : setInspectorOpen(false))}
|
|
11621
|
+
onOptimistic={applyOptimisticStyle}
|
|
11622
|
+
onRecordEdit={recordSourceEdit}
|
|
11623
|
+
onReplaceMedia={onReplaceMedia}
|
|
11624
|
+
onResizeArtboard={resizeArtboardShell}
|
|
11625
|
+
onSetArtboardHug={setArtboardHugShell}
|
|
11626
|
+
onSetArtboardStyle={setArtboardStyleShell}
|
|
11627
|
+
editScope={editScope}
|
|
11628
|
+
onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
|
|
11629
|
+
photoSel={photoSel}
|
|
11630
|
+
photoRev={photoRev}
|
|
11631
|
+
onPhotoEdit={onPhotoEdit}
|
|
11632
|
+
onPhotoRemoveBackground={onPhotoRemoveBackground}
|
|
11633
|
+
onPhotoRecordEdit={onPhotoRecordEdit}
|
|
11634
|
+
onPhotoUndoRedo={(dir) => performPhotoUndo(dir === 'redo')}
|
|
11635
|
+
layersTree={layersTree}
|
|
11636
|
+
canvasFile={activePath}
|
|
11637
|
+
onSelectLayer={(n) =>
|
|
11638
|
+
postToActiveCanvas({
|
|
11639
|
+
dgn: 'select-by-id',
|
|
11640
|
+
id: n.id,
|
|
11641
|
+
artboardId: layersTree?.artboardId,
|
|
11642
|
+
index: n.index,
|
|
11643
|
+
})
|
|
11644
|
+
}
|
|
11645
|
+
onHoverLayer={(n) =>
|
|
11646
|
+
postToActiveCanvas({
|
|
11647
|
+
dgn: 'highlight',
|
|
11648
|
+
id: n ? n.id : null,
|
|
11649
|
+
artboardId: layersTree?.artboardId,
|
|
11650
|
+
index: n ? n.index : 0,
|
|
11651
|
+
})
|
|
11652
|
+
}
|
|
11653
|
+
onReorderLayer={reorderLayer}
|
|
11654
|
+
layersBusyRef={layersBusyRef}
|
|
11655
|
+
resizing={resizingFor(id)}
|
|
11656
|
+
/>
|
|
11657
|
+
);
|
|
11658
|
+
if (id === 'comments')
|
|
11659
|
+
return (
|
|
11660
|
+
<CommentsPanel
|
|
11661
|
+
commentsByFile={commentsByFile}
|
|
11662
|
+
filter={commentsFilter}
|
|
11663
|
+
setFilter={setCommentsFilter}
|
|
11664
|
+
activePath={activePath}
|
|
11665
|
+
focusedId={focusedCommentId}
|
|
11666
|
+
onJump={jumpToComment}
|
|
11667
|
+
onResolve={resolveComment}
|
|
11668
|
+
onReopen={reopenComment}
|
|
11669
|
+
onDelete={deleteComment}
|
|
11670
|
+
resizing={resizingFor('comments')}
|
|
11671
|
+
/>
|
|
11672
|
+
);
|
|
11673
|
+
return null;
|
|
11674
|
+
};
|
|
11675
|
+
|
|
9972
11676
|
return (
|
|
9973
11677
|
<div
|
|
9974
11678
|
className={'maude' + (presentMode ? ' is-present' : '')}
|
|
@@ -10014,7 +11718,7 @@ function App() {
|
|
|
10014
11718
|
onToggleChanges={() => toggleRightPanel('changes')}
|
|
10015
11719
|
onOpenSystem={openSystem}
|
|
10016
11720
|
sidebarOpen={sidebarOpen}
|
|
10017
|
-
onToggleSidebar={() =>
|
|
11721
|
+
onToggleSidebar={() => togglePanel('tree')}
|
|
10018
11722
|
showHidden={showHidden}
|
|
10019
11723
|
onToggleShowHidden={() => setShowHidden((v) => !v)}
|
|
10020
11724
|
onOpenHelp={() => setHelpOpen(true)}
|
|
@@ -10031,6 +11735,8 @@ function App() {
|
|
|
10031
11735
|
onTogglePresent={togglePresent}
|
|
10032
11736
|
postToActiveCanvas={postToActiveCanvas}
|
|
10033
11737
|
onOpenReadiness={() => setReadinessOpen(true)}
|
|
11738
|
+
onOpenQuickSetup={() => setQuickSetupOpen(true)}
|
|
11739
|
+
onWatchIntro={() => setIntroOpen(true)}
|
|
10034
11740
|
onOpenWhatsNew={whatsNew.openPanel}
|
|
10035
11741
|
whatsNewCount={whatsNew.unseen.length}
|
|
10036
11742
|
exportCenter={exportCenter}
|
|
@@ -10049,17 +11755,20 @@ function App() {
|
|
|
10049
11755
|
assistantBusy={assistantBusy}
|
|
10050
11756
|
assistantUnseen={assistantUnseen}
|
|
10051
11757
|
onOpenLayers={() => {
|
|
10052
|
-
//
|
|
10053
|
-
//
|
|
10054
|
-
|
|
11758
|
+
// feature-configurable-panel-docking — Layers is its own dockable
|
|
11759
|
+
// panel when layersMode==='separate' (toggle it), else it's the
|
|
11760
|
+
// Inspector's Layers tab (open the inspector on that tab).
|
|
11761
|
+
if (layersMode === 'separate') {
|
|
11762
|
+
togglePanel('layers');
|
|
11763
|
+
} else if (inspectorOpen && inspectorTab === 'layers') {
|
|
10055
11764
|
setInspectorOpen(false);
|
|
10056
11765
|
} else {
|
|
10057
11766
|
setInspectorTab('layers');
|
|
10058
|
-
|
|
11767
|
+
openPanelExclusive('inspector');
|
|
10059
11768
|
}
|
|
10060
11769
|
}}
|
|
10061
11770
|
onNewCanvas={() => {
|
|
10062
|
-
|
|
11771
|
+
openPanelExclusive('tree');
|
|
10063
11772
|
setTimeout(
|
|
10064
11773
|
() => document.querySelector('[aria-label="New blank brief board"]')?.click(),
|
|
10065
11774
|
60
|
|
@@ -10067,6 +11776,8 @@ function App() {
|
|
|
10067
11776
|
}}
|
|
10068
11777
|
onAssembleVideo={assembleVideo}
|
|
10069
11778
|
onOpenExport={(mode) => setExportDialog({ mode })}
|
|
11779
|
+
onOpenSettings={() => setSettingsOpen(true)}
|
|
11780
|
+
onOpenGenerate={() => setGenerateOpen(true)}
|
|
10070
11781
|
onReload={reloadActive}
|
|
10071
11782
|
onCloseCanvas={() => activePath && closeTab(activePath)}
|
|
10072
11783
|
presence={
|
|
@@ -10088,42 +11799,44 @@ function App() {
|
|
|
10088
11799
|
}
|
|
10089
11800
|
/>
|
|
10090
11801
|
<div className={'st-body' + (dragSide ? ' is-resizing' : '')} ref={bodyRef}>
|
|
11802
|
+
{/* LEFT dock slot (feature-configurable-panel-docking) — the collapsed
|
|
11803
|
+
rail shows when the left slot is empty; the tree's expand hooks open
|
|
11804
|
+
it on the tree tab. */}
|
|
10091
11805
|
<CollapsedRail
|
|
10092
|
-
shown={!
|
|
10093
|
-
onExpand={() =>
|
|
11806
|
+
shown={!leftActive}
|
|
11807
|
+
onExpand={() => openPanelExclusive('tree')}
|
|
10094
11808
|
onSearch={() => {
|
|
10095
|
-
|
|
11809
|
+
openPanelExclusive('tree');
|
|
10096
11810
|
setTimeout(() => document.querySelector('.st-search input')?.focus(), 60);
|
|
10097
11811
|
}}
|
|
10098
11812
|
/>
|
|
10099
|
-
|
|
10100
|
-
|
|
10101
|
-
|
|
10102
|
-
|
|
10103
|
-
|
|
10104
|
-
|
|
10105
|
-
|
|
10106
|
-
|
|
10107
|
-
|
|
10108
|
-
|
|
10109
|
-
|
|
10110
|
-
|
|
10111
|
-
|
|
10112
|
-
|
|
10113
|
-
|
|
10114
|
-
|
|
10115
|
-
|
|
10116
|
-
|
|
10117
|
-
|
|
10118
|
-
|
|
10119
|
-
|
|
10120
|
-
|
|
10121
|
-
|
|
10122
|
-
|
|
10123
|
-
|
|
10124
|
-
{sidebarOpen && (
|
|
11813
|
+
{(leftActive || leftHostsAssistant) && (
|
|
11814
|
+
<DockSlot
|
|
11815
|
+
side="left"
|
|
11816
|
+
width={sbSize.w}
|
|
11817
|
+
open={!!leftActive}
|
|
11818
|
+
ids={leftIds}
|
|
11819
|
+
activeId={leftActive}
|
|
11820
|
+
onPick={togglePanel}
|
|
11821
|
+
>
|
|
11822
|
+
{leftHostsAssistant && (
|
|
11823
|
+
<ChatPanel
|
|
11824
|
+
hidden={leftActive !== 'assistant'}
|
|
11825
|
+
activeCanvas={activeCanvasFile}
|
|
11826
|
+
selected={selected}
|
|
11827
|
+
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
11828
|
+
resizing={resizingFor('assistant')}
|
|
11829
|
+
onClose={() => setAssistantOpen(false)}
|
|
11830
|
+
onBusyChange={setAssistantBusy}
|
|
11831
|
+
onFinished={handleAssistantFinished}
|
|
11832
|
+
/>
|
|
11833
|
+
)}
|
|
11834
|
+
{leftActive && leftActive !== 'assistant' && renderPanelBody(leftActive)}
|
|
11835
|
+
</DockSlot>
|
|
11836
|
+
)}
|
|
11837
|
+
{leftActive && (
|
|
10125
11838
|
<PanelGrip
|
|
10126
|
-
label="Resize
|
|
11839
|
+
label="Resize left panel"
|
|
10127
11840
|
size={sbSize}
|
|
10128
11841
|
active={dragSide === 'sb'}
|
|
10129
11842
|
onPointerDown={(e) => {
|
|
@@ -10145,11 +11858,13 @@ function App() {
|
|
|
10145
11858
|
cfg={cfg}
|
|
10146
11859
|
loadingPath={loadingPath}
|
|
10147
11860
|
onIframeLoad={onIframeLoad}
|
|
11861
|
+
showQuickSetup={isNativeApp() && !!setupReadiness && !setupReadiness.ready}
|
|
11862
|
+
onStartQuickSetup={() => setQuickSetupOpen(true)}
|
|
10148
11863
|
/>
|
|
10149
11864
|
</div>
|
|
10150
|
-
{
|
|
11865
|
+
{rightActive && (
|
|
10151
11866
|
<PanelGrip
|
|
10152
|
-
label="Resize
|
|
11867
|
+
label="Resize right panel"
|
|
10153
11868
|
dir="rtl"
|
|
10154
11869
|
size={rpSize}
|
|
10155
11870
|
active={dragSide === 'rp'}
|
|
@@ -10160,105 +11875,32 @@ function App() {
|
|
|
10160
11875
|
}}
|
|
10161
11876
|
/>
|
|
10162
11877
|
)}
|
|
10163
|
-
{/*
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10167
|
-
|
|
10168
|
-
|
|
10169
|
-
}
|
|
10170
|
-
project={project}
|
|
10171
|
-
readOnly={!isNativeApp()}
|
|
10172
|
-
width={rpSize.w}
|
|
10173
|
-
resizing={dragSide === 'rp'}
|
|
10174
|
-
onClose={() => setChangesOpen(false)}
|
|
10175
|
-
onCommit={gitCommit}
|
|
10176
|
-
onDiscard={gitDiscard}
|
|
10177
|
-
onPublish={gitPublish}
|
|
10178
|
-
onGetLatest={gitGetLatest}
|
|
10179
|
-
loadLog={gitLoadLog}
|
|
10180
|
-
onOpenCanvas={(p) => openTab(p)}
|
|
10181
|
-
onOpenDiff={(file) => setDiffTarget({ file, beforeSha: 'HEAD', conflict: false })}
|
|
10182
|
-
activeCanvas={
|
|
10183
|
-
activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath)
|
|
10184
|
-
? activePath
|
|
10185
|
-
: null
|
|
10186
|
-
}
|
|
10187
|
-
onPreviewVersion={(sha) =>
|
|
10188
|
-
setDiffTarget({ file: activePath, beforeSha: sha, conflict: false })
|
|
10189
|
-
}
|
|
10190
|
-
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
10191
|
-
/>
|
|
10192
|
-
) : inspectorOpen ? (
|
|
10193
|
-
<InspectorPanel
|
|
10194
|
-
selected={selected}
|
|
10195
|
-
cfg={cfg}
|
|
10196
|
-
tab={inspectorTab}
|
|
10197
|
-
onTabChange={setInspectorTab}
|
|
10198
|
-
onClose={() => setInspectorOpen(false)}
|
|
10199
|
-
onOptimistic={applyOptimisticStyle}
|
|
10200
|
-
onRecordEdit={recordSourceEdit}
|
|
10201
|
-
onReplaceMedia={onReplaceMedia}
|
|
10202
|
-
onResizeArtboard={resizeArtboardShell}
|
|
10203
|
-
editScope={editScope}
|
|
10204
|
-
onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
|
|
10205
|
-
layersTree={layersTree}
|
|
10206
|
-
canvasFile={activePath}
|
|
10207
|
-
onSelectLayer={(n) =>
|
|
10208
|
-
postToActiveCanvas({
|
|
10209
|
-
dgn: 'select-by-id',
|
|
10210
|
-
id: n.id,
|
|
10211
|
-
artboardId: layersTree?.artboardId,
|
|
10212
|
-
index: n.index,
|
|
10213
|
-
})
|
|
10214
|
-
}
|
|
10215
|
-
onHoverLayer={(n) =>
|
|
10216
|
-
postToActiveCanvas({
|
|
10217
|
-
dgn: 'highlight',
|
|
10218
|
-
id: n ? n.id : null,
|
|
10219
|
-
artboardId: layersTree?.artboardId,
|
|
10220
|
-
index: n ? n.index : 0,
|
|
10221
|
-
})
|
|
10222
|
-
}
|
|
10223
|
-
onReorderLayer={reorderLayer}
|
|
10224
|
-
layersBusyRef={layersBusyRef}
|
|
11878
|
+
{/* RIGHT dock slot (feature-configurable-panel-docking). The Assistant
|
|
11879
|
+
(ACP) chat stays MOUNTED (display:none when inactive) so its stream
|
|
11880
|
+
survives a tab switch — DDR-123. Native-only. */}
|
|
11881
|
+
{(rightActive || rightHostsAssistant) && (
|
|
11882
|
+
<DockSlot
|
|
11883
|
+
side="right"
|
|
10225
11884
|
width={rpSize.w}
|
|
10226
|
-
|
|
10227
|
-
|
|
10228
|
-
|
|
10229
|
-
|
|
10230
|
-
|
|
10231
|
-
|
|
10232
|
-
|
|
10233
|
-
|
|
10234
|
-
|
|
10235
|
-
|
|
10236
|
-
|
|
10237
|
-
|
|
10238
|
-
|
|
10239
|
-
|
|
10240
|
-
|
|
10241
|
-
|
|
10242
|
-
|
|
10243
|
-
|
|
10244
|
-
|
|
10245
|
-
switch to Changes/Inspector/Comments. Native-only. */}
|
|
10246
|
-
{isNativeApp() && (
|
|
10247
|
-
<ChatPanel
|
|
10248
|
-
hidden={!assistantOpen}
|
|
10249
|
-
activeCanvas={
|
|
10250
|
-
activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath)
|
|
10251
|
-
? activePath
|
|
10252
|
-
: null
|
|
10253
|
-
}
|
|
10254
|
-
selected={selected}
|
|
10255
|
-
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
10256
|
-
width={rpSize.w}
|
|
10257
|
-
resizing={dragSide === 'rp'}
|
|
10258
|
-
onClose={() => setAssistantOpen(false)}
|
|
10259
|
-
onBusyChange={setAssistantBusy}
|
|
10260
|
-
onFinished={handleAssistantFinished}
|
|
10261
|
-
/>
|
|
11885
|
+
open={!!rightActive}
|
|
11886
|
+
ids={rightIds}
|
|
11887
|
+
activeId={rightActive}
|
|
11888
|
+
onPick={togglePanel}
|
|
11889
|
+
>
|
|
11890
|
+
{rightHostsAssistant && (
|
|
11891
|
+
<ChatPanel
|
|
11892
|
+
hidden={rightActive !== 'assistant'}
|
|
11893
|
+
activeCanvas={activeCanvasFile}
|
|
11894
|
+
selected={selected}
|
|
11895
|
+
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
11896
|
+
resizing={resizingFor('assistant')}
|
|
11897
|
+
onClose={() => setAssistantOpen(false)}
|
|
11898
|
+
onBusyChange={setAssistantBusy}
|
|
11899
|
+
onFinished={handleAssistantFinished}
|
|
11900
|
+
/>
|
|
11901
|
+
)}
|
|
11902
|
+
{rightActive && rightActive !== 'assistant' && renderPanelBody(rightActive)}
|
|
11903
|
+
</DockSlot>
|
|
10262
11904
|
)}
|
|
10263
11905
|
</div>
|
|
10264
11906
|
{/* DDR-148 — Timeline is a BOTTOM dock (full-width strip below the stage,
|
|
@@ -10658,13 +12300,49 @@ function App() {
|
|
|
10658
12300
|
onClose={() => setExportDialog(null)}
|
|
10659
12301
|
/>
|
|
10660
12302
|
)}
|
|
12303
|
+
{settingsOpen && (
|
|
12304
|
+
<SettingsPanel
|
|
12305
|
+
onClose={() => setSettingsOpen(false)}
|
|
12306
|
+
initialTab={typeof settingsOpen === 'string' ? settingsOpen : undefined}
|
|
12307
|
+
theme={theme}
|
|
12308
|
+
onSetTheme={setTheme}
|
|
12309
|
+
cpMode={cpMode}
|
|
12310
|
+
onSetCpMode={setCpMode}
|
|
12311
|
+
minimapVisible={minimapVisible}
|
|
12312
|
+
onToggleMinimap={toggleMinimap}
|
|
12313
|
+
zoomCtlVisible={zoomCtlVisible}
|
|
12314
|
+
onToggleZoomCtl={toggleZoomCtl}
|
|
12315
|
+
annotationsVisible={annotationsVisible}
|
|
12316
|
+
onToggleAnnotations={toggleAnnotations}
|
|
12317
|
+
autoOpenInspector={autoOpenInspector}
|
|
12318
|
+
onToggleAutoOpenInspector={() => setAutoOpenInspector((v) => !v)}
|
|
12319
|
+
hasCanvas={!!activePath && activePath !== SYSTEM_TAB}
|
|
12320
|
+
panelSide={panelSide}
|
|
12321
|
+
onSetPanelSide={(id, side) => setPanelSide((prev) => ({ ...prev, [id]: side }))}
|
|
12322
|
+
layersMode={layersMode}
|
|
12323
|
+
onSetLayersMode={(m) => {
|
|
12324
|
+
setLayersMode(m);
|
|
12325
|
+
// Leaving separate mode retires the standalone Layers panel.
|
|
12326
|
+
if (m !== 'separate') setLayersOpen(false);
|
|
12327
|
+
}}
|
|
12328
|
+
/>
|
|
12329
|
+
)}
|
|
12330
|
+
{generateOpen && (
|
|
12331
|
+
<GenerateDialog onClose={() => setGenerateOpen(false)} onInsert={insertGeneratedImage} />
|
|
12332
|
+
)}
|
|
10661
12333
|
{assetPickerReq && (
|
|
10662
12334
|
<AssetPicker
|
|
10663
12335
|
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
10664
12336
|
onPick={onAssetPicked}
|
|
10665
12337
|
onClose={() => setAssetPickerReq(null)}
|
|
12338
|
+
multiple={!!assetPickerReq.multiple}
|
|
12339
|
+
hasArtboardAnchor={!!assetPickerReq.hasArtboardAnchor}
|
|
12340
|
+
onPickMany={onPickMany}
|
|
10666
12341
|
/>
|
|
10667
12342
|
)}
|
|
12343
|
+
{stickerPickerReq && (
|
|
12344
|
+
<StickerPicker onPick={onStickerPicked} onClose={() => setStickerPickerReq(null)} />
|
|
12345
|
+
)}
|
|
10668
12346
|
{diffTarget && (
|
|
10669
12347
|
<DiffView
|
|
10670
12348
|
target={diffTarget}
|
|
@@ -10702,6 +12380,14 @@ function App() {
|
|
|
10702
12380
|
<WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
|
|
10703
12381
|
<ExportPanel center={exportCenter} />
|
|
10704
12382
|
<ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />
|
|
12383
|
+
<IntroVideoDialog open={introOpen} onClose={() => setIntroOpen(false)} />
|
|
12384
|
+
<SetupChecklistDialog
|
|
12385
|
+
open={quickSetupOpen}
|
|
12386
|
+
onClose={() => setQuickSetupOpen(false)}
|
|
12387
|
+
onStartTour={() => startTour(QUICK_SETUP_TOUR)}
|
|
12388
|
+
onBringBrand={() => setBrandUploadOpen(true)}
|
|
12389
|
+
/>
|
|
12390
|
+
<BrandUploadPanel open={brandUploadOpen} onClose={() => setBrandUploadOpen(false)} />
|
|
10705
12391
|
{usageNudge && !tourSteps && !collabNudge && (
|
|
10706
12392
|
<div className="mdcc-tour-nudge" role="status" aria-live="polite">
|
|
10707
12393
|
<div className="mdcc-tour-nudge__body">
|