openpencil/src/components/DesignPanel.vue

189 lines
6.6 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n, useSelectionState, useEditorCommands } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import { openLibraryReview, useLibraryService } from '@/app/libraries'
import { COMPONENT_TYPES, nodeIcon } from '@/app/editor/icons'
import PanelHeader from '@/components/ui/panel/PanelHeader.vue'
import Tip from '@/components/ui/Tip.vue'
import VariablesDialog from './variables/VariablesDialog.vue'
import AppearanceSection from './properties/AppearanceSection.vue'
import EffectsSection from './properties/EffectsSection.vue'
import ExportSection from './properties/ExportSection.vue'
import FillSection from './properties/FillSection.vue'
import LayoutGridSection from './properties/LayoutSection/LayoutGridSection.vue'
Refactor architecture boundaries across core, app, and packages (#234) * refactor(core): decompose editor factory and action modules Split the monolithic editor factory and large action modules into focused domain helpers: - create.ts assembles context through bridge modules (clipboard, components, structure, undo) and delegates to graph-reads, graph-events, layout-runner, component-sync, and state factory - structure.ts delegates to group, container-wrap, auto-layout-wrap, reorder, and state-toggle helpers - selection.ts delegates to hit-test, overlays, container navigation, and read helpers - clipboard.ts delegates to subtree-history, images, export, copy, fonts, and placement helpers - shapes.ts delegates to pen actions and section-adopt - components.ts delegates to focus and instances helpers - alignment.ts delegates to flip-rotate helper - text.ts uses explicit TextEditSession for snapshot comparison New focused modules: nudge, variable-bindings, layout-mode, page-viewports, tool-registry, color-space Undo: history/position and history/snapshot helpers, hardened batch/rollback with nested batch support and configurable limit * refactor(core): split tool definitions by domain Split the monolithic tool registry into domain-specific modules: - read/ — selection, find, pages, fonts, components, nodes, query, jsx - create/ — basic shapes, components, vector, JSX render - modify/ — paint, effects, geometry, layout, state, text, update - structure/ — basic, arrange, batch, hierarchy, replace, tree - variables/ — bindings, collections, read, values - vector/ — boolean, path, export, viewport - analyze/ — colors, typography, spacing, clusters, diff, eval - describe/ — summaries, tree, roles, layout-issues - stock-photo/ — providers, requests, apply - codegen/ — component-map, tokens Split registry into core/extended tiers; refine schema and AI adapter * refactor(core): restructure kiwi codec and instance overrides Reorganize the Kiwi .fig codec into domain subdirectories: - binary/ — codec, schema, protocol - fig/ — file, import, parse (core, worker, transfer) - node-change/ — convert, export-node, serialize, plugin-data - instance-overrides/ — constraints, dsd, populate, props, resolve, symbol-overrides, symbol-props, sync, types Vendored kiwi-schema/ left isolated * refactor(core): split profiler, icons, IO, and add subpath exports Profiler: speedscope-export, capture-session, hud-controller Icons: api, svg, types, render, create-icons tool IO: format registry and subpath exports Canvas/color/text/vector: targeted cleanup Add deliberate subpath exports: random, xpath, vector, color, canvas, scene-graph, kiwi, design-jsx, io, tools, editor, layout, canvaskit, profiler, text, lint, rpc, figma-api, constants * refactor(vue): decompose canvas input, surface lifecycle, and controls Canvas surface: gl-surface, kit-loader, render-loop, resize-observer Canvas input handlers: - move: drop-target, move-snap, duplicate-drag - select: select-move, select-hover, select-hit - resize: resize-rect, resize-vector, resize-start - transform: rotation, marquee, pan, text-selection - text-edit: navigation, clipboard, textarea lifecycle - Shared: click-count, space-key, pan, pan-zoom, draw, raf-scheduler Editor composition: - commands split: actions, context, metadata, edit, selection, view - menu-model split: command-groups, builders, types - Gradient stop composable reuse in primitive root Controls: fill, layout, typography, appearance, effects, stroke, okhcl, prop-scrub, node-props, undo-batch, color-variable-binding Variables/i18n/document/export helpers Organize canvas, primitives, controls, editor, and variables into cohesive module directories with package-local import aliases Expose MenuActionNode/MenuSeparatorNode from public API * refactor(app): split document IO, editor session, and automation bridge Document IO: source-state, naming, writer, reload-source, reload-state, imported-document, watch-targets, save-targets Editor session: create, modules, types, accessors, computed, refs Editor canvas: loader-overlay, collaboration-awareness, context-selection, menu-actions, menu-model Automation bridge: eval, tools, exports, files, selection, RPC fallback AI/ACP: transport, map-update, permission, debug, chat effects/storage Collab: awareness, graph-bindings, yjs-sync, follow, session, types Shell keyboard: actions, bindings, clipboard, focus, nudging, raw-events, registry, reserved, shortcuts, space-tool Shell menu: app-menu, document-name, entry, files Demo: colors, effects, helpers, section builders (components, app-preview, effects, standalone, variables) — document.ts reduced from 981 to 32 lines as pure orchestrator Move app modules under src/app/ with organized domain structure: editor, document, ai, collab, shell, automation, demo, tabs * refactor(app): decompose UI components with provide/inject context Split monolithic components using Reka UI-inspired namespace folders with scoped provide/inject context — no prop drilling: - CollabPanel/ — context, avatars, share, connected, join - ColorPickerPanel/ — context, area, format, field groups, sliders - MobileHud/ — context, action toast, tool badge, file menu, presence - ProviderSettings/ — context, API key/type, endpoint, tokens, photos - Toolbar/ — actions, types, desktop, mobile, tool button, flyout - LayoutSection/ — types, auto-layout, flex, grid, padding, size, clip Properties helpers: fill-okhcl adapter, fill-label, color-style-row Menu: entry helpers, document-name rename, stale type removal * refactor(mcp): split server into focused modules - browser-rpc — WebSocket client management - mcp-sessions — session lifecycle - tool-output — response formatting - tool-schema — Zod schema generation from ToolDefs - jsx-preprocess — JSX source transformation - result — result helpers - tool-registration — MCP tool wiring - auth — API key validation - http-options — CORS/request handling - stdio-bridge — stdio transport adapter * refactor(cli): split analyze subcommands and shared helpers - Analyze subcommands: clusters, colors, spacing, typography - RPC data loading helper - Migrate imports to targeted core subpath exports * refactor(docs): split VitePress config and shared table component Config helpers: sdk-sidebar, seo, labels, sidebars, locale-theme, root-theme, locales Shared SdkDataTable component replaces duplicated table markup in SdkPropsTable, SdkEventsTable, and SdkSlotsTable Update contributing and testing docs * refactor(tauri): decompose desktop entrypoint Split lib.rs into focused service modules: - fig_container.rs — .fig archive/compression commands - fonts.rs — font cache and system font enumeration - menu.rs — native menu construction - menu_events.rs — menu event dispatch and devtools toggle - window.rs — main window show/focus lifecycle * test: share domain test factories and migrate fixtures New shared helpers: - tests/helpers/scene.ts — makeSceneGraph factory - tests/helpers/vector-network.ts — vertex/segment/network builders - tests/helpers/fig-traversal.ts — all-node collection, type counts - tests/helpers/undo.ts — undo test utilities - tests/helpers/editor-history.ts — editor history test helpers Migrate render, vector, fig-roundtrip, and undo tests to use shared factories instead of inline fixture construction * build: add structural lint rules, split vite config, update docs Structural lint (oxlint.structure.json + lint/plugin.js): - 20+ custom rules enforcing package boundaries, lifecycle patterns, naming conventions, and import discipline Vite config split: raw-markdown, canvaskit-assets, pwa, server, aliases, automation plugins Remove legacy shims and utils superseded by SDK/core modules Update AGENTS.md, CONTRIBUTING.md, eval-command docs, tsconfig * fix(vue): normalize canvas directory casing and remove duplicate export - Rename Canvas/ to canvas/ in git index to match #vue/canvas/* imports (PascalCase was correct for component primitives but canvas/ is a non-component domain directory) - Remove duplicate ./random subpath export in core package.json * fix: add #vue and #core Vite resolve aliases for dev server * refactor(core): reduce remaining large modules Split the remaining large core hotspots into cohesive domain modules while preserving public facades and behavior. - Extract scene graph types, variables, node defaults, and vector-network helpers - Decompose canvas renderer orchestration, state, paints, colors, lifecycle, labels, and delegated domain methods into renderer/ and labels/ subfolders - Split Kiwi node-change, binary variable binding, layout, RPC, vector, JSX export, clipboard, design JSX, and Figma proxy helpers - Replace collision-driven *Fn import aliases with namespace imports and enforce the pattern in lint Validation: - bun run check - bun --filter @open-pencil/vue build - bun run test:dupes * fix(app): forward color input attrs * fix(app): cover section drawing errors * fix(editor): undo option-drag duplicates * docs: document domain subfolder convention * fix(app): handle undo redo on keydown * refactor(app): dispatch shortcuts from keydown * refactor: group prefixed domain modules * refactor(app): use tinykeys for shortcuts * refactor(core): group symbol override modules * refactor(core): group fig kiwi container helper * refactor(canvas): split overlay rendering modules * refactor(vue): remove unused internal barrels * fix(app): lay out demo components before instancing * fix(app): restore demo badge spacing * perf(canvas): split scene and overlay rendering * refactor(vue): wrap wheel gesture lifecycle * fix(canvas): wait for fonts before hiding loader * docs: update unreleased changelog
2026-04-30 12:14:19 +00:00
import LayoutSection from './properties/LayoutSection/LayoutSection.vue'
import MaskSection from './properties/MaskSection.vue'
import PageSection from './properties/PageSection.vue'
import ConstraintsSection from './properties/constraints/ConstraintsSection.vue'
import PositionSection from './properties/PositionSection.vue'
import SelectionActionsControl from './properties/SelectionActionsControl.vue'
import StrokeSection from './properties/StrokeSection.vue'
import TypographySection from './properties/TypographySection.vue'
import VariablesSection from './properties/VariablesSection.vue'
import ComponentPropertiesSection from './properties/component-properties/ComponentPropertiesSection.vue'
import VariantAuthoringSection from './properties/component-properties/VariantAuthoringSection.vue'
import InstanceUpdateAction from './properties/component-properties/instance-update/InstanceUpdateAction.vue'
import FramePresetsSection from './properties/frame-presets/FramePresetsSection.vue'
import FramePresetSelect from './properties/frame-presets/FramePresetSelect.vue'
const variablesOpen = ref(false)
const store = useEditorStore()
const libraryService = useLibraryService()
const activeTool = computed(() => store.state.activeTool)
const { selectedNode: node, selectedCount: multiCount } = useSelectionState()
const showBooleanOperations = computed(() => multiCount.value >= 2)
const { getCommand } = useEditorCommands()
const goToMainComponent = getCommand('selection.goToMainComponent')
const detachInstance = getCommand('selection.detachInstance')
const isComponentType = computed(() => {
const type = node.value?.type
return type ? COMPONENT_TYPES.has(type) : false
})
const selectedIcon = computed(() => (node.value ? nodeIcon(node.value) : undefined))
function openSelectedInstanceReview() {
const instance = node.value
if (instance?.type !== 'INSTANCE' || !instance.componentId) return
const component = store.graph.getNode(instance.componentId)
const identity = component?.librarySource?.identity
if (!identity) return
openLibraryReview({
libraryId: identity.libraryId,
assetKey: identity.assetKey,
instanceIds: [instance.id],
initialInstanceId: instance.id
})
}
const supportsLayoutGuides = computed(() => {
const type = node.value?.type
return type === 'FRAME' || type === 'COMPONENT' || type === 'COMPONENT_SET' || type === 'INSTANCE'
})
const { panels } = useI18n()
</script>
<template>
<!-- Frame tool presets replace selection properties, matching Figma. -->
<div
v-if="activeTool === 'FRAME'"
class="scrollbar-thin flex-1 overflow-x-hidden overflow-y-auto pb-4"
>
<FramePresetsSection />
</div>
<!-- Multi-select summary -->
<div
v-else-if="multiCount > 1"
data-test-id="design-panel-multi"
class="scrollbar-thin flex-1 overflow-x-hidden overflow-y-auto pb-4"
>
<PanelHeader>
<template #icon>
<icon-lucide-layers-3 class="size-3.5" aria-hidden="true" />
</template>
<span role="heading" aria-level="2">
{{ panels.layersCount({ count: String(multiCount) }) }}
</span>
<template #actions>
<SelectionActionsControl :show-boolean-operations="showBooleanOperations" />
</template>
</PanelHeader>
<ComponentPropertiesSection />
<PositionSection />
<ConstraintsSection />
<AppearanceSection />
<FillSection />
<StrokeSection />
<EffectsSection />
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
<ExportSection />
</div>
<!-- Single selection -->
<div
v-else-if="node"
data-test-id="design-panel-single"
class="scrollbar-thin flex-1 overflow-x-hidden overflow-y-auto pb-4"
>
<PanelHeader :component="isComponentType">
<template #icon>
<Tip :label="node.type">
<span role="img" :aria-label="node.type" class="contents">
<component :is="selectedIcon" class="size-3.5" />
</span>
</Tip>
</template>
<span role="heading" aria-level="2">{{ node.name }}</span>
<template #actions>
<InstanceUpdateAction
v-if="node.type === 'INSTANCE'"
:node="node"
:editor="store"
:service="libraryService"
@review="openSelectedInstanceReview"
/>
<SelectionActionsControl />
</template>
</PanelHeader>
<!-- Component actions -->
<div
v-if="node.type === 'INSTANCE'"
class="flex flex-col gap-1 border-b border-border px-3 py-2"
>
<button
type="button"
class="rounded bg-component/10 px-2 py-1 text-left text-[11px] text-component hover:bg-component/20"
@click="goToMainComponent.run()"
>
{{ panels.goToMainComponent }}
</button>
<button
type="button"
class="rounded px-2 py-1 text-left text-[11px] text-muted hover:bg-hover"
@click="detachInstance.run()"
>
{{ panels.detachInstance }}
</button>
</div>
<ComponentPropertiesSection v-if="node.type === 'INSTANCE'" />
<VariantAuthoringSection
v-if="
node.type === 'COMPONENT_SET' ||
(node.type === 'COMPONENT' &&
node.parentId &&
store.graph.getNode(node.parentId)?.type === 'COMPONENT_SET')
"
/>
<FramePresetSelect v-if="node.type === 'FRAME'" />
<PositionSection />
<ConstraintsSection />
<LayoutSection />
<AppearanceSection />
<MaskSection />
<TypographySection v-if="node.type === 'TEXT'" />
<FillSection />
<StrokeSection />
<LayoutGridSection v-if="supportsLayoutGuides" />
<EffectsSection />
<ExportSection />
</div>
<div
v-else
data-test-id="design-panel-empty"
class="scrollbar-thin flex-1 overflow-x-hidden overflow-y-auto pb-4"
>
<PageSection />
<VariablesSection @open-dialog="variablesOpen = true" />
2026-03-29 16:16:45 +00:00
<ExportSection />
</div>
<VariablesDialog v-model:open="variablesOpen" />
</template>