@agent-native/core 0.79.27 → 0.80.1
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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +34 -0
- package/corpus/core/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/ar-SA/template-design.mdx +55 -1
- package/corpus/core/docs/content/locales/de-DE/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/de-DE/template-design.mdx +66 -1
- package/corpus/core/docs/content/locales/es-ES/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/es-ES/template-design.mdx +65 -1
- package/corpus/core/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/fr-FR/template-design.mdx +64 -1
- package/corpus/core/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/hi-IN/template-design.mdx +59 -1
- package/corpus/core/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/ja-JP/template-design.mdx +62 -1
- package/corpus/core/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/ko-KR/template-design.mdx +58 -1
- package/corpus/core/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/pt-BR/template-design.mdx +62 -1
- package/corpus/core/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/zh-CN/template-design.mdx +50 -1
- package/corpus/core/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/zh-TW/template-design.mdx +52 -1
- package/corpus/core/docs/content/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/template-design.mdx +59 -1
- package/corpus/core/package.json +2 -1
- package/corpus/core/src/agent/engine/index.ts +1 -0
- package/corpus/core/src/agent/engine/registry.ts +40 -0
- package/corpus/core/src/agent/production-agent.ts +6 -2
- package/corpus/core/src/application-state/handlers.ts +1 -1
- package/corpus/core/src/brand-kit/index.ts +1 -1
- package/corpus/core/src/cli/design-connect.ts +490 -0
- package/corpus/core/src/cli/index.ts +16 -0
- package/corpus/core/src/cli/skills.ts +144 -3
- package/corpus/core/src/client/AgentPanel.tsx +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +43 -7
- package/corpus/core/src/client/MultiTabAssistantChat.tsx +1 -1
- package/corpus/core/src/client/agent-chat.ts +27 -3
- package/corpus/core/src/client/blocks/library/MermaidBlock.tsx +1 -1
- package/corpus/core/src/client/blocks/library/diagram.tsx +2 -2
- package/corpus/core/src/client/blocks/library/wireframe.tsx +1 -1
- package/corpus/core/src/client/chat/message-components.tsx +1 -1
- package/corpus/core/src/client/chat-model-groups.ts +1 -2
- package/corpus/core/src/client/components/LiveCursorOverlay.tsx +33 -66
- package/corpus/core/src/client/composer/TiptapComposer.tsx +99 -24
- package/corpus/core/src/client/composer/useVoiceDictation.ts +429 -260
- package/corpus/core/src/client/extensions/portable-extension.ts +1 -1
- package/corpus/core/src/client/frame-protocol.ts +17 -2
- package/corpus/core/src/client/rich-markdown-editor/RegistryBlockNode.tsx +2 -2
- package/corpus/core/src/client/sharing/ShareButton.tsx +20 -15
- package/corpus/core/src/client/use-agent-engine-configured.ts +80 -28
- package/corpus/core/src/code-agents/transcript-normalizer.ts +1 -1
- package/corpus/core/src/extensions/path.ts +1 -4
- package/corpus/core/src/integrations/a2a-continuation-processor.ts +1 -1
- package/corpus/core/src/mcp/build-server.ts +1 -1
- package/corpus/core/src/org/handlers.ts +5 -5
- package/corpus/core/src/org/plugin.ts +2 -2
- package/corpus/core/src/scripts/call-agent.ts +1 -1
- package/corpus/core/src/scripts/db/safety.ts +1 -1
- package/corpus/core/src/server/auth.ts +3 -0
- package/corpus/core/src/server/builder-design-systems.ts +308 -0
- package/corpus/core/src/server/core-routes-plugin.ts +29 -3
- package/corpus/core/src/server/index.ts +8 -0
- package/corpus/core/src/server/transcribe-voice.ts +57 -22
- package/corpus/core/src/shared/mcp-embed-headers.ts +23 -1
- package/corpus/core/src/styles/agent-native.css +60 -4
- package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
- package/corpus/core/src/usage/store.ts +2 -2
- package/corpus/core/src/vite/client.ts +2 -0
- package/corpus/core/src/voice/index.ts +18 -0
- package/corpus/core/src/voice/voice-cleanup-prompt.ts +38 -0
- package/corpus/core/src/voice/voice-context.ts +217 -0
- package/corpus/core/src/voice/voice-replacements.ts +75 -0
- package/corpus/templates/analytics/AGENTS.md +4 -3
- package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +298 -15
- package/corpus/templates/analytics/actions/get-sql-dashboard.ts +9 -4
- package/corpus/templates/analytics/actions/list-session-recordings.ts +6 -1
- package/corpus/templates/analytics/actions/mutate-dashboard.ts +18 -2
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +12 -2
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +557 -400
- package/corpus/templates/analytics/app/global.css +0 -4
- package/corpus/templates/analytics/app/hooks/use-replay-storage-status.ts +66 -0
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +39 -0
- package/corpus/templates/analytics/app/i18n-data.ts +424 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +333 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +2 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +121 -3
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +82 -34
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +7 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +144 -36
- package/corpus/templates/analytics/changelog/2026-06-28-ask-tab-now-matches-the-darker-app-background.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-28-dashboard-email-reports-now-use-cleaner-light-mode-snapshots.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-28-sessions-now-show-only-signed-in-recordings-with-playable-re.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-analytics-agents-can-now-place-new-charts-into-requested-das.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-analytics-queries-now-report-failed-source-query-errors-inst.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-charts-can-now-be-dropped-between-full-dashboard-rows-to-cre.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-dashboard-email-reports-include-the-full-dashboard-image-wit.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-main-surface-border-reaches-the-top.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-session-replay-now-records-playable-sessions-in-production-a.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-set-up-session-replay-storage-from-settings-connect-builder-.md +6 -0
- package/corpus/templates/analytics/scripts/cleanup-empty-session-recordings.ts +102 -0
- package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +3 -3
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +75 -30
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +5 -2
- package/corpus/templates/analytics/server/lib/real-data-actions.ts +81 -20
- package/corpus/templates/analytics/server/lib/replay-storage.ts +79 -0
- package/corpus/templates/analytics/server/lib/s3-upload-provider.ts +255 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +53 -12
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +11 -2
- package/corpus/templates/analytics/server/plugins/onboarding.ts +97 -0
- package/corpus/templates/assets/app/components/layout/Sidebar.tsx +4 -4
- package/corpus/templates/assets/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
- package/corpus/templates/brain/app/components/layout/Layout.tsx +29 -2
- package/corpus/templates/brain/app/components/layout/Sidebar.tsx +131 -42
- package/corpus/templates/brain/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/brain/app/i18n-data.ts +20 -0
- package/corpus/templates/brain/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
- package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +29 -1
- package/corpus/templates/calendar/app/components/layout/Sidebar.tsx +417 -299
- package/corpus/templates/calendar/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/calendar/app/i18n-data.ts +20 -0
- package/corpus/templates/calendar/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
- package/corpus/templates/chat/app/components/layout/Sidebar.tsx +6 -19
- package/corpus/templates/chat/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
- package/corpus/templates/clips/AGENTS.md +4 -0
- package/corpus/templates/clips/actions/cleanup-transcript.ts +39 -9
- package/corpus/templates/clips/actions/finalize-meeting.ts +81 -12
- package/corpus/templates/clips/actions/lib/agents-md-context.ts +4 -1
- package/corpus/templates/clips/app/components/library/library-layout.tsx +3 -3
- package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +1 -1
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +2 -2
- package/corpus/templates/clips/changelog/2026-06-28-left-sidebar-footer-controls-use-less-divider-chrome.md +6 -0
- package/corpus/templates/clips/desktop/src/lib/personal-vocabulary.ts +13 -8
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +24 -0
- package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +105 -5
- package/corpus/templates/clips/desktop/src/lib/voice-dictation.ts +60 -1
- package/corpus/templates/content/actions/set-image-alt-text.ts +1 -1
- package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +1 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +1 -1
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +5 -5
- package/corpus/templates/content/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
- package/corpus/templates/content/shared/document-export.ts +1 -1
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +8 -18
- package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +154 -0
- package/corpus/templates/design/AGENTS.md +95 -5
- package/corpus/templates/design/DESIGN.md +82 -0
- package/corpus/templates/design/DEVELOPING.md +43 -0
- package/corpus/templates/design/actions/add-localhost-screens.ts +453 -0
- package/corpus/templates/design/actions/apply-shader.ts +277 -0
- package/corpus/templates/design/actions/apply-visual-edit.ts +303 -0
- package/corpus/templates/design/actions/connect-localhost.ts +183 -0
- package/corpus/templates/design/actions/edit-design.ts +195 -42
- package/corpus/templates/design/actions/generate-design.ts +71 -1
- package/corpus/templates/design/actions/generate-screens.ts +193 -0
- package/corpus/templates/design/actions/get-code-layer-projection.ts +191 -0
- package/corpus/templates/design/actions/get-shader.ts +119 -0
- package/corpus/templates/design/actions/index-design-system-with-builder.ts +259 -0
- package/corpus/templates/design/actions/list-localhost-connections.ts +95 -0
- package/corpus/templates/design/actions/navigate.ts +55 -2
- package/corpus/templates/design/actions/update-file.ts +9 -2
- package/corpus/templates/design/actions/view-screen.ts +68 -1
- package/corpus/templates/design/agent-native.app-skill.json +10 -0
- package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +719 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +2120 -103
- package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +74 -38
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +401 -0
- package/corpus/templates/design/app/components/design/EditPanel.tsx +3966 -440
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +1473 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +3940 -147
- package/corpus/templates/design/app/components/design/QuestionFlow.tsx +700 -13
- package/corpus/templates/design/app/components/design/TweaksPanel.tsx +105 -63
- package/corpus/templates/design/app/components/design/index.ts +0 -5
- package/corpus/templates/design/app/components/design/inspector/AlignmentMatrix.tsx +353 -0
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +1589 -0
- package/corpus/templates/design/app/components/design/inspector/ConstraintsWidget.tsx +511 -0
- package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +395 -0
- package/corpus/templates/design/app/components/design/inspector/FigmaColorPicker.tsx +2010 -0
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +601 -0
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +242 -0
- package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +420 -0
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +238 -0
- package/corpus/templates/design/app/components/design/inspector/ShaderControls.stories.tsx +71 -0
- package/corpus/templates/design/app/components/design/inspector/ShaderControls.tsx +575 -0
- package/corpus/templates/design/app/components/design/inspector/ShaderEffectRow.tsx +106 -0
- package/corpus/templates/design/app/components/design/inspector/ShaderFillsPanel.tsx +423 -0
- package/corpus/templates/design/app/components/design/inspector/figma-icons.tsx +741 -0
- package/corpus/templates/design/app/components/design/inspector/index.ts +87 -0
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +201 -0
- package/corpus/templates/design/app/components/design/types.ts +23 -0
- package/corpus/templates/design/app/components/layout/Layout.tsx +24 -13
- package/corpus/templates/design/app/components/layout/Sidebar.tsx +4 -4
- package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +474 -36
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +22 -5
- package/corpus/templates/design/app/components/visual-editor/SaveStatusIndicator.tsx +24 -11
- package/corpus/templates/design/app/global.css +149 -5
- package/corpus/templates/design/app/hooks/use-agent-generating.ts +4 -0
- package/corpus/templates/design/app/hooks/use-navigation-state.ts +130 -8
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +329 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +277 -0
- package/corpus/templates/design/app/i18n-data.ts +3028 -1
- package/corpus/templates/design/app/pages/DesignEditor.tsx +5831 -904
- package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +57 -221
- package/corpus/templates/design/changelog/2026-06-28-annotate-mode-now-combines-drawing-and-comment-pins-in-one-c.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-design-editor-panel-borders-are-cleaner-so-the-canvas-edge-s.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-drawing-prompts-now-reliably-open-and-submit-to-the-design-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-editor-app-menus-now-use-an-outline-agent-native-mark-with-b.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-figma-style-code-design-mode.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-layer-selection-now-updates-chat-context-without-opening-the.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-layer-selections-now-preserve-hidden-and-locked-canvas-state.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-live-cursors-now-keep-names-beside-a-cleaner-pointer-marker.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-mode-switching-now-lives-in-a-compact-bottom-right-icon-dock.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-screen-overview-now-handles-zooming-drawing-opening-and-dele.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-screen-overview-now-selects-screens-on-click-and-opens-them-.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-screen-overview-zooms-into-the-active-screen-with-smoother-e.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-selected-element-chips-in-chat-are-shorter-and-easier-to-sca.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-the-design-editor-toolbar-is-more-compact-and-the-app-menu-m.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-tweaks-now-live-at-the-top-of-the-design-sidebar-instead-of-.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-tweaks-now-live-in-the-right-inspector-beside-the-design-tab.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-active-sidebar-rows-use-soft-highlights.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-agents-can-now-open-screen-overview-focus-screens-and-add.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-canvas-selection-no-longer-flashes-broken-prototype-html-whe.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-command-menu-selections-are-clearer-in-dark-mode.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-design-agents-now-edit-broad-copy-changes-more-reliably.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-design-editor-extensions-now-run-inline-beside-the-artboard.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-design-mode-top-chrome-now-groups-collaborators-preview-shar.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-element-selection-now-stays-on-the-intended-layer-after-sele.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-full-view-controls-now-sit-above-frames-inst.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-tools-can-draw-shapes-create-screens-and-kee.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-sidebars-use-darker-chrome-and-chat-warns-immediately.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-pen-tool-now-creates-anchor-based-bezier-vectors-with-ha.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-visual-edit-can-open-localhost-routes-as-url-backed-screens.md +6 -0
- package/corpus/templates/design/package.json +1 -0
- package/corpus/templates/design/server/db/schema.ts +26 -0
- package/corpus/templates/design/server/handlers/import-figma-system.ts +38 -85
- package/corpus/templates/design/server/lib/design-snapshot.ts +9 -1
- package/corpus/templates/design/server/plugins/agent-chat.ts +3 -0
- package/corpus/templates/design/shared/api.ts +27 -0
- package/corpus/templates/design/shared/canvas-frames.ts +103 -0
- package/corpus/templates/design/shared/canvas-math.ts +1058 -0
- package/corpus/templates/design/shared/code-layer.ts +2412 -0
- package/corpus/templates/design/shared/color-utils.ts +180 -0
- package/corpus/templates/design/shared/generation-session.ts +61 -0
- package/corpus/templates/design/shared/html-content.ts +21 -0
- package/corpus/templates/design/shared/pen-path.ts +207 -0
- package/corpus/templates/design/shared/shader-presets.ts +782 -0
- package/corpus/templates/design/shared/shader-safety.ts +137 -0
- package/corpus/templates/design/shared/source-mode.ts +237 -0
- package/corpus/templates/forms/app/components/layout/Sidebar.tsx +149 -6
- package/corpus/templates/forms/app/i18n/ar-SA.ts +2 -0
- package/corpus/templates/forms/app/i18n/de-DE.ts +2 -0
- package/corpus/templates/forms/app/i18n/en-US.ts +2 -0
- package/corpus/templates/forms/app/i18n/es-ES.ts +2 -0
- package/corpus/templates/forms/app/i18n/fr-FR.ts +2 -0
- package/corpus/templates/forms/app/i18n/hi-IN.ts +2 -0
- package/corpus/templates/forms/app/i18n/ja-JP.ts +2 -0
- package/corpus/templates/forms/app/i18n/ko-KR.ts +2 -0
- package/corpus/templates/forms/app/i18n/pt-BR.ts +2 -0
- package/corpus/templates/forms/app/i18n/zh-CN.ts +2 -0
- package/corpus/templates/forms/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/forms/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
- package/corpus/templates/macros/app/components/layout/AppLayout.tsx +2 -2
- package/corpus/templates/macros/changelog/2026-06-28-left-sidebar-footer-controls-use-less-divider-chrome.md +6 -0
- package/corpus/templates/mail/app/components/layout/AppLayout.tsx +408 -247
- package/corpus/templates/mail/app/i18n/ar-SA.ts +4 -0
- package/corpus/templates/mail/app/i18n/de-DE.ts +4 -0
- package/corpus/templates/mail/app/i18n/en-US.ts +4 -0
- package/corpus/templates/mail/app/i18n/es-ES.ts +4 -0
- package/corpus/templates/mail/app/i18n/fr-FR.ts +4 -0
- package/corpus/templates/mail/app/i18n/hi-IN.ts +4 -0
- package/corpus/templates/mail/app/i18n/ja-JP.ts +4 -0
- package/corpus/templates/mail/app/i18n/ko-KR.ts +4 -0
- package/corpus/templates/mail/app/i18n/pt-BR.ts +4 -0
- package/corpus/templates/mail/app/i18n/zh-CN.ts +4 -0
- package/corpus/templates/mail/app/i18n/zh-TW.ts +4 -0
- package/corpus/templates/mail/app/lib/utils.ts +2 -2
- package/corpus/templates/mail/changelog/2026-06-28-pinned-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
- package/corpus/templates/plan/app/components/layout/Sidebar.tsx +6 -8
- package/corpus/templates/plan/app/components/plan/wireframe/Wireframe.tsx +1 -1
- package/corpus/templates/plan/app/global.css +61 -78
- package/corpus/templates/plan/app/pages/PlansPage.tsx +8 -0
- package/corpus/templates/plan/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
- package/corpus/templates/plan/changelog/2026-06-28-plans-use-the-shared-neutral-light-and-dark-theme.md +6 -0
- package/corpus/templates/plan/changelog/2026-06-28-sending-open-feedback-from-a-plan-now-opens-the-inline-plan-.md +6 -0
- package/corpus/templates/slides/.agents/skills/design-systems/SKILL.md +9 -10
- package/corpus/templates/slides/AGENTS.md +3 -2
- package/corpus/templates/slides/actions/import-file.ts +26 -17
- package/corpus/templates/slides/actions/index-design-system-with-builder.ts +259 -0
- package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +35 -167
- package/corpus/templates/slides/app/components/design-system/fig-import-response.ts +9 -13
- package/corpus/templates/slides/app/components/layout/Sidebar.tsx +5 -5
- package/corpus/templates/slides/app/context/DeckContext.tsx +1 -1
- package/corpus/templates/slides/app/i18n/ar-SA.ts +9 -37
- package/corpus/templates/slides/app/i18n/de-DE.ts +10 -25
- package/corpus/templates/slides/app/i18n/en-US.ts +9 -26
- package/corpus/templates/slides/app/i18n/es-ES.ts +10 -28
- package/corpus/templates/slides/app/i18n/fr-FR.ts +10 -28
- package/corpus/templates/slides/app/i18n/hi-IN.ts +9 -26
- package/corpus/templates/slides/app/i18n/ja-JP.ts +10 -22
- package/corpus/templates/slides/app/i18n/ko-KR.ts +9 -23
- package/corpus/templates/slides/app/i18n/pt-BR.ts +10 -28
- package/corpus/templates/slides/app/i18n/zh-CN.ts +9 -22
- package/corpus/templates/slides/app/i18n/zh-TW.ts +9 -22
- package/corpus/templates/slides/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
- package/corpus/templates/slides/server/handlers/import-figma-system.ts +44 -12
- package/corpus/templates/slides/server/lib/chat-attachments.ts +1 -1
- package/corpus/templates/videos/app/components/NewCompositionPopover.tsx +1 -1
- package/corpus/templates/videos/app/components/Sidebar.tsx +2 -2
- package/corpus/templates/videos/app/components/layout/Layout.tsx +29 -2
- package/corpus/templates/videos/app/components/layout/NavSidebar.tsx +113 -30
- package/corpus/templates/videos/app/i18n/ar-SA.ts +2 -0
- package/corpus/templates/videos/app/i18n/de-DE.ts +2 -0
- package/corpus/templates/videos/app/i18n/en-US.ts +2 -0
- package/corpus/templates/videos/app/i18n/es-ES.ts +2 -0
- package/corpus/templates/videos/app/i18n/fr-FR.ts +2 -0
- package/corpus/templates/videos/app/i18n/hi-IN.ts +2 -0
- package/corpus/templates/videos/app/i18n/ja-JP.ts +2 -0
- package/corpus/templates/videos/app/i18n/ko-KR.ts +2 -0
- package/corpus/templates/videos/app/i18n/pt-BR.ts +2 -0
- package/corpus/templates/videos/app/i18n/zh-CN.ts +2 -0
- package/corpus/templates/videos/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/videos/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
- package/dist/agent/engine/index.d.ts +1 -1
- package/dist/agent/engine/index.d.ts.map +1 -1
- package/dist/agent/engine/index.js +1 -1
- package/dist/agent/engine/index.js.map +1 -1
- package/dist/agent/engine/registry.d.ts +9 -0
- package/dist/agent/engine/registry.d.ts.map +1 -1
- package/dist/agent/engine/registry.js +37 -0
- package/dist/agent/engine/registry.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +4 -3
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/application-state/handlers.js +1 -1
- package/dist/application-state/handlers.js.map +1 -1
- package/dist/brand-kit/index.d.ts +1 -1
- package/dist/brand-kit/index.js +1 -1
- package/dist/brand-kit/index.js.map +1 -1
- package/dist/cli/design-connect.d.ts +52 -0
- package/dist/cli/design-connect.d.ts.map +1 -0
- package/dist/cli/design-connect.js +409 -0
- package/dist/cli/design-connect.js.map +1 -0
- package/dist/cli/index.js +15 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/skills.d.ts +3 -0
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +139 -3
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AgentPanel.js +1 -1
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +38 -8
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +1 -1
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/agent-chat.d.ts +6 -0
- package/dist/client/agent-chat.d.ts.map +1 -1
- package/dist/client/agent-chat.js +17 -3
- package/dist/client/agent-chat.js.map +1 -1
- package/dist/client/blocks/library/MermaidBlock.js +1 -1
- package/dist/client/blocks/library/MermaidBlock.js.map +1 -1
- package/dist/client/blocks/library/diagram.js +1 -1
- package/dist/client/blocks/library/diagram.js.map +1 -1
- package/dist/client/blocks/library/wireframe.js +1 -1
- package/dist/client/blocks/library/wireframe.js.map +1 -1
- package/dist/client/chat/message-components.js +1 -1
- package/dist/client/chat/message-components.js.map +1 -1
- package/dist/client/chat-model-groups.d.ts.map +1 -1
- package/dist/client/chat-model-groups.js +1 -2
- package/dist/client/chat-model-groups.js.map +1 -1
- package/dist/client/components/LiveCursorOverlay.d.ts +1 -2
- package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -1
- package/dist/client/components/LiveCursorOverlay.js +19 -40
- package/dist/client/components/LiveCursorOverlay.js.map +1 -1
- package/dist/client/composer/TiptapComposer.d.ts +3 -1
- package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/client/composer/TiptapComposer.js +86 -24
- package/dist/client/composer/TiptapComposer.js.map +1 -1
- package/dist/client/composer/useVoiceDictation.d.ts +9 -0
- package/dist/client/composer/useVoiceDictation.d.ts.map +1 -1
- package/dist/client/composer/useVoiceDictation.js +154 -32
- package/dist/client/composer/useVoiceDictation.js.map +1 -1
- package/dist/client/extensions/portable-extension.d.ts +1 -1
- package/dist/client/extensions/portable-extension.d.ts.map +1 -1
- package/dist/client/extensions/portable-extension.js.map +1 -1
- package/dist/client/frame-protocol.d.ts +17 -2
- package/dist/client/frame-protocol.d.ts.map +1 -1
- package/dist/client/frame-protocol.js.map +1 -1
- package/dist/client/rich-markdown-editor/RegistryBlockNode.js +2 -2
- package/dist/client/rich-markdown-editor/RegistryBlockNode.js.map +1 -1
- package/dist/client/sharing/ShareButton.d.ts +2 -0
- package/dist/client/sharing/ShareButton.d.ts.map +1 -1
- package/dist/client/sharing/ShareButton.js +7 -6
- package/dist/client/sharing/ShareButton.js.map +1 -1
- package/dist/client/use-agent-engine-configured.d.ts +5 -0
- package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
- package/dist/client/use-agent-engine-configured.js +51 -25
- package/dist/client/use-agent-engine-configured.js.map +1 -1
- package/dist/code-agents/transcript-normalizer.js +1 -1
- package/dist/code-agents/transcript-normalizer.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/extensions/path.d.ts +1 -1
- package/dist/extensions/path.d.ts.map +1 -1
- package/dist/extensions/path.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/integrations/a2a-continuation-processor.js +1 -1
- package/dist/integrations/a2a-continuation-processor.js.map +1 -1
- package/dist/mcp/build-server.d.ts +1 -1
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/org/handlers.js +5 -5
- package/dist/org/handlers.js.map +1 -1
- package/dist/org/plugin.js +2 -2
- package/dist/org/plugin.js.map +1 -1
- package/dist/scripts/call-agent.js +1 -1
- package/dist/scripts/call-agent.js.map +1 -1
- package/dist/scripts/db/safety.js +1 -1
- package/dist/scripts/db/safety.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +3 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/builder-design-systems.d.ts +28 -0
- package/dist/server/builder-design-systems.d.ts.map +1 -0
- package/dist/server/builder-design-systems.js +217 -0
- package/dist/server/builder-design-systems.js.map +1 -0
- package/dist/server/core-routes-plugin.d.ts +1 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +20 -3
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts.map +1 -1
- package/dist/server/transcribe-voice.js +40 -24
- package/dist/server/transcribe-voice.js.map +1 -1
- package/dist/shared/mcp-embed-headers.d.ts +1 -0
- package/dist/shared/mcp-embed-headers.d.ts.map +1 -1
- package/dist/shared/mcp-embed-headers.js +20 -1
- package/dist/shared/mcp-embed-headers.js.map +1 -1
- package/dist/styles/agent-native.css +60 -4
- package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
- package/dist/usage/store.js +2 -2
- package/dist/usage/store.js.map +1 -1
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +2 -0
- package/dist/vite/client.js.map +1 -1
- package/dist/voice/index.d.ts +5 -0
- package/dist/voice/index.d.ts.map +1 -0
- package/dist/voice/index.js +4 -0
- package/dist/voice/index.js.map +1 -0
- package/dist/voice/voice-cleanup-prompt.d.ts +6 -0
- package/dist/voice/voice-cleanup-prompt.d.ts.map +1 -0
- package/dist/voice/voice-cleanup-prompt.js +26 -0
- package/dist/voice/voice-cleanup-prompt.js.map +1 -0
- package/dist/voice/voice-context.d.ts +25 -0
- package/dist/voice/voice-context.d.ts.map +1 -0
- package/dist/voice/voice-context.js +156 -0
- package/dist/voice/voice-context.js.map +1 -0
- package/dist/voice/voice-replacements.d.ts +4 -0
- package/dist/voice/voice-replacements.d.ts.map +1 -0
- package/dist/voice/voice-replacements.js +52 -0
- package/dist/voice/voice-replacements.js.map +1 -0
- package/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
- package/docs/content/locales/ar-SA/template-design.mdx +55 -1
- package/docs/content/locales/de-DE/skills-guide.mdx +5 -0
- package/docs/content/locales/de-DE/template-design.mdx +66 -1
- package/docs/content/locales/es-ES/skills-guide.mdx +5 -0
- package/docs/content/locales/es-ES/template-design.mdx +65 -1
- package/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
- package/docs/content/locales/fr-FR/template-design.mdx +64 -1
- package/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
- package/docs/content/locales/hi-IN/template-design.mdx +59 -1
- package/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
- package/docs/content/locales/ja-JP/template-design.mdx +62 -1
- package/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
- package/docs/content/locales/ko-KR/template-design.mdx +58 -1
- package/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
- package/docs/content/locales/pt-BR/template-design.mdx +62 -1
- package/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
- package/docs/content/locales/zh-CN/template-design.mdx +50 -1
- package/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
- package/docs/content/locales/zh-TW/template-design.mdx +52 -1
- package/docs/content/skills-guide.mdx +5 -0
- package/docs/content/template-design.mdx +59 -1
- package/package.json +2 -1
- package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
- package/corpus/templates/design/actions/import-figma-file.ts +0 -166
- package/corpus/templates/design/app/components/design/DesignToolbar.tsx +0 -339
- package/corpus/templates/design/app/components/design/PresentMode.tsx +0 -68
- package/corpus/templates/design/app/components/design/ViewportTabs.tsx +0 -149
- package/corpus/templates/design/app/components/design/ZoomControls.tsx +0 -157
- package/corpus/templates/slides/server/lib/fig-design-system.ts +0 -110
|
@@ -1,9 +1,72 @@
|
|
|
1
1
|
import { useT } from "@agent-native/core/client";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import type { TweakDefinition } from "@shared/api";
|
|
3
|
+
import {
|
|
4
|
+
alphaToOpacity,
|
|
5
|
+
parseCssColor,
|
|
6
|
+
rgbaToCss,
|
|
7
|
+
rgbaToHex,
|
|
8
|
+
withColorOpacity,
|
|
9
|
+
} from "@shared/color-utils";
|
|
10
|
+
import {
|
|
11
|
+
IconAlignCenter,
|
|
12
|
+
IconAlignJustified,
|
|
13
|
+
IconAlignLeft,
|
|
14
|
+
IconAlignRight,
|
|
15
|
+
IconArrowAutofitHeight,
|
|
16
|
+
IconArrowAutofitWidth,
|
|
17
|
+
IconBackground,
|
|
18
|
+
IconBlur,
|
|
19
|
+
IconBorderStyle,
|
|
20
|
+
IconBrush,
|
|
21
|
+
IconChevronDown,
|
|
22
|
+
IconCode,
|
|
23
|
+
IconComponents,
|
|
24
|
+
IconDroplet,
|
|
25
|
+
IconEye,
|
|
26
|
+
IconEyeOff,
|
|
27
|
+
IconFlipHorizontal,
|
|
28
|
+
IconFlipVertical,
|
|
29
|
+
IconFrame,
|
|
30
|
+
IconLayoutDistributeHorizontal,
|
|
31
|
+
IconLayoutGrid,
|
|
32
|
+
IconSlice,
|
|
33
|
+
IconLayoutAlignBottom,
|
|
34
|
+
IconLayoutAlignCenter,
|
|
35
|
+
IconLayoutAlignLeft,
|
|
36
|
+
IconLayoutAlignMiddle,
|
|
37
|
+
IconLayoutAlignRight,
|
|
38
|
+
IconLayoutAlignTop,
|
|
39
|
+
IconLetterCase,
|
|
40
|
+
IconLetterSpacing,
|
|
41
|
+
IconLineHeight,
|
|
42
|
+
IconLink,
|
|
43
|
+
IconMaximize,
|
|
44
|
+
IconMinus,
|
|
45
|
+
IconPalette,
|
|
46
|
+
IconPhoto,
|
|
47
|
+
IconPlus,
|
|
48
|
+
IconShadow,
|
|
49
|
+
IconSquare,
|
|
50
|
+
IconTypography,
|
|
51
|
+
IconUnlink,
|
|
52
|
+
IconVector,
|
|
53
|
+
} from "@tabler/icons-react";
|
|
54
|
+
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
|
4
55
|
|
|
56
|
+
import { Button } from "@/components/ui/button";
|
|
57
|
+
import {
|
|
58
|
+
DropdownMenu,
|
|
59
|
+
DropdownMenuContent,
|
|
60
|
+
DropdownMenuItem,
|
|
61
|
+
DropdownMenuTrigger,
|
|
62
|
+
} from "@/components/ui/dropdown-menu";
|
|
5
63
|
import { Input } from "@/components/ui/input";
|
|
6
64
|
import { Label } from "@/components/ui/label";
|
|
65
|
+
import {
|
|
66
|
+
Popover,
|
|
67
|
+
PopoverContent,
|
|
68
|
+
PopoverTrigger,
|
|
69
|
+
} from "@/components/ui/popover";
|
|
7
70
|
import {
|
|
8
71
|
Select,
|
|
9
72
|
SelectContent,
|
|
@@ -11,16 +74,61 @@ import {
|
|
|
11
74
|
SelectTrigger,
|
|
12
75
|
SelectValue,
|
|
13
76
|
} from "@/components/ui/select";
|
|
14
|
-
import { Separator } from "@/components/ui/separator";
|
|
15
77
|
import { Slider } from "@/components/ui/slider";
|
|
78
|
+
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
79
|
+
import {
|
|
80
|
+
Tooltip,
|
|
81
|
+
TooltipContent,
|
|
82
|
+
TooltipTrigger,
|
|
83
|
+
} from "@/components/ui/tooltip";
|
|
16
84
|
import { cn } from "@/lib/utils";
|
|
17
85
|
|
|
86
|
+
import {
|
|
87
|
+
DesignExtensionsPanel,
|
|
88
|
+
type DesignExtensionSlotContext,
|
|
89
|
+
} from "./DesignExtensionsPanel";
|
|
90
|
+
import {
|
|
91
|
+
AutoLayoutMatrix,
|
|
92
|
+
ConstraintsWidget,
|
|
93
|
+
ExportSettingsPanel,
|
|
94
|
+
FigmaColorPicker,
|
|
95
|
+
ScrubInput,
|
|
96
|
+
SizingField,
|
|
97
|
+
type AlignmentMatrixValue,
|
|
98
|
+
type AutoLayoutMatrixValue,
|
|
99
|
+
type AutoLayoutSizing,
|
|
100
|
+
type AutoLayoutSizingAxis,
|
|
101
|
+
type ConstraintsValue,
|
|
102
|
+
type ExportSettingsValue,
|
|
103
|
+
type FigmaFillRow,
|
|
104
|
+
type FigmaFillRowPatch,
|
|
105
|
+
type FigmaGradientStop,
|
|
106
|
+
type FigmaGradientStopPatch,
|
|
107
|
+
type FigmaGradientType,
|
|
108
|
+
} from "./inspector";
|
|
109
|
+
import type { FigmaPaintType } from "./inspector/FigmaColorPicker";
|
|
110
|
+
import { TweaksPanelContent } from "./TweaksPanel";
|
|
18
111
|
import type { ElementInfo } from "./types";
|
|
19
112
|
|
|
113
|
+
export type InspectorTab = "design" | "tweaks" | "extensions";
|
|
114
|
+
|
|
20
115
|
interface EditPanelProps {
|
|
21
116
|
selectedElement: ElementInfo | null;
|
|
22
117
|
pageStyles?: Record<string, string>;
|
|
118
|
+
zoom?: number;
|
|
119
|
+
headerTrailing?: ReactNode;
|
|
120
|
+
width?: number;
|
|
121
|
+
activeTab?: InspectorTab;
|
|
122
|
+
onActiveTabChange?: (tab: InspectorTab) => void;
|
|
123
|
+
tweaks?: TweakDefinition[];
|
|
124
|
+
tweakValues?: Record<string, string | number | boolean>;
|
|
125
|
+
extensionContext?: DesignExtensionSlotContext;
|
|
126
|
+
onTweakChange?: (id: string, value: string | number | boolean) => void;
|
|
127
|
+
onRequestTweaks?: (anchor: HTMLElement) => void;
|
|
23
128
|
onStyleChange: (property: string, value: string) => void;
|
|
129
|
+
onStylesChange?: (styles: Record<string, string>) => void;
|
|
130
|
+
onExport?: (settings: ExportSettingsValue) => void;
|
|
131
|
+
exporting?: boolean;
|
|
24
132
|
}
|
|
25
133
|
|
|
26
134
|
/**
|
|
@@ -74,10 +182,8 @@ function PropInput({
|
|
|
74
182
|
};
|
|
75
183
|
|
|
76
184
|
return (
|
|
77
|
-
<div className="flex items-center gap-
|
|
78
|
-
<
|
|
79
|
-
{label}
|
|
80
|
-
</Label>
|
|
185
|
+
<div className="flex items-center gap-1.5">
|
|
186
|
+
<FieldLabel>{label}</FieldLabel>
|
|
81
187
|
<Input
|
|
82
188
|
type={type}
|
|
83
189
|
value={draft}
|
|
@@ -98,82 +204,582 @@ function PropInput({
|
|
|
98
204
|
}
|
|
99
205
|
}}
|
|
100
206
|
placeholder={placeholder}
|
|
101
|
-
className="h-
|
|
207
|
+
className="h-6 min-w-0 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
|
|
102
208
|
/>
|
|
103
209
|
</div>
|
|
104
210
|
);
|
|
105
211
|
}
|
|
106
212
|
|
|
107
|
-
/** Compact color input: label +
|
|
213
|
+
/** Compact color input: label + Figma-style picker popover. */
|
|
108
214
|
function ColorInput({
|
|
109
215
|
label,
|
|
110
216
|
value,
|
|
111
217
|
onChange,
|
|
218
|
+
backgroundImage,
|
|
219
|
+
onBackgroundImageChange,
|
|
220
|
+
blendMode,
|
|
221
|
+
onBlendModeChange,
|
|
222
|
+
supportsLayeredFills = false,
|
|
223
|
+
documentColors,
|
|
112
224
|
}: {
|
|
113
225
|
label: string;
|
|
114
226
|
value: string;
|
|
115
227
|
onChange: (value: string) => void;
|
|
228
|
+
backgroundImage?: string;
|
|
229
|
+
onBackgroundImageChange?: (value: string) => void;
|
|
230
|
+
blendMode?: string;
|
|
231
|
+
onBlendModeChange?: (value: string) => void;
|
|
232
|
+
supportsLayeredFills?: boolean;
|
|
233
|
+
/** Hex strings already in use on the page — forwarded to the color picker swatch grid. */
|
|
234
|
+
documentColors?: string[];
|
|
116
235
|
}) {
|
|
117
|
-
const t = useT();
|
|
118
236
|
const [draft, setDraft] = useState(value);
|
|
237
|
+
const [selectedFillId, setSelectedFillId] = useState(SOLID_FILL_ID);
|
|
238
|
+
const [selectedStopId, setSelectedStopId] = useState<string | undefined>();
|
|
119
239
|
|
|
120
240
|
useEffect(() => {
|
|
121
241
|
setDraft(value);
|
|
122
242
|
}, [value]);
|
|
123
243
|
|
|
244
|
+
const backgroundLayers = splitCssLayers(backgroundImage || "");
|
|
245
|
+
const selectedLayerIndex = fillLayerIndex(selectedFillId);
|
|
246
|
+
const selectedGradient =
|
|
247
|
+
selectedLayerIndex !== null
|
|
248
|
+
? parseGradientLayer(backgroundLayers[selectedLayerIndex] || "")
|
|
249
|
+
: null;
|
|
250
|
+
const fallbackGradientIndex = backgroundLayers.findIndex((layer) =>
|
|
251
|
+
Boolean(parseGradientLayer(layer)),
|
|
252
|
+
);
|
|
253
|
+
const activeGradientIndex =
|
|
254
|
+
selectedGradient && selectedLayerIndex !== null
|
|
255
|
+
? selectedLayerIndex
|
|
256
|
+
: fallbackGradientIndex >= 0
|
|
257
|
+
? fallbackGradientIndex
|
|
258
|
+
: null;
|
|
259
|
+
const activeGradient =
|
|
260
|
+
activeGradientIndex !== null
|
|
261
|
+
? parseGradientLayer(backgroundLayers[activeGradientIndex] || "")
|
|
262
|
+
: null;
|
|
263
|
+
const activeStopIds =
|
|
264
|
+
activeGradient?.stops.map((stop) => stop.id).join("|") ?? "";
|
|
265
|
+
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
if (
|
|
268
|
+
selectedFillId !== SOLID_FILL_ID &&
|
|
269
|
+
(selectedLayerIndex === null ||
|
|
270
|
+
selectedLayerIndex >= backgroundLayers.length)
|
|
271
|
+
) {
|
|
272
|
+
setSelectedFillId(SOLID_FILL_ID);
|
|
273
|
+
}
|
|
274
|
+
}, [backgroundLayers.length, selectedFillId, selectedLayerIndex]);
|
|
275
|
+
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
if (!activeStopIds) {
|
|
278
|
+
if (selectedStopId) setSelectedStopId(undefined);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const stopIds = activeStopIds.split("|").filter(Boolean);
|
|
282
|
+
if (!selectedStopId || !stopIds.includes(selectedStopId)) {
|
|
283
|
+
setSelectedStopId(stopIds[0]);
|
|
284
|
+
}
|
|
285
|
+
}, [activeStopIds, selectedStopId]);
|
|
286
|
+
|
|
124
287
|
const setNext = (next: string) => {
|
|
125
288
|
setDraft(next);
|
|
126
289
|
onChange(next);
|
|
127
290
|
};
|
|
128
291
|
|
|
292
|
+
const replaceBackgroundLayer = (index: number, nextLayer: string) => {
|
|
293
|
+
if (!onBackgroundImageChange) return;
|
|
294
|
+
const nextLayers = [...backgroundLayers];
|
|
295
|
+
nextLayers[index] = nextLayer;
|
|
296
|
+
onBackgroundImageChange(joinCssLayers(nextLayers));
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const removeBackgroundLayer = (index: number) => {
|
|
300
|
+
if (!onBackgroundImageChange) return;
|
|
301
|
+
const nextLayers = backgroundLayers.filter(
|
|
302
|
+
(_, layerIndex) => layerIndex !== index,
|
|
303
|
+
);
|
|
304
|
+
onBackgroundImageChange(joinCssLayers(nextLayers));
|
|
305
|
+
setSelectedFillId(SOLID_FILL_ID);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const handlePaintValueChange = (nextValue: string) => {
|
|
309
|
+
if (!supportsLayeredFills || !onBackgroundImageChange) {
|
|
310
|
+
setNext(nextValue);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const selectedLayer = fillLayerIndex(selectedFillId);
|
|
315
|
+
if (selectedLayer !== null) {
|
|
316
|
+
replaceBackgroundLayer(selectedLayer, nextValue);
|
|
317
|
+
const gradient = parseGradientLayer(nextValue);
|
|
318
|
+
if (gradient) setSelectedStopId(gradient.stops[0]?.id);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
onBackgroundImageChange(joinCssLayers([nextValue, ...backgroundLayers]));
|
|
323
|
+
setSelectedFillId(fillLayerId(0));
|
|
324
|
+
const gradient = parseGradientLayer(nextValue);
|
|
325
|
+
setSelectedStopId(gradient?.stops[0]?.id);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const fillRows = supportsLayeredFills
|
|
329
|
+
? buildFillRows(
|
|
330
|
+
draft || value || "#000000",
|
|
331
|
+
backgroundLayers,
|
|
332
|
+
selectedFillId,
|
|
333
|
+
)
|
|
334
|
+
: undefined;
|
|
335
|
+
|
|
336
|
+
const handleFillChange = (id: string, patch: FigmaFillRowPatch) => {
|
|
337
|
+
if (id === SOLID_FILL_ID) {
|
|
338
|
+
if (patch.value !== undefined) setNext(patch.value);
|
|
339
|
+
if (patch.opacity !== undefined) {
|
|
340
|
+
const parsed = parseCssColor(patch.value ?? draft);
|
|
341
|
+
if (parsed) setNext(rgbaToCss(withColorOpacity(parsed, patch.opacity)));
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const index = fillLayerIndex(id);
|
|
347
|
+
if (index === null || !onBackgroundImageChange) return;
|
|
348
|
+
const currentLayer = backgroundLayers[index] || "";
|
|
349
|
+
if (patch.value !== undefined) {
|
|
350
|
+
replaceBackgroundLayer(index, patch.value);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (patch.opacity === undefined) return;
|
|
354
|
+
const gradient = parseGradientLayer(currentLayer);
|
|
355
|
+
if (!gradient) return;
|
|
356
|
+
replaceBackgroundLayer(
|
|
357
|
+
index,
|
|
358
|
+
buildGradientLayer(
|
|
359
|
+
gradient.type,
|
|
360
|
+
gradient.stops.map((stop) => ({
|
|
361
|
+
...stop,
|
|
362
|
+
opacity: patch.opacity,
|
|
363
|
+
})),
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const handleAddFill = onBackgroundImageChange
|
|
369
|
+
? () => {
|
|
370
|
+
const nextLayers = [
|
|
371
|
+
defaultGradientLayer("linear", draft || value || "#000000"),
|
|
372
|
+
...backgroundLayers,
|
|
373
|
+
];
|
|
374
|
+
onBackgroundImageChange(joinCssLayers(nextLayers));
|
|
375
|
+
setSelectedFillId(fillLayerId(0));
|
|
376
|
+
setSelectedStopId("stop-0");
|
|
377
|
+
}
|
|
378
|
+
: undefined;
|
|
379
|
+
|
|
380
|
+
const handleRemoveFill = onBackgroundImageChange
|
|
381
|
+
? (id: string) => {
|
|
382
|
+
const index = fillLayerIndex(id);
|
|
383
|
+
if (index === null) return;
|
|
384
|
+
removeBackgroundLayer(index);
|
|
385
|
+
}
|
|
386
|
+
: undefined;
|
|
387
|
+
|
|
388
|
+
const handleGradientTypeChange =
|
|
389
|
+
activeGradient && activeGradientIndex !== null
|
|
390
|
+
? (type: FigmaGradientType) => {
|
|
391
|
+
replaceBackgroundLayer(
|
|
392
|
+
activeGradientIndex,
|
|
393
|
+
buildGradientLayer(type, activeGradient.stops),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
: undefined;
|
|
397
|
+
|
|
398
|
+
const handleGradientStopChange =
|
|
399
|
+
activeGradient && activeGradientIndex !== null
|
|
400
|
+
? (id: string, patch: FigmaGradientStopPatch) => {
|
|
401
|
+
const nextStops = activeGradient.stops.map((stop) =>
|
|
402
|
+
stop.id === id ? { ...stop, ...patch } : stop,
|
|
403
|
+
);
|
|
404
|
+
replaceBackgroundLayer(
|
|
405
|
+
activeGradientIndex,
|
|
406
|
+
buildGradientLayer(activeGradient.type, nextStops),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
: undefined;
|
|
410
|
+
|
|
411
|
+
const handleAddGradientStop = onBackgroundImageChange
|
|
412
|
+
? () => {
|
|
413
|
+
if (activeGradient && activeGradientIndex !== null) {
|
|
414
|
+
const nextStop: FigmaGradientStop = {
|
|
415
|
+
id: `stop-${activeGradient.stops.length}`,
|
|
416
|
+
color: draft || "#000000",
|
|
417
|
+
position: 50,
|
|
418
|
+
opacity: 100,
|
|
419
|
+
};
|
|
420
|
+
replaceBackgroundLayer(
|
|
421
|
+
activeGradientIndex,
|
|
422
|
+
buildGradientLayer(activeGradient.type, [
|
|
423
|
+
...activeGradient.stops,
|
|
424
|
+
nextStop,
|
|
425
|
+
]),
|
|
426
|
+
);
|
|
427
|
+
setSelectedStopId(nextStop.id);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
onBackgroundImageChange(
|
|
432
|
+
joinCssLayers([
|
|
433
|
+
defaultGradientLayer("linear", draft || value || "#000000"),
|
|
434
|
+
...backgroundLayers,
|
|
435
|
+
]),
|
|
436
|
+
);
|
|
437
|
+
setSelectedFillId(fillLayerId(0));
|
|
438
|
+
setSelectedStopId("stop-0");
|
|
439
|
+
}
|
|
440
|
+
: undefined;
|
|
441
|
+
|
|
442
|
+
const handleRemoveGradientStop =
|
|
443
|
+
activeGradient && activeGradientIndex !== null
|
|
444
|
+
? (id: string) => {
|
|
445
|
+
if (activeGradient.stops.length <= 2) return;
|
|
446
|
+
const nextStops = activeGradient.stops.filter(
|
|
447
|
+
(stop) => stop.id !== id,
|
|
448
|
+
);
|
|
449
|
+
replaceBackgroundLayer(
|
|
450
|
+
activeGradientIndex,
|
|
451
|
+
buildGradientLayer(activeGradient.type, nextStops),
|
|
452
|
+
);
|
|
453
|
+
setSelectedStopId(nextStops[0]?.id);
|
|
454
|
+
}
|
|
455
|
+
: undefined;
|
|
456
|
+
|
|
457
|
+
const selectedPaintType: FigmaPaintType =
|
|
458
|
+
selectedFillId !== SOLID_FILL_ID
|
|
459
|
+
? selectedGradient
|
|
460
|
+
? selectedGradient.type
|
|
461
|
+
: "image"
|
|
462
|
+
: colorHasVisibleAlpha(draft || value)
|
|
463
|
+
? "solid"
|
|
464
|
+
: "none";
|
|
465
|
+
const pickerValue =
|
|
466
|
+
selectedLayerIndex !== null
|
|
467
|
+
? (backgroundLayers[selectedLayerIndex] ?? draft ?? value ?? "#000000")
|
|
468
|
+
: draft || "#000000";
|
|
469
|
+
|
|
470
|
+
const handlePaintTypeChange = (type: FigmaPaintType) => {
|
|
471
|
+
const selectedLayer = fillLayerIndex(selectedFillId);
|
|
472
|
+
if (type === "solid") {
|
|
473
|
+
if (selectedLayer !== null) removeBackgroundLayer(selectedLayer);
|
|
474
|
+
setSelectedFillId(SOLID_FILL_ID);
|
|
475
|
+
setNext(cssColorOrFallback(draft || value, "#000000"));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (type === "none") {
|
|
479
|
+
if (selectedLayer !== null) {
|
|
480
|
+
removeBackgroundLayer(selectedLayer);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
setNext("transparent");
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (!onBackgroundImageChange) return;
|
|
487
|
+
|
|
488
|
+
if (
|
|
489
|
+
type !== "linear" &&
|
|
490
|
+
type !== "radial" &&
|
|
491
|
+
type !== "angular" &&
|
|
492
|
+
type !== "diamond"
|
|
493
|
+
) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const nextType: FigmaGradientType = type;
|
|
497
|
+
const layerIndex = selectedLayer ?? activeGradientIndex;
|
|
498
|
+
if (layerIndex !== null) {
|
|
499
|
+
const currentGradient = parseGradientLayer(
|
|
500
|
+
backgroundLayers[layerIndex] || "",
|
|
501
|
+
);
|
|
502
|
+
const stops =
|
|
503
|
+
currentGradient?.stops ?? defaultGradientStops(draft || value);
|
|
504
|
+
replaceBackgroundLayer(layerIndex, buildGradientLayer(nextType, stops));
|
|
505
|
+
setSelectedFillId(fillLayerId(layerIndex));
|
|
506
|
+
setSelectedStopId(stops[0]?.id);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
onBackgroundImageChange(
|
|
511
|
+
joinCssLayers([
|
|
512
|
+
defaultGradientLayer(nextType, draft || value || "#000000"),
|
|
513
|
+
...backgroundLayers,
|
|
514
|
+
]),
|
|
515
|
+
);
|
|
516
|
+
setSelectedFillId(fillLayerId(0));
|
|
517
|
+
setSelectedStopId("stop-0");
|
|
518
|
+
};
|
|
519
|
+
|
|
129
520
|
return (
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
521
|
+
<FigmaColorPicker
|
|
522
|
+
label={label}
|
|
523
|
+
value={pickerValue}
|
|
524
|
+
onChange={setNext}
|
|
525
|
+
onPaintValueChange={
|
|
526
|
+
supportsLayeredFills ? handlePaintValueChange : undefined
|
|
527
|
+
}
|
|
528
|
+
blendMode={blendMode}
|
|
529
|
+
onBlendModeChange={onBlendModeChange}
|
|
530
|
+
showBlendMode={supportsLayeredFills}
|
|
531
|
+
fillRows={fillRows}
|
|
532
|
+
selectedFillId={selectedFillId}
|
|
533
|
+
onFillSelect={supportsLayeredFills ? setSelectedFillId : undefined}
|
|
534
|
+
onFillChange={supportsLayeredFills ? handleFillChange : undefined}
|
|
535
|
+
onAddFill={supportsLayeredFills ? handleAddFill : undefined}
|
|
536
|
+
onRemoveFill={supportsLayeredFills ? handleRemoveFill : undefined}
|
|
537
|
+
paintType={selectedPaintType}
|
|
538
|
+
onPaintTypeChange={handlePaintTypeChange}
|
|
539
|
+
gradientType={activeGradient?.type}
|
|
540
|
+
onGradientTypeChange={handleGradientTypeChange}
|
|
541
|
+
gradientStops={activeGradient?.stops}
|
|
542
|
+
selectedStopId={selectedStopId}
|
|
543
|
+
onGradientStopSelect={setSelectedStopId}
|
|
544
|
+
onGradientStopChange={handleGradientStopChange}
|
|
545
|
+
onAddGradientStop={
|
|
546
|
+
supportsLayeredFills ? handleAddGradientStop : undefined
|
|
547
|
+
}
|
|
548
|
+
onRemoveGradientStop={handleRemoveGradientStop}
|
|
549
|
+
documentColors={documentColors}
|
|
550
|
+
/>
|
|
150
551
|
);
|
|
151
552
|
}
|
|
152
553
|
|
|
153
|
-
|
|
554
|
+
const SOLID_FILL_ID = "solid";
|
|
555
|
+
const FILL_LAYER_PREFIX = "layer:";
|
|
556
|
+
|
|
557
|
+
interface ParsedGradientLayer {
|
|
558
|
+
type: FigmaGradientType;
|
|
559
|
+
stops: FigmaGradientStop[];
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function fillLayerId(index: number): string {
|
|
563
|
+
return `${FILL_LAYER_PREFIX}${index}`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function fillLayerIndex(id: string): number | null {
|
|
567
|
+
if (!id.startsWith(FILL_LAYER_PREFIX)) return null;
|
|
568
|
+
const index = Number(id.slice(FILL_LAYER_PREFIX.length));
|
|
569
|
+
return Number.isInteger(index) && index >= 0 ? index : null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function buildFillRows(
|
|
573
|
+
colorValue: string,
|
|
574
|
+
backgroundLayers: string[],
|
|
575
|
+
selectedFillId: string,
|
|
576
|
+
): FigmaFillRow[] {
|
|
577
|
+
const solid = parseCssColor(colorValue);
|
|
578
|
+
const rows: FigmaFillRow[] = [
|
|
579
|
+
{
|
|
580
|
+
id: SOLID_FILL_ID,
|
|
581
|
+
label: "Solid", // i18n-ignore inspector fallback label
|
|
582
|
+
type: "solid",
|
|
583
|
+
value: colorValue,
|
|
584
|
+
swatch: colorValue,
|
|
585
|
+
opacity: solid ? alphaToOpacity(solid.a) : 100,
|
|
586
|
+
selected: selectedFillId === SOLID_FILL_ID,
|
|
587
|
+
},
|
|
588
|
+
];
|
|
589
|
+
|
|
590
|
+
backgroundLayers.forEach((layer, index) => {
|
|
591
|
+
const gradient = parseGradientLayer(layer);
|
|
592
|
+
rows.push({
|
|
593
|
+
id: fillLayerId(index),
|
|
594
|
+
label: gradient
|
|
595
|
+
? `Gradient ${index + 1}` // i18n-ignore inspector fallback label
|
|
596
|
+
: `Image ${index + 1}`, // i18n-ignore inspector fallback label
|
|
597
|
+
type: gradient ? "gradient" : "image",
|
|
598
|
+
value: layer,
|
|
599
|
+
swatch: layer,
|
|
600
|
+
opacity: gradient ? averageGradientOpacity(gradient.stops) : 100,
|
|
601
|
+
selected: selectedFillId === fillLayerId(index),
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
return rows;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function averageGradientOpacity(stops: FigmaGradientStop[]): number {
|
|
609
|
+
if (!stops.length) return 100;
|
|
610
|
+
const total = stops.reduce((sum, stop) => {
|
|
611
|
+
const parsed = parseCssColor(stop.color);
|
|
612
|
+
return sum + (stop.opacity ?? (parsed ? alphaToOpacity(parsed.a) : 100));
|
|
613
|
+
}, 0);
|
|
614
|
+
return Math.round(total / stops.length);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function splitCssLayers(value: string): string[] {
|
|
154
618
|
const trimmed = value.trim();
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
619
|
+
if (!trimmed || trimmed === "none") return [];
|
|
620
|
+
const layers: string[] = [];
|
|
621
|
+
let depth = 0;
|
|
622
|
+
let start = 0;
|
|
623
|
+
|
|
624
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
625
|
+
const char = trimmed[index];
|
|
626
|
+
if (char === "(") depth += 1;
|
|
627
|
+
if (char === ")") depth = Math.max(0, depth - 1);
|
|
628
|
+
if (char === "," && depth === 0) {
|
|
629
|
+
const layer = trimmed.slice(start, index).trim();
|
|
630
|
+
if (layer) layers.push(layer);
|
|
631
|
+
start = index + 1;
|
|
632
|
+
}
|
|
162
633
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
634
|
+
|
|
635
|
+
const finalLayer = trimmed.slice(start).trim();
|
|
636
|
+
if (finalLayer) layers.push(finalLayer);
|
|
637
|
+
return layers;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function joinCssLayers(layers: string[]): string {
|
|
641
|
+
const cleaned = layers.map((layer) => layer.trim()).filter(Boolean);
|
|
642
|
+
return cleaned.length ? cleaned.join(", ") : "none";
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function parseGradientLayer(layer: string): ParsedGradientLayer | null {
|
|
646
|
+
const match = layer.trim().match(/^(linear|radial|conic)-gradient\((.*)\)$/i);
|
|
647
|
+
if (!match) return null;
|
|
648
|
+
|
|
649
|
+
const parts = splitCssLayers(match[2] || "");
|
|
650
|
+
const type = gradientTypeFromCss(match[1] || "", layer);
|
|
651
|
+
const firstStop = parseGradientStop(parts[0] || "", 0, parts.length);
|
|
652
|
+
const stopParts = firstStop ? parts : parts.slice(1);
|
|
653
|
+
const stops = stopParts
|
|
654
|
+
.map((part, index) => parseGradientStop(part, index, stopParts.length))
|
|
655
|
+
.filter((stop): stop is FigmaGradientStop => Boolean(stop));
|
|
656
|
+
|
|
657
|
+
if (!stops.length) return null;
|
|
658
|
+
return { type, stops };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function parseGradientStop(
|
|
662
|
+
part: string,
|
|
663
|
+
index: number,
|
|
664
|
+
total: number,
|
|
665
|
+
): FigmaGradientStop | null {
|
|
666
|
+
const color = readLeadingColor(part);
|
|
667
|
+
if (!color) return null;
|
|
668
|
+
const parsed = parseCssColor(color.value);
|
|
669
|
+
const remaining = part.slice(color.raw.length);
|
|
670
|
+
const positionMatch = remaining.match(/(-?\d+(?:\.\d+)?)%/);
|
|
671
|
+
const position = positionMatch
|
|
672
|
+
? clampNumber(Number(positionMatch[1]), 0, 100)
|
|
673
|
+
: total <= 1
|
|
674
|
+
? 0
|
|
675
|
+
: Math.round((index / (total - 1)) * 100);
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
id: `stop-${index}`,
|
|
679
|
+
color: parsed ? rgbaToCss(parsed) : color.value,
|
|
680
|
+
position,
|
|
681
|
+
opacity: parsed ? alphaToOpacity(parsed.a) : 100,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function readLeadingColor(part: string): { raw: string; value: string } | null {
|
|
686
|
+
const trimmed = part.trim();
|
|
687
|
+
const hex = trimmed.match(/^#[0-9a-f]{3,8}\b/i);
|
|
688
|
+
if (hex) return { raw: hex[0], value: hex[0] };
|
|
689
|
+
const transparent = trimmed.match(/^transparent\b/i);
|
|
690
|
+
if (transparent) {
|
|
691
|
+
return { raw: transparent[0], value: "rgba(0, 0, 0, 0)" };
|
|
692
|
+
}
|
|
693
|
+
const functionName = trimmed.match(/^(rgb|rgba|hsl|hsla)\(/i);
|
|
694
|
+
if (!functionName) return null;
|
|
695
|
+
let depth = 0;
|
|
696
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
697
|
+
const char = trimmed[index];
|
|
698
|
+
if (char === "(") depth += 1;
|
|
699
|
+
if (char === ")") {
|
|
700
|
+
depth -= 1;
|
|
701
|
+
if (depth === 0) {
|
|
702
|
+
const raw = trimmed.slice(0, index + 1);
|
|
703
|
+
return { raw, value: raw };
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function gradientTypeFromCss(
|
|
711
|
+
functionName: string,
|
|
712
|
+
layer: string,
|
|
713
|
+
): FigmaGradientType {
|
|
714
|
+
if (functionName.toLowerCase() === "conic") return "angular";
|
|
715
|
+
if (/closest-corner/i.test(layer)) return "diamond";
|
|
716
|
+
if (functionName.toLowerCase() === "radial") return "radial";
|
|
717
|
+
return "linear";
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function gradientLabel(type: FigmaGradientType): string {
|
|
721
|
+
if (type === "radial") {
|
|
722
|
+
return "Radial gradient"; // i18n-ignore Figma inspector paint row
|
|
723
|
+
}
|
|
724
|
+
if (type === "angular") {
|
|
725
|
+
return "Angular gradient"; // i18n-ignore Figma inspector paint row
|
|
726
|
+
}
|
|
727
|
+
if (type === "diamond") {
|
|
728
|
+
return "Diamond gradient"; // i18n-ignore Figma inspector paint row
|
|
729
|
+
}
|
|
730
|
+
return "Linear gradient"; // i18n-ignore Figma inspector paint row
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function buildGradientLayer(
|
|
734
|
+
type: FigmaGradientType,
|
|
735
|
+
stops: FigmaGradientStop[],
|
|
736
|
+
): string {
|
|
737
|
+
const stopList = [...stops]
|
|
738
|
+
.sort((a, b) => a.position - b.position)
|
|
739
|
+
.map((stop) => {
|
|
740
|
+
const parsed = parseCssColor(stop.color);
|
|
741
|
+
const opacity = stop.opacity ?? (parsed ? alphaToOpacity(parsed.a) : 100);
|
|
742
|
+
const color = parsed
|
|
743
|
+
? rgbaToCss(withColorOpacity(parsed, opacity))
|
|
744
|
+
: stop.color;
|
|
745
|
+
return `${color} ${clampNumber(stop.position, 0, 100)}%`;
|
|
746
|
+
})
|
|
747
|
+
.join(", ");
|
|
748
|
+
|
|
749
|
+
if (type === "radial") {
|
|
750
|
+
return `radial-gradient(circle at 50% 50%, ${stopList})`;
|
|
751
|
+
}
|
|
752
|
+
if (type === "angular") {
|
|
753
|
+
return `conic-gradient(from 0deg at 50% 50%, ${stopList})`;
|
|
754
|
+
}
|
|
755
|
+
if (type === "diamond") {
|
|
756
|
+
return `radial-gradient(closest-corner at 50% 50%, ${stopList})`;
|
|
175
757
|
}
|
|
176
|
-
return
|
|
758
|
+
return `linear-gradient(90deg, ${stopList})`;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function defaultGradientStops(colorValue: string): FigmaGradientStop[] {
|
|
762
|
+
const parsed =
|
|
763
|
+
parseCssColor(cssColorOrFallback(colorValue, "#000000")) ??
|
|
764
|
+
parseCssColor("#000000");
|
|
765
|
+
const start = parsed ? rgbaToCss(withColorOpacity(parsed, 100)) : "#000000";
|
|
766
|
+
const end = parsed
|
|
767
|
+
? rgbaToCss(withColorOpacity(parsed, 0))
|
|
768
|
+
: "rgba(0, 0, 0, 0)";
|
|
769
|
+
|
|
770
|
+
return [
|
|
771
|
+
{ id: "stop-0", color: start, position: 0, opacity: 100 },
|
|
772
|
+
{ id: "stop-1", color: end, position: 100, opacity: 0 },
|
|
773
|
+
];
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function defaultGradientLayer(type: FigmaGradientType, colorValue: string) {
|
|
777
|
+
return buildGradientLayer(type, defaultGradientStops(colorValue));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function clampNumber(value: number, min: number, max: number): number {
|
|
781
|
+
if (!Number.isFinite(value)) return min;
|
|
782
|
+
return Math.max(min, Math.min(max, Math.round(value)));
|
|
177
783
|
}
|
|
178
784
|
|
|
179
785
|
/** Select dropdown */
|
|
@@ -189,17 +795,19 @@ function PropSelect({
|
|
|
189
795
|
options: { value: string; label: string }[];
|
|
190
796
|
}) {
|
|
191
797
|
return (
|
|
192
|
-
<div className="flex items-center gap-
|
|
193
|
-
<
|
|
194
|
-
{label}
|
|
195
|
-
</Label>
|
|
798
|
+
<div className="flex items-center gap-1.5">
|
|
799
|
+
<FieldLabel>{label}</FieldLabel>
|
|
196
800
|
<Select value={value} onValueChange={onChange}>
|
|
197
|
-
<SelectTrigger className="h-
|
|
801
|
+
<SelectTrigger className="h-6 min-w-0 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
|
|
198
802
|
<SelectValue />
|
|
199
803
|
</SelectTrigger>
|
|
200
804
|
<SelectContent>
|
|
201
805
|
{options.map((opt) => (
|
|
202
|
-
<SelectItem
|
|
806
|
+
<SelectItem
|
|
807
|
+
key={opt.value}
|
|
808
|
+
value={opt.value}
|
|
809
|
+
className="text-[11px]"
|
|
810
|
+
>
|
|
203
811
|
{opt.label}
|
|
204
812
|
</SelectItem>
|
|
205
813
|
))}
|
|
@@ -228,10 +836,8 @@ function PropSlider({
|
|
|
228
836
|
unit?: string;
|
|
229
837
|
}) {
|
|
230
838
|
return (
|
|
231
|
-
<div className="flex items-center gap-
|
|
232
|
-
<
|
|
233
|
-
{label}
|
|
234
|
-
</Label>
|
|
839
|
+
<div className="flex items-center gap-1.5">
|
|
840
|
+
<FieldLabel>{label}</FieldLabel>
|
|
235
841
|
<Slider
|
|
236
842
|
value={[value]}
|
|
237
843
|
onValueChange={([v]) => onChange(v)}
|
|
@@ -240,7 +846,7 @@ function PropSlider({
|
|
|
240
846
|
step={step}
|
|
241
847
|
className="flex-1"
|
|
242
848
|
/>
|
|
243
|
-
<span className="text-
|
|
849
|
+
<span className="w-12 text-right text-[11px] tabular-nums text-muted-foreground">
|
|
244
850
|
{value}
|
|
245
851
|
{unit}
|
|
246
852
|
</span>
|
|
@@ -248,55 +854,72 @@ function PropSlider({
|
|
|
248
854
|
);
|
|
249
855
|
}
|
|
250
856
|
|
|
251
|
-
|
|
252
|
-
return (
|
|
253
|
-
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
|
254
|
-
{children}
|
|
255
|
-
</h3>
|
|
256
|
-
);
|
|
257
|
-
}
|
|
857
|
+
const EmptyScrubIcon = () => null;
|
|
258
858
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
859
|
+
/**
|
|
860
|
+
* Figma-style inspector section. Matches Figma's real "Design" panel chrome:
|
|
861
|
+
* - NO left collapse chevron (Figma has none).
|
|
862
|
+
* - A thin divider line above each section.
|
|
863
|
+
* - A bold left-aligned title.
|
|
864
|
+
* - Right-aligned action icons (add layer, toggles, styles, etc.).
|
|
865
|
+
*
|
|
866
|
+
* The title is still clickable to collapse the body (Figma collapses sections
|
|
867
|
+
* on title click) but renders no chevron glyph, just like Figma.
|
|
868
|
+
*/
|
|
869
|
+
function PanelSection({
|
|
870
|
+
title,
|
|
871
|
+
actions,
|
|
872
|
+
children,
|
|
873
|
+
defaultCollapsed = false,
|
|
264
874
|
}: {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
875
|
+
title: string;
|
|
876
|
+
actions?: ReactNode;
|
|
877
|
+
children?: ReactNode;
|
|
878
|
+
defaultCollapsed?: boolean;
|
|
269
879
|
}) {
|
|
270
|
-
const [
|
|
271
|
-
useEffect(() => {
|
|
272
|
-
setDraft(value);
|
|
273
|
-
}, [value]);
|
|
880
|
+
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
|
274
881
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
882
|
+
return (
|
|
883
|
+
<section className="shrink-0 border-t border-[var(--design-editor-control-border)] first:border-t-0">
|
|
884
|
+
<div className="flex min-h-9 items-center gap-2 px-3">
|
|
885
|
+
<button
|
|
886
|
+
type="button"
|
|
887
|
+
className="flex min-w-0 flex-1 cursor-pointer items-center bg-transparent text-left"
|
|
888
|
+
onClick={() => setCollapsed((c) => !c)}
|
|
889
|
+
aria-expanded={!collapsed}
|
|
890
|
+
>
|
|
891
|
+
<h3 className="min-w-0 flex-1 truncate text-[11px] font-semibold text-foreground">
|
|
892
|
+
{title}
|
|
893
|
+
</h3>
|
|
894
|
+
</button>
|
|
895
|
+
{actions ? (
|
|
896
|
+
<div className="flex shrink-0 items-center gap-0.5">{actions}</div>
|
|
897
|
+
) : null}
|
|
898
|
+
</div>
|
|
899
|
+
{!collapsed && children ? (
|
|
900
|
+
<div className="space-y-1.5 px-3 pb-3 pt-0.5 text-[11px]">
|
|
901
|
+
{children}
|
|
902
|
+
</div>
|
|
903
|
+
) : null}
|
|
904
|
+
</section>
|
|
905
|
+
);
|
|
906
|
+
}
|
|
280
907
|
|
|
908
|
+
function FieldLabel({ children }: { children: ReactNode }) {
|
|
281
909
|
return (
|
|
282
|
-
<
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
}}
|
|
293
|
-
placeholder={placeholder}
|
|
294
|
-
className="h-7 text-xs text-center"
|
|
295
|
-
/>
|
|
910
|
+
<Label className="w-[64px] shrink-0 text-[11px] font-medium text-muted-foreground">
|
|
911
|
+
{children}
|
|
912
|
+
</Label>
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function SubsectionLabel({ children }: { children: ReactNode }) {
|
|
917
|
+
return (
|
|
918
|
+
<p className="text-[11px] font-medium text-muted-foreground">{children}</p>
|
|
296
919
|
);
|
|
297
920
|
}
|
|
298
921
|
|
|
299
|
-
function
|
|
922
|
+
function FigmaSpacingControl({
|
|
300
923
|
label,
|
|
301
924
|
values,
|
|
302
925
|
onChange,
|
|
@@ -306,39 +929,128 @@ function FourSideInput({
|
|
|
306
929
|
onChange: (side: string, value: string) => void;
|
|
307
930
|
}) {
|
|
308
931
|
const t = useT();
|
|
932
|
+
const [linked, setLinked] = useState(() => sidesAreLinked(values));
|
|
933
|
+
const numeric = {
|
|
934
|
+
top: parseNumericValue(values.top || "0"),
|
|
935
|
+
right: parseNumericValue(values.right || "0"),
|
|
936
|
+
bottom: parseNumericValue(values.bottom || "0"),
|
|
937
|
+
left: parseNumericValue(values.left || "0"),
|
|
938
|
+
};
|
|
939
|
+
const linkedValue = Math.round(
|
|
940
|
+
(numeric.top + numeric.right + numeric.bottom + numeric.left) / 4,
|
|
941
|
+
);
|
|
942
|
+
const setSide = (
|
|
943
|
+
side: "Top" | "Right" | "Bottom" | "Left",
|
|
944
|
+
value: number,
|
|
945
|
+
) => {
|
|
946
|
+
onChange(side, `${Math.round(value)}px`);
|
|
947
|
+
};
|
|
948
|
+
const setAll = (value: number) => {
|
|
949
|
+
(["Top", "Right", "Bottom", "Left"] as const).forEach((side) =>
|
|
950
|
+
setSide(side, value),
|
|
951
|
+
);
|
|
952
|
+
};
|
|
953
|
+
const linkedLabel = linked
|
|
954
|
+
? t("editPanel.labels.unlinkSides")
|
|
955
|
+
: t("editPanel.labels.linkSides");
|
|
956
|
+
|
|
309
957
|
return (
|
|
310
958
|
<div className="space-y-1.5">
|
|
311
|
-
<
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
value={values.left}
|
|
335
|
-
onChange={onChange}
|
|
336
|
-
/>
|
|
959
|
+
<div className="flex items-center justify-between gap-1.5">
|
|
960
|
+
<Label className="text-[11px] font-medium text-muted-foreground">
|
|
961
|
+
{label}
|
|
962
|
+
</Label>
|
|
963
|
+
<Tooltip>
|
|
964
|
+
<TooltipTrigger asChild>
|
|
965
|
+
<Button
|
|
966
|
+
type="button"
|
|
967
|
+
variant="ghost"
|
|
968
|
+
size="icon"
|
|
969
|
+
className="size-6 rounded-md text-muted-foreground hover:text-foreground"
|
|
970
|
+
onClick={() => setLinked((current) => !current)}
|
|
971
|
+
aria-label={linkedLabel}
|
|
972
|
+
>
|
|
973
|
+
{linked ? (
|
|
974
|
+
<IconLink className="size-3.5" />
|
|
975
|
+
) : (
|
|
976
|
+
<IconUnlink className="size-3.5" />
|
|
977
|
+
)}
|
|
978
|
+
</Button>
|
|
979
|
+
</TooltipTrigger>
|
|
980
|
+
<TooltipContent>{linkedLabel}</TooltipContent>
|
|
981
|
+
</Tooltip>
|
|
337
982
|
</div>
|
|
983
|
+
{linked ? (
|
|
984
|
+
<ScrubInput
|
|
985
|
+
label={t("editPanel.labels.allSides")}
|
|
986
|
+
value={linkedValue}
|
|
987
|
+
onChange={setAll}
|
|
988
|
+
unit="px"
|
|
989
|
+
min={0}
|
|
990
|
+
precision={1}
|
|
991
|
+
labelClassName="w-16"
|
|
992
|
+
inputClassName="h-6"
|
|
993
|
+
/>
|
|
994
|
+
) : (
|
|
995
|
+
<div className="grid grid-cols-2 gap-1.5">
|
|
996
|
+
<ScrubInput
|
|
997
|
+
label={t("editPanel.sidePlaceholders.top")}
|
|
998
|
+
value={numeric.top}
|
|
999
|
+
onChange={(value) => setSide("Top", value)}
|
|
1000
|
+
unit="px"
|
|
1001
|
+
min={0}
|
|
1002
|
+
precision={1}
|
|
1003
|
+
inputClassName="h-6"
|
|
1004
|
+
/>
|
|
1005
|
+
<ScrubInput
|
|
1006
|
+
label={t("editPanel.sidePlaceholders.right")}
|
|
1007
|
+
value={numeric.right}
|
|
1008
|
+
onChange={(value) => setSide("Right", value)}
|
|
1009
|
+
unit="px"
|
|
1010
|
+
min={0}
|
|
1011
|
+
precision={1}
|
|
1012
|
+
inputClassName="h-6"
|
|
1013
|
+
/>
|
|
1014
|
+
<ScrubInput
|
|
1015
|
+
label={t("editPanel.sidePlaceholders.bottom")}
|
|
1016
|
+
value={numeric.bottom}
|
|
1017
|
+
onChange={(value) => setSide("Bottom", value)}
|
|
1018
|
+
unit="px"
|
|
1019
|
+
min={0}
|
|
1020
|
+
precision={1}
|
|
1021
|
+
inputClassName="h-6"
|
|
1022
|
+
/>
|
|
1023
|
+
<ScrubInput
|
|
1024
|
+
label={t("editPanel.sidePlaceholders.left")}
|
|
1025
|
+
value={numeric.left}
|
|
1026
|
+
onChange={(value) => setSide("Left", value)}
|
|
1027
|
+
unit="px"
|
|
1028
|
+
min={0}
|
|
1029
|
+
precision={1}
|
|
1030
|
+
inputClassName="h-6"
|
|
1031
|
+
/>
|
|
1032
|
+
</div>
|
|
1033
|
+
)}
|
|
338
1034
|
</div>
|
|
339
1035
|
);
|
|
340
1036
|
}
|
|
341
1037
|
|
|
1038
|
+
function sidesAreLinked(values: {
|
|
1039
|
+
top: string;
|
|
1040
|
+
right: string;
|
|
1041
|
+
bottom: string;
|
|
1042
|
+
left: string;
|
|
1043
|
+
}) {
|
|
1044
|
+
return (
|
|
1045
|
+
parseNumericValue(values.top || "0") ===
|
|
1046
|
+
parseNumericValue(values.right || "0") &&
|
|
1047
|
+
parseNumericValue(values.top || "0") ===
|
|
1048
|
+
parseNumericValue(values.bottom || "0") &&
|
|
1049
|
+
parseNumericValue(values.top || "0") ===
|
|
1050
|
+
parseNumericValue(values.left || "0")
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
342
1054
|
const FONT_FAMILY_OPTIONS = [
|
|
343
1055
|
{ value: "inherit", key: "inherit" },
|
|
344
1056
|
{ value: "sans-serif", key: "sansSerif" },
|
|
@@ -362,224 +1074,1682 @@ const FONT_WEIGHT_OPTIONS = [
|
|
|
362
1074
|
{ value: "900", key: "black" },
|
|
363
1075
|
] as const;
|
|
364
1076
|
|
|
365
|
-
const
|
|
366
|
-
{ value: "
|
|
367
|
-
{ value: "center", key: "center" },
|
|
368
|
-
{ value: "right", key: "right" },
|
|
369
|
-
{ value: "justify", key: "justify" },
|
|
370
|
-
] as const;
|
|
371
|
-
const FLEX_DIRECTION_OPTIONS = [
|
|
372
|
-
{ value: "row", key: "row" },
|
|
373
|
-
{ value: "column", key: "column" },
|
|
374
|
-
{ value: "row-reverse", key: "rowReverse" },
|
|
375
|
-
{ value: "column-reverse", key: "columnReverse" },
|
|
376
|
-
] as const;
|
|
377
|
-
const JUSTIFY_OPTIONS = [
|
|
378
|
-
{ value: "flex-start", key: "start" },
|
|
379
|
-
{ value: "center", key: "center" },
|
|
380
|
-
{ value: "flex-end", key: "end" },
|
|
381
|
-
{ value: "space-between", key: "between" },
|
|
382
|
-
{ value: "space-around", key: "around" },
|
|
383
|
-
{ value: "space-evenly", key: "evenly" },
|
|
384
|
-
] as const;
|
|
385
|
-
const ALIGN_OPTIONS = [
|
|
1077
|
+
const ALIGN_SELF_OPTIONS = [
|
|
1078
|
+
{ value: "auto", key: "auto" },
|
|
386
1079
|
{ value: "flex-start", key: "start" },
|
|
387
1080
|
{ value: "center", key: "center" },
|
|
388
1081
|
{ value: "flex-end", key: "end" },
|
|
389
1082
|
{ value: "stretch", key: "stretch" },
|
|
390
1083
|
{ value: "baseline", key: "baseline" },
|
|
391
1084
|
] as const;
|
|
1085
|
+
const STROKE_POSITION_OPTIONS = [
|
|
1086
|
+
{ value: "inside", key: "inside" },
|
|
1087
|
+
{ value: "center", key: "center" },
|
|
1088
|
+
{ value: "outside", key: "outside" },
|
|
1089
|
+
] as const;
|
|
1090
|
+
const BLEND_MODE_OPTIONS = [
|
|
1091
|
+
{ value: "normal", label: "Normal" },
|
|
1092
|
+
{ value: "multiply", label: "Multiply" },
|
|
1093
|
+
{ value: "screen", label: "Screen" },
|
|
1094
|
+
{ value: "overlay", label: "Overlay" },
|
|
1095
|
+
{ value: "darken", label: "Darken" },
|
|
1096
|
+
{ value: "lighten", label: "Lighten" },
|
|
1097
|
+
{ value: "color-dodge", label: "Color dodge" }, // i18n-ignore Figma blend mode label
|
|
1098
|
+
{ value: "color-burn", label: "Color burn" }, // i18n-ignore Figma blend mode label
|
|
1099
|
+
{ value: "hard-light", label: "Hard light" }, // i18n-ignore Figma blend mode label
|
|
1100
|
+
{ value: "soft-light", label: "Soft light" }, // i18n-ignore Figma blend mode label
|
|
1101
|
+
{ value: "difference", label: "Difference" },
|
|
1102
|
+
{ value: "exclusion", label: "Exclusion" },
|
|
1103
|
+
{ value: "hue", label: "Hue" },
|
|
1104
|
+
{ value: "saturation", label: "Saturation" },
|
|
1105
|
+
{ value: "color", label: "Color" },
|
|
1106
|
+
{ value: "luminosity", label: "Luminosity" },
|
|
1107
|
+
] as const;
|
|
392
1108
|
|
|
393
1109
|
function parseNumericValue(value: string): number {
|
|
394
1110
|
return parseFloat(value) || 0;
|
|
395
1111
|
}
|
|
396
1112
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}: {
|
|
402
|
-
styles: Record<string, string>;
|
|
403
|
-
onStyleChange: (property: string, value: string) => void;
|
|
404
|
-
}) {
|
|
405
|
-
const t = useT();
|
|
406
|
-
const fontFamilyOptions = FONT_FAMILY_OPTIONS.map((option) => ({
|
|
407
|
-
value: option.value,
|
|
408
|
-
label: t(`editPanel.fontFamilies.${option.key}`),
|
|
409
|
-
}));
|
|
410
|
-
const fontFamily = FONT_FAMILY_OPTIONS.some(
|
|
411
|
-
(option) => option.value === styles.fontFamily,
|
|
412
|
-
)
|
|
413
|
-
? styles.fontFamily
|
|
414
|
-
: "sans-serif";
|
|
415
|
-
|
|
416
|
-
return (
|
|
417
|
-
<div className="space-y-4">
|
|
418
|
-
{/* Lead with a clear CTA so users discover the much richer per-element
|
|
419
|
-
panel. Without this it's easy to mistake the 3 page-level fields for
|
|
420
|
-
"the entire editor" — the cause of the "controls too limited"
|
|
421
|
-
feedback. */}
|
|
422
|
-
<div className="rounded-lg border border-border/70 bg-accent/30 p-3 text-xs text-muted-foreground/90 leading-relaxed">
|
|
423
|
-
<p className="font-medium text-foreground/85 mb-1 flex items-center gap-1.5">
|
|
424
|
-
<IconPointer className="w-3.5 h-3.5" />
|
|
425
|
-
{t("editPanel.pageHelpTitle")}
|
|
426
|
-
</p>
|
|
427
|
-
<p>{t("editPanel.pageHelpDescription")}</p>
|
|
428
|
-
</div>
|
|
1113
|
+
function parseRotationValue(transform: string | undefined): number {
|
|
1114
|
+
const match = transform?.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
|
|
1115
|
+
return match ? Number(match[1]) : 0;
|
|
1116
|
+
}
|
|
429
1117
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
label={t("editPanel.labels.font")}
|
|
438
|
-
value={fontFamily}
|
|
439
|
-
onChange={(v) => onStyleChange("fontFamily", v)}
|
|
440
|
-
options={fontFamilyOptions}
|
|
441
|
-
/>
|
|
442
|
-
<PropInput
|
|
443
|
-
label={t("editPanel.labels.baseSize")}
|
|
444
|
-
value={styles.fontSize || "16px"}
|
|
445
|
-
onChange={(v) => onStyleChange("fontSize", v)}
|
|
446
|
-
placeholder="16px"
|
|
447
|
-
defaultUnit="px"
|
|
448
|
-
/>
|
|
449
|
-
</div>
|
|
450
|
-
);
|
|
1118
|
+
function mergeRotationValue(transform: string | undefined, degrees: number) {
|
|
1119
|
+
const nextRotate = `rotate(${Math.round(degrees * 10) / 10}deg)`;
|
|
1120
|
+
if (!transform || transform === "none") return nextRotate;
|
|
1121
|
+
if (/rotate\((-?\d+(?:\.\d+)?)deg\)/.test(transform)) {
|
|
1122
|
+
return transform.replace(/rotate\((-?\d+(?:\.\d+)?)deg\)/, nextRotate);
|
|
1123
|
+
}
|
|
1124
|
+
return `${transform} ${nextRotate}`;
|
|
451
1125
|
}
|
|
452
1126
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
1127
|
+
function ScrubStyleInput({
|
|
1128
|
+
label,
|
|
1129
|
+
value,
|
|
1130
|
+
placeholder,
|
|
1131
|
+
onChange,
|
|
1132
|
+
unit = "px",
|
|
1133
|
+
min,
|
|
1134
|
+
max,
|
|
1135
|
+
step = 1,
|
|
1136
|
+
labelClassName,
|
|
1137
|
+
inputClassName,
|
|
1138
|
+
hideIcon = true,
|
|
457
1139
|
}: {
|
|
458
|
-
|
|
459
|
-
|
|
1140
|
+
label: string;
|
|
1141
|
+
value: string;
|
|
1142
|
+
placeholder?: number;
|
|
1143
|
+
onChange: (value: number) => void;
|
|
1144
|
+
unit?: string;
|
|
1145
|
+
min?: number;
|
|
1146
|
+
max?: number;
|
|
1147
|
+
step?: number;
|
|
1148
|
+
labelClassName?: string;
|
|
1149
|
+
inputClassName?: string;
|
|
1150
|
+
hideIcon?: boolean;
|
|
460
1151
|
}) {
|
|
461
|
-
const t = useT();
|
|
462
|
-
const styles = element.computedStyles;
|
|
463
|
-
const fontFamilyOptions = FONT_FAMILY_OPTIONS.map((option) => ({
|
|
464
|
-
value: option.value,
|
|
465
|
-
label: t(`editPanel.fontFamilies.${option.key}`),
|
|
466
|
-
}));
|
|
467
|
-
const fontWeightOptions = FONT_WEIGHT_OPTIONS.map((option) => ({
|
|
468
|
-
value: option.value,
|
|
469
|
-
label: t(`editPanel.fontWeights.${option.key}`),
|
|
470
|
-
}));
|
|
471
|
-
const textAlignOptions = TEXT_ALIGN_OPTIONS.map((option) => ({
|
|
472
|
-
value: option.value,
|
|
473
|
-
label: t(`editPanel.textAligns.${option.key}`),
|
|
474
|
-
}));
|
|
475
|
-
|
|
476
1152
|
return (
|
|
477
|
-
<
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
/>
|
|
498
|
-
<ColorInput
|
|
499
|
-
label={t("editPanel.labels.color")}
|
|
500
|
-
value={styles.color || ""}
|
|
501
|
-
onChange={(v) => onStyleChange("color", v)}
|
|
502
|
-
/>
|
|
503
|
-
<PropSelect
|
|
504
|
-
label={t("editPanel.labels.align")}
|
|
505
|
-
value={styles.textAlign || "left"}
|
|
506
|
-
onChange={(v) => onStyleChange("textAlign", v)}
|
|
507
|
-
options={textAlignOptions}
|
|
508
|
-
/>
|
|
509
|
-
<PropInput
|
|
510
|
-
label={t("editPanel.labels.lineHeight")}
|
|
511
|
-
value={styles.lineHeight || ""}
|
|
512
|
-
onChange={(v) => onStyleChange("lineHeight", v)}
|
|
513
|
-
placeholder="1.5"
|
|
514
|
-
/>
|
|
515
|
-
<PropInput
|
|
516
|
-
label={t("editPanel.labels.tracking")}
|
|
517
|
-
value={styles.letterSpacing || ""}
|
|
518
|
-
onChange={(v) => onStyleChange("letterSpacing", v)}
|
|
519
|
-
placeholder="0px"
|
|
520
|
-
defaultUnit="px"
|
|
521
|
-
/>
|
|
522
|
-
</div>
|
|
1153
|
+
<ScrubInput
|
|
1154
|
+
label={label}
|
|
1155
|
+
icon={hideIcon ? EmptyScrubIcon : undefined}
|
|
1156
|
+
value={value ? parseNumericValue(value) : (placeholder ?? 0)}
|
|
1157
|
+
onChange={onChange}
|
|
1158
|
+
unit={unit}
|
|
1159
|
+
min={min}
|
|
1160
|
+
max={max}
|
|
1161
|
+
step={step}
|
|
1162
|
+
precision={1}
|
|
1163
|
+
className="gap-0"
|
|
1164
|
+
labelClassName={cn(
|
|
1165
|
+
"h-6 w-7 justify-center gap-0 rounded-l-md border border-r-0 border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-[11px] tabular-nums",
|
|
1166
|
+
labelClassName,
|
|
1167
|
+
)}
|
|
1168
|
+
inputClassName={cn(
|
|
1169
|
+
"h-6 rounded-l-none rounded-r-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]",
|
|
1170
|
+
inputClassName,
|
|
1171
|
+
)}
|
|
1172
|
+
/>
|
|
523
1173
|
);
|
|
524
1174
|
}
|
|
525
1175
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
1176
|
+
function commitStylePatch(
|
|
1177
|
+
styles: Record<string, string>,
|
|
1178
|
+
onStyleChange: (property: string, value: string) => void,
|
|
1179
|
+
onStylesChange?: (styles: Record<string, string>) => void,
|
|
1180
|
+
) {
|
|
1181
|
+
if (onStylesChange) {
|
|
1182
|
+
onStylesChange(styles);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
Object.entries(styles).forEach(([property, value]) => {
|
|
1186
|
+
onStyleChange(property, value);
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function optionValue<T extends readonly { value: string }[]>(
|
|
1191
|
+
options: T,
|
|
1192
|
+
value: string | undefined,
|
|
1193
|
+
fallback: T[number]["value"],
|
|
1194
|
+
) {
|
|
1195
|
+
return options.some((option) => option.value === value) ? value! : fallback;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function cssLengthNumber(value: string | undefined, fallback = 0): number {
|
|
1199
|
+
const parsed = parseFloat(value || "");
|
|
1200
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function cssColorOrFallback(value: string | undefined, fallback: string) {
|
|
1204
|
+
const normalized = value?.trim();
|
|
1205
|
+
if (
|
|
1206
|
+
!normalized ||
|
|
1207
|
+
normalized === "transparent" ||
|
|
1208
|
+
normalized === "rgba(0, 0, 0, 0)"
|
|
1209
|
+
) {
|
|
1210
|
+
return fallback;
|
|
1211
|
+
}
|
|
1212
|
+
return normalized;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function strokeIsVisible(width: string | undefined, style: string | undefined) {
|
|
1216
|
+
return cssLengthNumber(width) > 0 && style !== "none";
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function swatchStyle(value: string | undefined) {
|
|
1220
|
+
return {
|
|
1221
|
+
background:
|
|
1222
|
+
value && value !== "none"
|
|
1223
|
+
? value
|
|
1224
|
+
: "linear-gradient(135deg, hsl(var(--muted)) 0 45%, hsl(var(--border)) 45% 55%, hsl(var(--muted)) 55% 100%)",
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function compactCssValue(value: string | undefined, fallback: string) {
|
|
1229
|
+
const normalized = value?.trim();
|
|
1230
|
+
if (!normalized || normalized === "none") return fallback;
|
|
1231
|
+
return normalized;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function colorHasVisibleAlpha(value: string | undefined): boolean {
|
|
1235
|
+
const parsed = parseCssColor(value || "");
|
|
1236
|
+
if (!parsed) return Boolean(value && value !== "transparent");
|
|
1237
|
+
return parsed.a > 0;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function inspectorObjectTitle(element: ElementInfo): string {
|
|
1241
|
+
const tag = element.tagName || "element";
|
|
1242
|
+
if (TEXT_TAGS.has(tag)) return "Text";
|
|
1243
|
+
if (tag === "section") return "Section";
|
|
1244
|
+
if (tag === "img" || tag === "video" || tag === "picture") return "Image";
|
|
1245
|
+
if (tag === "button" || tag === "a") return "Button";
|
|
1246
|
+
if (tag === "svg" || tag === "path") return "Vector";
|
|
1247
|
+
if (element.isFlexContainer || tag === "div" || tag === "main") {
|
|
1248
|
+
return "Frame";
|
|
1249
|
+
}
|
|
1250
|
+
return tag.charAt(0).toUpperCase() + tag.slice(1);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function displayLabel(value: string | undefined): string {
|
|
1254
|
+
const normalized = value?.trim();
|
|
1255
|
+
if (!normalized || normalized === "normal") return "flow";
|
|
1256
|
+
return normalized;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function justifyToHorizontal(
|
|
1260
|
+
value: string | undefined,
|
|
1261
|
+
): AlignmentMatrixValue["horizontal"] {
|
|
1262
|
+
if (value === "center") return "center";
|
|
1263
|
+
if (value === "flex-end" || value === "end" || value === "right") {
|
|
1264
|
+
return "right";
|
|
1265
|
+
}
|
|
1266
|
+
return "left";
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function alignToVertical(
|
|
1270
|
+
value: string | undefined,
|
|
1271
|
+
): AlignmentMatrixValue["vertical"] {
|
|
1272
|
+
if (value === "center") return "middle";
|
|
1273
|
+
if (value === "flex-end" || value === "end" || value === "bottom") {
|
|
1274
|
+
return "bottom";
|
|
1275
|
+
}
|
|
1276
|
+
return "top";
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function horizontalToJustify(
|
|
1280
|
+
value: AlignmentMatrixValue["horizontal"],
|
|
1281
|
+
): string {
|
|
1282
|
+
if (value === "center") return "center";
|
|
1283
|
+
if (value === "right") return "flex-end";
|
|
1284
|
+
return "flex-start";
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function verticalToAlign(value: AlignmentMatrixValue["vertical"]): string {
|
|
1288
|
+
if (value === "middle") return "center";
|
|
1289
|
+
if (value === "bottom") return "flex-end";
|
|
1290
|
+
return "flex-start";
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function autoLayoutAlignmentFromStyles(
|
|
1294
|
+
styles: Record<string, string>,
|
|
1295
|
+
direction: AutoLayoutMatrixValue["direction"],
|
|
1296
|
+
): AlignmentMatrixValue {
|
|
1297
|
+
if (direction === "vertical") {
|
|
1298
|
+
return {
|
|
1299
|
+
horizontal: justifyToHorizontal(styles.alignItems),
|
|
1300
|
+
vertical: alignToVertical(styles.justifyContent),
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
horizontal: justifyToHorizontal(styles.justifyContent),
|
|
1305
|
+
vertical: alignToVertical(styles.alignItems),
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Block-level container tags that act like Figma frames. Selecting any of
|
|
1311
|
+
* these shows the Auto layout section (in an "add" state when not yet flex),
|
|
1312
|
+
* mirroring Figma where any frame/container exposes auto-layout controls.
|
|
1313
|
+
*/
|
|
1314
|
+
const CONTAINER_TAGS = new Set([
|
|
1315
|
+
"div",
|
|
1316
|
+
"section",
|
|
1317
|
+
"main",
|
|
1318
|
+
"header",
|
|
1319
|
+
"footer",
|
|
1320
|
+
"nav",
|
|
1321
|
+
"article",
|
|
1322
|
+
"aside",
|
|
1323
|
+
"form",
|
|
1324
|
+
"ul",
|
|
1325
|
+
"ol",
|
|
1326
|
+
"figure",
|
|
1327
|
+
"fieldset",
|
|
1328
|
+
"details",
|
|
1329
|
+
"dialog",
|
|
1330
|
+
"blockquote",
|
|
1331
|
+
"table",
|
|
1332
|
+
"tbody",
|
|
1333
|
+
"thead",
|
|
1334
|
+
"tr",
|
|
1335
|
+
]);
|
|
1336
|
+
|
|
1337
|
+
/** Leaf tags that never get auto-layout (text, media, vectors, controls). */
|
|
1338
|
+
const LEAF_TAGS = new Set([
|
|
1339
|
+
"img",
|
|
1340
|
+
"video",
|
|
1341
|
+
"picture",
|
|
1342
|
+
"audio",
|
|
1343
|
+
"canvas",
|
|
1344
|
+
"svg",
|
|
1345
|
+
"path",
|
|
1346
|
+
"input",
|
|
1347
|
+
"textarea",
|
|
1348
|
+
"select",
|
|
1349
|
+
"br",
|
|
1350
|
+
"hr",
|
|
1351
|
+
"iframe",
|
|
1352
|
+
]);
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Whether the element should expose the Auto layout section. True for anything
|
|
1356
|
+
* already laid out with flexbox, or any block-level container tag that isn't a
|
|
1357
|
+
* known leaf/text element. This is what makes a plain frame/container with
|
|
1358
|
+
* children show the full Auto layout section like Figma does.
|
|
1359
|
+
*/
|
|
1360
|
+
function isContainerElement(element: ElementInfo): boolean {
|
|
1361
|
+
if (element.isFlexContainer) return true;
|
|
1362
|
+
const tag = (element.tagName || "").toLowerCase();
|
|
1363
|
+
if (TEXT_TAGS.has(tag) || LEAF_TAGS.has(tag)) return false;
|
|
1364
|
+
return CONTAINER_TAGS.has(tag);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function isParentFlex(element: ElementInfo): boolean {
|
|
1368
|
+
return (
|
|
1369
|
+
element.isFlexChild ||
|
|
1370
|
+
Boolean(element.parentDisplay?.toLowerCase().includes("flex"))
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function isParentGrid(element: ElementInfo): boolean {
|
|
1375
|
+
return Boolean(element.parentDisplay?.toLowerCase().includes("grid"));
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function parentFlexDirection(element: ElementInfo): AutoLayoutSizingAxis {
|
|
1379
|
+
return element.parentLayout?.flexDirection?.includes("column")
|
|
1380
|
+
? "vertical"
|
|
1381
|
+
: "horizontal";
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function isTextElement(element: ElementInfo): boolean {
|
|
1385
|
+
return TEXT_TAGS.has((element.tagName || "").toLowerCase());
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* Per-axis sizing availability following Figma's contextual rules:
|
|
1390
|
+
* - Fixed: always.
|
|
1391
|
+
* - Hug contents: only CONTAINERS (flex/container frames) and TEXT can hug
|
|
1392
|
+
* their content. Leaves like img/svg/input cannot.
|
|
1393
|
+
* - Fill container: only when the element is a CHILD of a flex/grid (auto
|
|
1394
|
+
* layout) parent, OR a block-flow child (which fills via width:100%).
|
|
1395
|
+
* Hug applies to width and height independently; the same set is offered on
|
|
1396
|
+
* both axes here and the per-axis CSS in `commitElementSizing` resolves the
|
|
1397
|
+
* exact behavior (main-axis grow vs cross-axis stretch).
|
|
1398
|
+
*/
|
|
1399
|
+
function availableSizingForElement(
|
|
1400
|
+
element: ElementInfo,
|
|
1401
|
+
): Partial<Record<AutoLayoutSizingAxis, AutoLayoutSizing[]>> {
|
|
1402
|
+
const canHug = isContainerElement(element) || isTextElement(element);
|
|
1403
|
+
const isFlexChildEl = isParentFlex(element) || isParentGrid(element);
|
|
1404
|
+
// Block-flow children can still "fill" via width:100% on the horizontal axis.
|
|
1405
|
+
const isBlockChild = Boolean(element.parentDisplay) && !isFlexChildEl;
|
|
1406
|
+
|
|
1407
|
+
const buildAxis = (axis: AutoLayoutSizingAxis): AutoLayoutSizing[] => {
|
|
1408
|
+
const options: AutoLayoutSizing[] = ["fixed"];
|
|
1409
|
+
if (canHug) options.push("hug");
|
|
1410
|
+
// Fill: flex/grid child on either axis; block child only fills width.
|
|
1411
|
+
if (isFlexChildEl || (isBlockChild && axis === "horizontal")) {
|
|
1412
|
+
options.push("fill");
|
|
1413
|
+
}
|
|
1414
|
+
return options;
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
return {
|
|
1418
|
+
horizontal: buildAxis("horizontal"),
|
|
1419
|
+
vertical: buildAxis("vertical"),
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/** Read the currently-set min/max constraints (px) for a sizing axis. */
|
|
1424
|
+
function readElementMinMax(
|
|
1425
|
+
element: ElementInfo,
|
|
1426
|
+
axis: AutoLayoutSizingAxis,
|
|
1427
|
+
): { min: number | null; max: number | null } {
|
|
1428
|
+
const styles = element.computedStyles;
|
|
1429
|
+
const minRaw = axis === "horizontal" ? styles.minWidth : styles.minHeight;
|
|
1430
|
+
const maxRaw = axis === "horizontal" ? styles.maxWidth : styles.maxHeight;
|
|
1431
|
+
return {
|
|
1432
|
+
min: parseConstraintLength(minRaw),
|
|
1433
|
+
max: parseConstraintLength(maxRaw),
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Parse a min/max CSS length into a px number, or null when unset. Browser
|
|
1439
|
+
* computed values are "0px"/"none" for the defaults — both read as "not set"
|
|
1440
|
+
* so we don't surface a constraint sub-row the user never added.
|
|
1441
|
+
*/
|
|
1442
|
+
function parseConstraintLength(value: string | undefined): number | null {
|
|
1443
|
+
const normalized = value?.trim().toLowerCase();
|
|
1444
|
+
if (
|
|
1445
|
+
!normalized ||
|
|
1446
|
+
normalized === "none" ||
|
|
1447
|
+
normalized === "auto" ||
|
|
1448
|
+
normalized === "0px" ||
|
|
1449
|
+
normalized === "0"
|
|
1450
|
+
) {
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
const parsed = Number.parseFloat(normalized);
|
|
1454
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** Commit a single min/max constraint (px) or clear it when value is null. */
|
|
1458
|
+
function commitElementMinMax(
|
|
1459
|
+
axis: AutoLayoutSizingAxis,
|
|
1460
|
+
kind: "min" | "max",
|
|
1461
|
+
value: number | null,
|
|
1462
|
+
onStyleChange: (property: string, value: string) => void,
|
|
1463
|
+
) {
|
|
1464
|
+
const isHorizontal = axis === "horizontal";
|
|
1465
|
+
const property =
|
|
1466
|
+
kind === "min"
|
|
1467
|
+
? isHorizontal
|
|
1468
|
+
? "minWidth"
|
|
1469
|
+
: "minHeight"
|
|
1470
|
+
: isHorizontal
|
|
1471
|
+
? "maxWidth"
|
|
1472
|
+
: "maxHeight";
|
|
1473
|
+
if (value == null) {
|
|
1474
|
+
// Clearing: min → 0 (CSS initial), max → none (CSS initial).
|
|
1475
|
+
onStyleChange(property, kind === "min" ? "0px" : "none");
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
onStyleChange(property, `${Math.max(0, Math.round(value))}px`);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function inferElementSizing(
|
|
1482
|
+
element: ElementInfo,
|
|
1483
|
+
axis: AutoLayoutSizingAxis,
|
|
1484
|
+
): AutoLayoutSizing {
|
|
1485
|
+
const styles = element.computedStyles;
|
|
1486
|
+
const size = axis === "horizontal" ? styles.width : styles.height;
|
|
1487
|
+
const parentDirection = parentFlexDirection(element);
|
|
1488
|
+
const isFlex = isParentFlex(element);
|
|
1489
|
+
const isMainFlexAxis = isFlex && parentDirection === axis;
|
|
1490
|
+
const isCrossFlexAxis = isFlex && parentDirection !== axis;
|
|
1491
|
+
const alignSelf = (styles.alignSelf || "").toLowerCase();
|
|
1492
|
+
|
|
1493
|
+
if (
|
|
1494
|
+
size === "100%" ||
|
|
1495
|
+
(isMainFlexAxis && Number.parseFloat(styles.flexGrow || "0") > 0) ||
|
|
1496
|
+
(isCrossFlexAxis && alignSelf === "stretch")
|
|
1497
|
+
) {
|
|
1498
|
+
return "fill";
|
|
1499
|
+
}
|
|
1500
|
+
if (size === "auto" || size === "fit-content" || size === "max-content") {
|
|
1501
|
+
return "hug";
|
|
1502
|
+
}
|
|
1503
|
+
return "fixed";
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function commitElementSizing(
|
|
1507
|
+
element: ElementInfo,
|
|
1508
|
+
axis: AutoLayoutSizingAxis,
|
|
1509
|
+
sizing: AutoLayoutSizing,
|
|
1510
|
+
onStyleChange: (property: string, value: string) => void,
|
|
1511
|
+
onStylesChange?: (styles: Record<string, string>) => void,
|
|
1512
|
+
) {
|
|
1513
|
+
const isHorizontal = axis === "horizontal";
|
|
1514
|
+
const sizeProperty = isHorizontal ? "width" : "height";
|
|
1515
|
+
const resolvedSize = Math.max(
|
|
1516
|
+
1,
|
|
1517
|
+
Math.round(
|
|
1518
|
+
isHorizontal ? element.boundingRect.width : element.boundingRect.height,
|
|
1519
|
+
),
|
|
1520
|
+
);
|
|
1521
|
+
const parentDirection = parentFlexDirection(element);
|
|
1522
|
+
const isFlex = isParentFlex(element);
|
|
1523
|
+
const isGrid = isParentGrid(element);
|
|
1524
|
+
const isMainFlexAxis = isFlex && parentDirection === axis;
|
|
1525
|
+
const patch: Record<string, string> = {};
|
|
1526
|
+
|
|
1527
|
+
if (sizing === "fixed") {
|
|
1528
|
+
// Fixed → explicit px dimension. Reset any grow/stretch on the flex
|
|
1529
|
+
// main-axis so the pixel value sticks.
|
|
1530
|
+
patch[sizeProperty] = `${resolvedSize}px`;
|
|
1531
|
+
if (isMainFlexAxis) {
|
|
1532
|
+
patch.flexGrow = "0";
|
|
1533
|
+
patch.flexShrink = "0";
|
|
1534
|
+
patch.flexBasis = "auto";
|
|
1535
|
+
}
|
|
1536
|
+
} else if (sizing === "hug") {
|
|
1537
|
+
// Hug contents → shrink to fit children/content.
|
|
1538
|
+
patch[sizeProperty] = "fit-content";
|
|
1539
|
+
if (isMainFlexAxis) {
|
|
1540
|
+
// A flex container hugging on its main axis uses flex-basis:auto + no
|
|
1541
|
+
// stretch (spec: "flex-basis: auto + no stretch").
|
|
1542
|
+
patch.flexGrow = "0";
|
|
1543
|
+
patch.flexShrink = "0";
|
|
1544
|
+
patch.flexBasis = "auto";
|
|
1545
|
+
}
|
|
1546
|
+
} else {
|
|
1547
|
+
// Fill container.
|
|
1548
|
+
if (isMainFlexAxis) {
|
|
1549
|
+
// Parent main axis → grow into available space: flex: 1 0 0.
|
|
1550
|
+
patch.flexGrow = "1";
|
|
1551
|
+
patch.flexShrink = "0";
|
|
1552
|
+
patch.flexBasis = "0";
|
|
1553
|
+
// Clear any explicit dimension so flex-basis governs.
|
|
1554
|
+
patch[sizeProperty] = "auto";
|
|
1555
|
+
} else if (isFlex) {
|
|
1556
|
+
// Parent cross axis → stretch to the parent's cross size.
|
|
1557
|
+
patch.alignSelf = "stretch";
|
|
1558
|
+
patch[sizeProperty] = "auto";
|
|
1559
|
+
} else if (isGrid) {
|
|
1560
|
+
patch[isHorizontal ? "justifySelf" : "alignSelf"] = "stretch";
|
|
1561
|
+
patch[sizeProperty] = "auto";
|
|
1562
|
+
} else {
|
|
1563
|
+
// Child of a non-flex (block) parent → fill width with 100%.
|
|
1564
|
+
patch[sizeProperty] = "100%";
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
commitStylePatch(patch, onStyleChange, onStylesChange);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function elementTypeIcon(element: ElementInfo) {
|
|
1572
|
+
const tag = element.tagName || "element";
|
|
1573
|
+
if (TEXT_TAGS.has(tag)) return IconTypography;
|
|
1574
|
+
if (tag === "img" || tag === "video" || tag === "picture") return IconPhoto;
|
|
1575
|
+
if (tag === "svg" || tag === "path") return IconVector;
|
|
1576
|
+
if (tag === "button" || tag === "a") return IconComponents;
|
|
1577
|
+
return IconFrame;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function SelectionHeader({ element }: { element: ElementInfo | null }) {
|
|
1581
|
+
if (!element) return null;
|
|
1582
|
+
|
|
1583
|
+
const title = inspectorObjectTitle(element);
|
|
1584
|
+
const TypeIcon = elementTypeIcon(element);
|
|
1585
|
+
|
|
1586
|
+
return (
|
|
1587
|
+
<div className="flex min-h-8 shrink-0 items-center justify-between gap-2 border-b border-border/90 px-3">
|
|
1588
|
+
{/* M3 · Node-type label + rename/type dropdown affordance (▾) */}
|
|
1589
|
+
<button
|
|
1590
|
+
type="button"
|
|
1591
|
+
className="flex min-w-0 items-center gap-1.5 bg-transparent text-left text-[13px] font-semibold text-foreground"
|
|
1592
|
+
>
|
|
1593
|
+
<TypeIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
|
1594
|
+
<span className="truncate">{title}</span>
|
|
1595
|
+
<IconChevronDown className="size-2.5 shrink-0 text-muted-foreground" />
|
|
1596
|
+
</button>
|
|
1597
|
+
{/* M3 · Right-aligned quick actions: create-component + dev inspect (</>) */}
|
|
1598
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
1599
|
+
<SectionIconButton
|
|
1600
|
+
label={"Create component" /* i18n-ignore Figma inspector action */}
|
|
1601
|
+
>
|
|
1602
|
+
<IconComponents className="size-3.5" />
|
|
1603
|
+
</SectionIconButton>
|
|
1604
|
+
<SectionIconButton
|
|
1605
|
+
label={"Inspect code" /* i18n-ignore Figma inspector action */}
|
|
1606
|
+
>
|
|
1607
|
+
<IconCode className="size-3.5" />
|
|
1608
|
+
</SectionIconButton>
|
|
1609
|
+
</div>
|
|
1610
|
+
</div>
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function InspectorTabsHeader({
|
|
1615
|
+
activeTab,
|
|
1616
|
+
onActiveTabChange,
|
|
1617
|
+
trailing,
|
|
1618
|
+
}: {
|
|
1619
|
+
activeTab: InspectorTab;
|
|
1620
|
+
onActiveTabChange: (tab: InspectorTab) => void;
|
|
1621
|
+
trailing?: ReactNode;
|
|
1622
|
+
}) {
|
|
1623
|
+
const t = useT();
|
|
1624
|
+
|
|
1625
|
+
return (
|
|
1626
|
+
<div className="flex min-h-8 shrink-0 items-center justify-between gap-1 border-b border-border/90 px-2">
|
|
1627
|
+
<Tabs
|
|
1628
|
+
value={activeTab}
|
|
1629
|
+
onValueChange={(value) => onActiveTabChange(value as InspectorTab)}
|
|
1630
|
+
>
|
|
1631
|
+
<TabsList className="h-7 justify-start gap-0.5 rounded-none bg-transparent p-0">
|
|
1632
|
+
<TabsTrigger
|
|
1633
|
+
value="design"
|
|
1634
|
+
className="h-6 rounded-md px-1.5 text-[11px] font-semibold text-muted-foreground shadow-none transition-colors hover:text-foreground data-[state=active]:bg-[var(--design-editor-panel-raised-bg)] data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
|
1635
|
+
>
|
|
1636
|
+
{"Design" /* i18n-ignore Figma inspector tab */}
|
|
1637
|
+
</TabsTrigger>
|
|
1638
|
+
<TabsTrigger
|
|
1639
|
+
value="tweaks"
|
|
1640
|
+
className="h-6 rounded-md px-1.5 text-[11px] font-semibold text-muted-foreground shadow-none transition-colors hover:text-foreground data-[state=active]:bg-[var(--design-editor-panel-raised-bg)] data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
|
1641
|
+
>
|
|
1642
|
+
{t("designEditor.tweaks")}
|
|
1643
|
+
</TabsTrigger>
|
|
1644
|
+
<TabsTrigger
|
|
1645
|
+
value="extensions"
|
|
1646
|
+
className="h-6 rounded-md px-1.5 text-[11px] font-semibold text-muted-foreground shadow-none transition-colors hover:text-foreground data-[state=active]:bg-[var(--design-editor-panel-raised-bg)] data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
|
1647
|
+
>
|
|
1648
|
+
{t("designEditor.extensions")}
|
|
1649
|
+
</TabsTrigger>
|
|
1650
|
+
</TabsList>
|
|
1651
|
+
</Tabs>
|
|
1652
|
+
{trailing ? <div className="shrink-0">{trailing}</div> : null}
|
|
1653
|
+
</div>
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function SectionIconButton({
|
|
1658
|
+
label,
|
|
1659
|
+
onClick,
|
|
1660
|
+
children,
|
|
1661
|
+
disabled = false,
|
|
1662
|
+
className,
|
|
1663
|
+
}: {
|
|
1664
|
+
label: string;
|
|
1665
|
+
onClick?: () => void;
|
|
1666
|
+
children: ReactNode;
|
|
1667
|
+
disabled?: boolean;
|
|
1668
|
+
className?: string;
|
|
1669
|
+
}) {
|
|
1670
|
+
return (
|
|
1671
|
+
<Tooltip>
|
|
1672
|
+
<TooltipTrigger asChild>
|
|
1673
|
+
<Button
|
|
1674
|
+
type="button"
|
|
1675
|
+
variant="ghost"
|
|
1676
|
+
size="icon"
|
|
1677
|
+
className={cn(
|
|
1678
|
+
"size-6 cursor-pointer rounded-md text-muted-foreground hover:text-foreground disabled:cursor-not-allowed",
|
|
1679
|
+
className,
|
|
1680
|
+
)}
|
|
1681
|
+
disabled={disabled}
|
|
1682
|
+
onClick={onClick}
|
|
1683
|
+
aria-label={label}
|
|
1684
|
+
>
|
|
1685
|
+
{children}
|
|
1686
|
+
</Button>
|
|
1687
|
+
</TooltipTrigger>
|
|
1688
|
+
<TooltipContent>{label}</TooltipContent>
|
|
1689
|
+
</Tooltip>
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* Section-header toggle icon (Figma's right-aligned section actions, e.g. the
|
|
1695
|
+
* auto-layout ⊞ toggle). Highlights with the accent color when active.
|
|
1696
|
+
*/
|
|
1697
|
+
function SectionIconToggle({
|
|
1698
|
+
label,
|
|
1699
|
+
active = false,
|
|
1700
|
+
onClick,
|
|
1701
|
+
children,
|
|
1702
|
+
}: {
|
|
1703
|
+
label: string;
|
|
1704
|
+
active?: boolean;
|
|
1705
|
+
onClick?: () => void;
|
|
1706
|
+
children: ReactNode;
|
|
1707
|
+
}) {
|
|
1708
|
+
return (
|
|
1709
|
+
<Tooltip>
|
|
1710
|
+
<TooltipTrigger asChild>
|
|
1711
|
+
<Button
|
|
1712
|
+
type="button"
|
|
1713
|
+
variant="ghost"
|
|
1714
|
+
size="icon"
|
|
1715
|
+
aria-label={label}
|
|
1716
|
+
aria-pressed={active}
|
|
1717
|
+
onClick={onClick}
|
|
1718
|
+
className={cn(
|
|
1719
|
+
"size-6 cursor-pointer rounded-md text-muted-foreground hover:text-foreground",
|
|
1720
|
+
active &&
|
|
1721
|
+
"bg-[var(--design-editor-accent-color)]/15 text-[var(--design-editor-accent-color)] hover:text-[var(--design-editor-accent-color)]",
|
|
1722
|
+
)}
|
|
1723
|
+
>
|
|
1724
|
+
{children}
|
|
1725
|
+
</Button>
|
|
1726
|
+
</TooltipTrigger>
|
|
1727
|
+
<TooltipContent>{label}</TooltipContent>
|
|
1728
|
+
</Tooltip>
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function InspectorIconButton({
|
|
1733
|
+
label,
|
|
1734
|
+
active,
|
|
1735
|
+
onClick,
|
|
1736
|
+
children,
|
|
1737
|
+
}: {
|
|
1738
|
+
label: string;
|
|
1739
|
+
active?: boolean;
|
|
1740
|
+
onClick?: () => void;
|
|
1741
|
+
children: ReactNode;
|
|
1742
|
+
}) {
|
|
1743
|
+
return (
|
|
1744
|
+
<Tooltip>
|
|
1745
|
+
<TooltipTrigger asChild>
|
|
1746
|
+
<Button
|
|
1747
|
+
type="button"
|
|
1748
|
+
variant="ghost"
|
|
1749
|
+
size="icon"
|
|
1750
|
+
className={cn(
|
|
1751
|
+
"h-6 min-w-6 cursor-pointer rounded-none border-r border-border/50 text-muted-foreground first:rounded-l-md last:rounded-r-md last:border-r-0 hover:bg-[var(--design-editor-panel-raised-bg)] hover:text-foreground disabled:cursor-not-allowed",
|
|
1752
|
+
active &&
|
|
1753
|
+
"bg-[var(--design-editor-panel-bg)] text-[var(--design-editor-accent-color)] shadow-[inset_0_0_0_1px_var(--design-editor-control-border)]",
|
|
1754
|
+
)}
|
|
1755
|
+
onClick={onClick}
|
|
1756
|
+
aria-label={label}
|
|
1757
|
+
>
|
|
1758
|
+
{children}
|
|
1759
|
+
</Button>
|
|
1760
|
+
</TooltipTrigger>
|
|
1761
|
+
<TooltipContent>{label}</TooltipContent>
|
|
1762
|
+
</Tooltip>
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function InspectorSegment({ children }: { children: ReactNode }) {
|
|
1767
|
+
return (
|
|
1768
|
+
<div className="flex min-w-0 overflow-hidden rounded-md bg-[var(--design-editor-control-bg)]">
|
|
1769
|
+
{children}
|
|
1770
|
+
</div>
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
function CornerRadiusControl({
|
|
1775
|
+
styles,
|
|
1776
|
+
onStyleChange,
|
|
1777
|
+
}: {
|
|
1778
|
+
styles: Record<string, string>;
|
|
1779
|
+
onStyleChange: (property: string, value: string) => void;
|
|
1780
|
+
}) {
|
|
1781
|
+
const t = useT();
|
|
1782
|
+
const independentCornersLabel = t("editPanel.labels.independentCorners");
|
|
1783
|
+
const radius = cssLengthNumber(styles.borderRadius || "0");
|
|
1784
|
+
const corners = {
|
|
1785
|
+
topLeft: cssLengthNumber(styles.borderTopLeftRadius || styles.borderRadius),
|
|
1786
|
+
topRight: cssLengthNumber(
|
|
1787
|
+
styles.borderTopRightRadius || styles.borderRadius,
|
|
1788
|
+
),
|
|
1789
|
+
bottomRight: cssLengthNumber(
|
|
1790
|
+
styles.borderBottomRightRadius || styles.borderRadius,
|
|
1791
|
+
),
|
|
1792
|
+
bottomLeft: cssLengthNumber(
|
|
1793
|
+
styles.borderBottomLeftRadius || styles.borderRadius,
|
|
1794
|
+
),
|
|
1795
|
+
};
|
|
1796
|
+
|
|
1797
|
+
return (
|
|
1798
|
+
<div className="grid grid-cols-[1fr_auto] items-center gap-1.5">
|
|
1799
|
+
<ScrubInput
|
|
1800
|
+
label={t("editPanel.labels.cornerRadius")}
|
|
1801
|
+
value={radius}
|
|
1802
|
+
onChange={(value) =>
|
|
1803
|
+
onStyleChange("borderRadius", `${Math.max(0, Math.round(value))}px`)
|
|
1804
|
+
}
|
|
1805
|
+
unit="px"
|
|
1806
|
+
min={0}
|
|
1807
|
+
precision={1}
|
|
1808
|
+
labelClassName="w-24"
|
|
1809
|
+
inputClassName="h-6"
|
|
1810
|
+
/>
|
|
1811
|
+
<Popover>
|
|
1812
|
+
<Tooltip>
|
|
1813
|
+
<TooltipTrigger asChild>
|
|
1814
|
+
<PopoverTrigger asChild>
|
|
1815
|
+
<Button
|
|
1816
|
+
type="button"
|
|
1817
|
+
variant="outline"
|
|
1818
|
+
size="icon"
|
|
1819
|
+
className="size-6 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)]"
|
|
1820
|
+
aria-label={independentCornersLabel}
|
|
1821
|
+
>
|
|
1822
|
+
<IconMaximize className="size-3.5" />
|
|
1823
|
+
</Button>
|
|
1824
|
+
</PopoverTrigger>
|
|
1825
|
+
</TooltipTrigger>
|
|
1826
|
+
<TooltipContent>{independentCornersLabel}</TooltipContent>
|
|
1827
|
+
</Tooltip>
|
|
1828
|
+
<PopoverContent
|
|
1829
|
+
side="left"
|
|
1830
|
+
align="start"
|
|
1831
|
+
sideOffset={8}
|
|
1832
|
+
className="w-64 p-3"
|
|
1833
|
+
>
|
|
1834
|
+
<div className="space-y-2">
|
|
1835
|
+
<p className="text-xs font-semibold text-foreground">
|
|
1836
|
+
{independentCornersLabel}
|
|
1837
|
+
</p>
|
|
1838
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1839
|
+
<ScrubInput
|
|
1840
|
+
label={t("editPanel.labels.topLeft")}
|
|
1841
|
+
value={corners.topLeft}
|
|
1842
|
+
onChange={(value) =>
|
|
1843
|
+
onStyleChange(
|
|
1844
|
+
"borderTopLeftRadius",
|
|
1845
|
+
`${Math.max(0, Math.round(value))}px`,
|
|
1846
|
+
)
|
|
1847
|
+
}
|
|
1848
|
+
unit="px"
|
|
1849
|
+
min={0}
|
|
1850
|
+
precision={1}
|
|
1851
|
+
inputClassName="h-6"
|
|
1852
|
+
/>
|
|
1853
|
+
<ScrubInput
|
|
1854
|
+
label={t("editPanel.labels.topRight")}
|
|
1855
|
+
value={corners.topRight}
|
|
1856
|
+
onChange={(value) =>
|
|
1857
|
+
onStyleChange(
|
|
1858
|
+
"borderTopRightRadius",
|
|
1859
|
+
`${Math.max(0, Math.round(value))}px`,
|
|
1860
|
+
)
|
|
1861
|
+
}
|
|
1862
|
+
unit="px"
|
|
1863
|
+
min={0}
|
|
1864
|
+
precision={1}
|
|
1865
|
+
inputClassName="h-6"
|
|
1866
|
+
/>
|
|
1867
|
+
<ScrubInput
|
|
1868
|
+
label={t("editPanel.labels.bottomLeft")}
|
|
1869
|
+
value={corners.bottomLeft}
|
|
1870
|
+
onChange={(value) =>
|
|
1871
|
+
onStyleChange(
|
|
1872
|
+
"borderBottomLeftRadius",
|
|
1873
|
+
`${Math.max(0, Math.round(value))}px`,
|
|
1874
|
+
)
|
|
1875
|
+
}
|
|
1876
|
+
unit="px"
|
|
1877
|
+
min={0}
|
|
1878
|
+
precision={1}
|
|
1879
|
+
inputClassName="h-6"
|
|
1880
|
+
/>
|
|
1881
|
+
<ScrubInput
|
|
1882
|
+
label={t("editPanel.labels.bottomRight")}
|
|
1883
|
+
value={corners.bottomRight}
|
|
1884
|
+
onChange={(value) =>
|
|
1885
|
+
onStyleChange(
|
|
1886
|
+
"borderBottomRightRadius",
|
|
1887
|
+
`${Math.max(0, Math.round(value))}px`,
|
|
1888
|
+
)
|
|
1889
|
+
}
|
|
1890
|
+
unit="px"
|
|
1891
|
+
min={0}
|
|
1892
|
+
precision={1}
|
|
1893
|
+
inputClassName="h-6"
|
|
1894
|
+
/>
|
|
1895
|
+
</div>
|
|
1896
|
+
</div>
|
|
1897
|
+
</PopoverContent>
|
|
1898
|
+
</Popover>
|
|
1899
|
+
</div>
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
type StrokeLayerKind = "border" | "outline";
|
|
1904
|
+
|
|
1905
|
+
function StrokeLayerControl({
|
|
1906
|
+
kind,
|
|
1907
|
+
visible,
|
|
1908
|
+
color,
|
|
1909
|
+
width,
|
|
1910
|
+
styleValue,
|
|
1911
|
+
onStyleChange,
|
|
1912
|
+
onRemove,
|
|
1913
|
+
}: {
|
|
1914
|
+
kind: StrokeLayerKind;
|
|
1915
|
+
visible: boolean;
|
|
1916
|
+
color: string;
|
|
1917
|
+
width: string;
|
|
1918
|
+
styleValue: string;
|
|
1919
|
+
onStyleChange: (property: string, value: string) => void;
|
|
1920
|
+
onRemove: () => void;
|
|
1921
|
+
}) {
|
|
1922
|
+
const t = useT();
|
|
1923
|
+
const strokePositionOptions = STROKE_POSITION_OPTIONS.map((option) => ({
|
|
1924
|
+
value: option.value,
|
|
1925
|
+
label:
|
|
1926
|
+
option.key === "center"
|
|
1927
|
+
? t("editPanel.textAligns.center")
|
|
1928
|
+
: t(`editPanel.labels.${option.key}`),
|
|
1929
|
+
}));
|
|
1930
|
+
const prefix = kind === "border" ? "border" : "outline";
|
|
1931
|
+
const position = kind === "border" ? "inside" : "outside";
|
|
1932
|
+
|
|
1933
|
+
const movePosition = (next: string) => {
|
|
1934
|
+
if (next === position) return;
|
|
1935
|
+
const nextPrefix = next === "outside" ? "outline" : "border";
|
|
1936
|
+
onStyleChange(`${nextPrefix}Color`, color);
|
|
1937
|
+
onStyleChange(`${nextPrefix}Width`, width || "1px");
|
|
1938
|
+
onStyleChange(
|
|
1939
|
+
`${nextPrefix}Style`,
|
|
1940
|
+
styleValue === "none" ? "solid" : styleValue,
|
|
1941
|
+
);
|
|
1942
|
+
onRemove();
|
|
1943
|
+
};
|
|
1944
|
+
|
|
1945
|
+
return (
|
|
1946
|
+
<div className="space-y-1.5">
|
|
1947
|
+
{/* Figma stroke row: [swatch+hex trigger (flex-1)] [eye] [remove] */}
|
|
1948
|
+
<div className="group flex items-center gap-1.5">
|
|
1949
|
+
<div className="min-w-0 flex-1">
|
|
1950
|
+
<ColorInput
|
|
1951
|
+
label=""
|
|
1952
|
+
value={cssColorOrFallback(color, "#000000")}
|
|
1953
|
+
onChange={(value) => onStyleChange(`${prefix}Color`, value)}
|
|
1954
|
+
/>
|
|
1955
|
+
</div>
|
|
1956
|
+
<SectionIconButton
|
|
1957
|
+
className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
|
1958
|
+
label={
|
|
1959
|
+
visible
|
|
1960
|
+
? t("editPanel.labels.hideLayer")
|
|
1961
|
+
: t("editPanel.labels.showLayer")
|
|
1962
|
+
}
|
|
1963
|
+
onClick={() => {
|
|
1964
|
+
if (visible) {
|
|
1965
|
+
onStyleChange(`${prefix}Style`, "none");
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
onStyleChange(`${prefix}Style`, "solid");
|
|
1969
|
+
onStyleChange(
|
|
1970
|
+
`${prefix}Width`,
|
|
1971
|
+
width === "0px" ? "1px" : width || "1px",
|
|
1972
|
+
);
|
|
1973
|
+
}}
|
|
1974
|
+
>
|
|
1975
|
+
{visible ? (
|
|
1976
|
+
<IconEye className="size-3.5" />
|
|
1977
|
+
) : (
|
|
1978
|
+
<IconEyeOff className="size-3.5" />
|
|
1979
|
+
)}
|
|
1980
|
+
</SectionIconButton>
|
|
1981
|
+
<SectionIconButton
|
|
1982
|
+
className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
|
1983
|
+
label={t("editPanel.labels.removeLayer")}
|
|
1984
|
+
onClick={onRemove}
|
|
1985
|
+
>
|
|
1986
|
+
<IconMinus className="size-3.5" />
|
|
1987
|
+
</SectionIconButton>
|
|
1988
|
+
</div>
|
|
1989
|
+
{/* Figma stroke geometry: position + weight side by side */}
|
|
1990
|
+
<div className="grid grid-cols-2 gap-1.5">
|
|
1991
|
+
<Select value={position} onValueChange={movePosition}>
|
|
1992
|
+
<SelectTrigger className="h-6 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
|
|
1993
|
+
<SelectValue />
|
|
1994
|
+
</SelectTrigger>
|
|
1995
|
+
<SelectContent>
|
|
1996
|
+
{strokePositionOptions.map((option) => (
|
|
1997
|
+
<SelectItem
|
|
1998
|
+
key={option.value}
|
|
1999
|
+
value={option.value}
|
|
2000
|
+
className="text-[11px]"
|
|
2001
|
+
>
|
|
2002
|
+
{option.label}
|
|
2003
|
+
</SelectItem>
|
|
2004
|
+
))}
|
|
2005
|
+
</SelectContent>
|
|
2006
|
+
</Select>
|
|
2007
|
+
<ScrubInput
|
|
2008
|
+
label={t("editPanel.labels.weight")}
|
|
2009
|
+
ariaLabel={t("editPanel.labels.weight")}
|
|
2010
|
+
icon={IconBorderStyle}
|
|
2011
|
+
value={cssLengthNumber(width)}
|
|
2012
|
+
onChange={(value) =>
|
|
2013
|
+
onStyleChange(
|
|
2014
|
+
`${prefix}Width`,
|
|
2015
|
+
`${Math.max(0, Math.round(value))}px`,
|
|
2016
|
+
)
|
|
2017
|
+
}
|
|
2018
|
+
unit="px"
|
|
2019
|
+
min={0}
|
|
2020
|
+
precision={1}
|
|
2021
|
+
className="gap-0"
|
|
2022
|
+
labelClassName="h-6 w-6 justify-center gap-0 rounded-l-md border border-r-0 border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-[11px] [&>span]:hidden"
|
|
2023
|
+
inputClassName="h-6 rounded-l-none rounded-r-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
|
|
2024
|
+
/>
|
|
2025
|
+
</div>
|
|
2026
|
+
</div>
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
interface ShadowLayer {
|
|
2031
|
+
id: string;
|
|
2032
|
+
x: number;
|
|
2033
|
+
y: number;
|
|
2034
|
+
blur: number;
|
|
2035
|
+
spread: number;
|
|
2036
|
+
color: string;
|
|
2037
|
+
inset: boolean;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
function defaultDropShadowLayer(index: number): ShadowLayer {
|
|
2041
|
+
return {
|
|
2042
|
+
id: `shadow-${index}`,
|
|
2043
|
+
x: 0,
|
|
2044
|
+
y: 4,
|
|
2045
|
+
blur: 12,
|
|
2046
|
+
spread: 0,
|
|
2047
|
+
color: "rgba(0, 0, 0, 0.25)",
|
|
2048
|
+
inset: false,
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function parseShadowLayers(value: string | undefined): ShadowLayer[] {
|
|
2053
|
+
return splitCssLayers(value || "")
|
|
2054
|
+
.filter((layer) => layer && layer !== "none")
|
|
2055
|
+
.map((layer, index) => parseShadowLayer(layer, index));
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
function parseShadowLayer(layer: string, index: number): ShadowLayer {
|
|
2059
|
+
const tokens = splitCssTokens(layer);
|
|
2060
|
+
const inset = tokens.includes("inset");
|
|
2061
|
+
const colorToken =
|
|
2062
|
+
tokens.find((token) => parseCssColor(token) || token === "transparent") ||
|
|
2063
|
+
"rgba(0, 0, 0, 0.25)";
|
|
2064
|
+
const numericTokens = tokens
|
|
2065
|
+
.filter((token) => token !== "inset" && token !== colorToken)
|
|
2066
|
+
.map((token) => parseFloat(token))
|
|
2067
|
+
.filter((value) => Number.isFinite(value));
|
|
2068
|
+
|
|
2069
|
+
return {
|
|
2070
|
+
id: `shadow-${index}`,
|
|
2071
|
+
x: numericTokens[0] ?? 0,
|
|
2072
|
+
y: numericTokens[1] ?? 4,
|
|
2073
|
+
blur: numericTokens[2] ?? 12,
|
|
2074
|
+
spread: numericTokens[3] ?? 0,
|
|
2075
|
+
color: colorToken,
|
|
2076
|
+
inset,
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
function splitCssTokens(value: string): string[] {
|
|
2081
|
+
const tokens: string[] = [];
|
|
2082
|
+
let start = 0;
|
|
2083
|
+
let depth = 0;
|
|
2084
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
2085
|
+
const char = value[index];
|
|
2086
|
+
if (char === "(") depth += 1;
|
|
2087
|
+
if (char === ")") depth = Math.max(0, depth - 1);
|
|
2088
|
+
if (/\s/.test(char) && depth === 0) {
|
|
2089
|
+
const token = value.slice(start, index).trim();
|
|
2090
|
+
if (token) tokens.push(token);
|
|
2091
|
+
start = index + 1;
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
const finalToken = value.slice(start).trim();
|
|
2095
|
+
if (finalToken) tokens.push(finalToken);
|
|
2096
|
+
return tokens;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
function serializeShadowLayers(layers: ShadowLayer[]) {
|
|
2100
|
+
if (!layers.length) return "none";
|
|
2101
|
+
return layers
|
|
2102
|
+
.map((layer) =>
|
|
2103
|
+
[
|
|
2104
|
+
layer.inset ? "inset" : "",
|
|
2105
|
+
`${Math.round(layer.x)}px`,
|
|
2106
|
+
`${Math.round(layer.y)}px`,
|
|
2107
|
+
`${Math.max(0, Math.round(layer.blur))}px`,
|
|
2108
|
+
`${Math.max(0, Math.round(layer.spread))}px`,
|
|
2109
|
+
layer.color,
|
|
2110
|
+
]
|
|
2111
|
+
.filter(Boolean)
|
|
2112
|
+
.join(" "),
|
|
2113
|
+
)
|
|
2114
|
+
.join(", ");
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function readBlurFilter(value: string | undefined): number {
|
|
2118
|
+
const match = value?.match(/blur\((-?\d+(?:\.\d+)?)px\)/);
|
|
2119
|
+
return match ? Math.max(0, Number(match[1])) : 0;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
function ShadowEffectRow({
|
|
2123
|
+
layer,
|
|
2124
|
+
index,
|
|
2125
|
+
onChange,
|
|
2126
|
+
onRemove,
|
|
2127
|
+
}: {
|
|
2128
|
+
layer: ShadowLayer;
|
|
2129
|
+
index: number;
|
|
2130
|
+
onChange: (patch: Partial<ShadowLayer>) => void;
|
|
2131
|
+
onRemove: () => void;
|
|
2132
|
+
}) {
|
|
2133
|
+
const t = useT();
|
|
2134
|
+
return (
|
|
2135
|
+
<Popover>
|
|
2136
|
+
{/* Figma effect row: [swatch+label+x,y,blur trigger (flex-1)] [remove] */}
|
|
2137
|
+
<div className="group flex items-center gap-1.5">
|
|
2138
|
+
<PopoverTrigger asChild>
|
|
2139
|
+
<button
|
|
2140
|
+
type="button"
|
|
2141
|
+
className="flex h-6 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-left text-[11px] hover:bg-[var(--design-editor-panel-raised-bg)]"
|
|
2142
|
+
>
|
|
2143
|
+
<span
|
|
2144
|
+
className="size-4 shrink-0 rounded-sm border border-[var(--design-editor-control-border)]"
|
|
2145
|
+
style={swatchStyle(layer.color)}
|
|
2146
|
+
/>
|
|
2147
|
+
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
|
|
2148
|
+
{index === 0
|
|
2149
|
+
? t("editPanel.labels.dropShadow")
|
|
2150
|
+
: `${t("editPanel.labels.dropShadow")} ${index + 1}`}
|
|
2151
|
+
</span>
|
|
2152
|
+
<span className="shrink-0 tabular-nums text-muted-foreground">
|
|
2153
|
+
{Math.round(layer.x)}, {Math.round(layer.y)},{" "}
|
|
2154
|
+
{Math.round(layer.blur)}
|
|
2155
|
+
</span>
|
|
2156
|
+
</button>
|
|
2157
|
+
</PopoverTrigger>
|
|
2158
|
+
<SectionIconButton
|
|
2159
|
+
className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
|
2160
|
+
label={t("editPanel.labels.removeLayer")}
|
|
2161
|
+
onClick={onRemove}
|
|
2162
|
+
>
|
|
2163
|
+
<IconMinus className="size-3.5" />
|
|
2164
|
+
</SectionIconButton>
|
|
2165
|
+
</div>
|
|
2166
|
+
<PopoverContent
|
|
2167
|
+
side="left"
|
|
2168
|
+
align="start"
|
|
2169
|
+
sideOffset={8}
|
|
2170
|
+
className="w-72 p-3"
|
|
2171
|
+
>
|
|
2172
|
+
<div className="space-y-3">
|
|
2173
|
+
<div className="flex items-center justify-between gap-2">
|
|
2174
|
+
<p className="text-xs font-semibold text-foreground">
|
|
2175
|
+
{t("editPanel.labels.dropShadow")}
|
|
2176
|
+
</p>
|
|
2177
|
+
<button
|
|
2178
|
+
type="button"
|
|
2179
|
+
className={cn(
|
|
2180
|
+
"rounded border px-2 py-1 text-[11px]",
|
|
2181
|
+
layer.inset
|
|
2182
|
+
? "border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)] text-foreground"
|
|
2183
|
+
: "border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-muted-foreground",
|
|
2184
|
+
)}
|
|
2185
|
+
onClick={() => onChange({ inset: !layer.inset })}
|
|
2186
|
+
>
|
|
2187
|
+
{t("editPanel.labels.innerShadow")}
|
|
2188
|
+
</button>
|
|
2189
|
+
</div>
|
|
2190
|
+
<div className="grid grid-cols-2 gap-2">
|
|
2191
|
+
<ScrubInput
|
|
2192
|
+
label="X"
|
|
2193
|
+
value={layer.x}
|
|
2194
|
+
onChange={(value) => onChange({ x: value })}
|
|
2195
|
+
unit="px"
|
|
2196
|
+
precision={1}
|
|
2197
|
+
inputClassName="h-6"
|
|
2198
|
+
/>
|
|
2199
|
+
<ScrubInput
|
|
2200
|
+
label="Y"
|
|
2201
|
+
value={layer.y}
|
|
2202
|
+
onChange={(value) => onChange({ y: value })}
|
|
2203
|
+
unit="px"
|
|
2204
|
+
precision={1}
|
|
2205
|
+
inputClassName="h-6"
|
|
2206
|
+
/>
|
|
2207
|
+
<ScrubInput
|
|
2208
|
+
label={t("editPanel.labels.blur")}
|
|
2209
|
+
value={layer.blur}
|
|
2210
|
+
onChange={(value) => onChange({ blur: Math.max(0, value) })}
|
|
2211
|
+
unit="px"
|
|
2212
|
+
min={0}
|
|
2213
|
+
precision={1}
|
|
2214
|
+
inputClassName="h-6"
|
|
2215
|
+
/>
|
|
2216
|
+
<ScrubInput
|
|
2217
|
+
label={t("editPanel.labels.spread")}
|
|
2218
|
+
value={layer.spread}
|
|
2219
|
+
onChange={(value) => onChange({ spread: Math.max(0, value) })}
|
|
2220
|
+
unit="px"
|
|
2221
|
+
min={0}
|
|
2222
|
+
precision={1}
|
|
2223
|
+
inputClassName="h-6"
|
|
2224
|
+
/>
|
|
2225
|
+
</div>
|
|
2226
|
+
<ColorInput
|
|
2227
|
+
label={t("editPanel.labels.color")}
|
|
2228
|
+
value={cssColorOrFallback(layer.color, "rgba(0, 0, 0, 0.25)")}
|
|
2229
|
+
onChange={(value) => onChange({ color: value })}
|
|
2230
|
+
/>
|
|
2231
|
+
</div>
|
|
2232
|
+
</PopoverContent>
|
|
2233
|
+
</Popover>
|
|
2234
|
+
);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/** Page-level properties when nothing is selected */
|
|
2238
|
+
function PageProperties({
|
|
2239
|
+
styles,
|
|
2240
|
+
onStyleChange,
|
|
2241
|
+
}: {
|
|
2242
|
+
styles: Record<string, string>;
|
|
2243
|
+
onStyleChange: (property: string, value: string) => void;
|
|
2244
|
+
}) {
|
|
2245
|
+
const t = useT();
|
|
2246
|
+
const fontFamilyOptions = FONT_FAMILY_OPTIONS.map((option) => ({
|
|
2247
|
+
value: option.value,
|
|
2248
|
+
label: t(`editPanel.fontFamilies.${option.key}`),
|
|
2249
|
+
}));
|
|
2250
|
+
const fontFamily = FONT_FAMILY_OPTIONS.some(
|
|
2251
|
+
(option) => option.value === styles.fontFamily,
|
|
2252
|
+
)
|
|
2253
|
+
? styles.fontFamily
|
|
2254
|
+
: "sans-serif";
|
|
2255
|
+
|
|
2256
|
+
return (
|
|
2257
|
+
<div>
|
|
2258
|
+
<PanelSection title={t("editPanel.sections.page")}>
|
|
2259
|
+
<ColorInput
|
|
2260
|
+
label={t("editPanel.labels.background")}
|
|
2261
|
+
value={styles.backgroundColor || ""}
|
|
2262
|
+
onChange={(v) => onStyleChange("backgroundColor", v)}
|
|
2263
|
+
backgroundImage={styles.backgroundImage || ""}
|
|
2264
|
+
onBackgroundImageChange={(v) => onStyleChange("backgroundImage", v)}
|
|
2265
|
+
blendMode={styles.backgroundBlendMode || "normal"}
|
|
2266
|
+
onBlendModeChange={(v) => onStyleChange("backgroundBlendMode", v)}
|
|
2267
|
+
supportsLayeredFills
|
|
2268
|
+
/>
|
|
2269
|
+
<PropSelect
|
|
2270
|
+
label={t("editPanel.labels.font")}
|
|
2271
|
+
value={fontFamily}
|
|
2272
|
+
onChange={(v) => onStyleChange("fontFamily", v)}
|
|
2273
|
+
options={fontFamilyOptions}
|
|
2274
|
+
/>
|
|
2275
|
+
<PropInput
|
|
2276
|
+
label={t("editPanel.labels.baseSize")}
|
|
2277
|
+
value={styles.fontSize || "16px"}
|
|
2278
|
+
onChange={(v) => onStyleChange("fontSize", v)}
|
|
2279
|
+
placeholder="16px"
|
|
2280
|
+
defaultUnit="px"
|
|
2281
|
+
/>
|
|
2282
|
+
</PanelSection>
|
|
2283
|
+
</div>
|
|
2284
|
+
);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
/** Text element properties */
|
|
2288
|
+
function TypographyProperties({
|
|
2289
|
+
element,
|
|
2290
|
+
onStyleChange,
|
|
2291
|
+
}: {
|
|
2292
|
+
element: ElementInfo;
|
|
2293
|
+
onStyleChange: (property: string, value: string) => void;
|
|
2294
|
+
}) {
|
|
2295
|
+
const t = useT();
|
|
2296
|
+
const styles = element.computedStyles;
|
|
2297
|
+
const fontFamilyOptions = FONT_FAMILY_OPTIONS.map((option) => ({
|
|
2298
|
+
value: option.value,
|
|
2299
|
+
label: t(`editPanel.fontFamilies.${option.key}`),
|
|
2300
|
+
}));
|
|
2301
|
+
const fontWeightOptions = FONT_WEIGHT_OPTIONS.map((option) => ({
|
|
2302
|
+
value: option.value,
|
|
2303
|
+
label: t(`editPanel.fontWeights.${option.key}`),
|
|
2304
|
+
}));
|
|
2305
|
+
const textAlign = styles.textAlign || "left";
|
|
2306
|
+
|
|
2307
|
+
// M1 · Text resizing mode (auto-width / auto-height / fixed). Figma's text
|
|
2308
|
+
// nodes always expose this segment. Infer the current mode from the live CSS:
|
|
2309
|
+
// auto-width hugs both axes (width:auto + no wrapping), auto-height hugs the
|
|
2310
|
+
// height only (fixed width, content wraps), fixed pins both width and height.
|
|
2311
|
+
const widthIsAuto =
|
|
2312
|
+
!styles.width || styles.width === "auto" || styles.width === "max-content";
|
|
2313
|
+
const heightIsAuto = !styles.height || styles.height === "auto";
|
|
2314
|
+
const noWrap = styles.whiteSpace === "nowrap";
|
|
2315
|
+
const resizeMode: "auto-width" | "auto-height" | "fixed" =
|
|
2316
|
+
widthIsAuto && noWrap
|
|
2317
|
+
? "auto-width"
|
|
2318
|
+
: !heightIsAuto && !widthIsAuto
|
|
2319
|
+
? "fixed"
|
|
2320
|
+
: "auto-height";
|
|
2321
|
+
const currentWidth = styles.width && !widthIsAuto ? styles.width : "200px";
|
|
2322
|
+
const currentHeight = styles.height && !heightIsAuto ? styles.height : "48px";
|
|
2323
|
+
const setResizeMode = (mode: "auto-width" | "auto-height" | "fixed") => {
|
|
2324
|
+
if (mode === "auto-width") {
|
|
2325
|
+
onStyleChange("width", "auto");
|
|
2326
|
+
onStyleChange("height", "auto");
|
|
2327
|
+
onStyleChange("whiteSpace", "nowrap");
|
|
2328
|
+
} else if (mode === "auto-height") {
|
|
2329
|
+
onStyleChange("width", currentWidth);
|
|
2330
|
+
onStyleChange("height", "auto");
|
|
2331
|
+
onStyleChange("whiteSpace", "normal");
|
|
2332
|
+
} else {
|
|
2333
|
+
onStyleChange("width", currentWidth);
|
|
2334
|
+
onStyleChange("height", currentHeight);
|
|
2335
|
+
onStyleChange("whiteSpace", "normal");
|
|
2336
|
+
}
|
|
2337
|
+
};
|
|
2338
|
+
|
|
2339
|
+
// M2 · Vertical text alignment (top / middle / bottom). For auto-layout text
|
|
2340
|
+
// containers (display:flex) Figma maps this to `justifyContent`; for normal
|
|
2341
|
+
// text we fall back to `verticalAlign`, which is what an inline/grid text box
|
|
2342
|
+
// honors. Read whichever the element currently expresses.
|
|
2343
|
+
const display = (styles.display || "").toLowerCase();
|
|
2344
|
+
const isFlexText = display.includes("flex");
|
|
2345
|
+
const verticalAlign = isFlexText
|
|
2346
|
+
? styles.justifyContent === "center"
|
|
2347
|
+
? "middle"
|
|
2348
|
+
: styles.justifyContent === "flex-end"
|
|
2349
|
+
? "bottom"
|
|
2350
|
+
: "top"
|
|
2351
|
+
: styles.verticalAlign === "middle"
|
|
2352
|
+
? "middle"
|
|
2353
|
+
: styles.verticalAlign === "bottom"
|
|
2354
|
+
? "bottom"
|
|
2355
|
+
: "top";
|
|
2356
|
+
const setVerticalAlign = (mode: "top" | "middle" | "bottom") => {
|
|
2357
|
+
if (isFlexText) {
|
|
2358
|
+
onStyleChange(
|
|
2359
|
+
"justifyContent",
|
|
2360
|
+
mode === "middle"
|
|
2361
|
+
? "center"
|
|
2362
|
+
: mode === "bottom"
|
|
2363
|
+
? "flex-end"
|
|
2364
|
+
: "flex-start",
|
|
2365
|
+
);
|
|
2366
|
+
} else {
|
|
2367
|
+
onStyleChange("verticalAlign", mode);
|
|
2368
|
+
}
|
|
2369
|
+
};
|
|
2370
|
+
|
|
2371
|
+
return (
|
|
2372
|
+
<PanelSection title={t("editPanel.sections.typography")}>
|
|
2373
|
+
{/* Row 0: text resizing (auto-width / auto-height / fixed) */}
|
|
2374
|
+
<InspectorSegment>
|
|
2375
|
+
<InspectorIconButton
|
|
2376
|
+
label={"Auto width" /* i18n-ignore Figma text-resize mode */}
|
|
2377
|
+
active={resizeMode === "auto-width"}
|
|
2378
|
+
onClick={() => setResizeMode("auto-width")}
|
|
2379
|
+
>
|
|
2380
|
+
<IconArrowAutofitWidth className="size-3.5" />
|
|
2381
|
+
</InspectorIconButton>
|
|
2382
|
+
<InspectorIconButton
|
|
2383
|
+
label={"Auto height" /* i18n-ignore Figma text-resize mode */}
|
|
2384
|
+
active={resizeMode === "auto-height"}
|
|
2385
|
+
onClick={() => setResizeMode("auto-height")}
|
|
2386
|
+
>
|
|
2387
|
+
<IconArrowAutofitHeight className="size-3.5" />
|
|
2388
|
+
</InspectorIconButton>
|
|
2389
|
+
<InspectorIconButton
|
|
2390
|
+
label={"Fixed size" /* i18n-ignore Figma text-resize mode */}
|
|
2391
|
+
active={resizeMode === "fixed"}
|
|
2392
|
+
onClick={() => setResizeMode("fixed")}
|
|
2393
|
+
>
|
|
2394
|
+
<IconSquare className="size-3.5" />
|
|
2395
|
+
</InspectorIconButton>
|
|
2396
|
+
</InspectorSegment>
|
|
2397
|
+
|
|
2398
|
+
{/* Row 1: font family full-width */}
|
|
2399
|
+
<Select
|
|
2400
|
+
value={styles.fontFamily || "sans-serif"}
|
|
2401
|
+
onValueChange={(v) => onStyleChange("fontFamily", v)}
|
|
2402
|
+
>
|
|
2403
|
+
<SelectTrigger className="h-6 w-full rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
|
|
2404
|
+
<SelectValue />
|
|
2405
|
+
</SelectTrigger>
|
|
2406
|
+
<SelectContent>
|
|
2407
|
+
{fontFamilyOptions.map((opt) => (
|
|
2408
|
+
<SelectItem
|
|
2409
|
+
key={opt.value}
|
|
2410
|
+
value={opt.value}
|
|
2411
|
+
className="text-[11px]"
|
|
2412
|
+
>
|
|
2413
|
+
{opt.label}
|
|
2414
|
+
</SelectItem>
|
|
2415
|
+
))}
|
|
2416
|
+
</SelectContent>
|
|
2417
|
+
</Select>
|
|
2418
|
+
|
|
2419
|
+
{/* Row 2: weight + size side by side */}
|
|
2420
|
+
<div className="grid grid-cols-2 gap-1.5">
|
|
2421
|
+
<Select
|
|
2422
|
+
value={styles.fontWeight || "400"}
|
|
2423
|
+
onValueChange={(v) => onStyleChange("fontWeight", v)}
|
|
2424
|
+
>
|
|
2425
|
+
<SelectTrigger className="h-6 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
|
|
2426
|
+
<SelectValue />
|
|
2427
|
+
</SelectTrigger>
|
|
2428
|
+
<SelectContent>
|
|
2429
|
+
{fontWeightOptions.map((opt) => (
|
|
2430
|
+
<SelectItem
|
|
2431
|
+
key={opt.value}
|
|
2432
|
+
value={opt.value}
|
|
2433
|
+
className="text-[11px]"
|
|
2434
|
+
>
|
|
2435
|
+
{opt.label}
|
|
2436
|
+
</SelectItem>
|
|
2437
|
+
))}
|
|
2438
|
+
</SelectContent>
|
|
2439
|
+
</Select>
|
|
2440
|
+
<ScrubInput
|
|
2441
|
+
label={t("editPanel.labels.size")}
|
|
2442
|
+
ariaLabel={t("editPanel.labels.size")}
|
|
2443
|
+
icon={IconLetterCase}
|
|
2444
|
+
value={styles.fontSize ? parseNumericValue(styles.fontSize) : 16}
|
|
2445
|
+
onChange={(value) =>
|
|
2446
|
+
onStyleChange("fontSize", `${Math.max(1, Math.round(value))}px`)
|
|
2447
|
+
}
|
|
2448
|
+
unit="px"
|
|
2449
|
+
min={1}
|
|
2450
|
+
precision={1}
|
|
2451
|
+
className="gap-0"
|
|
2452
|
+
labelClassName="h-6 w-6 justify-center gap-0 rounded-l-md border border-r-0 border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-[11px] [&>span]:hidden"
|
|
2453
|
+
inputClassName="h-6 rounded-l-none rounded-r-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
|
|
2454
|
+
/>
|
|
2455
|
+
</div>
|
|
2456
|
+
|
|
2457
|
+
{/* Row 3: line-height + letter-spacing with Figma-style leading icons */}
|
|
2458
|
+
<div className="grid grid-cols-2 gap-1.5">
|
|
2459
|
+
<ScrubInput
|
|
2460
|
+
label={t("editPanel.labels.lineHeight")}
|
|
2461
|
+
ariaLabel={t("editPanel.labels.lineHeight")}
|
|
2462
|
+
icon={IconLineHeight}
|
|
2463
|
+
value={parseNumericValue(styles.lineHeight || "1.2")}
|
|
2464
|
+
onChange={(value) =>
|
|
2465
|
+
onStyleChange("lineHeight", String(Math.max(0.1, value)))
|
|
2466
|
+
}
|
|
2467
|
+
min={0.1}
|
|
2468
|
+
step={0.1}
|
|
2469
|
+
precision={2}
|
|
2470
|
+
className="gap-0"
|
|
2471
|
+
labelClassName="h-6 w-6 justify-center gap-0 rounded-l-md border border-r-0 border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-[11px] [&>span]:hidden"
|
|
2472
|
+
inputClassName="h-6 rounded-l-none rounded-r-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
|
|
2473
|
+
/>
|
|
2474
|
+
<ScrubInput
|
|
2475
|
+
label={t("editPanel.labels.tracking")}
|
|
2476
|
+
ariaLabel={t("editPanel.labels.tracking")}
|
|
2477
|
+
icon={IconLetterSpacing}
|
|
2478
|
+
value={
|
|
2479
|
+
styles.letterSpacing ? parseNumericValue(styles.letterSpacing) : 0
|
|
2480
|
+
}
|
|
2481
|
+
onChange={(value) => onStyleChange("letterSpacing", `${value}px`)}
|
|
2482
|
+
unit="px"
|
|
2483
|
+
precision={1}
|
|
2484
|
+
className="gap-0"
|
|
2485
|
+
labelClassName="h-6 w-6 justify-center gap-0 rounded-l-md border border-r-0 border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-[11px] [&>span]:hidden"
|
|
2486
|
+
inputClassName="h-6 rounded-l-none rounded-r-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
|
|
2487
|
+
/>
|
|
2488
|
+
</div>
|
|
2489
|
+
|
|
2490
|
+
{/* Row 4: horizontal + vertical text alignment */}
|
|
2491
|
+
<div className="grid grid-cols-2 gap-1.5">
|
|
2492
|
+
<InspectorSegment>
|
|
2493
|
+
<InspectorIconButton
|
|
2494
|
+
label={t("editPanel.textAligns.left")}
|
|
2495
|
+
active={textAlign === "left" || textAlign === "start"}
|
|
2496
|
+
onClick={() => onStyleChange("textAlign", "left")}
|
|
2497
|
+
>
|
|
2498
|
+
<IconAlignLeft className="size-3.5" />
|
|
2499
|
+
</InspectorIconButton>
|
|
2500
|
+
<InspectorIconButton
|
|
2501
|
+
label={t("editPanel.textAligns.center")}
|
|
2502
|
+
active={textAlign === "center"}
|
|
2503
|
+
onClick={() => onStyleChange("textAlign", "center")}
|
|
2504
|
+
>
|
|
2505
|
+
<IconAlignCenter className="size-3.5" />
|
|
2506
|
+
</InspectorIconButton>
|
|
2507
|
+
<InspectorIconButton
|
|
2508
|
+
label={t("editPanel.textAligns.right")}
|
|
2509
|
+
active={textAlign === "right" || textAlign === "end"}
|
|
2510
|
+
onClick={() => onStyleChange("textAlign", "right")}
|
|
2511
|
+
>
|
|
2512
|
+
<IconAlignRight className="size-3.5" />
|
|
2513
|
+
</InspectorIconButton>
|
|
2514
|
+
<InspectorIconButton
|
|
2515
|
+
label={t("editPanel.textAligns.justify")}
|
|
2516
|
+
active={textAlign === "justify"}
|
|
2517
|
+
onClick={() => onStyleChange("textAlign", "justify")}
|
|
2518
|
+
>
|
|
2519
|
+
<IconAlignJustified className="size-3.5" />
|
|
2520
|
+
</InspectorIconButton>
|
|
2521
|
+
</InspectorSegment>
|
|
2522
|
+
<InspectorSegment>
|
|
2523
|
+
<InspectorIconButton
|
|
2524
|
+
label={"Align top" /* i18n-ignore Figma vertical text align */}
|
|
2525
|
+
active={verticalAlign === "top"}
|
|
2526
|
+
onClick={() => setVerticalAlign("top")}
|
|
2527
|
+
>
|
|
2528
|
+
<IconLayoutAlignTop className="size-3.5" />
|
|
2529
|
+
</InspectorIconButton>
|
|
2530
|
+
<InspectorIconButton
|
|
2531
|
+
label={"Align middle" /* i18n-ignore Figma vertical text align */}
|
|
2532
|
+
active={verticalAlign === "middle"}
|
|
2533
|
+
onClick={() => setVerticalAlign("middle")}
|
|
2534
|
+
>
|
|
2535
|
+
<IconLayoutAlignMiddle className="size-3.5" />
|
|
2536
|
+
</InspectorIconButton>
|
|
2537
|
+
<InspectorIconButton
|
|
2538
|
+
label={"Align bottom" /* i18n-ignore Figma vertical text align */}
|
|
2539
|
+
active={verticalAlign === "bottom"}
|
|
2540
|
+
onClick={() => setVerticalAlign("bottom")}
|
|
2541
|
+
>
|
|
2542
|
+
<IconLayoutAlignBottom className="size-3.5" />
|
|
2543
|
+
</InspectorIconButton>
|
|
2544
|
+
</InspectorSegment>
|
|
2545
|
+
</div>
|
|
2546
|
+
</PanelSection>
|
|
2547
|
+
);
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/** Flex container properties */
|
|
2551
|
+
function FlexContainerControls({
|
|
2552
|
+
element,
|
|
2553
|
+
onStyleChange,
|
|
2554
|
+
onStylesChange,
|
|
2555
|
+
}: {
|
|
2556
|
+
element: ElementInfo;
|
|
2557
|
+
onStyleChange: (property: string, value: string) => void;
|
|
2558
|
+
onStylesChange?: (styles: Record<string, string>) => void;
|
|
2559
|
+
}) {
|
|
2560
|
+
const t = useT();
|
|
2561
|
+
const styles = element.computedStyles;
|
|
2562
|
+
// The element's CURRENT layout flow as authored in code, read from its own
|
|
2563
|
+
// computed `display`: block/flow-root/grid/etc. = "normal flow",
|
|
2564
|
+
// flex/inline-flex = auto layout. We forward it so the AutoLayoutMatrix Flow
|
|
2565
|
+
// control can show the right state (normal vs horizontal/vertical/wrap)
|
|
2566
|
+
// instead of an empty "add" affordance.
|
|
2567
|
+
const display = (styles.display || "").toLowerCase();
|
|
2568
|
+
const isFlex = display.includes("flex");
|
|
2569
|
+
const flexDirection: AutoLayoutMatrixValue["direction"] =
|
|
2570
|
+
styles.flexDirection?.includes("column") ? "vertical" : "horizontal";
|
|
2571
|
+
// When the element is in normal flow (not flex yet), picking any flow option
|
|
2572
|
+
// must first turn it into a flex container; otherwise setting flex-direction
|
|
2573
|
+
// alone is a no-op against a block element.
|
|
2574
|
+
const ensureFlex = () => {
|
|
2575
|
+
if (!isFlex) onStyleChange("display", "flex");
|
|
2576
|
+
};
|
|
2577
|
+
const padding = {
|
|
2578
|
+
top: parseNumericValue(styles.paddingTop || "0"),
|
|
2579
|
+
right: parseNumericValue(styles.paddingRight || "0"),
|
|
2580
|
+
bottom: parseNumericValue(styles.paddingBottom || "0"),
|
|
2581
|
+
left: parseNumericValue(styles.paddingLeft || "0"),
|
|
2582
|
+
};
|
|
2583
|
+
const allPaddingEqual =
|
|
2584
|
+
padding.top === padding.right &&
|
|
2585
|
+
padding.top === padding.bottom &&
|
|
2586
|
+
padding.top === padding.left;
|
|
2587
|
+
const [paddingLinked, setPaddingLinked] = useState(allPaddingEqual);
|
|
2588
|
+
|
|
2589
|
+
useEffect(() => {
|
|
2590
|
+
if (!allPaddingEqual && paddingLinked) setPaddingLinked(false);
|
|
2591
|
+
}, [allPaddingEqual, paddingLinked]);
|
|
2592
|
+
|
|
2593
|
+
const autoLayoutValue: AutoLayoutMatrixValue = {
|
|
2594
|
+
direction: flexDirection,
|
|
2595
|
+
wrap: styles.flexWrap === "wrap" ? "wrap" : "nowrap",
|
|
2596
|
+
alignment: autoLayoutAlignmentFromStyles(styles, flexDirection),
|
|
2597
|
+
gap: parseNumericValue(styles.gap || "0"),
|
|
2598
|
+
padding,
|
|
2599
|
+
paddingLinked,
|
|
2600
|
+
childSizing: {
|
|
2601
|
+
horizontal: inferElementSizing(element, "horizontal"),
|
|
2602
|
+
vertical: inferElementSizing(element, "vertical"),
|
|
2603
|
+
},
|
|
2604
|
+
childMinMax: {
|
|
2605
|
+
horizontal: readElementMinMax(element, "horizontal"),
|
|
2606
|
+
vertical: readElementMinMax(element, "vertical"),
|
|
2607
|
+
},
|
|
2608
|
+
clipContent: styles.overflow === "hidden",
|
|
2609
|
+
resolvedSize: {
|
|
2610
|
+
horizontal: element.boundingRect.width,
|
|
2611
|
+
vertical: element.boundingRect.height,
|
|
2612
|
+
},
|
|
2613
|
+
// Forward the raw CSS display so the matrix can render the correct Flow
|
|
2614
|
+
// state (normal flow for block/grid, flex for flex/inline-flex). Added as an
|
|
2615
|
+
// optional contract field consumed by AutoLayoutMatrix; harmless when the
|
|
2616
|
+
// matrix ignores it.
|
|
2617
|
+
...({ display } as Partial<AutoLayoutMatrixValue>),
|
|
2618
|
+
};
|
|
548
2619
|
|
|
549
2620
|
return (
|
|
550
|
-
<div className="space-y-
|
|
551
|
-
<
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
2621
|
+
<div className="space-y-2">
|
|
2622
|
+
<AutoLayoutMatrix
|
|
2623
|
+
value={autoLayoutValue}
|
|
2624
|
+
onDirectionChange={(direction) => {
|
|
2625
|
+
ensureFlex();
|
|
2626
|
+
onStyleChange(
|
|
2627
|
+
"flexDirection",
|
|
2628
|
+
direction === "vertical" ? "column" : "row",
|
|
2629
|
+
);
|
|
2630
|
+
}}
|
|
2631
|
+
onWrapChange={(wrap) => {
|
|
2632
|
+
ensureFlex();
|
|
2633
|
+
onStyleChange("flexWrap", wrap);
|
|
2634
|
+
}}
|
|
2635
|
+
onAlignmentChange={(alignment) => {
|
|
2636
|
+
if (autoLayoutValue.direction === "vertical") {
|
|
2637
|
+
onStyleChange(
|
|
2638
|
+
"alignItems",
|
|
2639
|
+
horizontalToJustify(alignment.horizontal),
|
|
2640
|
+
);
|
|
2641
|
+
onStyleChange(
|
|
2642
|
+
"justifyContent",
|
|
2643
|
+
verticalToAlign(alignment.vertical),
|
|
2644
|
+
);
|
|
2645
|
+
return;
|
|
2646
|
+
}
|
|
2647
|
+
onStyleChange(
|
|
2648
|
+
"justifyContent",
|
|
2649
|
+
horizontalToJustify(alignment.horizontal),
|
|
2650
|
+
);
|
|
2651
|
+
onStyleChange("alignItems", verticalToAlign(alignment.vertical));
|
|
2652
|
+
}}
|
|
2653
|
+
onGapChange={(gap) => onStyleChange("gap", `${gap}px`)}
|
|
2654
|
+
onPaddingChange={(nextPadding) => {
|
|
2655
|
+
onStyleChange("paddingTop", `${nextPadding.top}px`);
|
|
2656
|
+
onStyleChange("paddingRight", `${nextPadding.right}px`);
|
|
2657
|
+
onStyleChange("paddingBottom", `${nextPadding.bottom}px`);
|
|
2658
|
+
onStyleChange("paddingLeft", `${nextPadding.left}px`);
|
|
2659
|
+
}}
|
|
2660
|
+
onPaddingLinkedChange={(linked) => {
|
|
2661
|
+
setPaddingLinked(linked);
|
|
2662
|
+
if (!linked) return;
|
|
2663
|
+
onStyleChange("paddingTop", `${padding.top}px`);
|
|
2664
|
+
onStyleChange("paddingRight", `${padding.top}px`);
|
|
2665
|
+
onStyleChange("paddingBottom", `${padding.top}px`);
|
|
2666
|
+
onStyleChange("paddingLeft", `${padding.top}px`);
|
|
2667
|
+
}}
|
|
2668
|
+
onClipContentChange={(clipContent) =>
|
|
2669
|
+
onStyleChange("overflow", clipContent ? "hidden" : "visible")
|
|
2670
|
+
}
|
|
2671
|
+
onDistribute={(axis) => {
|
|
2672
|
+
const mainAxis =
|
|
2673
|
+
autoLayoutValue.direction === "horizontal"
|
|
2674
|
+
? "horizontal"
|
|
2675
|
+
: "vertical";
|
|
2676
|
+
if (axis === mainAxis) {
|
|
2677
|
+
onStyleChange("justifyContent", "space-between");
|
|
2678
|
+
} else if (autoLayoutValue.wrap === "wrap") {
|
|
2679
|
+
onStyleChange("alignContent", "space-between");
|
|
2680
|
+
}
|
|
2681
|
+
}}
|
|
2682
|
+
availableChildSizing={availableSizingForElement(element)}
|
|
2683
|
+
onChildSizingChange={(axis, sizing) => {
|
|
2684
|
+
commitElementSizing(
|
|
2685
|
+
element,
|
|
2686
|
+
axis,
|
|
2687
|
+
sizing,
|
|
2688
|
+
onStyleChange,
|
|
2689
|
+
onStylesChange,
|
|
2690
|
+
);
|
|
2691
|
+
}}
|
|
2692
|
+
onChildMinMaxChange={(axis, kind, val) =>
|
|
2693
|
+
commitElementMinMax(axis, kind, val, onStyleChange)
|
|
2694
|
+
}
|
|
557
2695
|
/>
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
2696
|
+
</div>
|
|
2697
|
+
);
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
function FlexChildControls({
|
|
2701
|
+
element,
|
|
2702
|
+
onStyleChange,
|
|
2703
|
+
}: {
|
|
2704
|
+
element: ElementInfo;
|
|
2705
|
+
onStyleChange: (property: string, value: string) => void;
|
|
2706
|
+
}) {
|
|
2707
|
+
const t = useT();
|
|
2708
|
+
const styles = element.computedStyles;
|
|
2709
|
+
const alignSelfOptions = ALIGN_SELF_OPTIONS.map((option) => ({
|
|
2710
|
+
value: option.value,
|
|
2711
|
+
label: t(`editPanel.alignSelfOptions.${option.key}`),
|
|
2712
|
+
}));
|
|
2713
|
+
|
|
2714
|
+
return (
|
|
2715
|
+
<div className="space-y-2">
|
|
2716
|
+
<SubsectionLabel>{t("editPanel.layoutContext.child")}</SubsectionLabel>
|
|
2717
|
+
<PropInput
|
|
2718
|
+
label={t("editPanel.labels.flexGrow")}
|
|
2719
|
+
value={styles.flexGrow || ""}
|
|
2720
|
+
onChange={(v) => onStyleChange("flexGrow", v)}
|
|
2721
|
+
placeholder="0"
|
|
563
2722
|
/>
|
|
564
|
-
<
|
|
565
|
-
label={t("editPanel.labels.
|
|
566
|
-
value={styles.
|
|
567
|
-
onChange={(v) => onStyleChange("
|
|
568
|
-
|
|
2723
|
+
<PropInput
|
|
2724
|
+
label={t("editPanel.labels.flexShrink")}
|
|
2725
|
+
value={styles.flexShrink || ""}
|
|
2726
|
+
onChange={(v) => onStyleChange("flexShrink", v)}
|
|
2727
|
+
placeholder="1"
|
|
569
2728
|
/>
|
|
570
2729
|
<PropInput
|
|
571
|
-
label={t("editPanel.labels.
|
|
572
|
-
value={styles.
|
|
573
|
-
onChange={(v) => onStyleChange("
|
|
574
|
-
placeholder="
|
|
2730
|
+
label={t("editPanel.labels.flexBasis")}
|
|
2731
|
+
value={styles.flexBasis || ""}
|
|
2732
|
+
onChange={(v) => onStyleChange("flexBasis", v)}
|
|
2733
|
+
placeholder="auto"
|
|
575
2734
|
defaultUnit="px"
|
|
576
2735
|
/>
|
|
2736
|
+
<PropInput
|
|
2737
|
+
label={t("editPanel.labels.order")}
|
|
2738
|
+
value={styles.order || ""}
|
|
2739
|
+
onChange={(v) => onStyleChange("order", v)}
|
|
2740
|
+
placeholder="0"
|
|
2741
|
+
/>
|
|
2742
|
+
<PropSelect
|
|
2743
|
+
label={t("editPanel.labels.alignSelf")}
|
|
2744
|
+
value={optionValue(ALIGN_SELF_OPTIONS, styles.alignSelf, "auto")}
|
|
2745
|
+
onChange={(v) => onStyleChange("alignSelf", v)}
|
|
2746
|
+
options={alignSelfOptions}
|
|
2747
|
+
/>
|
|
577
2748
|
</div>
|
|
578
2749
|
);
|
|
579
2750
|
}
|
|
580
2751
|
|
|
581
|
-
|
|
582
|
-
function ElementProperties({
|
|
2752
|
+
function GridChildControls({
|
|
583
2753
|
element,
|
|
584
2754
|
onStyleChange,
|
|
585
2755
|
}: {
|
|
@@ -588,107 +2758,1344 @@ function ElementProperties({
|
|
|
588
2758
|
}) {
|
|
589
2759
|
const t = useT();
|
|
590
2760
|
const styles = element.computedStyles;
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
},
|
|
596
|
-
[onStyleChange],
|
|
597
|
-
);
|
|
598
|
-
|
|
599
|
-
const handleMarginChange = useCallback(
|
|
600
|
-
(side: string, value: string) => {
|
|
601
|
-
onStyleChange(`margin${side}`, value);
|
|
602
|
-
},
|
|
603
|
-
[onStyleChange],
|
|
604
|
-
);
|
|
2761
|
+
const alignSelfOptions = ALIGN_SELF_OPTIONS.map((option) => ({
|
|
2762
|
+
value: option.value,
|
|
2763
|
+
label: t(`editPanel.alignSelfOptions.${option.key}`),
|
|
2764
|
+
}));
|
|
605
2765
|
|
|
606
2766
|
return (
|
|
607
|
-
<div className="space-y-
|
|
608
|
-
<
|
|
2767
|
+
<div className="space-y-2">
|
|
2768
|
+
<SubsectionLabel>
|
|
2769
|
+
{t("editPanel.layoutContext.gridChild")}
|
|
2770
|
+
</SubsectionLabel>
|
|
609
2771
|
<PropInput
|
|
610
|
-
label={t("editPanel.labels.
|
|
611
|
-
value={styles.
|
|
612
|
-
onChange={(v) => onStyleChange("
|
|
2772
|
+
label={t("editPanel.labels.gridColumn")}
|
|
2773
|
+
value={styles.gridColumn || ""}
|
|
2774
|
+
onChange={(v) => onStyleChange("gridColumn", v)}
|
|
613
2775
|
placeholder="auto"
|
|
614
|
-
defaultUnit="px"
|
|
615
2776
|
/>
|
|
616
2777
|
<PropInput
|
|
617
|
-
label={t("editPanel.labels.
|
|
618
|
-
value={styles.
|
|
619
|
-
onChange={(v) => onStyleChange("
|
|
2778
|
+
label={t("editPanel.labels.gridRow")}
|
|
2779
|
+
value={styles.gridRow || ""}
|
|
2780
|
+
onChange={(v) => onStyleChange("gridRow", v)}
|
|
620
2781
|
placeholder="auto"
|
|
621
|
-
defaultUnit="px"
|
|
622
2782
|
/>
|
|
623
|
-
<
|
|
624
|
-
label={t("editPanel.labels.
|
|
625
|
-
value={
|
|
626
|
-
onChange={(v) => onStyleChange("
|
|
627
|
-
|
|
628
|
-
max={100}
|
|
629
|
-
step={1}
|
|
630
|
-
unit="%"
|
|
2783
|
+
<PropSelect
|
|
2784
|
+
label={t("editPanel.labels.alignSelf")}
|
|
2785
|
+
value={optionValue(ALIGN_SELF_OPTIONS, styles.alignSelf, "auto")}
|
|
2786
|
+
onChange={(v) => onStyleChange("alignSelf", v)}
|
|
2787
|
+
options={alignSelfOptions}
|
|
631
2788
|
/>
|
|
2789
|
+
</div>
|
|
2790
|
+
);
|
|
2791
|
+
}
|
|
632
2792
|
|
|
633
|
-
|
|
2793
|
+
function LayoutContextProperties({
|
|
2794
|
+
element,
|
|
2795
|
+
onStyleChange,
|
|
2796
|
+
onStylesChange,
|
|
2797
|
+
}: {
|
|
2798
|
+
element: ElementInfo;
|
|
2799
|
+
onStyleChange: (property: string, value: string) => void;
|
|
2800
|
+
onStylesChange?: (styles: Record<string, string>) => void;
|
|
2801
|
+
}) {
|
|
2802
|
+
const t = useT();
|
|
2803
|
+
const flexChild = isParentFlex(element);
|
|
2804
|
+
const gridChild = isParentGrid(element);
|
|
2805
|
+
const availableSizing = availableSizingForElement(element);
|
|
2806
|
+
const isContainer = isContainerElement(element);
|
|
634
2807
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
top: styles.marginTop || "0",
|
|
650
|
-
right: styles.marginRight || "0",
|
|
651
|
-
bottom: styles.marginBottom || "0",
|
|
652
|
-
left: styles.marginLeft || "0",
|
|
653
|
-
}}
|
|
654
|
-
onChange={handleMarginChange}
|
|
655
|
-
/>
|
|
2808
|
+
const childControls = (
|
|
2809
|
+
<>
|
|
2810
|
+
{flexChild ? (
|
|
2811
|
+
<div className="border-t border-border/70 pt-2">
|
|
2812
|
+
<FlexChildControls element={element} onStyleChange={onStyleChange} />
|
|
2813
|
+
</div>
|
|
2814
|
+
) : null}
|
|
2815
|
+
{gridChild ? (
|
|
2816
|
+
<div className="border-t border-border/70 pt-2">
|
|
2817
|
+
<GridChildControls element={element} onStyleChange={onStyleChange} />
|
|
2818
|
+
</div>
|
|
2819
|
+
) : null}
|
|
2820
|
+
</>
|
|
2821
|
+
);
|
|
656
2822
|
|
|
657
|
-
|
|
2823
|
+
// Leaf elements (text, img, svg, etc.) never get auto layout — show the plain
|
|
2824
|
+
// Figma W/H sizing block instead.
|
|
2825
|
+
if (!isContainer) {
|
|
2826
|
+
return (
|
|
2827
|
+
<PanelSection title={t("editPanel.sections.layout")}>
|
|
2828
|
+
{/* Figma-style single-row-per-axis: [W | value | Fixed/Hug/Fill ▾] with
|
|
2829
|
+
the full sizing menu (modes + min/max + variable) per axis. */}
|
|
2830
|
+
<div className="grid grid-cols-2 items-start gap-1.5">
|
|
2831
|
+
<SizingField
|
|
2832
|
+
axis="W"
|
|
2833
|
+
sizingAxis="horizontal"
|
|
2834
|
+
value={inferElementSizing(element, "horizontal")}
|
|
2835
|
+
resolvedSize={element.boundingRect.width}
|
|
2836
|
+
minMax={readElementMinMax(element, "horizontal")}
|
|
2837
|
+
options={availableSizing.horizontal ?? ["fixed"]}
|
|
2838
|
+
disabled={false}
|
|
2839
|
+
onChange={(mode) =>
|
|
2840
|
+
commitElementSizing(
|
|
2841
|
+
element,
|
|
2842
|
+
"horizontal",
|
|
2843
|
+
mode,
|
|
2844
|
+
onStyleChange,
|
|
2845
|
+
onStylesChange,
|
|
2846
|
+
)
|
|
2847
|
+
}
|
|
2848
|
+
onMinMaxChange={(axis, kind, val) =>
|
|
2849
|
+
commitElementMinMax(axis, kind, val, onStyleChange)
|
|
2850
|
+
}
|
|
2851
|
+
/>
|
|
2852
|
+
<SizingField
|
|
2853
|
+
axis="H"
|
|
2854
|
+
sizingAxis="vertical"
|
|
2855
|
+
value={inferElementSizing(element, "vertical")}
|
|
2856
|
+
resolvedSize={element.boundingRect.height}
|
|
2857
|
+
minMax={readElementMinMax(element, "vertical")}
|
|
2858
|
+
options={availableSizing.vertical ?? ["fixed"]}
|
|
2859
|
+
disabled={false}
|
|
2860
|
+
onChange={(mode) =>
|
|
2861
|
+
commitElementSizing(
|
|
2862
|
+
element,
|
|
2863
|
+
"vertical",
|
|
2864
|
+
mode,
|
|
2865
|
+
onStyleChange,
|
|
2866
|
+
onStylesChange,
|
|
2867
|
+
)
|
|
2868
|
+
}
|
|
2869
|
+
onMinMaxChange={(axis, kind, val) =>
|
|
2870
|
+
commitElementMinMax(axis, kind, val, onStyleChange)
|
|
2871
|
+
}
|
|
2872
|
+
/>
|
|
2873
|
+
</div>
|
|
2874
|
+
{childControls}
|
|
2875
|
+
</PanelSection>
|
|
2876
|
+
);
|
|
2877
|
+
}
|
|
658
2878
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
<
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
/>
|
|
672
|
-
<PropInput
|
|
673
|
-
label={t("editPanel.labels.radius")}
|
|
674
|
-
value={styles.borderRadius || "0"}
|
|
675
|
-
onChange={(v) => onStyleChange("borderRadius", v)}
|
|
676
|
-
placeholder="0px"
|
|
677
|
-
defaultUnit="px"
|
|
2879
|
+
// Any container element ALREADY has a layout in code — normal flow (block) by
|
|
2880
|
+
// default, or flex when it uses flexbox. Figma never makes you "add" auto
|
|
2881
|
+
// layout for a frame, so we always render the full layout controls and let
|
|
2882
|
+
// the Flow control reflect/switch the element's current `display`. Choosing a
|
|
2883
|
+
// horizontal/vertical/wrap/grid flow applies `display:flex`; choosing the
|
|
2884
|
+
// normal-flow option resets to `display:block`.
|
|
2885
|
+
return (
|
|
2886
|
+
<PanelSection title={t("editPanel.sections.autoLayout")}>
|
|
2887
|
+
<FlexContainerControls
|
|
2888
|
+
element={element}
|
|
2889
|
+
onStyleChange={onStyleChange}
|
|
2890
|
+
onStylesChange={onStylesChange}
|
|
678
2891
|
/>
|
|
2892
|
+
{childControls}
|
|
2893
|
+
</PanelSection>
|
|
2894
|
+
);
|
|
2895
|
+
}
|
|
679
2896
|
|
|
680
|
-
|
|
2897
|
+
/**
|
|
2898
|
+
* Figma "Layout grid" / layout-guide section. Shown for frame/container
|
|
2899
|
+
* elements. Renders an overlay column/row guide by applying a non-destructive
|
|
2900
|
+
* `backgroundImage` repeating gradient layer tagged so it can be toggled off
|
|
2901
|
+
* without disturbing real fills.
|
|
2902
|
+
*/
|
|
2903
|
+
const LAYOUT_GUIDE_MARKER = "/* an-layout-guide */";
|
|
681
2904
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
2905
|
+
function hasLayoutGuide(styles: Record<string, string>): boolean {
|
|
2906
|
+
return Boolean(styles.backgroundImage?.includes("repeating-linear-gradient"));
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
function LayoutGuideProperties({
|
|
2910
|
+
element,
|
|
2911
|
+
onStyleChange,
|
|
2912
|
+
}: {
|
|
2913
|
+
element: ElementInfo;
|
|
2914
|
+
onStyleChange: (property: string, value: string) => void;
|
|
2915
|
+
}) {
|
|
2916
|
+
const styles = element.computedStyles;
|
|
2917
|
+
const active = hasLayoutGuide(styles);
|
|
2918
|
+
|
|
2919
|
+
const addGuide = () => {
|
|
2920
|
+
// 12-column overlay guide — Figma's default columns layout grid.
|
|
2921
|
+
const guide =
|
|
2922
|
+
"repeating-linear-gradient(to right, color-mix(in srgb, var(--design-editor-accent-color) 22%, transparent) 0 1px, transparent 1px calc(100% / 12))";
|
|
2923
|
+
const existing = compactCssValue(styles.backgroundImage, "");
|
|
2924
|
+
onStyleChange(
|
|
2925
|
+
"backgroundImage",
|
|
2926
|
+
existing ? `${guide}, ${existing}` : guide,
|
|
2927
|
+
);
|
|
2928
|
+
};
|
|
2929
|
+
|
|
2930
|
+
const removeGuide = () => {
|
|
2931
|
+
const layers = splitCssLayers(styles.backgroundImage || "").filter(
|
|
2932
|
+
(layer) => !layer.includes("repeating-linear-gradient"),
|
|
2933
|
+
);
|
|
2934
|
+
onStyleChange(
|
|
2935
|
+
"backgroundImage",
|
|
2936
|
+
layers.length ? joinCssLayers(layers) : "none",
|
|
2937
|
+
);
|
|
2938
|
+
};
|
|
2939
|
+
|
|
2940
|
+
return (
|
|
2941
|
+
<PanelSection
|
|
2942
|
+
title={"Layout guide" /* i18n-ignore Figma inspector label */}
|
|
2943
|
+
defaultCollapsed
|
|
2944
|
+
actions={
|
|
2945
|
+
<SectionIconButton
|
|
2946
|
+
label={
|
|
2947
|
+
active
|
|
2948
|
+
? "Remove layout guide" /* i18n-ignore Figma inspector action */
|
|
2949
|
+
: "Add layout guide" /* i18n-ignore Figma inspector action */
|
|
2950
|
+
}
|
|
2951
|
+
onClick={active ? removeGuide : addGuide}
|
|
2952
|
+
>
|
|
2953
|
+
{active ? (
|
|
2954
|
+
<IconMinus className="size-3.5" />
|
|
2955
|
+
) : (
|
|
2956
|
+
<IconPlus className="size-3.5" />
|
|
2957
|
+
)}
|
|
2958
|
+
</SectionIconButton>
|
|
2959
|
+
}
|
|
2960
|
+
>
|
|
2961
|
+
{active ? (
|
|
2962
|
+
<div className="flex items-center gap-2 rounded-md bg-[var(--design-editor-control-bg)] px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
2963
|
+
<IconLayoutGrid className="size-3.5 shrink-0" />
|
|
2964
|
+
<span className="min-w-0 flex-1 truncate text-foreground">
|
|
2965
|
+
{"Columns" /* i18n-ignore Figma inspector label */}
|
|
2966
|
+
</span>
|
|
2967
|
+
<span className="shrink-0 tabular-nums">12</span>
|
|
2968
|
+
</div>
|
|
2969
|
+
) : (
|
|
2970
|
+
<p className="text-[11px] text-muted-foreground">
|
|
2971
|
+
{"No layout guides" /* i18n-ignore Figma inspector empty state */}
|
|
2972
|
+
</p>
|
|
2973
|
+
)}
|
|
2974
|
+
</PanelSection>
|
|
2975
|
+
);
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
/**
|
|
2979
|
+
* Togglable export preview thumbnail (Figma shows a small preview of the export
|
|
2980
|
+
* frame above the export rows). Renders a proportional placeholder reflecting
|
|
2981
|
+
* the selected element's aspect ratio, fill, radius and dimensions.
|
|
2982
|
+
*/
|
|
2983
|
+
function ExportPreview({ element }: { element: ElementInfo | null }) {
|
|
2984
|
+
const rect = element?.boundingRect;
|
|
2985
|
+
const width = rect?.width ?? 0;
|
|
2986
|
+
const height = rect?.height ?? 0;
|
|
2987
|
+
const aspect = width > 0 && height > 0 ? width / height : 1;
|
|
2988
|
+
const styles = element?.computedStyles ?? {};
|
|
2989
|
+
const fill = cssColorOrFallback(
|
|
2990
|
+
styles.backgroundColor || styles.color,
|
|
2991
|
+
"var(--design-editor-control-bg)",
|
|
2992
|
+
);
|
|
2993
|
+
const radius = Math.min(8, cssLengthNumber(styles.borderRadius || "0"));
|
|
2994
|
+
|
|
2995
|
+
return (
|
|
2996
|
+
<div className="mt-1.5 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] p-3">
|
|
2997
|
+
<div
|
|
2998
|
+
className="mx-auto flex max-h-28 items-center justify-center"
|
|
2999
|
+
style={{
|
|
3000
|
+
aspectRatio: aspect,
|
|
3001
|
+
width: aspect >= 1 ? "100%" : "auto",
|
|
3002
|
+
height: aspect < 1 ? "7rem" : "auto",
|
|
3003
|
+
}}
|
|
3004
|
+
>
|
|
3005
|
+
<div
|
|
3006
|
+
className="size-full border border-[var(--design-editor-control-border)] shadow-sm"
|
|
3007
|
+
style={{ background: fill, borderRadius: radius }}
|
|
3008
|
+
/>
|
|
3009
|
+
</div>
|
|
3010
|
+
<p className="mt-2 text-center text-[10px] tabular-nums text-muted-foreground">
|
|
3011
|
+
{Math.round(width)} × {Math.round(height)}
|
|
3012
|
+
</p>
|
|
688
3013
|
</div>
|
|
689
3014
|
);
|
|
690
3015
|
}
|
|
691
3016
|
|
|
3017
|
+
/** Position, size, and spacing properties */
|
|
3018
|
+
function PositionLayoutProperties({
|
|
3019
|
+
element,
|
|
3020
|
+
onStyleChange,
|
|
3021
|
+
}: {
|
|
3022
|
+
element: ElementInfo;
|
|
3023
|
+
onStyleChange: (property: string, value: string) => void;
|
|
3024
|
+
}) {
|
|
3025
|
+
const t = useT();
|
|
3026
|
+
const styles = element.computedStyles;
|
|
3027
|
+
const constrainedPosition =
|
|
3028
|
+
styles.position === "absolute" || styles.position === "fixed";
|
|
3029
|
+
// Reflect the active packing in the alignment segments (Figma highlights the
|
|
3030
|
+
// current alignment). For a flex container the main axis is justifyContent.
|
|
3031
|
+
const alignH = justifyToHorizontal(styles.justifyContent);
|
|
3032
|
+
const alignV = alignToVertical(styles.alignItems);
|
|
3033
|
+
const constraintsValue: ConstraintsValue = {
|
|
3034
|
+
horizontal:
|
|
3035
|
+
styles.left && styles.right
|
|
3036
|
+
? "left-right"
|
|
3037
|
+
: styles.right
|
|
3038
|
+
? "right"
|
|
3039
|
+
: styles.transform?.includes("translateX(-50%)")
|
|
3040
|
+
? "center"
|
|
3041
|
+
: styles.width === "100%"
|
|
3042
|
+
? "scale"
|
|
3043
|
+
: "left",
|
|
3044
|
+
vertical:
|
|
3045
|
+
styles.top && styles.bottom
|
|
3046
|
+
? "top-bottom"
|
|
3047
|
+
: styles.bottom
|
|
3048
|
+
? "bottom"
|
|
3049
|
+
: styles.transform?.includes("translateY(-50%)")
|
|
3050
|
+
? "center"
|
|
3051
|
+
: styles.height === "100%"
|
|
3052
|
+
? "scale"
|
|
3053
|
+
: "top",
|
|
3054
|
+
};
|
|
3055
|
+
|
|
3056
|
+
const handleConstraintsChange = useCallback(
|
|
3057
|
+
(value: ConstraintsValue) => {
|
|
3058
|
+
onStyleChange("position", "absolute");
|
|
3059
|
+
if (value.horizontal === "left") {
|
|
3060
|
+
onStyleChange(
|
|
3061
|
+
"left",
|
|
3062
|
+
styles.left || `${Math.round(element.boundingRect.x)}px`,
|
|
3063
|
+
);
|
|
3064
|
+
onStyleChange("right", "auto");
|
|
3065
|
+
} else if (value.horizontal === "right") {
|
|
3066
|
+
onStyleChange("right", "0px");
|
|
3067
|
+
onStyleChange("left", "auto");
|
|
3068
|
+
} else if (value.horizontal === "left-right") {
|
|
3069
|
+
onStyleChange(
|
|
3070
|
+
"left",
|
|
3071
|
+
styles.left || `${Math.round(element.boundingRect.x)}px`,
|
|
3072
|
+
);
|
|
3073
|
+
onStyleChange("right", "0px");
|
|
3074
|
+
} else if (value.horizontal === "center") {
|
|
3075
|
+
onStyleChange("left", "50%");
|
|
3076
|
+
onStyleChange("right", "auto");
|
|
3077
|
+
onStyleChange("transform", "translateX(-50%)");
|
|
3078
|
+
} else {
|
|
3079
|
+
onStyleChange("left", "0px");
|
|
3080
|
+
onStyleChange("right", "0px");
|
|
3081
|
+
onStyleChange("width", "100%");
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
if (value.vertical === "top") {
|
|
3085
|
+
onStyleChange(
|
|
3086
|
+
"top",
|
|
3087
|
+
styles.top || `${Math.round(element.boundingRect.y)}px`,
|
|
3088
|
+
);
|
|
3089
|
+
onStyleChange("bottom", "auto");
|
|
3090
|
+
} else if (value.vertical === "bottom") {
|
|
3091
|
+
onStyleChange("bottom", "0px");
|
|
3092
|
+
onStyleChange("top", "auto");
|
|
3093
|
+
} else if (value.vertical === "top-bottom") {
|
|
3094
|
+
onStyleChange(
|
|
3095
|
+
"top",
|
|
3096
|
+
styles.top || `${Math.round(element.boundingRect.y)}px`,
|
|
3097
|
+
);
|
|
3098
|
+
onStyleChange("bottom", "0px");
|
|
3099
|
+
} else if (value.vertical === "center") {
|
|
3100
|
+
onStyleChange("top", "50%");
|
|
3101
|
+
onStyleChange("bottom", "auto");
|
|
3102
|
+
onStyleChange("transform", "translateY(-50%)");
|
|
3103
|
+
} else {
|
|
3104
|
+
onStyleChange("top", "0px");
|
|
3105
|
+
onStyleChange("bottom", "0px");
|
|
3106
|
+
onStyleChange("height", "100%");
|
|
3107
|
+
}
|
|
3108
|
+
},
|
|
3109
|
+
[
|
|
3110
|
+
element.boundingRect.x,
|
|
3111
|
+
element.boundingRect.y,
|
|
3112
|
+
onStyleChange,
|
|
3113
|
+
styles.left,
|
|
3114
|
+
styles.top,
|
|
3115
|
+
],
|
|
3116
|
+
);
|
|
3117
|
+
|
|
3118
|
+
return (
|
|
3119
|
+
<PanelSection
|
|
3120
|
+
title={t("editPanel.sections.positionLayout")}
|
|
3121
|
+
actions={
|
|
3122
|
+
<SectionIconToggle
|
|
3123
|
+
label={"Absolute position" /* i18n-ignore Figma inspector action */}
|
|
3124
|
+
active={constrainedPosition}
|
|
3125
|
+
onClick={() =>
|
|
3126
|
+
onStyleChange(
|
|
3127
|
+
"position",
|
|
3128
|
+
constrainedPosition ? "relative" : "absolute",
|
|
3129
|
+
)
|
|
3130
|
+
}
|
|
3131
|
+
>
|
|
3132
|
+
<IconLayoutDistributeHorizontal className="size-3.5" />
|
|
3133
|
+
</SectionIconToggle>
|
|
3134
|
+
}
|
|
3135
|
+
>
|
|
3136
|
+
<div className="space-y-1.5">
|
|
3137
|
+
<SubsectionLabel>
|
|
3138
|
+
{"Alignment" /* i18n-ignore Figma inspector label */}
|
|
3139
|
+
</SubsectionLabel>
|
|
3140
|
+
<div className="flex items-center gap-3">
|
|
3141
|
+
<InspectorSegment>
|
|
3142
|
+
<InspectorIconButton
|
|
3143
|
+
label={t("editPanel.textAligns.left")}
|
|
3144
|
+
active={alignH === "left"}
|
|
3145
|
+
onClick={() => onStyleChange("justifyContent", "flex-start")}
|
|
3146
|
+
>
|
|
3147
|
+
<IconLayoutAlignLeft className="size-3.5" />
|
|
3148
|
+
</InspectorIconButton>
|
|
3149
|
+
<InspectorIconButton
|
|
3150
|
+
label={t("editPanel.textAligns.center")}
|
|
3151
|
+
active={alignH === "center"}
|
|
3152
|
+
onClick={() => onStyleChange("justifyContent", "center")}
|
|
3153
|
+
>
|
|
3154
|
+
<IconLayoutAlignCenter className="size-3.5" />
|
|
3155
|
+
</InspectorIconButton>
|
|
3156
|
+
<InspectorIconButton
|
|
3157
|
+
label={t("editPanel.textAligns.right")}
|
|
3158
|
+
active={alignH === "right"}
|
|
3159
|
+
onClick={() => onStyleChange("justifyContent", "flex-end")}
|
|
3160
|
+
>
|
|
3161
|
+
<IconLayoutAlignRight className="size-3.5" />
|
|
3162
|
+
</InspectorIconButton>
|
|
3163
|
+
</InspectorSegment>
|
|
3164
|
+
<InspectorSegment>
|
|
3165
|
+
<InspectorIconButton
|
|
3166
|
+
label={t("editPanel.alignSelfOptions.start")}
|
|
3167
|
+
active={alignV === "top"}
|
|
3168
|
+
onClick={() => onStyleChange("alignItems", "flex-start")}
|
|
3169
|
+
>
|
|
3170
|
+
<IconLayoutAlignTop className="size-3.5" />
|
|
3171
|
+
</InspectorIconButton>
|
|
3172
|
+
<InspectorIconButton
|
|
3173
|
+
label={t("editPanel.alignSelfOptions.center")}
|
|
3174
|
+
active={alignV === "middle"}
|
|
3175
|
+
onClick={() => onStyleChange("alignItems", "center")}
|
|
3176
|
+
>
|
|
3177
|
+
<IconLayoutAlignMiddle className="size-3.5" />
|
|
3178
|
+
</InspectorIconButton>
|
|
3179
|
+
<InspectorIconButton
|
|
3180
|
+
label={t("editPanel.alignSelfOptions.end")}
|
|
3181
|
+
active={alignV === "bottom"}
|
|
3182
|
+
onClick={() => onStyleChange("alignItems", "flex-end")}
|
|
3183
|
+
>
|
|
3184
|
+
<IconLayoutAlignBottom className="size-3.5" />
|
|
3185
|
+
</InspectorIconButton>
|
|
3186
|
+
</InspectorSegment>
|
|
3187
|
+
</div>
|
|
3188
|
+
</div>
|
|
3189
|
+
|
|
3190
|
+
<div className="space-y-1.5">
|
|
3191
|
+
<SubsectionLabel>{t("editPanel.labels.position")}</SubsectionLabel>
|
|
3192
|
+
<div className="grid grid-cols-2 gap-2">
|
|
3193
|
+
<ScrubStyleInput
|
|
3194
|
+
label="X"
|
|
3195
|
+
value={styles.left || ""}
|
|
3196
|
+
placeholder={element.boundingRect.x}
|
|
3197
|
+
inputClassName="h-6"
|
|
3198
|
+
onChange={(v) => onStyleChange("left", `${Math.round(v)}px`)}
|
|
3199
|
+
/>
|
|
3200
|
+
<ScrubStyleInput
|
|
3201
|
+
label="Y"
|
|
3202
|
+
value={styles.top || ""}
|
|
3203
|
+
placeholder={element.boundingRect.y}
|
|
3204
|
+
inputClassName="h-6"
|
|
3205
|
+
onChange={(v) => onStyleChange("top", `${Math.round(v)}px`)}
|
|
3206
|
+
/>
|
|
3207
|
+
</div>
|
|
3208
|
+
</div>
|
|
3209
|
+
|
|
3210
|
+
<div className="space-y-1.5">
|
|
3211
|
+
<SubsectionLabel>{t("editPanel.labels.rotation")}</SubsectionLabel>
|
|
3212
|
+
<div className="flex items-center gap-2">
|
|
3213
|
+
<div className="min-w-0 flex-1">
|
|
3214
|
+
<ScrubStyleInput
|
|
3215
|
+
label="R"
|
|
3216
|
+
value={`${parseRotationValue(styles.transform)}deg`}
|
|
3217
|
+
unit="deg"
|
|
3218
|
+
inputClassName="h-6"
|
|
3219
|
+
onChange={(v) =>
|
|
3220
|
+
onStyleChange(
|
|
3221
|
+
"transform",
|
|
3222
|
+
mergeRotationValue(styles.transform, v),
|
|
3223
|
+
)
|
|
3224
|
+
}
|
|
3225
|
+
/>
|
|
3226
|
+
</div>
|
|
3227
|
+
<InspectorSegment>
|
|
3228
|
+
<InspectorIconButton
|
|
3229
|
+
label={t("editPanel.labels.flipHorizontal")}
|
|
3230
|
+
onClick={() => onStyleChange("scale", "-1 1")}
|
|
3231
|
+
>
|
|
3232
|
+
<IconFlipHorizontal className="size-4" />
|
|
3233
|
+
</InspectorIconButton>
|
|
3234
|
+
<InspectorIconButton
|
|
3235
|
+
label={t("editPanel.labels.flipVertical")}
|
|
3236
|
+
onClick={() => onStyleChange("scale", "1 -1")}
|
|
3237
|
+
>
|
|
3238
|
+
<IconFlipVertical className="size-4" />
|
|
3239
|
+
</InspectorIconButton>
|
|
3240
|
+
</InspectorSegment>
|
|
3241
|
+
</div>
|
|
3242
|
+
</div>
|
|
3243
|
+
|
|
3244
|
+
{constrainedPosition ? (
|
|
3245
|
+
<ConstraintsWidget
|
|
3246
|
+
value={constraintsValue}
|
|
3247
|
+
onChange={handleConstraintsChange}
|
|
3248
|
+
/>
|
|
3249
|
+
) : null}
|
|
3250
|
+
</PanelSection>
|
|
3251
|
+
);
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
function FillProperties({
|
|
3255
|
+
element,
|
|
3256
|
+
onStyleChange,
|
|
3257
|
+
onStylesChange,
|
|
3258
|
+
}: {
|
|
3259
|
+
element: ElementInfo;
|
|
3260
|
+
onStyleChange: (property: string, value: string) => void;
|
|
3261
|
+
onStylesChange?: (styles: Record<string, string>) => void;
|
|
3262
|
+
}) {
|
|
3263
|
+
const t = useT();
|
|
3264
|
+
const styles = element.computedStyles;
|
|
3265
|
+
const isTextElement = TEXT_TAGS.has(element.tagName);
|
|
3266
|
+
const fillProperty = isTextElement ? "color" : "backgroundColor";
|
|
3267
|
+
const fillValue = isTextElement
|
|
3268
|
+
? styles.color || ""
|
|
3269
|
+
: styles.backgroundColor || "";
|
|
3270
|
+
const backgroundLayers = isTextElement
|
|
3271
|
+
? []
|
|
3272
|
+
: splitCssLayers(styles.backgroundImage || "");
|
|
3273
|
+
const hasBackgroundLayer = !isTextElement && backgroundLayers.length > 0;
|
|
3274
|
+
const hasVisibleFill =
|
|
3275
|
+
isTextElement || colorHasVisibleAlpha(fillValue) || hasBackgroundLayer;
|
|
3276
|
+
|
|
3277
|
+
// Non-destructive fill hide: stash the color before hiding so toggling
|
|
3278
|
+
// visible again restores the exact original value (Figma never loses color).
|
|
3279
|
+
// Keyed by `${element.id ?? tagName}:${fillProperty}` so separate elements
|
|
3280
|
+
// don't share stash slots.
|
|
3281
|
+
const [hiddenFillStash, setHiddenFillStash] = useState<
|
|
3282
|
+
Record<string, string>
|
|
3283
|
+
>({});
|
|
3284
|
+
const stashKey = `${element.id ?? element.tagName}:${fillProperty}`;
|
|
3285
|
+
const isHidden = fillValue === "transparent" || fillValue === "";
|
|
3286
|
+
const handleFillVisibilityToggle = () => {
|
|
3287
|
+
if (isHidden) {
|
|
3288
|
+
// Restore the stashed color, or fall back to a sensible default.
|
|
3289
|
+
const restored =
|
|
3290
|
+
hiddenFillStash[stashKey] ?? (isTextElement ? "#000000" : "#ffffff");
|
|
3291
|
+
onStyleChange(fillProperty, restored);
|
|
3292
|
+
setHiddenFillStash((prev) => {
|
|
3293
|
+
const next = { ...prev };
|
|
3294
|
+
delete next[stashKey];
|
|
3295
|
+
return next;
|
|
3296
|
+
});
|
|
3297
|
+
} else {
|
|
3298
|
+
// Stash the current color before going transparent.
|
|
3299
|
+
setHiddenFillStash((prev) => ({ ...prev, [stashKey]: fillValue }));
|
|
3300
|
+
onStyleChange(fillProperty, "transparent");
|
|
3301
|
+
}
|
|
3302
|
+
};
|
|
3303
|
+
|
|
3304
|
+
// Document colors: unique hex strings from all CSS color properties on the
|
|
3305
|
+
// selected element, collected via the existing selectionColorValues helper.
|
|
3306
|
+
const docColorHexes = selectionColorValues(element)
|
|
3307
|
+
.map((c) => {
|
|
3308
|
+
const parsed = parseCssColor(c.value);
|
|
3309
|
+
return parsed ? rgbaToHex(parsed) : null;
|
|
3310
|
+
})
|
|
3311
|
+
.filter((h): h is string => Boolean(h));
|
|
3312
|
+
// Deduplicate (selectionColorValues already dedupes by raw CSS value, but
|
|
3313
|
+
// hex normalisation may collapse additional entries e.g. rgb vs #hex).
|
|
3314
|
+
const seenHex = new Set<string>();
|
|
3315
|
+
const documentColors = docColorHexes.filter((h) => {
|
|
3316
|
+
const key = h.toUpperCase();
|
|
3317
|
+
if (seenHex.has(key)) return false;
|
|
3318
|
+
seenHex.add(key);
|
|
3319
|
+
return true;
|
|
3320
|
+
});
|
|
3321
|
+
|
|
3322
|
+
return (
|
|
3323
|
+
<PanelSection
|
|
3324
|
+
title={t("editPanel.sections.fill")}
|
|
3325
|
+
actions={
|
|
3326
|
+
<>
|
|
3327
|
+
{/* Figma color-styles affordance (grid icon) to the left of "+". */}
|
|
3328
|
+
<SectionIconButton
|
|
3329
|
+
label={"Styles" /* i18n-ignore Figma inspector action */}
|
|
3330
|
+
>
|
|
3331
|
+
<IconLayoutGrid className="size-3.5" />
|
|
3332
|
+
</SectionIconButton>
|
|
3333
|
+
<SectionIconButton
|
|
3334
|
+
label={t("editPanel.labels.addLayer")}
|
|
3335
|
+
onClick={() => {
|
|
3336
|
+
if (isTextElement) {
|
|
3337
|
+
onStyleChange(
|
|
3338
|
+
"color",
|
|
3339
|
+
cssColorOrFallback(styles.color, "#000000"),
|
|
3340
|
+
);
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
if (!colorHasVisibleAlpha(styles.backgroundColor)) {
|
|
3344
|
+
onStyleChange(
|
|
3345
|
+
"backgroundColor",
|
|
3346
|
+
cssColorOrFallback(styles.backgroundColor, "#ffffff"),
|
|
3347
|
+
);
|
|
3348
|
+
return;
|
|
3349
|
+
}
|
|
3350
|
+
const current = compactCssValue(styles.backgroundImage, "");
|
|
3351
|
+
const nextLayer = defaultGradientLayer(
|
|
3352
|
+
"linear",
|
|
3353
|
+
styles.backgroundColor || "#ffffff",
|
|
3354
|
+
);
|
|
3355
|
+
onStyleChange(
|
|
3356
|
+
"backgroundImage",
|
|
3357
|
+
current ? `${nextLayer}, ${current}` : nextLayer,
|
|
3358
|
+
);
|
|
3359
|
+
}}
|
|
3360
|
+
>
|
|
3361
|
+
<IconPlus className="size-3.5" />
|
|
3362
|
+
</SectionIconButton>
|
|
3363
|
+
</>
|
|
3364
|
+
}
|
|
3365
|
+
>
|
|
3366
|
+
{hasVisibleFill ? (
|
|
3367
|
+
<div className="space-y-1.5">
|
|
3368
|
+
{isTextElement || colorHasVisibleAlpha(fillValue) ? (
|
|
3369
|
+
/* Figma row: [swatch+hex trigger (flex-1)] [eye] [remove] */
|
|
3370
|
+
<div className="group flex items-center gap-1.5">
|
|
3371
|
+
<div className="min-w-0 flex-1">
|
|
3372
|
+
<ColorInput
|
|
3373
|
+
label=""
|
|
3374
|
+
value={fillValue}
|
|
3375
|
+
onChange={(v) => onStyleChange(fillProperty, v)}
|
|
3376
|
+
backgroundImage=""
|
|
3377
|
+
blendMode={
|
|
3378
|
+
isTextElement
|
|
3379
|
+
? undefined
|
|
3380
|
+
: styles.backgroundBlendMode || "normal"
|
|
3381
|
+
}
|
|
3382
|
+
onBlendModeChange={
|
|
3383
|
+
isTextElement
|
|
3384
|
+
? undefined
|
|
3385
|
+
: (v) => onStyleChange("backgroundBlendMode", v)
|
|
3386
|
+
}
|
|
3387
|
+
documentColors={documentColors}
|
|
3388
|
+
/>
|
|
3389
|
+
</div>
|
|
3390
|
+
<SectionIconButton
|
|
3391
|
+
className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
|
3392
|
+
label={
|
|
3393
|
+
isHidden
|
|
3394
|
+
? t("editPanel.labels.showLayer")
|
|
3395
|
+
: t("editPanel.labels.hideLayer")
|
|
3396
|
+
}
|
|
3397
|
+
onClick={handleFillVisibilityToggle}
|
|
3398
|
+
>
|
|
3399
|
+
{isHidden ? (
|
|
3400
|
+
<IconEyeOff className="size-3.5" />
|
|
3401
|
+
) : (
|
|
3402
|
+
<IconEye className="size-3.5" />
|
|
3403
|
+
)}
|
|
3404
|
+
</SectionIconButton>
|
|
3405
|
+
<SectionIconButton
|
|
3406
|
+
className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
|
3407
|
+
label={t("editPanel.labels.removeLayer")}
|
|
3408
|
+
onClick={() => {
|
|
3409
|
+
if (isTextElement) {
|
|
3410
|
+
onStyleChange(fillProperty, "transparent");
|
|
3411
|
+
return;
|
|
3412
|
+
}
|
|
3413
|
+
if (onStylesChange) {
|
|
3414
|
+
onStylesChange({
|
|
3415
|
+
backgroundColor: "transparent",
|
|
3416
|
+
backgroundImage: "none",
|
|
3417
|
+
});
|
|
3418
|
+
} else {
|
|
3419
|
+
onStyleChange(fillProperty, "transparent");
|
|
3420
|
+
}
|
|
3421
|
+
}}
|
|
3422
|
+
>
|
|
3423
|
+
<IconMinus className="size-3.5" />
|
|
3424
|
+
</SectionIconButton>
|
|
3425
|
+
</div>
|
|
3426
|
+
) : null}
|
|
3427
|
+
{!isTextElement
|
|
3428
|
+
? backgroundLayers.map((layer, index) => {
|
|
3429
|
+
const gradient = parseGradientLayer(layer);
|
|
3430
|
+
const opacity = gradient
|
|
3431
|
+
? averageGradientOpacity(gradient.stops)
|
|
3432
|
+
: 100;
|
|
3433
|
+
const label = gradient
|
|
3434
|
+
? `${gradientLabel(gradient.type)} ${index + 1}`
|
|
3435
|
+
: `${"Image" /* i18n-ignore Figma inspector paint row */} ${
|
|
3436
|
+
index + 1
|
|
3437
|
+
}`;
|
|
3438
|
+
const replaceLayer = (nextLayer: string) => {
|
|
3439
|
+
const nextLayers = [...backgroundLayers];
|
|
3440
|
+
nextLayers[index] = nextLayer;
|
|
3441
|
+
onStyleChange("backgroundImage", joinCssLayers(nextLayers));
|
|
3442
|
+
};
|
|
3443
|
+
const removeLayer = () => {
|
|
3444
|
+
onStyleChange(
|
|
3445
|
+
"backgroundImage",
|
|
3446
|
+
joinCssLayers(
|
|
3447
|
+
backgroundLayers.filter(
|
|
3448
|
+
(_, layerIndex) => layerIndex !== index,
|
|
3449
|
+
),
|
|
3450
|
+
),
|
|
3451
|
+
);
|
|
3452
|
+
};
|
|
3453
|
+
|
|
3454
|
+
return (
|
|
3455
|
+
/* Figma row: [swatch+label+opacity% trigger (flex-1)] [eye] [remove] */
|
|
3456
|
+
<div
|
|
3457
|
+
key={`${layer}-${index}`}
|
|
3458
|
+
className="group flex items-center gap-1.5"
|
|
3459
|
+
>
|
|
3460
|
+
<Popover>
|
|
3461
|
+
<PopoverTrigger asChild>
|
|
3462
|
+
<button
|
|
3463
|
+
type="button"
|
|
3464
|
+
className="flex h-6 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-left text-[11px] hover:bg-[var(--design-editor-panel-raised-bg)]"
|
|
3465
|
+
>
|
|
3466
|
+
<span
|
|
3467
|
+
className="size-4 shrink-0 rounded-sm border border-[var(--design-editor-control-border)]"
|
|
3468
|
+
style={swatchStyle(layer)}
|
|
3469
|
+
/>
|
|
3470
|
+
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
|
|
3471
|
+
{label}
|
|
3472
|
+
</span>
|
|
3473
|
+
<span className="shrink-0 tabular-nums text-muted-foreground">
|
|
3474
|
+
{opacity}%
|
|
3475
|
+
</span>
|
|
3476
|
+
</button>
|
|
3477
|
+
</PopoverTrigger>
|
|
3478
|
+
<PopoverContent
|
|
3479
|
+
side="left"
|
|
3480
|
+
align="start"
|
|
3481
|
+
sideOffset={8}
|
|
3482
|
+
className="w-80 p-0"
|
|
3483
|
+
>
|
|
3484
|
+
<FigmaColorPicker
|
|
3485
|
+
value={gradient?.stops[0]?.color ?? layer}
|
|
3486
|
+
onPaintValueChange={replaceLayer}
|
|
3487
|
+
onChange={(nextColor) => {
|
|
3488
|
+
if (!gradient) return;
|
|
3489
|
+
const firstStop = gradient.stops[0];
|
|
3490
|
+
if (!firstStop) return;
|
|
3491
|
+
replaceLayer(
|
|
3492
|
+
buildGradientLayer(gradient.type, [
|
|
3493
|
+
{ ...firstStop, color: nextColor },
|
|
3494
|
+
...gradient.stops.slice(1),
|
|
3495
|
+
]),
|
|
3496
|
+
);
|
|
3497
|
+
}}
|
|
3498
|
+
paintType={gradient?.type ?? "image"}
|
|
3499
|
+
gradientType={gradient?.type}
|
|
3500
|
+
onGradientTypeChange={(type) => {
|
|
3501
|
+
if (!gradient) return;
|
|
3502
|
+
replaceLayer(
|
|
3503
|
+
buildGradientLayer(type, gradient.stops),
|
|
3504
|
+
);
|
|
3505
|
+
}}
|
|
3506
|
+
fillRows={[
|
|
3507
|
+
{
|
|
3508
|
+
id: `layer-${index}`,
|
|
3509
|
+
label,
|
|
3510
|
+
value: layer,
|
|
3511
|
+
type: gradient ? "gradient" : "image",
|
|
3512
|
+
selected: true,
|
|
3513
|
+
swatch: layer,
|
|
3514
|
+
},
|
|
3515
|
+
]}
|
|
3516
|
+
selectedFillId={`layer-${index}`}
|
|
3517
|
+
/>
|
|
3518
|
+
</PopoverContent>
|
|
3519
|
+
</Popover>
|
|
3520
|
+
<SectionIconButton
|
|
3521
|
+
className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
|
3522
|
+
label={
|
|
3523
|
+
opacity <= 0
|
|
3524
|
+
? t("editPanel.labels.showLayer")
|
|
3525
|
+
: t("editPanel.labels.hideLayer")
|
|
3526
|
+
}
|
|
3527
|
+
onClick={() => {
|
|
3528
|
+
if (!gradient) return;
|
|
3529
|
+
replaceLayer(
|
|
3530
|
+
buildGradientLayer(
|
|
3531
|
+
gradient.type,
|
|
3532
|
+
gradient.stops.map((stop) => ({
|
|
3533
|
+
...stop,
|
|
3534
|
+
opacity: opacity <= 0 ? 100 : 0,
|
|
3535
|
+
})),
|
|
3536
|
+
),
|
|
3537
|
+
);
|
|
3538
|
+
}}
|
|
3539
|
+
>
|
|
3540
|
+
{opacity <= 0 ? (
|
|
3541
|
+
<IconEyeOff className="size-3.5" />
|
|
3542
|
+
) : (
|
|
3543
|
+
<IconEye className="size-3.5" />
|
|
3544
|
+
)}
|
|
3545
|
+
</SectionIconButton>
|
|
3546
|
+
<SectionIconButton
|
|
3547
|
+
className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
|
3548
|
+
label={t("editPanel.labels.removeLayer")}
|
|
3549
|
+
onClick={removeLayer}
|
|
3550
|
+
>
|
|
3551
|
+
<IconMinus className="size-3.5" />
|
|
3552
|
+
</SectionIconButton>
|
|
3553
|
+
</div>
|
|
3554
|
+
);
|
|
3555
|
+
})
|
|
3556
|
+
: null}
|
|
3557
|
+
</div>
|
|
3558
|
+
) : null}
|
|
3559
|
+
</PanelSection>
|
|
3560
|
+
);
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
function StrokeProperties({
|
|
3564
|
+
element,
|
|
3565
|
+
onStyleChange,
|
|
3566
|
+
onStylesChange,
|
|
3567
|
+
}: {
|
|
3568
|
+
element: ElementInfo;
|
|
3569
|
+
onStyleChange: (property: string, value: string) => void;
|
|
3570
|
+
onStylesChange?: (styles: Record<string, string>) => void;
|
|
3571
|
+
}) {
|
|
3572
|
+
const t = useT();
|
|
3573
|
+
const styles = element.computedStyles;
|
|
3574
|
+
const borderVisible = strokeIsVisible(styles.borderWidth, styles.borderStyle);
|
|
3575
|
+
const outlineVisible = strokeIsVisible(
|
|
3576
|
+
styles.outlineWidth,
|
|
3577
|
+
styles.outlineStyle,
|
|
3578
|
+
);
|
|
3579
|
+
|
|
3580
|
+
return (
|
|
3581
|
+
<PanelSection
|
|
3582
|
+
title={t("editPanel.sections.stroke")}
|
|
3583
|
+
actions={
|
|
3584
|
+
<SectionIconButton
|
|
3585
|
+
label={t("editPanel.labels.addLayer")}
|
|
3586
|
+
onClick={() => {
|
|
3587
|
+
if (!borderVisible) {
|
|
3588
|
+
const borderColor = cssColorOrFallback(
|
|
3589
|
+
styles.borderColor || styles.color,
|
|
3590
|
+
"#000000",
|
|
3591
|
+
);
|
|
3592
|
+
commitStylePatch(
|
|
3593
|
+
{
|
|
3594
|
+
borderWidth: "1px",
|
|
3595
|
+
borderStyle: "solid",
|
|
3596
|
+
borderColor,
|
|
3597
|
+
},
|
|
3598
|
+
onStyleChange,
|
|
3599
|
+
onStylesChange,
|
|
3600
|
+
);
|
|
3601
|
+
return;
|
|
3602
|
+
}
|
|
3603
|
+
if (outlineVisible) {
|
|
3604
|
+
const outlineWidth = `${
|
|
3605
|
+
Math.max(1, cssLengthNumber(styles.outlineWidth, 1)) + 1
|
|
3606
|
+
}px`;
|
|
3607
|
+
const outlineStyle =
|
|
3608
|
+
styles.outlineStyle === "none"
|
|
3609
|
+
? "solid"
|
|
3610
|
+
: styles.outlineStyle || "solid";
|
|
3611
|
+
const outlineColor = cssColorOrFallback(
|
|
3612
|
+
styles.outlineColor || styles.borderColor,
|
|
3613
|
+
"#000000",
|
|
3614
|
+
);
|
|
3615
|
+
commitStylePatch(
|
|
3616
|
+
{
|
|
3617
|
+
outlineWidth,
|
|
3618
|
+
outlineStyle,
|
|
3619
|
+
outlineColor,
|
|
3620
|
+
outlineOffset: styles.outlineOffset || "0px",
|
|
3621
|
+
},
|
|
3622
|
+
onStyleChange,
|
|
3623
|
+
onStylesChange,
|
|
3624
|
+
);
|
|
3625
|
+
return;
|
|
3626
|
+
}
|
|
3627
|
+
commitStylePatch(
|
|
3628
|
+
{
|
|
3629
|
+
outlineWidth: "1px",
|
|
3630
|
+
outlineStyle: "solid",
|
|
3631
|
+
outlineColor: cssColorOrFallback(styles.borderColor, "#000000"),
|
|
3632
|
+
outlineOffset: "0px",
|
|
3633
|
+
},
|
|
3634
|
+
onStyleChange,
|
|
3635
|
+
onStylesChange,
|
|
3636
|
+
);
|
|
3637
|
+
}}
|
|
3638
|
+
>
|
|
3639
|
+
<IconPlus className="size-3.5" />
|
|
3640
|
+
</SectionIconButton>
|
|
3641
|
+
}
|
|
3642
|
+
>
|
|
3643
|
+
{borderVisible ? (
|
|
3644
|
+
<StrokeLayerControl
|
|
3645
|
+
kind="border"
|
|
3646
|
+
visible={borderVisible}
|
|
3647
|
+
color={styles.borderColor || "#000000"}
|
|
3648
|
+
width={styles.borderWidth || "0px"}
|
|
3649
|
+
styleValue={styles.borderStyle || "none"}
|
|
3650
|
+
onStyleChange={onStyleChange}
|
|
3651
|
+
onRemove={() => {
|
|
3652
|
+
if (onStylesChange) {
|
|
3653
|
+
onStylesChange({ borderWidth: "0px", borderStyle: "none" });
|
|
3654
|
+
} else {
|
|
3655
|
+
onStyleChange("borderWidth", "0px");
|
|
3656
|
+
}
|
|
3657
|
+
}}
|
|
3658
|
+
/>
|
|
3659
|
+
) : null}
|
|
3660
|
+
{outlineVisible ? (
|
|
3661
|
+
<StrokeLayerControl
|
|
3662
|
+
kind="outline"
|
|
3663
|
+
visible={outlineVisible}
|
|
3664
|
+
color={styles.outlineColor || styles.borderColor || "#000000"}
|
|
3665
|
+
width={styles.outlineWidth || "0px"}
|
|
3666
|
+
styleValue={styles.outlineStyle || "solid"}
|
|
3667
|
+
onStyleChange={onStyleChange}
|
|
3668
|
+
onRemove={() => {
|
|
3669
|
+
if (onStylesChange) {
|
|
3670
|
+
onStylesChange({ outlineWidth: "0px", outlineStyle: "none" });
|
|
3671
|
+
} else {
|
|
3672
|
+
onStyleChange("outlineWidth", "0px");
|
|
3673
|
+
}
|
|
3674
|
+
}}
|
|
3675
|
+
/>
|
|
3676
|
+
) : null}
|
|
3677
|
+
</PanelSection>
|
|
3678
|
+
);
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
function AppearanceProperties({
|
|
3682
|
+
element,
|
|
3683
|
+
onStyleChange,
|
|
3684
|
+
}: {
|
|
3685
|
+
element: ElementInfo;
|
|
3686
|
+
onStyleChange: (property: string, value: string) => void;
|
|
3687
|
+
}) {
|
|
3688
|
+
const t = useT();
|
|
3689
|
+
const styles = element.computedStyles;
|
|
3690
|
+
const hidden =
|
|
3691
|
+
styles.visibility === "hidden" ||
|
|
3692
|
+
styles.display === "none" ||
|
|
3693
|
+
parseNumericValue(styles.opacity || "1") === 0;
|
|
3694
|
+
return (
|
|
3695
|
+
<PanelSection
|
|
3696
|
+
title={t("root.commandAppearance")}
|
|
3697
|
+
actions={
|
|
3698
|
+
<>
|
|
3699
|
+
{/* Opacity / blend-mode affordance — matches Figma's pill icon */}
|
|
3700
|
+
<SectionIconButton
|
|
3701
|
+
label={
|
|
3702
|
+
"Opacity & blend mode" /* i18n-ignore Figma inspector action */
|
|
3703
|
+
}
|
|
3704
|
+
>
|
|
3705
|
+
<IconSlice className="size-3.5" />
|
|
3706
|
+
</SectionIconButton>
|
|
3707
|
+
{/* Visibility toggle */}
|
|
3708
|
+
<SectionIconToggle
|
|
3709
|
+
label={
|
|
3710
|
+
hidden
|
|
3711
|
+
? "Show" /* i18n-ignore Figma inspector action */
|
|
3712
|
+
: "Hide" /* i18n-ignore Figma inspector action */
|
|
3713
|
+
}
|
|
3714
|
+
active={hidden}
|
|
3715
|
+
onClick={() =>
|
|
3716
|
+
onStyleChange("visibility", hidden ? "visible" : "hidden")
|
|
3717
|
+
}
|
|
3718
|
+
>
|
|
3719
|
+
{hidden ? (
|
|
3720
|
+
<IconEyeOff className="size-3.5" />
|
|
3721
|
+
) : (
|
|
3722
|
+
<IconEye className="size-3.5" />
|
|
3723
|
+
)}
|
|
3724
|
+
</SectionIconToggle>
|
|
3725
|
+
{/* Styles / fill library affordance — matches Figma's droplet icon */}
|
|
3726
|
+
<SectionIconButton
|
|
3727
|
+
label={"Styles" /* i18n-ignore Figma inspector action */}
|
|
3728
|
+
>
|
|
3729
|
+
<IconDroplet className="size-3.5" />
|
|
3730
|
+
</SectionIconButton>
|
|
3731
|
+
</>
|
|
3732
|
+
}
|
|
3733
|
+
>
|
|
3734
|
+
<div className="grid grid-cols-2 gap-2">
|
|
3735
|
+
<ScrubInput
|
|
3736
|
+
label={t("editPanel.labels.opacity")}
|
|
3737
|
+
value={parseNumericValue(styles.opacity || "1") * 100}
|
|
3738
|
+
onChange={(v) => onStyleChange("opacity", String(v / 100))}
|
|
3739
|
+
min={0}
|
|
3740
|
+
max={100}
|
|
3741
|
+
step={1}
|
|
3742
|
+
unit="%"
|
|
3743
|
+
precision={1}
|
|
3744
|
+
labelClassName="w-0 overflow-hidden"
|
|
3745
|
+
inputClassName="h-6 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none"
|
|
3746
|
+
/>
|
|
3747
|
+
{/* M9: blend mode compact — inline select next to opacity, no separate labeled row */}
|
|
3748
|
+
<Select
|
|
3749
|
+
value={optionValue(
|
|
3750
|
+
BLEND_MODE_OPTIONS,
|
|
3751
|
+
styles.mixBlendMode || "normal",
|
|
3752
|
+
"normal",
|
|
3753
|
+
)}
|
|
3754
|
+
onValueChange={(value) => onStyleChange("mixBlendMode", value)}
|
|
3755
|
+
>
|
|
3756
|
+
<SelectTrigger className="h-6 min-w-0 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
|
|
3757
|
+
<SelectValue />
|
|
3758
|
+
</SelectTrigger>
|
|
3759
|
+
<SelectContent>
|
|
3760
|
+
{BLEND_MODE_OPTIONS.map((opt) => (
|
|
3761
|
+
<SelectItem
|
|
3762
|
+
key={opt.value}
|
|
3763
|
+
value={opt.value}
|
|
3764
|
+
className="text-[11px]"
|
|
3765
|
+
>
|
|
3766
|
+
{opt.label}
|
|
3767
|
+
</SelectItem>
|
|
3768
|
+
))}
|
|
3769
|
+
</SelectContent>
|
|
3770
|
+
</Select>
|
|
3771
|
+
</div>
|
|
3772
|
+
{/* M7: use CornerRadiusControl (with independent-corners toggle) instead of bare ScrubInput */}
|
|
3773
|
+
<CornerRadiusControl styles={styles} onStyleChange={onStyleChange} />
|
|
3774
|
+
</PanelSection>
|
|
3775
|
+
);
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
function EffectsProperties({
|
|
3779
|
+
element,
|
|
3780
|
+
onStyleChange,
|
|
3781
|
+
onStylesChange,
|
|
3782
|
+
}: {
|
|
3783
|
+
element: ElementInfo;
|
|
3784
|
+
onStyleChange: (property: string, value: string) => void;
|
|
3785
|
+
onStylesChange?: (styles: Record<string, string>) => void;
|
|
3786
|
+
}) {
|
|
3787
|
+
const t = useT();
|
|
3788
|
+
const styles = element.computedStyles;
|
|
3789
|
+
const blurValue = readBlurFilter(styles.filter);
|
|
3790
|
+
// M5 · Background (backdrop) blur is a distinct Figma effect type, backed by
|
|
3791
|
+
// CSS `backdrop-filter: blur()` (vs layer blur's `filter: blur()`).
|
|
3792
|
+
const backdropBlurValue = readBlurFilter(
|
|
3793
|
+
styles.backdropFilter || styles.webkitBackdropFilter,
|
|
3794
|
+
);
|
|
3795
|
+
const shadowLayers = parseShadowLayers(styles.boxShadow);
|
|
3796
|
+
const setShadowLayers = (layers: ShadowLayer[]) => {
|
|
3797
|
+
const boxShadow = serializeShadowLayers(layers);
|
|
3798
|
+
if (onStylesChange) onStylesChange({ boxShadow });
|
|
3799
|
+
else onStyleChange("boxShadow", boxShadow);
|
|
3800
|
+
};
|
|
3801
|
+
const addDropShadow = () =>
|
|
3802
|
+
setShadowLayers([
|
|
3803
|
+
...shadowLayers,
|
|
3804
|
+
defaultDropShadowLayer(shadowLayers.length),
|
|
3805
|
+
]);
|
|
3806
|
+
const addLayerBlur = () => onStyleChange("filter", "blur(4px)");
|
|
3807
|
+
const addBackgroundBlur = () => onStyleChange("backdropFilter", "blur(8px)");
|
|
3808
|
+
|
|
3809
|
+
return (
|
|
3810
|
+
<PanelSection
|
|
3811
|
+
title={t("editPanel.sections.effects")}
|
|
3812
|
+
actions={
|
|
3813
|
+
<DropdownMenu>
|
|
3814
|
+
<DropdownMenuTrigger asChild>
|
|
3815
|
+
<Button
|
|
3816
|
+
type="button"
|
|
3817
|
+
variant="ghost"
|
|
3818
|
+
size="icon"
|
|
3819
|
+
className="size-6 cursor-pointer rounded-md text-muted-foreground hover:text-foreground"
|
|
3820
|
+
aria-label={t("editPanel.labels.addLayer")}
|
|
3821
|
+
>
|
|
3822
|
+
<IconPlus className="size-3.5" />
|
|
3823
|
+
</Button>
|
|
3824
|
+
</DropdownMenuTrigger>
|
|
3825
|
+
<DropdownMenuContent align="end" className="min-w-44">
|
|
3826
|
+
<DropdownMenuItem
|
|
3827
|
+
className="gap-2 text-[11px]"
|
|
3828
|
+
onSelect={addDropShadow}
|
|
3829
|
+
>
|
|
3830
|
+
<IconShadow className="size-3.5" />
|
|
3831
|
+
{t("editPanel.labels.dropShadow")}
|
|
3832
|
+
</DropdownMenuItem>
|
|
3833
|
+
<DropdownMenuItem
|
|
3834
|
+
className="gap-2 text-[11px]"
|
|
3835
|
+
onSelect={addLayerBlur}
|
|
3836
|
+
>
|
|
3837
|
+
<IconBlur className="size-3.5" />
|
|
3838
|
+
{t("editPanel.labels.layerBlur")}
|
|
3839
|
+
</DropdownMenuItem>
|
|
3840
|
+
<DropdownMenuItem
|
|
3841
|
+
className="gap-2 text-[11px]"
|
|
3842
|
+
onSelect={addBackgroundBlur}
|
|
3843
|
+
>
|
|
3844
|
+
<IconBackground className="size-3.5" />
|
|
3845
|
+
{"Background blur" /* i18n-ignore Figma effect type */}
|
|
3846
|
+
</DropdownMenuItem>
|
|
3847
|
+
</DropdownMenuContent>
|
|
3848
|
+
</DropdownMenu>
|
|
3849
|
+
}
|
|
3850
|
+
>
|
|
3851
|
+
{shadowLayers.length ? (
|
|
3852
|
+
<div className="space-y-1.5">
|
|
3853
|
+
{shadowLayers.map((layer, index) => (
|
|
3854
|
+
<ShadowEffectRow
|
|
3855
|
+
key={layer.id}
|
|
3856
|
+
layer={layer}
|
|
3857
|
+
index={index}
|
|
3858
|
+
onChange={(patch) => {
|
|
3859
|
+
const next = shadowLayers.map((candidate) =>
|
|
3860
|
+
candidate.id === layer.id
|
|
3861
|
+
? { ...candidate, ...patch }
|
|
3862
|
+
: candidate,
|
|
3863
|
+
);
|
|
3864
|
+
setShadowLayers(next);
|
|
3865
|
+
}}
|
|
3866
|
+
onRemove={() =>
|
|
3867
|
+
setShadowLayers(
|
|
3868
|
+
shadowLayers.filter((candidate) => candidate.id !== layer.id),
|
|
3869
|
+
)
|
|
3870
|
+
}
|
|
3871
|
+
/>
|
|
3872
|
+
))}
|
|
3873
|
+
</div>
|
|
3874
|
+
) : null}
|
|
3875
|
+
{blurValue > 0 ? (
|
|
3876
|
+
/* Figma effect row for layer blur: flat row matching shadow rows */
|
|
3877
|
+
<Popover>
|
|
3878
|
+
<div className="group flex items-center gap-1.5">
|
|
3879
|
+
<PopoverTrigger asChild>
|
|
3880
|
+
<button
|
|
3881
|
+
type="button"
|
|
3882
|
+
className="flex h-6 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-left text-[11px] hover:bg-[var(--design-editor-panel-raised-bg)]"
|
|
3883
|
+
>
|
|
3884
|
+
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
|
|
3885
|
+
{t("editPanel.labels.layerBlur")}
|
|
3886
|
+
</span>
|
|
3887
|
+
<span className="shrink-0 tabular-nums text-muted-foreground">
|
|
3888
|
+
{Math.round(blurValue)}px
|
|
3889
|
+
</span>
|
|
3890
|
+
</button>
|
|
3891
|
+
</PopoverTrigger>
|
|
3892
|
+
<SectionIconButton
|
|
3893
|
+
className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
|
3894
|
+
label={t("editPanel.labels.removeLayer")}
|
|
3895
|
+
onClick={() => onStyleChange("filter", "none")}
|
|
3896
|
+
disabled={!styles.filter || styles.filter === "none"}
|
|
3897
|
+
>
|
|
3898
|
+
<IconMinus className="size-3.5" />
|
|
3899
|
+
</SectionIconButton>
|
|
3900
|
+
</div>
|
|
3901
|
+
<PopoverContent
|
|
3902
|
+
side="left"
|
|
3903
|
+
align="start"
|
|
3904
|
+
sideOffset={8}
|
|
3905
|
+
className="w-56 p-3"
|
|
3906
|
+
>
|
|
3907
|
+
<ScrubInput
|
|
3908
|
+
label={t("editPanel.labels.blur")}
|
|
3909
|
+
value={blurValue}
|
|
3910
|
+
onChange={(value) =>
|
|
3911
|
+
onStyleChange(
|
|
3912
|
+
"filter",
|
|
3913
|
+
`blur(${Math.max(0, Math.round(value))}px)`,
|
|
3914
|
+
)
|
|
3915
|
+
}
|
|
3916
|
+
unit="px"
|
|
3917
|
+
min={0}
|
|
3918
|
+
precision={1}
|
|
3919
|
+
labelClassName="w-16"
|
|
3920
|
+
inputClassName="h-6"
|
|
3921
|
+
/>
|
|
3922
|
+
</PopoverContent>
|
|
3923
|
+
</Popover>
|
|
3924
|
+
) : null}
|
|
3925
|
+
{backdropBlurValue > 0 ? (
|
|
3926
|
+
/* M5 · Background (backdrop) blur effect row — mirrors the layer-blur row */
|
|
3927
|
+
<Popover>
|
|
3928
|
+
<div className="group flex items-center gap-1.5">
|
|
3929
|
+
<PopoverTrigger asChild>
|
|
3930
|
+
<button
|
|
3931
|
+
type="button"
|
|
3932
|
+
className="flex h-6 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-left text-[11px] hover:bg-[var(--design-editor-panel-raised-bg)]"
|
|
3933
|
+
>
|
|
3934
|
+
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
|
|
3935
|
+
{"Background blur" /* i18n-ignore Figma effect type */}
|
|
3936
|
+
</span>
|
|
3937
|
+
<span className="shrink-0 tabular-nums text-muted-foreground">
|
|
3938
|
+
{Math.round(backdropBlurValue)}px
|
|
3939
|
+
</span>
|
|
3940
|
+
</button>
|
|
3941
|
+
</PopoverTrigger>
|
|
3942
|
+
<SectionIconButton
|
|
3943
|
+
className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
|
3944
|
+
label={t("editPanel.labels.removeLayer")}
|
|
3945
|
+
onClick={() => onStyleChange("backdropFilter", "none")}
|
|
3946
|
+
disabled={
|
|
3947
|
+
!styles.backdropFilter || styles.backdropFilter === "none"
|
|
3948
|
+
}
|
|
3949
|
+
>
|
|
3950
|
+
<IconMinus className="size-3.5" />
|
|
3951
|
+
</SectionIconButton>
|
|
3952
|
+
</div>
|
|
3953
|
+
<PopoverContent
|
|
3954
|
+
side="left"
|
|
3955
|
+
align="start"
|
|
3956
|
+
sideOffset={8}
|
|
3957
|
+
className="w-56 p-3"
|
|
3958
|
+
>
|
|
3959
|
+
<ScrubInput
|
|
3960
|
+
label={t("editPanel.labels.blur")}
|
|
3961
|
+
value={backdropBlurValue}
|
|
3962
|
+
onChange={(value) =>
|
|
3963
|
+
onStyleChange(
|
|
3964
|
+
"backdropFilter",
|
|
3965
|
+
`blur(${Math.max(0, Math.round(value))}px)`,
|
|
3966
|
+
)
|
|
3967
|
+
}
|
|
3968
|
+
unit="px"
|
|
3969
|
+
min={0}
|
|
3970
|
+
precision={1}
|
|
3971
|
+
labelClassName="w-16"
|
|
3972
|
+
inputClassName="h-6"
|
|
3973
|
+
/>
|
|
3974
|
+
</PopoverContent>
|
|
3975
|
+
</Popover>
|
|
3976
|
+
) : null}
|
|
3977
|
+
</PanelSection>
|
|
3978
|
+
);
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3981
|
+
interface SelectionColorValue {
|
|
3982
|
+
property: string;
|
|
3983
|
+
value: string;
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3986
|
+
function selectionColorValues(element: ElementInfo): SelectionColorValue[] {
|
|
3987
|
+
const styles = element.computedStyles;
|
|
3988
|
+
const rawValues: SelectionColorValue[] = [
|
|
3989
|
+
{ property: "color", value: styles.color },
|
|
3990
|
+
{ property: "backgroundColor", value: styles.backgroundColor },
|
|
3991
|
+
{ property: "borderColor", value: styles.borderColor },
|
|
3992
|
+
{ property: "outlineColor", value: styles.outlineColor },
|
|
3993
|
+
];
|
|
3994
|
+
const seen = new Set<string>();
|
|
3995
|
+
return rawValues
|
|
3996
|
+
.map((color) => ({ ...color, value: color.value?.trim() }))
|
|
3997
|
+
.filter((color): color is SelectionColorValue => Boolean(color.value))
|
|
3998
|
+
.filter(
|
|
3999
|
+
(color) =>
|
|
4000
|
+
color.value !== "transparent" && color.value !== "rgba(0, 0, 0, 0)",
|
|
4001
|
+
)
|
|
4002
|
+
.filter((color) => {
|
|
4003
|
+
const key = color.value.toLowerCase();
|
|
4004
|
+
if (seen.has(key)) return false;
|
|
4005
|
+
seen.add(key);
|
|
4006
|
+
return true;
|
|
4007
|
+
});
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
/** Uppercase 6-char hex (no #) for a CSS color, matching Figma's row readout. */
|
|
4011
|
+
function selectionDisplayHex(value: string): string {
|
|
4012
|
+
const parsed = parseCssColor(value);
|
|
4013
|
+
if (!parsed) return value.replace(/^#/, "").toUpperCase();
|
|
4014
|
+
return rgbaToHex(parsed).replace(/^#/, "").toUpperCase();
|
|
4015
|
+
}
|
|
4016
|
+
|
|
4017
|
+
function SelectionColorsProperties({
|
|
4018
|
+
element,
|
|
4019
|
+
onStyleChange,
|
|
4020
|
+
}: {
|
|
4021
|
+
element: ElementInfo;
|
|
4022
|
+
onStyleChange: (property: string, value: string) => void;
|
|
4023
|
+
}) {
|
|
4024
|
+
// M6 · Figma's Selection colors collapses to a single "Show selection colors"
|
|
4025
|
+
// affordance, expanding to one editable [swatch · hex · opacity] row per
|
|
4026
|
+
// unique color — matching the Fill row grammar instead of a swatch strip.
|
|
4027
|
+
const [expanded, setExpanded] = useState(false);
|
|
4028
|
+
const colors = selectionColorValues(element);
|
|
4029
|
+
if (!colors.length) return null;
|
|
4030
|
+
|
|
4031
|
+
return (
|
|
4032
|
+
<PanelSection
|
|
4033
|
+
title={"Selection colors" /* i18n-ignore Figma inspector label */}
|
|
4034
|
+
>
|
|
4035
|
+
{expanded ? (
|
|
4036
|
+
<div className="space-y-1.5">
|
|
4037
|
+
{colors.map((color, index) => {
|
|
4038
|
+
const parsed = parseCssColor(color.value);
|
|
4039
|
+
const opacity = parsed ? alphaToOpacity(parsed.a) : 100;
|
|
4040
|
+
return (
|
|
4041
|
+
<Popover key={`${color.value}-${index}`}>
|
|
4042
|
+
<PopoverTrigger asChild>
|
|
4043
|
+
<button
|
|
4044
|
+
type="button"
|
|
4045
|
+
className="flex h-6 w-full items-center gap-1.5 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-2 text-[11px] hover:bg-[var(--design-editor-panel-raised-bg)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
|
|
4046
|
+
aria-label={color.value}
|
|
4047
|
+
>
|
|
4048
|
+
<span
|
|
4049
|
+
className="size-4 shrink-0 rounded-[3px] border border-border/60"
|
|
4050
|
+
style={swatchStyle(color.value)}
|
|
4051
|
+
/>
|
|
4052
|
+
<span className="min-w-0 flex-1 truncate text-left uppercase tabular-nums">
|
|
4053
|
+
{selectionDisplayHex(color.value)}
|
|
4054
|
+
</span>
|
|
4055
|
+
<span className="shrink-0 tabular-nums text-muted-foreground">
|
|
4056
|
+
{opacity}%
|
|
4057
|
+
</span>
|
|
4058
|
+
</button>
|
|
4059
|
+
</PopoverTrigger>
|
|
4060
|
+
<PopoverContent
|
|
4061
|
+
side="left"
|
|
4062
|
+
align="start"
|
|
4063
|
+
sideOffset={8}
|
|
4064
|
+
className="w-80 p-0"
|
|
4065
|
+
>
|
|
4066
|
+
<FigmaColorPicker
|
|
4067
|
+
value={cssColorOrFallback(color.value, "#000000")}
|
|
4068
|
+
onChange={(value) => onStyleChange(color.property, value)}
|
|
4069
|
+
/>
|
|
4070
|
+
</PopoverContent>
|
|
4071
|
+
</Popover>
|
|
4072
|
+
);
|
|
4073
|
+
})}
|
|
4074
|
+
</div>
|
|
4075
|
+
) : (
|
|
4076
|
+
<button
|
|
4077
|
+
type="button"
|
|
4078
|
+
className="flex h-6 w-full items-center justify-between gap-2 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-2 text-left text-[11px] text-muted-foreground hover:bg-[var(--design-editor-panel-raised-bg)] hover:text-foreground"
|
|
4079
|
+
onClick={() => setExpanded(true)}
|
|
4080
|
+
>
|
|
4081
|
+
<span className="truncate">
|
|
4082
|
+
{"Show selection colors" /* i18n-ignore Figma inspector label */}
|
|
4083
|
+
</span>
|
|
4084
|
+
<div className="flex shrink-0 items-center -space-x-1">
|
|
4085
|
+
{colors.slice(0, 3).map((color, index) => (
|
|
4086
|
+
<span
|
|
4087
|
+
key={`${color.value}-${index}`}
|
|
4088
|
+
className="size-3.5 rounded-sm border border-[var(--design-editor-panel-bg)]"
|
|
4089
|
+
style={swatchStyle(color.value)}
|
|
4090
|
+
/>
|
|
4091
|
+
))}
|
|
4092
|
+
</div>
|
|
4093
|
+
</button>
|
|
4094
|
+
)}
|
|
4095
|
+
</PanelSection>
|
|
4096
|
+
);
|
|
4097
|
+
}
|
|
4098
|
+
|
|
692
4099
|
const TEXT_TAGS = new Set([
|
|
693
4100
|
"p",
|
|
694
4101
|
"h1",
|
|
@@ -708,66 +4115,185 @@ const TEXT_TAGS = new Set([
|
|
|
708
4115
|
export function EditPanel({
|
|
709
4116
|
selectedElement,
|
|
710
4117
|
pageStyles = {},
|
|
4118
|
+
headerTrailing,
|
|
4119
|
+
width = 256,
|
|
4120
|
+
activeTab = "design",
|
|
4121
|
+
onActiveTabChange,
|
|
4122
|
+
tweaks = [],
|
|
4123
|
+
tweakValues = {},
|
|
4124
|
+
extensionContext,
|
|
4125
|
+
onTweakChange,
|
|
4126
|
+
onRequestTweaks,
|
|
711
4127
|
onStyleChange,
|
|
4128
|
+
onStylesChange,
|
|
4129
|
+
onExport,
|
|
4130
|
+
exporting = false,
|
|
712
4131
|
}: EditPanelProps) {
|
|
713
4132
|
const t = useT();
|
|
4133
|
+
const [exportSettings, setExportSettings] = useState<ExportSettingsValue>({
|
|
4134
|
+
scale: 1,
|
|
4135
|
+
format: "png",
|
|
4136
|
+
suffix: "",
|
|
4137
|
+
});
|
|
4138
|
+
const [showExportPreview, setShowExportPreview] = useState(false);
|
|
714
4139
|
const isTextElement = selectedElement
|
|
715
4140
|
? TEXT_TAGS.has(selectedElement.tagName)
|
|
716
4141
|
: false;
|
|
717
|
-
const
|
|
4142
|
+
const handleActiveTabChange = useCallback(
|
|
4143
|
+
(tab: InspectorTab) => onActiveTabChange?.(tab),
|
|
4144
|
+
[onActiveTabChange],
|
|
4145
|
+
);
|
|
4146
|
+
const handleTweakChange = useCallback(
|
|
4147
|
+
(tweakId: string, value: string | number | boolean) => {
|
|
4148
|
+
onTweakChange?.(tweakId, value);
|
|
4149
|
+
},
|
|
4150
|
+
[onTweakChange],
|
|
4151
|
+
);
|
|
718
4152
|
|
|
719
4153
|
return (
|
|
720
4154
|
<div
|
|
721
4155
|
className={cn(
|
|
722
|
-
"
|
|
723
|
-
"flex flex-col",
|
|
4156
|
+
"shrink-0 bg-[var(--design-editor-panel-bg)]",
|
|
4157
|
+
"flex h-full min-h-0 flex-col overflow-hidden",
|
|
724
4158
|
)}
|
|
4159
|
+
style={{ width }}
|
|
725
4160
|
>
|
|
726
|
-
<
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
</h2>
|
|
732
|
-
{selectedElement?.classes.length ? (
|
|
733
|
-
<p className="text-[10px] text-muted-foreground mt-0.5 truncate">
|
|
734
|
-
.{selectedElement.classes.join(".")}
|
|
735
|
-
</p>
|
|
736
|
-
) : null}
|
|
737
|
-
</div>
|
|
4161
|
+
<InspectorTabsHeader
|
|
4162
|
+
activeTab={activeTab}
|
|
4163
|
+
onActiveTabChange={handleActiveTabChange}
|
|
4164
|
+
trailing={headerTrailing}
|
|
4165
|
+
/>
|
|
738
4166
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
<
|
|
742
|
-
)}
|
|
4167
|
+
{activeTab === "design" ? (
|
|
4168
|
+
<>
|
|
4169
|
+
<SelectionHeader element={selectedElement} />
|
|
743
4170
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
4171
|
+
<div className="design-inspector-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
4172
|
+
{!selectedElement && (
|
|
4173
|
+
<PageProperties
|
|
4174
|
+
styles={pageStyles}
|
|
4175
|
+
onStyleChange={onStyleChange}
|
|
4176
|
+
/>
|
|
4177
|
+
)}
|
|
4178
|
+
|
|
4179
|
+
{selectedElement && (
|
|
4180
|
+
<>
|
|
4181
|
+
<PositionLayoutProperties
|
|
4182
|
+
element={selectedElement}
|
|
4183
|
+
onStyleChange={onStyleChange}
|
|
4184
|
+
/>
|
|
4185
|
+
<LayoutContextProperties
|
|
4186
|
+
element={selectedElement}
|
|
4187
|
+
onStyleChange={onStyleChange}
|
|
4188
|
+
onStylesChange={onStylesChange}
|
|
4189
|
+
/>
|
|
4190
|
+
<AppearanceProperties
|
|
4191
|
+
element={selectedElement}
|
|
4192
|
+
onStyleChange={onStyleChange}
|
|
4193
|
+
/>
|
|
4194
|
+
{isTextElement ? (
|
|
4195
|
+
<TypographyProperties
|
|
4196
|
+
element={selectedElement}
|
|
4197
|
+
onStyleChange={onStyleChange}
|
|
4198
|
+
/>
|
|
4199
|
+
) : null}
|
|
4200
|
+
<FillProperties
|
|
4201
|
+
element={selectedElement}
|
|
4202
|
+
onStyleChange={onStyleChange}
|
|
4203
|
+
onStylesChange={onStylesChange}
|
|
4204
|
+
/>
|
|
4205
|
+
<StrokeProperties
|
|
4206
|
+
element={selectedElement}
|
|
4207
|
+
onStyleChange={onStyleChange}
|
|
4208
|
+
onStylesChange={onStylesChange}
|
|
4209
|
+
/>
|
|
4210
|
+
<EffectsProperties
|
|
4211
|
+
element={selectedElement}
|
|
4212
|
+
onStyleChange={onStyleChange}
|
|
4213
|
+
onStylesChange={onStylesChange}
|
|
4214
|
+
/>
|
|
4215
|
+
<SelectionColorsProperties
|
|
4216
|
+
element={selectedElement}
|
|
4217
|
+
onStyleChange={onStyleChange}
|
|
4218
|
+
/>
|
|
4219
|
+
{isContainerElement(selectedElement) ? (
|
|
4220
|
+
<LayoutGuideProperties
|
|
4221
|
+
element={selectedElement}
|
|
4222
|
+
onStyleChange={onStyleChange}
|
|
4223
|
+
/>
|
|
4224
|
+
) : null}
|
|
4225
|
+
</>
|
|
4226
|
+
)}
|
|
4227
|
+
{onExport ? (
|
|
4228
|
+
<PanelSection
|
|
4229
|
+
title={t("editPanel.sections.export")}
|
|
4230
|
+
actions={
|
|
4231
|
+
<SectionIconToggle
|
|
4232
|
+
label={
|
|
4233
|
+
showExportPreview
|
|
4234
|
+
? "Hide preview" /* i18n-ignore Figma inspector action */
|
|
4235
|
+
: "Show preview" /* i18n-ignore Figma inspector action */
|
|
4236
|
+
}
|
|
4237
|
+
active={showExportPreview}
|
|
4238
|
+
onClick={() => setShowExportPreview((shown) => !shown)}
|
|
4239
|
+
>
|
|
4240
|
+
<IconPhoto className="size-3.5" />
|
|
4241
|
+
</SectionIconToggle>
|
|
4242
|
+
}
|
|
4243
|
+
>
|
|
4244
|
+
<ExportSettingsPanel
|
|
4245
|
+
value={exportSettings}
|
|
4246
|
+
formats={["png", "svg"]}
|
|
4247
|
+
exporting={exporting}
|
|
4248
|
+
onChange={(patch) =>
|
|
4249
|
+
setExportSettings((current) => ({ ...current, ...patch }))
|
|
4250
|
+
}
|
|
4251
|
+
onExport={onExport}
|
|
4252
|
+
/>
|
|
4253
|
+
{showExportPreview ? (
|
|
4254
|
+
<ExportPreview element={selectedElement} />
|
|
4255
|
+
) : null}
|
|
4256
|
+
</PanelSection>
|
|
4257
|
+
) : null}
|
|
4258
|
+
</div>
|
|
4259
|
+
</>
|
|
4260
|
+
) : activeTab === "tweaks" ? (
|
|
4261
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
4262
|
+
<div className="flex h-10 shrink-0 items-center justify-between gap-2 border-b border-border/90 px-3">
|
|
4263
|
+
<h3 className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
|
|
4264
|
+
{t("designEditor.tweaks")}
|
|
4265
|
+
</h3>
|
|
4266
|
+
{onRequestTweaks ? (
|
|
4267
|
+
<Tooltip>
|
|
4268
|
+
<TooltipTrigger asChild>
|
|
4269
|
+
<Button
|
|
4270
|
+
type="button"
|
|
4271
|
+
variant="ghost"
|
|
4272
|
+
size="icon"
|
|
4273
|
+
className="size-7 rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
4274
|
+
aria-label={t("designEditor.addTweaks")}
|
|
4275
|
+
onClick={(event) => onRequestTweaks(event.currentTarget)}
|
|
4276
|
+
>
|
|
4277
|
+
<IconPlus className="size-3.5" />
|
|
4278
|
+
</Button>
|
|
4279
|
+
</TooltipTrigger>
|
|
4280
|
+
<TooltipContent>{t("designEditor.addTweaks")}</TooltipContent>
|
|
4281
|
+
</Tooltip>
|
|
4282
|
+
) : null}
|
|
4283
|
+
</div>
|
|
4284
|
+
<div className="design-inspector-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
4285
|
+
<TweaksPanelContent
|
|
4286
|
+
tweaks={tweaks}
|
|
4287
|
+
values={tweakValues}
|
|
4288
|
+
onChange={handleTweakChange}
|
|
4289
|
+
onRequestTweaks={onRequestTweaks}
|
|
4290
|
+
className="px-3 py-3"
|
|
759
4291
|
/>
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
<ElementProperties
|
|
766
|
-
element={selectedElement}
|
|
767
|
-
onStyleChange={onStyleChange}
|
|
768
|
-
/>
|
|
769
|
-
)}
|
|
770
|
-
</div>
|
|
4292
|
+
</div>
|
|
4293
|
+
</div>
|
|
4294
|
+
) : extensionContext ? (
|
|
4295
|
+
<DesignExtensionsPanel context={extensionContext} />
|
|
4296
|
+
) : null}
|
|
771
4297
|
</div>
|
|
772
4298
|
);
|
|
773
4299
|
}
|