auricui-skill 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +29 -2
- package/bin/install.mjs +66 -0
- package/package.json +29 -4
- package/skill/SKILL.md +64 -0
- package/skill/agents/openai.yaml +4 -0
- package/skill/assets/examples/ApprovalCard.tsx +8 -0
- package/skill/assets/examples/BasicApp.tsx +38 -0
- package/skill/assets/examples/BriefingCard.tsx +8 -0
- package/skill/assets/examples/ChatControls.tsx +49 -0
- package/skill/assets/examples/ChatPanel.tsx +73 -0
- package/skill/assets/examples/Checkbox.tsx +13 -0
- package/skill/assets/examples/Combobox.tsx +14 -0
- package/skill/assets/examples/Drawer.tsx +10 -0
- package/skill/assets/examples/MemoryCard.tsx +9 -0
- package/skill/assets/examples/MessageList.tsx +8 -0
- package/skill/assets/examples/Theme.tsx +14 -0
- package/skill/assets/examples/ToolCall.tsx +8 -0
- package/skill/assets/examples/Tree.tsx +15 -0
- package/skill/assets/examples/examples.css +11 -0
- package/skill/assets/library/auricui-0.1.0-preview.1.tgz +0 -0
- package/skill/assets/library/auricui-0.1.0-preview.3.tgz +0 -0
- package/skill/assets/library/auricui-0.1.0-preview.4.tgz +0 -0
- package/skill/assets/upstream/LICENSE +21 -0
- package/skill/assets/upstream/apps/desktop/chat/src/App.tsx +1099 -0
- package/skill/assets/upstream/apps/desktop/chat/src/app-bridge.ts +154 -0
- package/skill/assets/upstream/apps/desktop/chat/src/chat.css +1812 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AddSkill.tsx +223 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AgentActivityRow.tsx +716 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AgentIcons.tsx +46 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentCard.tsx +34 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentPreview.tsx +331 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/BalanceNotice.tsx +22 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/BrandLogo.tsx +35 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ComposerCard.tsx +182 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ConversationSessionBar.tsx +292 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/CopyDiagnostics.tsx +24 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/Divider.tsx +47 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/FileChangesList.tsx +119 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/Markdown.tsx +198 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ModelPopover.tsx +280 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/NoticeCard.tsx +87 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/Popover.tsx +195 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/PreviewFrame.tsx +129 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectConversation.tsx +852 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectStarters.tsx +118 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/Tooltip.tsx +191 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/TurnLog.tsx +583 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/TurnQuestionCard.tsx +133 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/dock-pane-bar.ts +14 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/project-version.ts +37 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/MentionMenuPlugin.tsx +372 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptEditor.tsx +476 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptPill.tsx +399 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptReferenceNode.tsx +164 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/index.ts +31 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/mention-menu.ts +206 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/prompt-text.ts +171 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/useStickToBottom.ts +320 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/useTicker.ts +55 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/index.ts +42 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/en-US.ts +408 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/es.ts +407 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ja.ts +407 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ko.ts +403 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/words.ts +6 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/zh-CN.ts +407 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/lookup.ts +12 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-activity-runs.ts +154 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-menu-rows.ts +41 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-refs.ts +105 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-transcript.ts +440 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/asset-lists.ts +75 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/attachment-place.ts +17 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/balance.ts +16 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-layer.ts +10 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-menu.ts +18 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-store.ts +101 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/composer-attachments.ts +189 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-attachments.ts +178 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-bridge.ts +291 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/developer.ts +39 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/esc-stop.ts +23 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/file-changes.ts +32 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/live-activity.ts +20 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/local-agent-run.ts +1174 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/local-sessions.ts +25 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/model-calls.ts +87 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/pretty-json.ts +12 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/preview-top.ts +15 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/project-resources.ts +66 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/project-sessions.ts +66 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/project-versions.ts +116 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/ref-pictures.ts +119 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/relative-time.ts +46 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/skill-pages.ts +29 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/step-file.ts +23 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/structural-share.ts +52 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-film.ts +44 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-transfer.ts +40 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/unprompted.ts +27 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/upload-limits.ts +53 -0
- package/skill/assets/upstream/apps/desktop/chat/src/mount.tsx +19 -0
- package/skill/assets/upstream/apps/desktop/chat/src/state/local-agent-store.ts +296 -0
- package/skill/assets/upstream/apps/desktop/chat/src/studio-refs.ts +307 -0
- package/skill/assets/upstream/apps/desktop/package.json +51 -0
- package/skill/assets/upstream/apps/desktop/shell/bridge.ts +415 -0
- package/skill/assets/upstream/apps/desktop/shell/index.html +27 -0
- package/skill/assets/upstream/apps/desktop/shell/main.tsx +17 -0
- package/skill/assets/upstream/apps/desktop/shell/shell.css +17 -0
- package/skill/assets/upstream/apps/desktop/shell/vite.config.ts +65 -0
- package/skill/assets/upstream/apps/desktop/src/agent-definitions.mjs +31 -0
- package/skill/assets/upstream/packages/openfilm/LICENSE +21 -0
- package/skill/assets/upstream/packages/openfilm/package.json +88 -0
- package/skill/assets/upstream/packages/openfilm/src/film-doc.d.mts +125 -0
- package/skill/assets/upstream/packages/openfilm/src/film-doc.mjs +986 -0
- package/skill/assets/upstream/packages/openfilm/src/overrides.mjs +73 -0
- package/skill/assets/upstream/packages/openfilm/src/timeline.mjs +1280 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/bridge.js +209 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/film-subtitle.mjs +785 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/ops.mjs +403 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/stage.js +1649 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/track-layout.mjs +43 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/App.tsx +156 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/api.ts +328 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/AudioMeters.tsx +188 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockThumbs.tsx +84 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockWaveform.tsx +201 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLoadingScreen.tsx +56 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLogo.tsx +25 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Card.tsx +89 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ColorPicker.tsx +204 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ConfirmDialog.tsx +96 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ContextMenu.tsx +422 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/DesktopDialog.tsx +271 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ExamplesGallery.tsx +372 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmFullscreenBar.tsx +147 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmInspector.tsx +2310 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmStageSelect.tsx +3296 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FocusNav.tsx +28 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FontFamilyPicker.tsx +293 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FramePicker.tsx +69 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/InspectorPanel.tsx +25 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Launcher.tsx +38 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LayersPanel.tsx +277 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LiveTime.tsx +101 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPool.tsx +1413 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPreview.tsx +188 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MenuSelect.tsx +161 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MgOutline.tsx +24 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/OperationHistory.tsx +184 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PaneDivider.tsx +292 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PasteAttrsDialog.tsx +69 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PlaybackMenu.tsx +110 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Popover.tsx +159 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectCover.tsx +60 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectHistoryControls.tsx +748 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectLayoutMenu.tsx +82 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectMenu.tsx +111 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectSources.tsx +587 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectView.tsx +3222 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsLibrary.tsx +567 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsView.tsx +1593 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PropertiesSection.tsx +418 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ResourceInspector.tsx +100 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SelectionToolbar.tsx +157 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SkillsLibrary.tsx +504 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SourcePreview.tsx +464 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SpendAsks.tsx +88 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageMaskOverlay.tsx +299 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageTransformFrame.tsx +246 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.module.css +46 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.tsx +884 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleOverlay.tsx +148 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleRow.tsx +419 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TabStrip.tsx +188 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Timeline.tsx +4911 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TimelineMarkers.tsx +329 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Toast.tsx +55 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Tooltip.tsx +193 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/UpdateButton.tsx +104 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ViewerBar.tsx +98 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/WhatsNew.tsx +147 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/dock-pane-bar.ts +16 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportCenter.tsx +1502 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportTray.tsx +284 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/film-stage-hit.ts +18 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-fields.tsx +863 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-look.tsx +1357 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/controls-bar.tsx +397 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/transport.tsx +294 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/replace-drop.ts +20 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/AgentsSection.tsx +613 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/CustomServices.tsx +104 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/DiagnosticsGroup.tsx +202 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ModelsSection.tsx +179 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProviderMark.tsx +24 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProvidersSection.tsx +177 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsModal.tsx +511 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsSelect.tsx +84 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ShortcutsSection.tsx +291 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/StorageGroup.tsx +119 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/theme.ts +47 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ui.tsx +139 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/use-settings.ts +10 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/stage-menu.tsx +283 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/timeline-head-extras.tsx +91 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/typing-target.ts +45 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-editor-tools.tsx +408 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-page-changes.ts +48 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-project-history.ts +113 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/useBlockDrag.ts +639 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/Preview.tsx +426 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/player.ts +558 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/preview-frames.ts +36 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/steps.ts +36 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/undo.ts +156 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-film.ts +212 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-preview-renders.ts +89 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/globals.css +559 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/en.ts +1901 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/es.ts +1900 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/index.ts +130 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ja.ts +1900 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ko.ts +1896 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/words.ts +6 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/zh-CN.ts +1901 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/agent-row.ts +21 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/asset-timeline.ts +469 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/audio-levels.ts +60 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/batch-map.ts +28 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/browser.ts +10 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/canvas-fit.ts +116 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/chat-marks.ts +114 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-attrs.ts +102 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-fade.ts +70 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-look.ts +520 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-mask.ts +536 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-replace.ts +60 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/component-props.ts +128 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/context-length.ts +12 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/desktop-build.ts +44 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/diagnostics.ts +68 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/double-press.ts +22 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/dropped-overrides.ts +139 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-jobs.ts +191 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-spec.ts +261 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/field-input.ts +155 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-clip-edits.ts +82 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-frame.ts +134 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-overrides.ts +107 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-shape.ts +172 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film.ts +80 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-menu.ts +236 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-styles.ts +129 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/freeze-frame.ts +49 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/host.ts +438 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/inspect-scrub.ts +26 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap-store.ts +74 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap.ts +529 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/layers-tree.ts +133 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/look.ts +83 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/loudness.ts +58 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/markers.ts +152 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-bar.ts +58 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-folders.ts +45 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-place.ts +102 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-queue.ts +107 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-selection.ts +64 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/menu-position.ts +77 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mg-transform.ts +111 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mixed.ts +3 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/page-changes.ts +45 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/picture-files.ts +19 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-prefs.ts +46 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-renders.ts +71 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-delete.ts +20 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-folders.ts +144 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-history.ts +188 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-sources.ts +283 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/projects-page.ts +40 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/provider-marks.ts +35 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/providers.ts +163 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/resource-drag.ts +131 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/retry-image.ts +17 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved-insert.ts +40 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved.ts +125 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/selection-toolbar.ts +158 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/shortcut-hint.ts +21 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/similar-layers.ts +46 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/solo.ts +19 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-clip.ts +60 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-crop.ts +232 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-gesture.ts +277 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-layers.ts +71 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-mask.ts +176 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-measure.ts +53 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-types.ts +155 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-refs.ts +248 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-version.ts +41 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitle-cues.ts +238 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitles.ts +263 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/tab-menu.ts +6 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/template-controls.ts +131 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/theme-changes.ts +31 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cache.ts +63 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cell.ts +8 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-frame.ts +76 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-pick.ts +42 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timecode.ts +161 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-clipboard.ts +279 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-drag.ts +589 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-edit.ts +1138 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-keys.ts +63 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-layout.ts +1038 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-nav.ts +160 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-prefs.ts +73 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-split.ts +40 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-view.ts +105 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/title.ts +39 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-heights.ts +55 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-lock.ts +27 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/transitions.ts +459 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/type-metrics.ts +83 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-film-subtitles.ts +111 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-fonts.ts +181 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-loaded-thumbs.ts +91 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-edits.ts +134 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-font.ts +51 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-style.ts +68 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/waveform-columns.ts +48 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/web-update.ts +41 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/whats-new.ts +401 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/workspace-resources.ts +373 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/main.tsx +10 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/vite.config.ts +12 -0
- package/skill/assets/upstream/packages/shared/package.json +45 -0
- package/skill/assets/upstream/packages/shared/src/agent-activity.ts +263 -0
- package/skill/assets/upstream/packages/shared/src/contract.ts +285 -0
- package/skill/assets/upstream/packages/shared/src/index.ts +17 -0
- package/skill/assets/upstream/packages/shared/src/markdown-blocks.ts +252 -0
- package/skill/assets/upstream/packages/shared/src/modality.ts +4 -0
- package/skill/assets/upstream/packages/shared/src/prompt-reference.ts +113 -0
- package/skill/assets/upstream/packages/shared/src/title.ts +76 -0
- package/skill/assets/upstream/packages/shared/src/turn.ts +68 -0
- package/skill/assets/upstream/packages/shared/src/video.ts +26 -0
- package/skill/references/agent-components.md +33 -0
- package/skill/references/api-index.json +2693 -0
- package/skill/references/chat-components.md +65 -0
- package/skill/references/components.md +23 -0
- package/skill/references/core-components.md +44 -0
- package/skill/references/dependencies.md +22 -0
- package/skill/references/esm-package.md +48 -0
- package/skill/references/integration.md +43 -0
- package/skill/references/library-manifest.json +12 -0
- package/skill/references/source-manifest.json +325 -0
- package/skill/references/studio-components.md +24 -0
- package/skill/references/theme-motion.md +49 -0
- package/skill/references/troubleshooting.md +27 -0
- package/skill/references/verification.md +13 -0
- package/skill/scripts/check_dependencies.py +109 -0
- package/skill/scripts/inspect_api.py +60 -0
|
@@ -0,0 +1,1357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a picture looks, as the inspector shows it: crop and fill, appearance, adjustments, effects, type — the same
|
|
3
|
+
* sections, the same controls, for a whole clip, a layer inside a page, and several of either.
|
|
4
|
+
*
|
|
5
|
+
* Each section works on a `Look`: one or more things, each read and written as standard CSS properties (see
|
|
6
|
+
* lib/clip-look). A control turns what it is given into the properties to write for each thing (`LookEdit`), from
|
|
7
|
+
* that thing's own values — so `+10` on several opacities raises each. What the properties are stored in (a clip's
|
|
8
|
+
* `style` in film.html, a layer's override) is the panel's business, not the section's.
|
|
9
|
+
*/
|
|
10
|
+
import * as React from 'react';
|
|
11
|
+
import {
|
|
12
|
+
AlignCenter, AlignJustify, AlignLeft, AlignRight, Ban, ChevronDown, Circle, Crop as CropIcon, Heart, Minus, Plus, RotateCcw, Scan, Square,
|
|
13
|
+
SquareDashed, Star, Strikethrough, Underline,
|
|
14
|
+
} from 'lucide-react';
|
|
15
|
+
|
|
16
|
+
import type { useT } from '@/i18n';
|
|
17
|
+
import {
|
|
18
|
+
ADJUST_KEYS, BLEND_MODES, FADE_DEFAULT, INNER_SHADOW_DEFAULT, PLACED_STROKE_DEFAULT, SHADOW_DEFAULT, adjustOf, backdropBlurCss,
|
|
19
|
+
backdropBlurOf, cropCss, cropForRatio, cropOf, cropRatio, filterParts, framingCss, framingOf, innerShadowCss, innerShadowOf,
|
|
20
|
+
ownedDeclaration, placedStrokeCss, placedStrokeOf, pxOf, radiusCorners, shadowOf, strokeOf, textColorCss, textColorOf, withAdjust, withShadow, type AdjustKey, type Crop, type Fade,
|
|
21
|
+
type FadeSide, type InnerShadow, type PlacedStroke, type Shadow, type StrokePosition, type StrokeStyle,
|
|
22
|
+
} from '@/lib/clip-look';
|
|
23
|
+
import {
|
|
24
|
+
MASK_FIELDS, MASK_SHAPES, fadeIn, maskDefault, maskOf, maskOwned, withFade, withMask, type Mask, type MaskBox, type MaskShape,
|
|
25
|
+
} from '@/lib/clip-mask';
|
|
26
|
+
import type { MaskSession } from '@/lib/stage-mask';
|
|
27
|
+
import { previewSettled, type Look, type LookEdit, type LookRead, type LookSet } from '@/lib/look';
|
|
28
|
+
import { knownAvailable, stylesOf } from '@/lib/font-menu';
|
|
29
|
+
import {
|
|
30
|
+
NAMED_WEIGHTS, isItalic, joinFontFamily, parseStyleValue, snapStyle, splitFontFamily, styleOptions, styleValue, weightKey, weightNumber,
|
|
31
|
+
type FontStyles,
|
|
32
|
+
} from '@/lib/font-styles';
|
|
33
|
+
import { FONT_SIZES, metricOf } from '@/lib/type-metrics';
|
|
34
|
+
import { familyRenders, useInstalledFonts, useProjectFonts } from '@/lib/use-fonts';
|
|
35
|
+
import { declarations } from '../../../../src/film-doc.mjs';
|
|
36
|
+
import {
|
|
37
|
+
ColorField, EdgeGlyph, FontSizeGlyph, ICON, LetterSpacingGlyph, LineHeightGlyph, MIXED, MetricField, NumField, RadiusGlyph, RotateGlyph, Row, Section, SectionAction, Segmented, SelectField, SliderField, SubLabel, type FieldApply,
|
|
38
|
+
} from './inspector-fields';
|
|
39
|
+
import { FontFamilyPicker } from './FontFamilyPicker';
|
|
40
|
+
import { Popover } from './Popover';
|
|
41
|
+
|
|
42
|
+
type T = ReturnType<typeof useT>;
|
|
43
|
+
|
|
44
|
+
/** What a property is when nothing sets it: writing "none of mine" over a look the film's CSS gives, this is written. */
|
|
45
|
+
export const NEUTRAL: Record<string, string> = {
|
|
46
|
+
opacity: '1', filter: 'none', 'clip-path': 'none', 'border-radius': '0', 'mix-blend-mode': 'normal', 'object-fit': 'contain',
|
|
47
|
+
'object-position': '50% 50%', 'mask-image': 'none', border: 'none', 'text-transform': 'none',
|
|
48
|
+
outline: 'none', 'outline-offset': '0px', 'box-shadow': 'none', 'backdrop-filter': 'none', 'text-decoration-line': 'none',
|
|
49
|
+
'border-top-left-radius': '0', 'border-top-right-radius': '0', 'border-bottom-right-radius': '0', 'border-bottom-left-radius': '0',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Whether `v` looks as nothing set (`1`, `none`, `0px`, …). */
|
|
53
|
+
export function isNeutral(prop: string, v: string | undefined): boolean {
|
|
54
|
+
if (v == null || v === '') return true;
|
|
55
|
+
const s = v.trim().toLowerCase();
|
|
56
|
+
if (s === NEUTRAL[prop]) return true;
|
|
57
|
+
if (prop === 'opacity') return Number(s) === 1;
|
|
58
|
+
if (prop.endsWith('-radius') || prop === 'outline-offset') return pxOf(s) === 0;
|
|
59
|
+
if (prop === 'object-position') { const f = framingOf(s); return f != null && f[0] === 50 && f[1] === 50; }
|
|
60
|
+
if (prop === 'border' || prop === 'outline') return strokeOf(s) === undefined;
|
|
61
|
+
return s === 'none' || s === 'normal';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const camelOf = (prop: string) => prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
65
|
+
export const kebabOf = (key: string) => key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
66
|
+
|
|
67
|
+
const round = (n: number, d = 1) => Math.round(n * 10 ** d) / 10 ** d;
|
|
68
|
+
|
|
69
|
+
/* ───────────────────────────── crop & fill ───────────────────────────── */
|
|
70
|
+
|
|
71
|
+
const RATIOS: { id: string; ratio?: number }[] = [
|
|
72
|
+
{ id: 'free' }, { id: 'original' }, { id: '16:9', ratio: 16 / 9 }, { id: '9:16', ratio: 9 / 16 }, { id: '1:1', ratio: 1 }, { id: '4:3', ratio: 4 / 3 },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How the picture fills its box (`object-fit`), the crop of its edges (`clip-path: inset()`, in % of the box, its
|
|
77
|
+
* corners kept round), a crop to a shape (centered), round corners, and where the picture sits in its box
|
|
78
|
+
* (`object-position`). A `clip-path` of another shape is the raw CSS's, and the crop is not shown over it.
|
|
79
|
+
*/
|
|
80
|
+
export function CropSection({
|
|
81
|
+
look, t, fit, framing, box, own, onStartCrop,
|
|
82
|
+
}: {
|
|
83
|
+
look: Look;
|
|
84
|
+
t: T;
|
|
85
|
+
/** Whether the picture can be fitted or framed in its box (a video, a still, an image): a page always shows whole. */
|
|
86
|
+
fit: boolean;
|
|
87
|
+
framing: boolean;
|
|
88
|
+
/** The box on the stage (unturned): what a crop to a shape is worked out on. Unknown: no shapes. */
|
|
89
|
+
box?: { w: number; h: number } | null;
|
|
90
|
+
/** The picture's own size: the "Original" shape. */
|
|
91
|
+
own?: { w: number; h: number } | null;
|
|
92
|
+
onStartCrop?: () => void;
|
|
93
|
+
}) {
|
|
94
|
+
const crop = look.common((read) => cropOf(read('clip-path')));
|
|
95
|
+
const fitValue = look.common((read) => {
|
|
96
|
+
const v = read('object-fit') ?? 'contain';
|
|
97
|
+
return v === 'cover' || v === 'fill' ? v : 'contain';
|
|
98
|
+
});
|
|
99
|
+
const frame = look.common((read) => framingOf(read('object-position')));
|
|
100
|
+
const cropUnreadable = look.common((read) => cropOf(read('clip-path')) == null);
|
|
101
|
+
const touched = look.common((read) => !isNeutral('clip-path', read('clip-path')) || !isNeutral('object-position', read('object-position'))
|
|
102
|
+
|| (read('object-fit') ?? 'contain') !== 'contain');
|
|
103
|
+
|
|
104
|
+
const cropEdit = (side: number, apply: FieldApply): LookEdit => (read): LookSet => {
|
|
105
|
+
const now = cropOf(read('clip-path'));
|
|
106
|
+
if (!now) return {};
|
|
107
|
+
const next = [...now] as Crop;
|
|
108
|
+
/* the opposite edge stays: the two never meet */
|
|
109
|
+
next[side] = Math.max(0, Math.min(99 - now[(side + 2) % 4]!, round(apply(now[side]!), 2)));
|
|
110
|
+
return { 'clip-path': cropCss(next, pxOf(read('border-radius')) ?? 0) };
|
|
111
|
+
};
|
|
112
|
+
const ratioOf = (id: string) => (id === 'original' ? (own && own.h > 0 ? own.w / own.h : undefined) : RATIOS.find((r) => r.id === id)?.ratio);
|
|
113
|
+
const shape = (() => {
|
|
114
|
+
if (!box || crop === MIXED || !crop) return 'free';
|
|
115
|
+
const now = cropRatio(crop, box.w, box.h);
|
|
116
|
+
for (const r of RATIOS) {
|
|
117
|
+
const ratio = ratioOf(r.id);
|
|
118
|
+
if (!ratio) continue;
|
|
119
|
+
/* a crop to a shape is centered, and as large as fits */
|
|
120
|
+
const want = cropForRatio(box.w, box.h, ratio);
|
|
121
|
+
if (Math.abs(now - ratio) / ratio < 0.005 && want.every((v, i) => Math.abs(v - crop[i]!) < 0.1)) return r.id;
|
|
122
|
+
}
|
|
123
|
+
return 'free';
|
|
124
|
+
})();
|
|
125
|
+
const toShape = (id: string) => {
|
|
126
|
+
const ratio = ratioOf(id);
|
|
127
|
+
if (!ratio || !box) return;
|
|
128
|
+
look.commit((read) => ({ 'clip-path': cropCss(cropForRatio(box.w, box.h, ratio), pxOf(read('border-radius')) ?? 0) }));
|
|
129
|
+
};
|
|
130
|
+
const sides: { i: number; side: FadeSide; label: string }[] = [
|
|
131
|
+
{ i: 0, side: 'top', label: t('inspector.cropTop') },
|
|
132
|
+
{ i: 2, side: 'bottom', label: t('inspector.cropBottom') },
|
|
133
|
+
{ i: 3, side: 'left', label: t('inspector.cropLeft') },
|
|
134
|
+
{ i: 1, side: 'right', label: t('inspector.cropRight') },
|
|
135
|
+
];
|
|
136
|
+
return (
|
|
137
|
+
<Section
|
|
138
|
+
id="crop"
|
|
139
|
+
title={t('inspector.cropFill')}
|
|
140
|
+
aside={(
|
|
141
|
+
<>
|
|
142
|
+
{onStartCrop ? (
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
onClick={onStartCrop}
|
|
146
|
+
className="flex h-5 items-center gap-1 rounded-[4px] px-1.5 text-[10.5px] font-medium text-[var(--accent)] transition hover:bg-[var(--bg-hover)]"
|
|
147
|
+
>
|
|
148
|
+
<CropIcon size={11} />{t('inspector.cropStart')}
|
|
149
|
+
</button>
|
|
150
|
+
) : null}
|
|
151
|
+
{touched === true || touched === MIXED ? (
|
|
152
|
+
<SectionAction
|
|
153
|
+
label={t('inspector.resetSection')}
|
|
154
|
+
onClick={() => look.commit(() => ({ 'object-fit': null, 'object-position': null, 'clip-path': null, 'border-radius': null }))}
|
|
155
|
+
>
|
|
156
|
+
<RotateCcw size={11} />
|
|
157
|
+
</SectionAction>
|
|
158
|
+
) : null}
|
|
159
|
+
</>
|
|
160
|
+
)}
|
|
161
|
+
>
|
|
162
|
+
{fit ? (
|
|
163
|
+
<Segmented
|
|
164
|
+
label={t('inspector.fit')}
|
|
165
|
+
value={fitValue}
|
|
166
|
+
options={[
|
|
167
|
+
{ value: 'contain', label: t('inspector.fitContain') },
|
|
168
|
+
{ value: 'cover', label: t('inspector.fitCover') },
|
|
169
|
+
{ value: 'fill', label: t('inspector.fitFill') },
|
|
170
|
+
]}
|
|
171
|
+
/* said outright: "none of mine" on a picture in a page brings back the page's own, which for an <img> is
|
|
172
|
+
stretched (CSS's fill) */
|
|
173
|
+
onChange={(v) => look.commit(() => ({ 'object-fit': v }))}
|
|
174
|
+
/>
|
|
175
|
+
) : null}
|
|
176
|
+
{cropUnreadable === false ? (
|
|
177
|
+
<>
|
|
178
|
+
<Row>
|
|
179
|
+
{sides.map(({ i, side, label }) => (
|
|
180
|
+
<NumField
|
|
181
|
+
key={side}
|
|
182
|
+
label={label}
|
|
183
|
+
prefix={<EdgeGlyph side={side} />}
|
|
184
|
+
value={crop === MIXED || !crop ? MIXED : crop[i]}
|
|
185
|
+
unit="%"
|
|
186
|
+
min={0}
|
|
187
|
+
max={99}
|
|
188
|
+
step={0.5}
|
|
189
|
+
onPreview={(_, apply) => look.preview(cropEdit(i, apply))}
|
|
190
|
+
onCommit={(_, apply) => look.commit(cropEdit(i, apply))}
|
|
191
|
+
onRevert={() => look.revert()}
|
|
192
|
+
/>
|
|
193
|
+
))}
|
|
194
|
+
</Row>
|
|
195
|
+
{box && look.n === 1 ? (
|
|
196
|
+
<Segmented
|
|
197
|
+
label={t('inspector.cropShape')}
|
|
198
|
+
value={shape}
|
|
199
|
+
small
|
|
200
|
+
options={RATIOS.filter((r) => r.id !== 'original' || (own && own.h > 0)).map((r) => ({
|
|
201
|
+
value: r.id,
|
|
202
|
+
label: r.id === 'free' ? t('inspector.cropFree') : r.id === 'original' ? t('inspector.cropOriginal') : r.id,
|
|
203
|
+
}))}
|
|
204
|
+
onChange={toShape}
|
|
205
|
+
/>
|
|
206
|
+
) : null}
|
|
207
|
+
</>
|
|
208
|
+
) : (
|
|
209
|
+
<p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t('inspector.cropCustom')}</p>
|
|
210
|
+
)}
|
|
211
|
+
{framing && frame !== null ? (
|
|
212
|
+
<>
|
|
213
|
+
<SubLabel>{t('inspector.framing')}</SubLabel>
|
|
214
|
+
<Row>
|
|
215
|
+
{([0, 1] as const).map((axis) => {
|
|
216
|
+
const edit = (apply: FieldApply): LookEdit => (read) => {
|
|
217
|
+
const now = framingOf(read('object-position')) ?? [50, 50];
|
|
218
|
+
const next: [number, number] = [...now];
|
|
219
|
+
next[axis] = round(apply(now[axis]), 2);
|
|
220
|
+
return { 'object-position': framingCss(next) };
|
|
221
|
+
};
|
|
222
|
+
return (
|
|
223
|
+
<NumField
|
|
224
|
+
key={axis}
|
|
225
|
+
label={axis === 0 ? t('inspector.framingX') : t('inspector.framingY')}
|
|
226
|
+
prefix={axis === 0 ? 'X' : 'Y'}
|
|
227
|
+
value={frame === MIXED ? MIXED : frame[axis]}
|
|
228
|
+
unit="%"
|
|
229
|
+
min={-100}
|
|
230
|
+
max={200}
|
|
231
|
+
onPreview={(_, apply) => look.preview(edit(apply))}
|
|
232
|
+
onCommit={(_, apply) => look.commit(edit(apply))}
|
|
233
|
+
onRevert={() => look.revert()}
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
})}
|
|
237
|
+
</Row>
|
|
238
|
+
</>
|
|
239
|
+
) : null}
|
|
240
|
+
</Section>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* ───────────────────────────── mask ───────────────────────────── */
|
|
245
|
+
|
|
246
|
+
/** The shape buttons' glyphs: a line, a band, then lucide's. */
|
|
247
|
+
export function MaskGlyph({ shape }: { shape: MaskShape | 'none' }) {
|
|
248
|
+
if (shape === 'linear' || shape === 'mirror') {
|
|
249
|
+
return (
|
|
250
|
+
<svg width={ICON} height={ICON} viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden>
|
|
251
|
+
<rect x="1.5" y="1.5" width="11" height="11" rx="2" />
|
|
252
|
+
{shape === 'linear'
|
|
253
|
+
? <path d="M1.5 7.5h11V10.5a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2z" fill="currentColor" stroke="none" />
|
|
254
|
+
: <path d="M1.5 5h11v4h-11z" fill="currentColor" stroke="none" />}
|
|
255
|
+
</svg>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
const Icon = { none: Ban, ellipse: Circle, rect: Square, star: Star, heart: Heart }[shape];
|
|
259
|
+
return <Icon size={ICON} strokeWidth={1.6} />;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A mask: a shape the picture shows through (or, inverted, is hidden by), placed, sized, turned and
|
|
264
|
+
* feathered in % of the box. Written as `mask-image` layers (lib/clip-mask: what each shape is in CSS, and how it sits
|
|
265
|
+
* with the faded edges, which share the property, and with the crop's clip-path). A mask written by hand is "custom":
|
|
266
|
+
* the raw CSS (Advanced) has it.
|
|
267
|
+
*
|
|
268
|
+
* "Edit on picture" draws the mask's outline and handles over the thing in hand (`onEditMask`, see lib/stage-mask):
|
|
269
|
+
* the stage previews a drag and writes once, at the release; it is turned on with a new shape.
|
|
270
|
+
*/
|
|
271
|
+
export function MaskSection({
|
|
272
|
+
look, t, box, target, onEditMask, ask, onAsked,
|
|
273
|
+
}: {
|
|
274
|
+
look: Look;
|
|
275
|
+
t: T;
|
|
276
|
+
/** The box on the stage (unturned): its proportions place a line's stops. Unknown: as if square. */
|
|
277
|
+
box?: MaskBox | null;
|
|
278
|
+
/** Whose mask, for the stage's handles. */
|
|
279
|
+
target?: MaskSession['target'] | null;
|
|
280
|
+
onEditMask?: (session: MaskSession | null) => void;
|
|
281
|
+
/** A shape picked on the picture (the selection bar's Mask): put on, with its handles, once. */
|
|
282
|
+
ask?: { shape: MaskShape | 'none'; n: number } | null;
|
|
283
|
+
onAsked?: () => void;
|
|
284
|
+
}) {
|
|
285
|
+
const mbox = box && box.w > 0 && box.h > 0 ? box : null;
|
|
286
|
+
const mask = look.common((read) => maskOf(read('mask-image'), read('mask-composite'), mbox));
|
|
287
|
+
const now = mask === MIXED ? null : mask;
|
|
288
|
+
const [editing, setEditing] = React.useState(false);
|
|
289
|
+
const canEdit = Boolean(onEditMask && target && look.n === 1);
|
|
290
|
+
|
|
291
|
+
const write = (next: Mask | null): LookEdit => (read) => withMask(read('mask-image'), read('mask-composite'), next, mbox) ?? {};
|
|
292
|
+
const edit = (fn: (m: Mask) => Mask): LookEdit => (read) => {
|
|
293
|
+
const m = maskOf(read('mask-image'), read('mask-composite'), mbox);
|
|
294
|
+
return m ? write(fn(m))(read) : {};
|
|
295
|
+
};
|
|
296
|
+
const pick = (shape: string) => {
|
|
297
|
+
if (shape === 'none') { setEditing(false); look.commit(write(null)); return; }
|
|
298
|
+
look.commit((read) => {
|
|
299
|
+
const m = maskOf(read('mask-image'), read('mask-composite'), mbox);
|
|
300
|
+
return m === null ? {} : write(maskDefault(shape as MaskShape, mbox, m))(read);
|
|
301
|
+
});
|
|
302
|
+
if (canEdit) setEditing(true);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
React.useEffect(() => {
|
|
306
|
+
if (!ask) return;
|
|
307
|
+
pick(ask.shape);
|
|
308
|
+
onAsked?.();
|
|
309
|
+
}, [ask?.n]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
310
|
+
|
|
311
|
+
/* the stage's handles: the mask as written, written back through this look (one change at the release) */
|
|
312
|
+
const lookRef = React.useRef(look);
|
|
313
|
+
lookRef.current = look;
|
|
314
|
+
const boxRef = React.useRef(mbox);
|
|
315
|
+
boxRef.current = mbox;
|
|
316
|
+
const maskKey = editing && canEdit && now ? JSON.stringify(now) : null;
|
|
317
|
+
const targetKey = target ? JSON.stringify(target) : null;
|
|
318
|
+
React.useEffect(() => {
|
|
319
|
+
if (!onEditMask) return;
|
|
320
|
+
if (!maskKey || !targetKey) { onEditMask(null); return; }
|
|
321
|
+
const put = (m: Mask): LookEdit => (read) => withMask(read('mask-image'), read('mask-composite'), m, boxRef.current) ?? {};
|
|
322
|
+
onEditMask({
|
|
323
|
+
target: JSON.parse(targetKey) as MaskSession['target'],
|
|
324
|
+
mask: JSON.parse(maskKey) as Mask,
|
|
325
|
+
preview: (m) => lookRef.current.preview(put(m)),
|
|
326
|
+
commit: (m) => lookRef.current.commit(put(m)),
|
|
327
|
+
cancel: () => lookRef.current.revert(),
|
|
328
|
+
});
|
|
329
|
+
}, [maskKey, targetKey, onEditMask]);
|
|
330
|
+
React.useEffect(() => () => onEditMask?.(null), [onEditMask]);
|
|
331
|
+
|
|
332
|
+
const num = (key: 'x' | 'y' | 'w' | 'h' | 'rotate' | 'radius', label: string, prefix: React.ReactNode, unit: string, range: { min?: number; max?: number } = {}) => {
|
|
333
|
+
const change = (apply: FieldApply) => edit((m) => ({ ...m, [key]: round(apply(m[key]), 2) }));
|
|
334
|
+
return (
|
|
335
|
+
<NumField
|
|
336
|
+
label={label}
|
|
337
|
+
prefix={prefix}
|
|
338
|
+
value={mask === MIXED ? MIXED : now?.[key]}
|
|
339
|
+
unit={unit}
|
|
340
|
+
{...range}
|
|
341
|
+
onPreview={(_, apply) => look.preview(change(apply))}
|
|
342
|
+
onCommit={(_, apply) => look.commit(change(apply))}
|
|
343
|
+
onRevert={() => look.revert()}
|
|
344
|
+
/>
|
|
345
|
+
);
|
|
346
|
+
};
|
|
347
|
+
const fields = now ? MASK_FIELDS[now.shape] : null;
|
|
348
|
+
const shapeValue = mask === MIXED ? MIXED : mask === null ? 'custom' : mask?.shape ?? 'none';
|
|
349
|
+
const featherEdit = (apply: FieldApply) => edit((m) => ({ ...m, feather: Math.max(0, Math.min(100, round(apply(m.feather)))) }));
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<Section
|
|
353
|
+
id="mask"
|
|
354
|
+
title={t('inspector.mask')}
|
|
355
|
+
aside={(
|
|
356
|
+
<>
|
|
357
|
+
{canEdit && now ? (
|
|
358
|
+
<button
|
|
359
|
+
type="button"
|
|
360
|
+
aria-pressed={editing}
|
|
361
|
+
onClick={() => setEditing((v) => !v)}
|
|
362
|
+
className={`flex h-5 items-center gap-1 rounded-[4px] px-1.5 text-[10.5px] font-medium transition hover:bg-[var(--bg-hover)] ${editing ? 'bg-[var(--fill-tsp)] text-[var(--accent)]' : 'text-[var(--text-muted)]'}`}
|
|
363
|
+
>
|
|
364
|
+
<SquareDashed size={11} />{t('inspector.maskEditOnPicture')}
|
|
365
|
+
</button>
|
|
366
|
+
) : null}
|
|
367
|
+
{mask ? (
|
|
368
|
+
<SectionAction label={t('inspector.maskRemove')} onClick={() => pick('none')}><RotateCcw size={11} /></SectionAction>
|
|
369
|
+
) : null}
|
|
370
|
+
</>
|
|
371
|
+
)}
|
|
372
|
+
>
|
|
373
|
+
{mask === null ? (
|
|
374
|
+
<p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t('inspector.maskCustom')}</p>
|
|
375
|
+
) : (
|
|
376
|
+
<Segmented
|
|
377
|
+
label={t('inspector.maskShape')}
|
|
378
|
+
value={shapeValue}
|
|
379
|
+
options={(['none', ...MASK_SHAPES] as const).map((s) => ({
|
|
380
|
+
value: s,
|
|
381
|
+
title: t(`inspector.maskShapes.${s}`),
|
|
382
|
+
label: <MaskGlyph shape={s} />,
|
|
383
|
+
}))}
|
|
384
|
+
onChange={pick}
|
|
385
|
+
/>
|
|
386
|
+
)}
|
|
387
|
+
{now && fields ? (
|
|
388
|
+
<>
|
|
389
|
+
<Row>
|
|
390
|
+
{num('x', t('inspector.maskX'), 'X', '%', { min: -500, max: 600 })}
|
|
391
|
+
{num('y', t('inspector.maskY'), 'Y', '%', { min: -500, max: 600 })}
|
|
392
|
+
</Row>
|
|
393
|
+
{fields.size === 'box' ? (
|
|
394
|
+
<Row>
|
|
395
|
+
{num('w', t('inspector.maskW'), 'W', '%', { min: 0.5, max: 1000 })}
|
|
396
|
+
{num('h', t('inspector.maskH'), 'H', '%', { min: 0.5, max: 1000 })}
|
|
397
|
+
</Row>
|
|
398
|
+
) : null}
|
|
399
|
+
<Row>
|
|
400
|
+
{fields.size === 'band' ? num('h', t('inspector.maskBand'), <LineHeightGlyph />, '%', { min: 0.5, max: 1000 }) : null}
|
|
401
|
+
{fields.rotate ? num('rotate', t('inspector.rotate'), <RotateGlyph />, '°') : null}
|
|
402
|
+
{fields.radius ? num('radius', t('inspector.radius'), <RadiusGlyph />, '%', { min: 0, max: 500 }) : null}
|
|
403
|
+
<button
|
|
404
|
+
type="button"
|
|
405
|
+
aria-pressed={now.invert}
|
|
406
|
+
onClick={() => look.commit(edit((m) => ({ ...m, invert: !m.invert })))}
|
|
407
|
+
className={`flex h-[26px] items-center justify-center gap-1 rounded-[5px] text-[11px] transition ${now.invert
|
|
408
|
+
? 'bg-[var(--bg)] text-[var(--text)] shadow-[0_0_0_0.5px_var(--border-strong)]'
|
|
409
|
+
: 'bg-[var(--fill-tsp)] text-[var(--text-muted)] hover:text-[var(--text)]'}`}
|
|
410
|
+
>
|
|
411
|
+
{t('inspector.maskInvert')}
|
|
412
|
+
</button>
|
|
413
|
+
{[fields.size === 'band', fields.rotate, fields.radius].filter(Boolean).length % 2 === 0 ? <span /> : null}
|
|
414
|
+
</Row>
|
|
415
|
+
{fields.feather ? (
|
|
416
|
+
<SliderField
|
|
417
|
+
label={t('inspector.maskFeather')}
|
|
418
|
+
value={mask === MIXED ? MIXED : now.feather}
|
|
419
|
+
min={0}
|
|
420
|
+
max={50}
|
|
421
|
+
fieldMax={100}
|
|
422
|
+
neutral={0}
|
|
423
|
+
unit="%"
|
|
424
|
+
onPreview={(_, apply) => look.preview(featherEdit(apply))}
|
|
425
|
+
onCommit={(_, apply) => look.commit(featherEdit(apply))}
|
|
426
|
+
onRevert={() => look.revert()}
|
|
427
|
+
/>
|
|
428
|
+
) : null}
|
|
429
|
+
</>
|
|
430
|
+
) : null}
|
|
431
|
+
</Section>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/* ───────────────────────────── appearance ───────────────────────────── */
|
|
436
|
+
|
|
437
|
+
/** Opacity (`opacity`) and blending (`mix-blend-mode`), with names people use; `children` adds fills. */
|
|
438
|
+
/** A box's four corners (CSS order: top left, top right, bottom right, bottom left). */
|
|
439
|
+
const CORNERS = ['top-left', 'top-right', 'bottom-right', 'bottom-left'] as const;
|
|
440
|
+
type Corner = (typeof CORNERS)[number];
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Corner radius: one number for all four, or (the button beside it) each corner its own. A crop cuts the
|
|
444
|
+
* box's own corners away, so it carries the same roundness (one for all: a crop's shape rounds its corners alike).
|
|
445
|
+
*/
|
|
446
|
+
function RadiusField({ look, t }: { look: Look; t: T }) {
|
|
447
|
+
/* the shorthand, as written or as the page draws it */
|
|
448
|
+
const cornerOf = (read: LookRead, c: Corner) => radiusCorners(read('border-radius'))[CORNERS.indexOf(c)]!;
|
|
449
|
+
const corners = look.common((read) => CORNERS.map((c) => cornerOf(read, c)).join(' '));
|
|
450
|
+
const uneven = corners !== MIXED && new Set(corners.split(' ')).size > 1;
|
|
451
|
+
const [each, setEach] = React.useState(uneven);
|
|
452
|
+
React.useEffect(() => { if (uneven) setEach(true); }, [uneven]);
|
|
453
|
+
const cropped = (read: LookRead) => { const now = cropOf(read('clip-path')); return now && now.some((v) => v > 0) ? now : null; };
|
|
454
|
+
const all = look.common((read) => (CORNERS.every((c) => cornerOf(read, c) === cornerOf(read, 'top-left')) ? cornerOf(read, 'top-left') : MIXED));
|
|
455
|
+
const allEdit = (apply: FieldApply): LookEdit => (read) => {
|
|
456
|
+
const r = Math.max(0, round(apply(cornerOf(read, 'top-left'))));
|
|
457
|
+
const crop = cropped(read);
|
|
458
|
+
return { 'border-radius': r > 0 ? `${r}px` : null, ...(crop ? { 'clip-path': cropCss(crop, r) } : {}) };
|
|
459
|
+
};
|
|
460
|
+
const cornerEdit = (corner: Corner, apply: FieldApply): LookEdit => (read) => {
|
|
461
|
+
const now = CORNERS.map((c) => cornerOf(read, c));
|
|
462
|
+
now[CORNERS.indexOf(corner)] = Math.max(0, round(apply(now[CORNERS.indexOf(corner)]!)));
|
|
463
|
+
return { 'border-radius': now.every((v) => v === 0) ? null : now.map((v) => `${v}px`).join(' ') };
|
|
464
|
+
};
|
|
465
|
+
const bind = (edit: (apply: FieldApply) => LookEdit) => ({
|
|
466
|
+
onPreview: (_: number, apply: FieldApply) => look.preview(edit(apply)),
|
|
467
|
+
onCommit: (_: number, apply: FieldApply) => look.commit(edit(apply)),
|
|
468
|
+
onRevert: () => look.revert(),
|
|
469
|
+
});
|
|
470
|
+
const croppedNow = look.common((read) => cropped(read) != null) === true;
|
|
471
|
+
return (
|
|
472
|
+
<>
|
|
473
|
+
<div className="grid grid-cols-[1fr_24px] items-center gap-1.5">
|
|
474
|
+
<NumField label={t('inspector.radius')} prefix={<RadiusGlyph />} value={each ? MIXED : all} unit="px" min={0} max={100_000}
|
|
475
|
+
placeholder={each ? t('inspector.cornersEach') : undefined} {...bind(allEdit)} />
|
|
476
|
+
<button
|
|
477
|
+
type="button"
|
|
478
|
+
aria-pressed={each}
|
|
479
|
+
aria-label={t('inspector.cornersEach')}
|
|
480
|
+
title={croppedNow ? t('inspector.cornersCropped') : t('inspector.cornersEach')}
|
|
481
|
+
disabled={croppedNow}
|
|
482
|
+
onClick={() => setEach((v) => !v)}
|
|
483
|
+
className={`flex h-[26px] w-6 items-center justify-center rounded-[5px] transition disabled:opacity-40 ${each ? 'bg-[var(--bg-active)] text-[var(--text)]' : 'text-[var(--text-faint)] hover:bg-[var(--bg-hover)] hover:text-[var(--text)]'}`}
|
|
484
|
+
>
|
|
485
|
+
<Scan size={12} />
|
|
486
|
+
</button>
|
|
487
|
+
</div>
|
|
488
|
+
{each && !croppedNow ? (
|
|
489
|
+
<Row>
|
|
490
|
+
{CORNERS.map((c) => (
|
|
491
|
+
<NumField key={c} label={t(`inspector.corners.${c}`)} prefix={<CornerGlyph corner={c} />}
|
|
492
|
+
value={look.common((read) => cornerOf(read, c))} min={0} max={100_000} {...bind((apply) => cornerEdit(c, apply))} />
|
|
493
|
+
))}
|
|
494
|
+
</Row>
|
|
495
|
+
) : null}
|
|
496
|
+
</>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** A corner's mark: the quarter of a rounded square at that corner. */
|
|
501
|
+
function CornerGlyph({ corner }: { corner: Corner }) {
|
|
502
|
+
const turn = { 'top-left': 0, 'top-right': 90, 'bottom-right': 180, 'bottom-left': 270 }[corner];
|
|
503
|
+
return (
|
|
504
|
+
<svg width="9" height="9" viewBox="0 0 9 9" aria-hidden style={{ transform: `rotate(${turn}deg)` }}>
|
|
505
|
+
<path d="M1.5 8V5a3.5 3.5 0 0 1 3.5-3.5H8" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
|
506
|
+
</svg>
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Opacity, corner radius (`radius`: for what has corners — a box, a picture, a line with its own fill) and blend,
|
|
512
|
+
* the Appearance section.
|
|
513
|
+
*/
|
|
514
|
+
/**
|
|
515
|
+
* What the things in hand looked like before a menu showed something else on the picture (a hovered font, weight,
|
|
516
|
+
* size or blend mode, shown on the picture before it is picked): the stage measures a layer again after
|
|
517
|
+
* each preview, so by then it reads as the preview. Giving up (`restore`) paints that back; a pick (`commit`) keeps it.
|
|
518
|
+
*/
|
|
519
|
+
function usePreviewed(look: Look) {
|
|
520
|
+
const before = React.useRef<Record<string, string> | null>(null);
|
|
521
|
+
const remember = (props: readonly string[]) => {
|
|
522
|
+
const now = look.common((read) => Object.fromEntries(props.map((p) => [p, read(p) ?? TYPE_NEUTRAL[p] ?? ''])));
|
|
523
|
+
if (now !== MIXED) before.current = { ...now, ...before.current };
|
|
524
|
+
};
|
|
525
|
+
const restore = () => {
|
|
526
|
+
const was = before.current;
|
|
527
|
+
before.current = null;
|
|
528
|
+
if (was) { look.preview(() => was); previewSettled(); } else look.revert();
|
|
529
|
+
};
|
|
530
|
+
/** Show an edit, what it changes remembered first. */
|
|
531
|
+
const preview = (edit: LookEdit) => {
|
|
532
|
+
const keys = look.common((read) => Object.keys(edit(read)).sort());
|
|
533
|
+
remember(keys === MIXED ? [] : keys);
|
|
534
|
+
look.preview(edit);
|
|
535
|
+
};
|
|
536
|
+
const commit = (edit: LookEdit) => { before.current = null; look.commit(edit); };
|
|
537
|
+
return { before, remember, restore, preview, commit };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export function AppearanceSection({ look, t, children, radius = false, aside }: { look: Look; t: T; children?: React.ReactNode; radius?: boolean; aside?: React.ReactNode }) {
|
|
541
|
+
const previewed = usePreviewed(look);
|
|
542
|
+
const opacity = look.common((read) => {
|
|
543
|
+
const v = Number(read('opacity') ?? 1);
|
|
544
|
+
return Number.isFinite(v) ? round(v * 100) : 100;
|
|
545
|
+
});
|
|
546
|
+
const blend = look.common((read) => read('mix-blend-mode') ?? 'normal');
|
|
547
|
+
const opacityEdit = (apply: FieldApply): LookEdit => (read) => {
|
|
548
|
+
const now = Number(read('opacity') ?? 1);
|
|
549
|
+
const n = Math.max(0, Math.min(100, apply(Number.isFinite(now) ? now * 100 : 100)));
|
|
550
|
+
return { opacity: n >= 100 ? null : String(Math.round(n * 10) / 1000) };
|
|
551
|
+
};
|
|
552
|
+
return (
|
|
553
|
+
<Section id="appearance" title={t('inspector.appearance')} {...(aside ? { aside } : {})}>
|
|
554
|
+
<SliderField
|
|
555
|
+
label={t('inspector.opacity')}
|
|
556
|
+
value={opacity}
|
|
557
|
+
min={0}
|
|
558
|
+
max={100}
|
|
559
|
+
neutral={100}
|
|
560
|
+
unit="%"
|
|
561
|
+
onPreview={(_, apply) => look.preview(opacityEdit(apply))}
|
|
562
|
+
onCommit={(_, apply) => look.commit(opacityEdit(apply))}
|
|
563
|
+
onRevert={() => look.revert()}
|
|
564
|
+
/>
|
|
565
|
+
{radius ? <RadiusField look={look} t={t} /> : null}
|
|
566
|
+
<SelectField
|
|
567
|
+
label={t('inspector.blend')}
|
|
568
|
+
prefix={t('inspector.blendShort')}
|
|
569
|
+
value={blend}
|
|
570
|
+
options={BLEND_MODES.map((b) => ({ value: b, label: t(`inspector.blendModes.${b}`) }))}
|
|
571
|
+
onPreview={(v) => previewed.preview(() => ({ 'mix-blend-mode': v }))}
|
|
572
|
+
onRevert={previewed.restore}
|
|
573
|
+
onCommit={(v) => previewed.commit(() => ({ 'mix-blend-mode': v === 'normal' ? null : v }))}
|
|
574
|
+
/>
|
|
575
|
+
{children}
|
|
576
|
+
</Section>
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/* ───────────────────────────── fill ───────────────────────────── */
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Fill: what paints the thing — a line's color, a box's background, a shape's fill. One the thing may go
|
|
584
|
+
* without (a box's background) has "−" to take it off, and "+" brings one back.
|
|
585
|
+
*/
|
|
586
|
+
export function FillSection({ look, t, fills, add }: {
|
|
587
|
+
look: Look;
|
|
588
|
+
t: T;
|
|
589
|
+
/** The fills shown: their CSS property, label, and whether it can be taken off. */
|
|
590
|
+
fills: { prop: string; label: string; removable?: boolean }[];
|
|
591
|
+
/** A fill to offer with "+" when it is not there (a box with no background). */
|
|
592
|
+
add?: { prop: string; label: string; value: string } | null;
|
|
593
|
+
}) {
|
|
594
|
+
if (!fills.length && !add) return null;
|
|
595
|
+
return (
|
|
596
|
+
<Section
|
|
597
|
+
id="fill"
|
|
598
|
+
title={t('inspector.fill')}
|
|
599
|
+
aside={add ? (
|
|
600
|
+
<SectionAction label={t('inspector.fillAdd')} onClick={() => look.commit(() => ({ [add.prop]: add.value }))}><Plus size={12} /></SectionAction>
|
|
601
|
+
) : null}
|
|
602
|
+
>
|
|
603
|
+
{fills.map((f) => {
|
|
604
|
+
/* words: the color they are drawn in, which a fill of their page's own paints over `color` */
|
|
605
|
+
const of = (read: LookRead) => (f.prop === 'color' ? textColorOf(read) : read(f.prop));
|
|
606
|
+
const to = (c: string): LookEdit => (read) => (f.prop === 'color' ? textColorCss(read, c) : { [f.prop]: c });
|
|
607
|
+
return (
|
|
608
|
+
<div key={f.prop} className="flex items-center gap-1.5">
|
|
609
|
+
<div className="min-w-0 flex-1">
|
|
610
|
+
<ColorField
|
|
611
|
+
label={f.label}
|
|
612
|
+
value={look.common((read) => of(read) ?? '')}
|
|
613
|
+
onPreview={(c) => look.preview(to(c))}
|
|
614
|
+
onCommit={(c) => look.commit(to(c))}
|
|
615
|
+
/>
|
|
616
|
+
</div>
|
|
617
|
+
{f.removable ? (
|
|
618
|
+
/* see-through, not "none of mine": that would bring back the page's own fill */
|
|
619
|
+
<SectionAction label={t('inspector.fillRemove')} onClick={() => look.commit(() => ({ [f.prop]: 'transparent' }))}><Minus size={12} /></SectionAction>
|
|
620
|
+
) : null}
|
|
621
|
+
</div>
|
|
622
|
+
);
|
|
623
|
+
})}
|
|
624
|
+
</Section>
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/* ───────────────────────────── stroke ───────────────────────────── */
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Stroke: color, weight, where it lies against the edge (inside, across, outside) and its line (solid,
|
|
632
|
+
* dashed, dotted). Drawn as an `outline` with its offset (lib/clip-look placedStrokeOf): it adds nothing to the box,
|
|
633
|
+
* so the layout does not move as it is added or thickened.
|
|
634
|
+
*/
|
|
635
|
+
export function StrokeSection({ look, t }: { look: Look; t: T }) {
|
|
636
|
+
const previewed = usePreviewed(look);
|
|
637
|
+
const stroke = look.common((read) => placedStrokeOf(read));
|
|
638
|
+
const has = stroke !== undefined;
|
|
639
|
+
const edit = (part: Partial<PlacedStroke> | ((now: PlacedStroke) => Partial<PlacedStroke>)): LookEdit => (read) => {
|
|
640
|
+
const was = placedStrokeOf(read);
|
|
641
|
+
const now: PlacedStroke = was ?? PLACED_STROKE_DEFAULT;
|
|
642
|
+
return placedStrokeCss({ ...now, ...(typeof part === 'function' ? part(now) : part) }, was);
|
|
643
|
+
};
|
|
644
|
+
const width = (apply: FieldApply): LookEdit => edit((now) => ({ width: Math.max(0, round(apply(now.width))) }));
|
|
645
|
+
/* a mask (faded edges, a mask shape) or a crop shows the box and nothing past it: a stroke outside, or half of one
|
|
646
|
+
across the edge, is cut away with it */
|
|
647
|
+
const cut = look.common((read) => {
|
|
648
|
+
const m = read('mask-image');
|
|
649
|
+
const c = read('clip-path');
|
|
650
|
+
return Boolean((m && m !== 'none') || (c && c !== 'none'));
|
|
651
|
+
});
|
|
652
|
+
const position = look.common((read) => placedStrokeOf(read)?.position);
|
|
653
|
+
return (
|
|
654
|
+
<Section
|
|
655
|
+
id="stroke"
|
|
656
|
+
title={t('inspector.stroke')}
|
|
657
|
+
aside={has ? (
|
|
658
|
+
<SectionAction label={t('inspector.effectRemove')} onClick={() => look.commit((read) => placedStrokeCss(null, placedStrokeOf(read)))}><Minus size={12} /></SectionAction>
|
|
659
|
+
) : (
|
|
660
|
+
<SectionAction label={t('inspector.strokeAdd')} onClick={() => look.commit(edit({}))}><Plus size={12} /></SectionAction>
|
|
661
|
+
)}
|
|
662
|
+
>
|
|
663
|
+
{stroke === null ? (
|
|
664
|
+
<p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t('inspector.effectCustom')}</p>
|
|
665
|
+
) : has ? (
|
|
666
|
+
<>
|
|
667
|
+
<ColorField
|
|
668
|
+
label={t('inspector.strokeColor')}
|
|
669
|
+
value={look.common((read) => placedStrokeOf(read)?.color ?? '')}
|
|
670
|
+
onPreview={(c) => look.preview(edit({ color: c }))}
|
|
671
|
+
onCommit={(c) => look.commit(edit({ color: c }))}
|
|
672
|
+
/>
|
|
673
|
+
<Row>
|
|
674
|
+
<SelectField
|
|
675
|
+
label={t('inspector.strokePosition')}
|
|
676
|
+
value={look.common((read) => placedStrokeOf(read)?.position ?? 'inside')}
|
|
677
|
+
/* a mask or a crop cuts away all that is past the edge: only inside is drawn whole there (a stroke placed
|
|
678
|
+
otherwise before shows where it is, to be moved inside) */
|
|
679
|
+
options={(['inside', 'center', 'outside'] as const)
|
|
680
|
+
.filter((p) => cut === false || p === 'inside' || p === position)
|
|
681
|
+
.map((p) => ({ value: p, label: t(`inspector.strokePositions.${p}`) }))}
|
|
682
|
+
onPreview={(v) => previewed.preview(edit({ position: v as StrokePosition }))}
|
|
683
|
+
onRevert={previewed.restore}
|
|
684
|
+
onCommit={(v) => previewed.commit(edit({ position: v as StrokePosition }))}
|
|
685
|
+
/>
|
|
686
|
+
<NumField
|
|
687
|
+
label={t('inspector.strokeWidth')}
|
|
688
|
+
prefix={<span className="block h-[7px] w-[9px] rounded-[1.5px] border-[1.5px]" style={{ borderColor: 'currentColor' }} />}
|
|
689
|
+
value={look.common((read) => placedStrokeOf(read)?.width)}
|
|
690
|
+
unit="px"
|
|
691
|
+
min={0}
|
|
692
|
+
max={1000}
|
|
693
|
+
onPreview={(_, apply) => look.preview(width(apply))}
|
|
694
|
+
onCommit={(_, apply) => look.commit(width(apply))}
|
|
695
|
+
onRevert={() => look.revert()}
|
|
696
|
+
/>
|
|
697
|
+
</Row>
|
|
698
|
+
<Segmented
|
|
699
|
+
label={t('inspector.strokeStyle')}
|
|
700
|
+
value={look.common((read) => placedStrokeOf(read)?.style ?? '')}
|
|
701
|
+
small
|
|
702
|
+
options={(['solid', 'dashed', 'dotted'] as const).map((s) => ({
|
|
703
|
+
value: s,
|
|
704
|
+
title: t(`inspector.strokeStyles.${s}`),
|
|
705
|
+
label: <span className="block w-4 border-t-[1.5px]" style={{ borderTopStyle: s, borderTopColor: 'currentColor' }} />,
|
|
706
|
+
}))}
|
|
707
|
+
onChange={(s) => look.commit(edit({ style: s as StrokeStyle }))}
|
|
708
|
+
/>
|
|
709
|
+
{cut !== false && position !== 'inside' ? (
|
|
710
|
+
<p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t('inspector.strokeCut')}</p>
|
|
711
|
+
) : null}
|
|
712
|
+
</>
|
|
713
|
+
) : null}
|
|
714
|
+
</Section>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/* ───────────────────────────── adjust ───────────────────────────── */
|
|
719
|
+
|
|
720
|
+
const ADJUST_RANGE: Record<AdjustKey, { min: number; max: number; unit: string; fieldMax?: number }> = {
|
|
721
|
+
brightness: { min: -100, max: 100, unit: '' },
|
|
722
|
+
contrast: { min: -100, max: 100, unit: '' },
|
|
723
|
+
saturate: { min: -100, max: 100, unit: '' },
|
|
724
|
+
hue: { min: -180, max: 180, unit: '°' },
|
|
725
|
+
blur: { min: 0, max: 40, unit: 'px', fieldMax: 1000 },
|
|
726
|
+
grayscale: { min: 0, max: 100, unit: '%' },
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* The picture's color and focus, each a CSS `filter` function (brightness, contrast, saturate, hue-rotate, blur,
|
|
731
|
+
* grayscale): 0 is as is, a double-click puts one back, the reset all. Filter functions without a control here
|
|
732
|
+
* (sepia, a url) are kept as written, in their place.
|
|
733
|
+
*/
|
|
734
|
+
/* the picture's color; its blur is an effect (a layer blur) */
|
|
735
|
+
const COLOR_KEYS = ADJUST_KEYS.filter((k) => k !== 'blur');
|
|
736
|
+
|
|
737
|
+
export function AdjustSection({ look, t }: { look: Look; t: T }) {
|
|
738
|
+
const readable = look.common((read) => filterParts(read('filter')) != null);
|
|
739
|
+
const touched = look.common((read) => COLOR_KEYS.some((k) => adjustOf(read('filter'))[k] !== 0));
|
|
740
|
+
const edit = (key: AdjustKey, apply: FieldApply): LookEdit => (read) => {
|
|
741
|
+
const now = adjustOf(read('filter'))[key];
|
|
742
|
+
const r = ADJUST_RANGE[key];
|
|
743
|
+
const n = Math.max(r.min, Math.min(r.fieldMax ?? r.max, round(apply(now ?? 0))));
|
|
744
|
+
return { filter: withAdjust(read('filter'), key, n) };
|
|
745
|
+
};
|
|
746
|
+
if (readable === false) {
|
|
747
|
+
return (
|
|
748
|
+
<Section id="adjust" title={t('inspector.adjust')}>
|
|
749
|
+
<p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t('inspector.adjustCustom')}</p>
|
|
750
|
+
</Section>
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
return (
|
|
754
|
+
<Section
|
|
755
|
+
id="adjust"
|
|
756
|
+
title={t('inspector.adjust')}
|
|
757
|
+
aside={touched === false ? null : (
|
|
758
|
+
<SectionAction
|
|
759
|
+
label={t('inspector.resetSection')}
|
|
760
|
+
onClick={() => look.commit((read) => ({ filter: COLOR_KEYS.reduce<string | null>((f, k) => withAdjust(f ?? undefined, k, 0), read('filter') ?? null) }))}
|
|
761
|
+
>
|
|
762
|
+
<RotateCcw size={11} />
|
|
763
|
+
</SectionAction>
|
|
764
|
+
)}
|
|
765
|
+
>
|
|
766
|
+
{COLOR_KEYS.map((key) => {
|
|
767
|
+
const r = ADJUST_RANGE[key];
|
|
768
|
+
const v = look.common((read) => adjustOf(read('filter'))[key]);
|
|
769
|
+
return (
|
|
770
|
+
<SliderField
|
|
771
|
+
key={key}
|
|
772
|
+
label={t(`inspector.adjustKeys.${key}`)}
|
|
773
|
+
value={v === null ? undefined : v}
|
|
774
|
+
{...(v === null ? { placeholder: t('inspector.custom') } : {})}
|
|
775
|
+
min={r.min}
|
|
776
|
+
max={r.max}
|
|
777
|
+
neutral={0}
|
|
778
|
+
unit={r.unit}
|
|
779
|
+
fieldMin={r.min}
|
|
780
|
+
fieldMax={r.fieldMax ?? r.max}
|
|
781
|
+
onPreview={(_, apply) => look.preview(edit(key, apply))}
|
|
782
|
+
onCommit={(_, apply) => look.commit(edit(key, apply))}
|
|
783
|
+
onRevert={() => look.revert()}
|
|
784
|
+
/>
|
|
785
|
+
);
|
|
786
|
+
})}
|
|
787
|
+
</Section>
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/* ───────────────────────────── effects ───────────────────────────── */
|
|
792
|
+
|
|
793
|
+
type EffectId = 'shadow' | 'inner' | 'blur' | 'backdrop' | 'fade';
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Effects, each added with "+" and taken off with "−", none taking room unless used: a drop shadow, an
|
|
797
|
+
* inner shadow (`inner`: for what has a box of its own to cast it in), a layer blur, a background blur — and faded
|
|
798
|
+
* edges, a video editor's.
|
|
799
|
+
*
|
|
800
|
+
* The drop shadow is `filter: drop-shadow()`, not `box-shadow`: it follows what is drawn — the letters of a title on a
|
|
801
|
+
* see-through page, a cut-out PNG — where a box shadow would draw the rectangle behind them. The inner shadow is an
|
|
802
|
+
* `inset` box-shadow; the layer blur `filter: blur()`; the background blur `backdrop-filter: blur()`; faded edges are
|
|
803
|
+
* `mask-image` gradients, one an axis, intersected by `mask-composite`.
|
|
804
|
+
*/
|
|
805
|
+
export function EffectsSection({ look, t, inner = false }: { look: Look; t: T; inner?: boolean }) {
|
|
806
|
+
const shadow = look.common((read) => shadowOf(read('filter')));
|
|
807
|
+
const innerShadow = look.common((read) => innerShadowOf(read('box-shadow')));
|
|
808
|
+
const blur = look.common((read) => { const b = adjustOf(read('filter')).blur; return b ? b : undefined; });
|
|
809
|
+
const backdrop = look.common((read) => backdropBlurOf(read('backdrop-filter')));
|
|
810
|
+
/* faded edges share mask-image with the Mask section's shape (clip-mask): read and written beside it */
|
|
811
|
+
const fadeRead = (read: LookRead) => fadeIn(read('mask-image'), read('mask-composite'));
|
|
812
|
+
const fadeWrite = (read: LookRead, next: Fade | null): LookSet => withFade(read('mask-image'), read('mask-composite'), next) ?? {};
|
|
813
|
+
const fade = look.common(fadeRead);
|
|
814
|
+
const filterReadable = look.common((read) => filterParts(read('filter')) != null) !== false;
|
|
815
|
+
const [adding, setAdding] = React.useState(false);
|
|
816
|
+
const has = (v: unknown) => v !== undefined;
|
|
817
|
+
const missing: { id: EffectId; label: string }[] = [
|
|
818
|
+
...(!has(shadow) && filterReadable ? [{ id: 'shadow' as const, label: t('inspector.shadow') }] : []),
|
|
819
|
+
...(inner && !has(innerShadow) ? [{ id: 'inner' as const, label: t('inspector.innerShadow') }] : []),
|
|
820
|
+
...(!has(blur) && filterReadable ? [{ id: 'blur' as const, label: t('inspector.layerBlur') }] : []),
|
|
821
|
+
...(!has(backdrop) ? [{ id: 'backdrop' as const, label: t('inspector.backgroundBlur') }] : []),
|
|
822
|
+
...(!has(fade) ? [{ id: 'fade' as const, label: t('inspector.fade') }] : []),
|
|
823
|
+
];
|
|
824
|
+
const add = (id: EffectId) => {
|
|
825
|
+
setAdding(false);
|
|
826
|
+
if (id === 'shadow') look.commit((read) => ({ filter: withShadow(read('filter'), shadowOf(read('filter')) ?? SHADOW_DEFAULT) }));
|
|
827
|
+
if (id === 'inner') look.commit(() => ({ 'box-shadow': innerShadowCss(INNER_SHADOW_DEFAULT) }));
|
|
828
|
+
if (id === 'blur') look.commit((read) => ({ filter: withAdjust(read('filter'), 'blur', 4) }));
|
|
829
|
+
if (id === 'backdrop') look.commit(() => ({ 'backdrop-filter': backdropBlurCss(8) }));
|
|
830
|
+
if (id === 'fade') look.commit((read) => fadeWrite(read, fadeRead(read) ?? FADE_DEFAULT));
|
|
831
|
+
};
|
|
832
|
+
const remove = (id: EffectId) => {
|
|
833
|
+
if (id === 'shadow') look.commit((read) => ({ filter: withShadow(read('filter'), null) }));
|
|
834
|
+
if (id === 'inner') look.commit(() => ({ 'box-shadow': null }));
|
|
835
|
+
if (id === 'blur') look.commit((read) => ({ filter: withAdjust(read('filter'), 'blur', 0) }));
|
|
836
|
+
if (id === 'backdrop') look.commit(() => ({ 'backdrop-filter': null }));
|
|
837
|
+
if (id === 'fade') look.commit((read) => fadeWrite(read, null));
|
|
838
|
+
};
|
|
839
|
+
const removeBtn = (id: EffectId) => (
|
|
840
|
+
<SectionAction label={t('inspector.effectRemove')} onClick={() => remove(id)}><Minus size={12} /></SectionAction>
|
|
841
|
+
);
|
|
842
|
+
const bind = (edit: (apply: FieldApply) => LookEdit) => ({
|
|
843
|
+
onPreview: (_: number, apply: FieldApply) => look.preview(edit(apply)),
|
|
844
|
+
onCommit: (_: number, apply: FieldApply) => look.commit(edit(apply)),
|
|
845
|
+
onRevert: () => look.revert(),
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
/* drop shadow: each thing's own, or the default for one without */
|
|
849
|
+
const shadowField = (key: 'x' | 'y' | 'blur') => {
|
|
850
|
+
const edit = (apply: FieldApply): LookEdit => (read) => {
|
|
851
|
+
const now: Shadow = shadowOf(read('filter')) ?? SHADOW_DEFAULT;
|
|
852
|
+
const n = round(apply(now[key]));
|
|
853
|
+
return { filter: withShadow(read('filter'), { ...now, [key]: key === 'blur' ? Math.max(0, n) : n }) };
|
|
854
|
+
};
|
|
855
|
+
return (
|
|
856
|
+
<NumField
|
|
857
|
+
label={t(`inspector.shadow${key === 'x' ? 'X' : key === 'y' ? 'Y' : 'Blur'}`)}
|
|
858
|
+
prefix={key === 'x' ? 'X' : key === 'y' ? 'Y' : 'B'}
|
|
859
|
+
value={look.common((read) => shadowOf(read('filter'))?.[key])}
|
|
860
|
+
unit="px"
|
|
861
|
+
{...(key === 'blur' ? { min: 0 } : {})}
|
|
862
|
+
{...bind(edit)}
|
|
863
|
+
/>
|
|
864
|
+
);
|
|
865
|
+
};
|
|
866
|
+
const shadowColor = (color: string): LookEdit => (read) => ({ filter: withShadow(read('filter'), { ...(shadowOf(read('filter')) ?? SHADOW_DEFAULT), color }) });
|
|
867
|
+
/* inner shadow: x, y, blur, spread and color */
|
|
868
|
+
const innerEdit = (part: (now: InnerShadow) => Partial<InnerShadow>): LookEdit => (read) => {
|
|
869
|
+
const now = innerShadowOf(read('box-shadow')) ?? INNER_SHADOW_DEFAULT;
|
|
870
|
+
return { 'box-shadow': innerShadowCss({ ...now, ...part(now) }) };
|
|
871
|
+
};
|
|
872
|
+
const innerField = (key: 'x' | 'y' | 'blur' | 'spread', prefix: string, label: string) => (
|
|
873
|
+
<NumField
|
|
874
|
+
label={label}
|
|
875
|
+
prefix={prefix}
|
|
876
|
+
value={look.common((read) => innerShadowOf(read('box-shadow'))?.[key])}
|
|
877
|
+
unit="px"
|
|
878
|
+
{...(key === 'blur' ? { min: 0 } : {})}
|
|
879
|
+
{...bind((apply) => innerEdit((now) => ({ [key]: key === 'blur' ? Math.max(0, round(apply(now[key]))) : round(apply(now[key])) })))}
|
|
880
|
+
/>
|
|
881
|
+
);
|
|
882
|
+
const fadeOn = (side: FadeSide) => look.common((read) => fadeRead(read)?.sides[side] ?? false);
|
|
883
|
+
const fadeSize = look.common((read) => fadeRead(read)?.size ?? null);
|
|
884
|
+
const fadeToggle = (side: FadeSide) => {
|
|
885
|
+
const on = fadeOn(side) === true;
|
|
886
|
+
look.commit((read) => {
|
|
887
|
+
const now: Fade = fadeRead(read) ?? { ...FADE_DEFAULT, sides: { top: false, right: false, bottom: false, left: false } };
|
|
888
|
+
const sides = { ...now.sides, [side]: !on };
|
|
889
|
+
return fadeWrite(read, Object.values(sides).some(Boolean) ? { ...now, sides } : null);
|
|
890
|
+
});
|
|
891
|
+
};
|
|
892
|
+
const fadeSizeEdit = (apply: FieldApply): LookEdit => (read): LookSet => {
|
|
893
|
+
const now = fadeRead(read);
|
|
894
|
+
return now ? fadeWrite(read, { ...now, size: Math.max(0.5, Math.min(50, round(apply(now.size)))) }) : {};
|
|
895
|
+
};
|
|
896
|
+
const custom = (label: string) => (
|
|
897
|
+
<SubLabel>{label} · <span className="text-[var(--text-faint)]">{t('inspector.effectCustom')}</span></SubLabel>
|
|
898
|
+
);
|
|
899
|
+
const none = !has(shadow) && !has(innerShadow) && !has(blur) && !has(backdrop) && !has(fade);
|
|
900
|
+
|
|
901
|
+
return (
|
|
902
|
+
<Section
|
|
903
|
+
id="effects"
|
|
904
|
+
title={t('inspector.effects')}
|
|
905
|
+
aside={missing.length ? (
|
|
906
|
+
<Popover
|
|
907
|
+
open={adding}
|
|
908
|
+
onOpenChange={setAdding}
|
|
909
|
+
align="end"
|
|
910
|
+
trigger={(
|
|
911
|
+
<button type="button" aria-label={t('inspector.effectAdd')} className="flex h-5 w-5 items-center justify-center rounded-[5px] text-[var(--text-muted)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]">
|
|
912
|
+
<Plus size={12} />
|
|
913
|
+
</button>
|
|
914
|
+
)}
|
|
915
|
+
>
|
|
916
|
+
<div className="min-w-[140px] rounded-[7px] border border-[var(--border)] bg-[var(--surface)] p-1 shadow-[var(--shadow-lg)]">
|
|
917
|
+
{missing.map((m) => (
|
|
918
|
+
<button
|
|
919
|
+
key={m.id}
|
|
920
|
+
type="button"
|
|
921
|
+
onClick={() => add(m.id)}
|
|
922
|
+
className="flex w-full items-center rounded-[5px] px-2 py-1.5 text-left text-[11.5px] text-[var(--text)] transition hover:bg-[var(--bg-hover)]"
|
|
923
|
+
>
|
|
924
|
+
{m.label}
|
|
925
|
+
</button>
|
|
926
|
+
))}
|
|
927
|
+
</div>
|
|
928
|
+
</Popover>
|
|
929
|
+
) : null}
|
|
930
|
+
>
|
|
931
|
+
{none ? <p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t('inspector.effectsNone')}</p> : null}
|
|
932
|
+
{shadow === null ? custom(t('inspector.shadow')) : has(shadow) ? (
|
|
933
|
+
<>
|
|
934
|
+
<SubLabel aside={removeBtn('shadow')}>{t('inspector.shadow')}</SubLabel>
|
|
935
|
+
<Row cols={3}>{shadowField('x')}{shadowField('y')}{shadowField('blur')}</Row>
|
|
936
|
+
<ColorField
|
|
937
|
+
label={t('inspector.shadowColor')}
|
|
938
|
+
value={look.common((read) => shadowOf(read('filter'))?.color ?? '')}
|
|
939
|
+
onPreview={(c) => look.preview(shadowColor(c))}
|
|
940
|
+
onCommit={(c) => look.commit(shadowColor(c))}
|
|
941
|
+
/>
|
|
942
|
+
</>
|
|
943
|
+
) : null}
|
|
944
|
+
{innerShadow === null ? custom(t('inspector.innerShadow')) : has(innerShadow) ? (
|
|
945
|
+
<>
|
|
946
|
+
<SubLabel aside={removeBtn('inner')}>{t('inspector.innerShadow')}</SubLabel>
|
|
947
|
+
<Row>{innerField('x', 'X', t('inspector.shadowX'))}{innerField('y', 'Y', t('inspector.shadowY'))}</Row>
|
|
948
|
+
<Row>{innerField('blur', 'B', t('inspector.shadowBlur'))}{innerField('spread', 'S', t('inspector.shadowSpread'))}</Row>
|
|
949
|
+
<ColorField
|
|
950
|
+
label={t('inspector.shadowColor')}
|
|
951
|
+
value={look.common((read) => innerShadowOf(read('box-shadow'))?.color ?? '')}
|
|
952
|
+
onPreview={(c) => look.preview(innerEdit(() => ({ color: c })))}
|
|
953
|
+
onCommit={(c) => look.commit(innerEdit(() => ({ color: c })))}
|
|
954
|
+
/>
|
|
955
|
+
</>
|
|
956
|
+
) : null}
|
|
957
|
+
{blur === null ? custom(t('inspector.layerBlur')) : has(blur) ? (
|
|
958
|
+
<>
|
|
959
|
+
<SubLabel aside={removeBtn('blur')}>{t('inspector.layerBlur')}</SubLabel>
|
|
960
|
+
<SliderField
|
|
961
|
+
label={t('inspector.layerBlur')}
|
|
962
|
+
value={blur === MIXED ? MIXED : blur ?? 0}
|
|
963
|
+
min={0}
|
|
964
|
+
max={40}
|
|
965
|
+
neutral={0}
|
|
966
|
+
unit="px"
|
|
967
|
+
fieldMin={0}
|
|
968
|
+
fieldMax={1000}
|
|
969
|
+
onPreview={(_, apply) => look.preview((read) => ({ filter: withAdjust(read('filter'), 'blur', Math.max(0, round(apply(adjustOf(read('filter')).blur ?? 0)))) }))}
|
|
970
|
+
onCommit={(_, apply) => look.commit((read) => ({ filter: withAdjust(read('filter'), 'blur', Math.max(0, round(apply(adjustOf(read('filter')).blur ?? 0)))) }))}
|
|
971
|
+
onRevert={() => look.revert()}
|
|
972
|
+
/>
|
|
973
|
+
</>
|
|
974
|
+
) : null}
|
|
975
|
+
{backdrop === null ? custom(t('inspector.backgroundBlur')) : has(backdrop) ? (
|
|
976
|
+
<>
|
|
977
|
+
<SubLabel aside={removeBtn('backdrop')}>{t('inspector.backgroundBlur')}</SubLabel>
|
|
978
|
+
<SliderField
|
|
979
|
+
label={t('inspector.backgroundBlur')}
|
|
980
|
+
value={backdrop === MIXED ? MIXED : backdrop ?? 0}
|
|
981
|
+
min={0}
|
|
982
|
+
max={40}
|
|
983
|
+
neutral={0}
|
|
984
|
+
unit="px"
|
|
985
|
+
fieldMin={0}
|
|
986
|
+
fieldMax={1000}
|
|
987
|
+
onPreview={(_, apply) => look.preview((read) => ({ 'backdrop-filter': backdropBlurCss(Math.max(0, round(apply(backdropBlurOf(read('backdrop-filter')) ?? 0)))) }))}
|
|
988
|
+
onCommit={(_, apply) => look.commit((read) => ({ 'backdrop-filter': backdropBlurCss(Math.max(0, round(apply(backdropBlurOf(read('backdrop-filter')) ?? 0)))) }))}
|
|
989
|
+
onRevert={() => look.revert()}
|
|
990
|
+
/>
|
|
991
|
+
</>
|
|
992
|
+
) : null}
|
|
993
|
+
{fade === null ? custom(t('inspector.fade')) : has(fade) ? (
|
|
994
|
+
<>
|
|
995
|
+
<SubLabel aside={removeBtn('fade')}>{t('inspector.fade')}</SubLabel>
|
|
996
|
+
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1.5">
|
|
997
|
+
<div role="group" aria-label={t('inspector.fadeSides')} className="flex h-[26px] items-center gap-0.5 rounded-[5px] bg-[var(--fill-tsp)] p-px">
|
|
998
|
+
{(['top', 'right', 'bottom', 'left'] as const).map((side) => {
|
|
999
|
+
const on = fadeOn(side) === true;
|
|
1000
|
+
return (
|
|
1001
|
+
<button
|
|
1002
|
+
key={side}
|
|
1003
|
+
type="button"
|
|
1004
|
+
aria-pressed={on}
|
|
1005
|
+
aria-label={t(`inspector.fadeSide.${side}`)}
|
|
1006
|
+
title={t(`inspector.fadeSide.${side}`)}
|
|
1007
|
+
onClick={() => fadeToggle(side)}
|
|
1008
|
+
className={`flex h-6 flex-1 items-center justify-center rounded-[4px] transition ${on
|
|
1009
|
+
? 'bg-[var(--bg)] text-[var(--text)] shadow-[0_0_0_0.5px_var(--border-strong)]'
|
|
1010
|
+
: 'text-[var(--text-faint)] hover:text-[var(--text)]'}`}
|
|
1011
|
+
>
|
|
1012
|
+
<EdgeGlyph side={side} />
|
|
1013
|
+
</button>
|
|
1014
|
+
);
|
|
1015
|
+
})}
|
|
1016
|
+
</div>
|
|
1017
|
+
<NumField
|
|
1018
|
+
label={t('inspector.fadeSize')}
|
|
1019
|
+
prefix={<LineHeightGlyph />}
|
|
1020
|
+
value={fadeSize === MIXED ? MIXED : fadeSize ?? undefined}
|
|
1021
|
+
unit="%"
|
|
1022
|
+
min={0.5}
|
|
1023
|
+
max={50}
|
|
1024
|
+
onPreview={(_, apply) => look.preview(fadeSizeEdit(apply))}
|
|
1025
|
+
onCommit={(_, apply) => look.commit(fadeSizeEdit(apply))}
|
|
1026
|
+
onRevert={() => look.revert()}
|
|
1027
|
+
/>
|
|
1028
|
+
</div>
|
|
1029
|
+
</>
|
|
1030
|
+
) : null}
|
|
1031
|
+
</Section>
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/* ───────────────────────────── typography ───────────────────────────── */
|
|
1036
|
+
|
|
1037
|
+
/** What a type property is when nothing sets it (what giving up a preview paints back when the layer had none). */
|
|
1038
|
+
const TYPE_NEUTRAL: Record<string, string> = { 'font-weight': '400', 'font-style': 'normal', 'line-height': 'normal', 'letter-spacing': 'normal' };
|
|
1039
|
+
|
|
1040
|
+
/** A family's weight menu: its own weights and italics by name ("Semi Bold", "Bold Italic"). */
|
|
1041
|
+
function weightOptions(styles: FontStyles | null, t: T): { value: string; label: string }[] {
|
|
1042
|
+
const list = styleOptions(styles ?? { weights: NAMED_WEIGHTS });
|
|
1043
|
+
return list.map((s) => {
|
|
1044
|
+
const key = weightKey(s.weight);
|
|
1045
|
+
/* a weight between the named ones (Hiragino's W2 is 250) says its number, or two would read the same */
|
|
1046
|
+
const name = `${t(`inspector.weights.w${key}`)}${key === s.weight ? '' : ` ${s.weight}`}`;
|
|
1047
|
+
return { value: styleValue(s), label: s.italic ? t('inspector.weightItalic').replace('{name}', name) : name };
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Type: font (its menu shows each family on the picture as it is pointed at), weight
|
|
1053
|
+
* (the family's own), size (with a menu of common sizes), line height, letter spacing, alignment, case
|
|
1054
|
+
* (`text-transform`) and color. Text in an SVG has no line layout and is colored by its fill.
|
|
1055
|
+
*/
|
|
1056
|
+
export function TypographySection({ look, t, svg, sample }: { look: Look; t: T; svg?: boolean; sample: string }) {
|
|
1057
|
+
const installed = useInstalledFonts();
|
|
1058
|
+
const { fonts: project, page, ready } = useProjectFonts();
|
|
1059
|
+
const family = look.common((read) => splitFontFamily(read('font-family') ?? '')[0]);
|
|
1060
|
+
const style = look.common((read) => ({ weight: weightNumber(read('font-weight')) ?? 400, italic: isItalic(read('font-style')) }));
|
|
1061
|
+
const size = look.common((read) => pxOf(read('font-size')));
|
|
1062
|
+
const lineHeight = look.common((read) => metricOf('line-height', read('line-height')));
|
|
1063
|
+
const spacing = look.common((read) => metricOf('letter-spacing', read('letter-spacing')));
|
|
1064
|
+
const align = look.common((read) => read('text-align') ?? '');
|
|
1065
|
+
const textCase = look.common((read) => read('text-transform') ?? 'none');
|
|
1066
|
+
const decoration = look.common((read) => {
|
|
1067
|
+
const v = read('text-decoration-line') ?? 'none';
|
|
1068
|
+
return /underline/.test(v) ? 'underline' : /line-through/.test(v) ? 'line-through' : 'none';
|
|
1069
|
+
});
|
|
1070
|
+
const styles = family === MIXED || !family ? null : stylesOf(family, installed ?? [], project);
|
|
1071
|
+
/* not drawn as named: not installed, not declared for the page — once the lists came, and the browser agrees */
|
|
1072
|
+
const missing = React.useMemo(() => {
|
|
1073
|
+
if (family === MIXED || !family || installed == null || !ready) return false;
|
|
1074
|
+
const known = knownAvailable(family, installed, project, page);
|
|
1075
|
+
return known == null ? !familyRenders(family) : !known;
|
|
1076
|
+
}, [family, installed, project, page, ready]);
|
|
1077
|
+
/* families tried are worked out from what the type was before (usePreviewed) */
|
|
1078
|
+
const { before, remember, restore, preview, commit } = usePreviewed(look);
|
|
1079
|
+
/* a weight (and its slant) from the menu's value; italic taken off a style that had it */
|
|
1080
|
+
const weightEdit = (v: string): LookEdit | null => {
|
|
1081
|
+
const next = parseStyleValue(v);
|
|
1082
|
+
return next ? (read) => ({ 'font-weight': String(next.weight), 'font-style': next.italic ? 'italic' : isItalic(read('font-style')) ? 'normal' : null }) : null;
|
|
1083
|
+
};
|
|
1084
|
+
/**
|
|
1085
|
+
* A new family, the fallbacks kept; a weight or an italic it does not have snapped to what it has. Shown only
|
|
1086
|
+
* (`preview`): the weight and slant of before are shown again too, after a family that snapped them.
|
|
1087
|
+
*/
|
|
1088
|
+
const familyEdit = (next: string, preview: boolean, was = before.current): LookEdit => (current) => {
|
|
1089
|
+
const read: LookRead = was ? (p) => was[p] ?? current(p) : current;
|
|
1090
|
+
const set: LookSet = { 'font-family': joinFontFamily(next, splitFontFamily(read('font-family') ?? '')[1]) };
|
|
1091
|
+
const has = stylesOf(next, installed ?? [], project);
|
|
1092
|
+
const now = { weight: weightNumber(read('font-weight')) ?? 400, italic: isItalic(read('font-style')) };
|
|
1093
|
+
const snapped = has ? snapStyle(has, now) : now;
|
|
1094
|
+
if (snapped.weight !== now.weight) set['font-weight'] = String(snapped.weight);
|
|
1095
|
+
else if (was && preview) set['font-weight'] = read('font-weight') ?? '400';
|
|
1096
|
+
if (now.italic && !snapped.italic) set['font-style'] = 'normal';
|
|
1097
|
+
else if (was && preview) set['font-style'] = read('font-style') ?? 'normal';
|
|
1098
|
+
return set;
|
|
1099
|
+
};
|
|
1100
|
+
const num = (prop: string, now: (read: LookRead) => number | undefined, out: (n: number) => string, lo: number, hi: number) => (apply: FieldApply): LookEdit => (read) => {
|
|
1101
|
+
const n = Math.max(lo, Math.min(hi, round(apply(now(read) ?? 0), 2)));
|
|
1102
|
+
return { [prop]: out(n) };
|
|
1103
|
+
};
|
|
1104
|
+
const sizeEdit = num('font-size', (r) => pxOf(r('font-size')), (n) => `${n}px`, 1, 5000);
|
|
1105
|
+
const bind = (edit: (apply: FieldApply) => LookEdit) => ({
|
|
1106
|
+
onPreview: (_: number, apply: FieldApply) => look.preview(edit(apply)),
|
|
1107
|
+
onCommit: (_: number, apply: FieldApply) => look.commit(edit(apply)),
|
|
1108
|
+
onRevert: () => look.revert(),
|
|
1109
|
+
});
|
|
1110
|
+
const metric = (prop: string) => ({
|
|
1111
|
+
onPreview: (css: string) => { remember([prop]); look.preview(() => ({ [prop]: css })); },
|
|
1112
|
+
onCommit: (css: string) => { before.current = null; look.commit(() => ({ [prop]: css })); },
|
|
1113
|
+
onRevert: restore,
|
|
1114
|
+
});
|
|
1115
|
+
const CASES = [
|
|
1116
|
+
{ value: 'none', label: '—', title: t('inspector.caseNone') },
|
|
1117
|
+
{ value: 'uppercase', label: 'AG', title: t('inspector.caseUpper') },
|
|
1118
|
+
{ value: 'lowercase', label: 'ag', title: t('inspector.caseLower') },
|
|
1119
|
+
{ value: 'capitalize', label: 'Ag', title: t('inspector.caseTitle') },
|
|
1120
|
+
];
|
|
1121
|
+
return (
|
|
1122
|
+
<Section id="typography" title={t('inspector.typography')}>
|
|
1123
|
+
<FontFamilyPicker
|
|
1124
|
+
label={t('inspector.fontFamily')}
|
|
1125
|
+
value={family === MIXED ? '' : family}
|
|
1126
|
+
sample={sample}
|
|
1127
|
+
missing={missing}
|
|
1128
|
+
onPreview={(next) => {
|
|
1129
|
+
remember(['font-family', 'font-weight', 'font-style']);
|
|
1130
|
+
look.preview(familyEdit(next, true));
|
|
1131
|
+
}}
|
|
1132
|
+
onRevert={restore}
|
|
1133
|
+
onCommit={(next) => {
|
|
1134
|
+
const edit = familyEdit(next, false);
|
|
1135
|
+
before.current = null;
|
|
1136
|
+
look.commit(edit);
|
|
1137
|
+
}}
|
|
1138
|
+
className="group/field relative flex h-[26px] min-w-0 w-full items-center overflow-hidden rounded-[5px] bg-[var(--fill-tsp)] ring-1 ring-inset ring-transparent transition-[box-shadow,background-color] hover:ring-[var(--border)] focus-within:bg-[var(--bg)] focus-within:ring-[1.5px] focus-within:ring-[var(--accent)]"
|
|
1139
|
+
/>
|
|
1140
|
+
<Row>
|
|
1141
|
+
<SelectField
|
|
1142
|
+
label={t('inspector.fontWeight')}
|
|
1143
|
+
value={style === MIXED ? MIXED : styleValue(style)}
|
|
1144
|
+
options={weightOptions(styles, t)}
|
|
1145
|
+
onPreview={(v) => { const edit = weightEdit(v); if (edit) preview(edit); }}
|
|
1146
|
+
onRevert={restore}
|
|
1147
|
+
onCommit={(v) => { const edit = weightEdit(v); if (edit) commit(edit); }}
|
|
1148
|
+
/>
|
|
1149
|
+
<NumField
|
|
1150
|
+
label={t('inspector.fontSize')}
|
|
1151
|
+
prefix={<FontSizeGlyph />}
|
|
1152
|
+
value={size}
|
|
1153
|
+
unit="px"
|
|
1154
|
+
min={1}
|
|
1155
|
+
max={5000}
|
|
1156
|
+
{...bind(sizeEdit)}
|
|
1157
|
+
trailing={(
|
|
1158
|
+
<FontSizeMenu
|
|
1159
|
+
t={t}
|
|
1160
|
+
onPreview={(n) => preview(() => ({ 'font-size': `${n}px` }))}
|
|
1161
|
+
onRevert={restore}
|
|
1162
|
+
onPick={(n) => commit(() => ({ 'font-size': `${n}px` }))}
|
|
1163
|
+
/>
|
|
1164
|
+
)}
|
|
1165
|
+
/>
|
|
1166
|
+
</Row>
|
|
1167
|
+
<Row>
|
|
1168
|
+
{svg ? null : (
|
|
1169
|
+
<MetricField label={t('inspector.lineHeight')} prefix={<LineHeightGlyph />} kind="line-height" value={lineHeight} placeholder={t('inspector.auto')} {...metric('line-height')} />
|
|
1170
|
+
)}
|
|
1171
|
+
<MetricField label={t('inspector.letterSpacing')} prefix={<LetterSpacingGlyph />} kind="letter-spacing" value={spacing} placeholder="0" {...metric('letter-spacing')} />
|
|
1172
|
+
{svg ? <span /> : null}
|
|
1173
|
+
</Row>
|
|
1174
|
+
{svg ? null : (
|
|
1175
|
+
<Row>
|
|
1176
|
+
<Segmented
|
|
1177
|
+
label={t('inspector.textAlign')}
|
|
1178
|
+
value={align}
|
|
1179
|
+
options={[
|
|
1180
|
+
{ value: 'left', title: t('inspector.alignTextLeft'), label: <AlignLeft size={ICON} /> },
|
|
1181
|
+
{ value: 'center', title: t('inspector.alignTextCenter'), label: <AlignCenter size={ICON} /> },
|
|
1182
|
+
{ value: 'right', title: t('inspector.alignTextRight'), label: <AlignRight size={ICON} /> },
|
|
1183
|
+
{ value: 'justify', title: t('inspector.alignTextJustify'), label: <AlignJustify size={ICON} /> },
|
|
1184
|
+
]}
|
|
1185
|
+
onChange={(v) => look.commit(() => ({ 'text-align': v }))}
|
|
1186
|
+
/>
|
|
1187
|
+
<Segmented
|
|
1188
|
+
label={t('inspector.textCase')}
|
|
1189
|
+
value={textCase}
|
|
1190
|
+
small
|
|
1191
|
+
options={CASES}
|
|
1192
|
+
onChange={(v) => look.commit(() => ({ 'text-transform': v === 'none' ? null : v }))}
|
|
1193
|
+
/>
|
|
1194
|
+
</Row>
|
|
1195
|
+
)}
|
|
1196
|
+
{svg ? null : (
|
|
1197
|
+
<Row>
|
|
1198
|
+
<Segmented
|
|
1199
|
+
label={t('inspector.textDecoration')}
|
|
1200
|
+
value={decoration}
|
|
1201
|
+
small
|
|
1202
|
+
options={[
|
|
1203
|
+
{ value: 'none', label: '—', title: t('inspector.decorations.none') },
|
|
1204
|
+
{ value: 'underline', label: <Underline size={ICON} />, title: t('inspector.decorations.underline') },
|
|
1205
|
+
{ value: 'line-through', label: <Strikethrough size={ICON} />, title: t('inspector.decorations.lineThrough') },
|
|
1206
|
+
]}
|
|
1207
|
+
onChange={(v) => look.commit(() => ({ 'text-decoration-line': v === 'none' ? null : v }))}
|
|
1208
|
+
/>
|
|
1209
|
+
<span />
|
|
1210
|
+
</Row>
|
|
1211
|
+
)}
|
|
1212
|
+
</Section>
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** The font size field's menu: the common sizes; the one under the pointer shown before it is picked. */
|
|
1217
|
+
function FontSizeMenu({ t, onPick, onPreview, onRevert }: { t: T; onPick: (n: number) => void; onPreview: (n: number) => void; onRevert: () => void }) {
|
|
1218
|
+
const [open, setOpen] = React.useState(false);
|
|
1219
|
+
const previewing = React.useRef(false);
|
|
1220
|
+
const show = (n: number | null) => {
|
|
1221
|
+
if (n != null) { previewing.current = true; onPreview(n); }
|
|
1222
|
+
else if (previewing.current) { previewing.current = false; onRevert(); }
|
|
1223
|
+
};
|
|
1224
|
+
return (
|
|
1225
|
+
<Popover
|
|
1226
|
+
open={open}
|
|
1227
|
+
onOpenChange={(next) => { if (!next) show(null); setOpen(next); }}
|
|
1228
|
+
placement="auto"
|
|
1229
|
+
align="end"
|
|
1230
|
+
trigger={(
|
|
1231
|
+
<button
|
|
1232
|
+
type="button"
|
|
1233
|
+
aria-label={t('inspector.fontSizes')}
|
|
1234
|
+
aria-haspopup="listbox"
|
|
1235
|
+
aria-expanded={open}
|
|
1236
|
+
className="flex h-[26px] w-[18px] shrink-0 items-center justify-center text-[var(--text-faint)] hover:text-[var(--text)]"
|
|
1237
|
+
>
|
|
1238
|
+
<ChevronDown size={11} aria-hidden />
|
|
1239
|
+
</button>
|
|
1240
|
+
)}
|
|
1241
|
+
>
|
|
1242
|
+
<div
|
|
1243
|
+
data-film-inspector=""
|
|
1244
|
+
role="listbox"
|
|
1245
|
+
aria-label={t('inspector.fontSizes')}
|
|
1246
|
+
onMouseLeave={() => show(null)}
|
|
1247
|
+
className="w-[72px] overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--panel)] py-1 shadow-[var(--shadow-lg)]"
|
|
1248
|
+
style={{ maxHeight: 'min(320px, var(--popover-max-h, 320px))' }}
|
|
1249
|
+
>
|
|
1250
|
+
{FONT_SIZES.map((n) => (
|
|
1251
|
+
<button
|
|
1252
|
+
key={n}
|
|
1253
|
+
type="button"
|
|
1254
|
+
role="option"
|
|
1255
|
+
aria-selected={false}
|
|
1256
|
+
onMouseEnter={() => show(n)}
|
|
1257
|
+
onClick={() => { previewing.current = false; setOpen(false); onPick(n); }}
|
|
1258
|
+
className="block w-full px-3 py-[3px] text-left text-[11.5px] tabular-nums text-[var(--text)] hover:bg-[var(--bg-hover)]"
|
|
1259
|
+
>
|
|
1260
|
+
{n}
|
|
1261
|
+
</button>
|
|
1262
|
+
))}
|
|
1263
|
+
</div>
|
|
1264
|
+
</Popover>
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/* ───────────────────────────── advanced ───────────────────────────── */
|
|
1269
|
+
|
|
1270
|
+
/** The declarations of `css` none of the controls says (as `prop: value` lines). */
|
|
1271
|
+
export function rawDeclarations(css: string): string {
|
|
1272
|
+
const all = declarations(css);
|
|
1273
|
+
const get = (p: string) => all.find(([k]) => k === p)?.[1];
|
|
1274
|
+
return all.filter(([k, v]) => !ownedDeclaration(k, v, get) && !maskOwned(k, v, get)).map(([k, v]) => `${k}: ${v}`).join(';\n');
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/** What was typed into the raw CSS, as properties to write: those shown before and not typed again go. */
|
|
1278
|
+
export function rawEdit(before: string, typed: string): LookSet {
|
|
1279
|
+
const set: LookSet = {};
|
|
1280
|
+
for (const [k] of declarations(before)) set[k] = null;
|
|
1281
|
+
for (const [k, v] of declarations(typed.replace(/\n/g, ';'))) if (k) set[k] = v;
|
|
1282
|
+
return set;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* The raw CSS, folded away: what the controls above do not say, as written, one property a line (⌘↵ or leaving
|
|
1287
|
+
* keeps it, Esc gives up). A clip's `class` is here too.
|
|
1288
|
+
*/
|
|
1289
|
+
export function AdvancedSection({
|
|
1290
|
+
t, css, onCommit, cls, onClear,
|
|
1291
|
+
}: {
|
|
1292
|
+
t: T;
|
|
1293
|
+
/** The declarations to show (rawDeclarations). */
|
|
1294
|
+
css: string;
|
|
1295
|
+
onCommit: (set: LookSet) => void;
|
|
1296
|
+
/** The element's `class` (a clip's), and how to write it. */
|
|
1297
|
+
cls?: { value: string; onCommit: (next: string | null) => void };
|
|
1298
|
+
/** Takes every change of the person's away (a layer's overrides). */
|
|
1299
|
+
onClear?: () => void;
|
|
1300
|
+
}) {
|
|
1301
|
+
const [draft, setDraft] = React.useState<string | null>(null);
|
|
1302
|
+
const [clsDraft, setClsDraft] = React.useState<string | null>(null);
|
|
1303
|
+
const dropDraft = React.useRef(false);
|
|
1304
|
+
return (
|
|
1305
|
+
<Section
|
|
1306
|
+
id="advanced"
|
|
1307
|
+
title={t('inspector.advanced')}
|
|
1308
|
+
closedByDefault
|
|
1309
|
+
aside={onClear ? (
|
|
1310
|
+
<SectionAction label={t('inspector.clearOverride')} onClick={onClear}><RotateCcw size={11} /></SectionAction>
|
|
1311
|
+
) : null}
|
|
1312
|
+
>
|
|
1313
|
+
{cls ? (
|
|
1314
|
+
<label className="flex flex-col gap-1">
|
|
1315
|
+
<span className="text-[10.5px] text-[var(--text-muted)]">{t('inspector.classes')}</span>
|
|
1316
|
+
<input
|
|
1317
|
+
type="text"
|
|
1318
|
+
spellCheck={false}
|
|
1319
|
+
value={clsDraft ?? cls.value}
|
|
1320
|
+
placeholder={t('inspector.classesEmpty')}
|
|
1321
|
+
onChange={(e) => setClsDraft(e.target.value)}
|
|
1322
|
+
onBlur={() => {
|
|
1323
|
+
const next = clsDraft?.trim().split(/\s+/).filter(Boolean).join(' ');
|
|
1324
|
+
if (!dropDraft.current && next != null && next !== cls.value) cls.onCommit(next || null);
|
|
1325
|
+
dropDraft.current = false;
|
|
1326
|
+
setClsDraft(null);
|
|
1327
|
+
}}
|
|
1328
|
+
onKeyDown={(e) => {
|
|
1329
|
+
if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); }
|
|
1330
|
+
if (e.key === 'Escape') { e.preventDefault(); dropDraft.current = true; e.currentTarget.blur(); }
|
|
1331
|
+
}}
|
|
1332
|
+
className="h-[26px] w-full rounded-[5px] bg-[var(--fill-tsp)] px-2 font-mono text-[10.5px] text-[var(--text)] outline-none ring-1 ring-inset ring-transparent placeholder:text-[var(--text-faint)] hover:ring-[var(--border)] focus:bg-[var(--bg)] focus:ring-[1.5px] focus:ring-[var(--accent)]"
|
|
1333
|
+
/>
|
|
1334
|
+
</label>
|
|
1335
|
+
) : null}
|
|
1336
|
+
<span className="text-[10.5px] text-[var(--text-muted)]">{t('inspector.clipCss')}</span>
|
|
1337
|
+
<textarea
|
|
1338
|
+
aria-label={t('inspector.clipCss')}
|
|
1339
|
+
spellCheck={false}
|
|
1340
|
+
placeholder={t('inspector.clipCssEmpty')}
|
|
1341
|
+
value={draft ?? css}
|
|
1342
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
1343
|
+
onBlur={() => {
|
|
1344
|
+
if (!dropDraft.current && draft != null && draft.trim() !== css.trim()) onCommit(rawEdit(css, draft));
|
|
1345
|
+
dropDraft.current = false;
|
|
1346
|
+
setDraft(null);
|
|
1347
|
+
}}
|
|
1348
|
+
onKeyDown={(e) => {
|
|
1349
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); e.currentTarget.blur(); }
|
|
1350
|
+
if (e.key === 'Escape') { e.preventDefault(); dropDraft.current = true; e.currentTarget.blur(); }
|
|
1351
|
+
}}
|
|
1352
|
+
rows={Math.min(8, Math.max(2, (draft ?? css).split('\n').length))}
|
|
1353
|
+
className="block w-full resize-y rounded-[5px] bg-[var(--fill-tsp)] px-2 py-1.5 font-mono text-[10.5px] leading-[1.5] text-[var(--text)] outline-none ring-1 ring-inset ring-transparent placeholder:text-[var(--text-faint)] hover:ring-[var(--border)] focus:bg-[var(--bg)] focus:ring-[1.5px] focus:ring-[var(--accent)]"
|
|
1354
|
+
/>
|
|
1355
|
+
</Section>
|
|
1356
|
+
);
|
|
1357
|
+
}
|