From 1b7934b98ebf95eaed49f9dcb7cf552b201943d1 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 12:36:03 +0300 Subject: [PATCH] feat(vue): add property panel primitives - Add headless PropertySection and accessible SegmentedControl anatomy - Make PropertyList controlled and exactly typed with an undo-aware editor adapter - Consolidate SDK metadata loaders and link untranslated docs to canonical pages --- .storybook/main.ts | 1 - AGENTS.md | 4 +- CHANGELOG.md | 1 + packages/docs/.vitepress/config.ts | 1 + packages/docs/.vitepress/sdk-sidebar.ts | 8 +- .../docs/.vitepress/sdk/component-meta.ts | 31 ++- .../sdk/api/components/bindable-value.md | 10 - .../sdk/api/components/number-field.md | 10 - .../sdk/api/components/bindable-value.md | 10 - .../sdk/api/components/number-field.md | 10 - .../sdk/api/components/bindable-value.md | 10 - .../sdk/api/components/number-field.md | 10 - .../sdk/api/components/bindable-value.md | 10 - .../sdk/api/components/number-field.md | 10 - packages/docs/package.json | 3 + .../sdk/api/components/bindable-value.md | 10 - .../sdk/api/components/number-field.md | 10 - .../sdk/api/advanced/use-property-list.md | 4 +- .../sdk/api/components/bindable-value.data.ts | 18 +- .../sdk/api/components/bindable-value.md | 2 +- .../programmable/sdk/api/components/index.md | 2 + .../sdk/api/components/number-field.data.ts | 18 +- .../sdk/api/components/number-field.md | 2 +- .../sdk/api/components/property-list-root.md | 78 ++++-- .../sdk/api/components/property-list.data.ts | 11 + .../api/components/property-section.data.ts | 12 + .../sdk/api/components/property-section.md | 49 ++++ .../api/components/segmented-control.data.ts | 8 + .../sdk/api/components/segmented-control.md | 43 ++++ .../docs/programmable/sdk/architecture.md | 7 +- .../sdk/guides/property-panels.md | 20 +- .../sdk/api/components/bindable-value.md | 10 - .../sdk/api/components/number-field.md | 10 - packages/vue/README.md | 10 + .../vue/src/controls/property-list/index.ts | 1 + .../vue/src/controls/property-list/use.ts | 163 +++++++++++++ packages/vue/src/index.ts | 59 ++++- .../vue/src/primitives/BindableValue/types.ts | 10 +- .../PropertyList/PropertyListAdd.vue | 39 +++ .../PropertyList/PropertyListItem.vue | 84 +++++-- .../PropertyList/PropertyListRemove.vue | 39 +++ .../PropertyList/PropertyListRoot.vue | 229 +++++------------- .../PropertyList/PropertyListVisibility.vue | 42 ++++ .../src/primitives/PropertyList/context.ts | 43 ++-- .../vue/src/primitives/PropertyList/index.ts | 19 +- .../vue/src/primitives/PropertyList/types.ts | 86 +++++++ .../PropertyPrimitives.stories.ts | 52 ++++ .../PropertySectionActions.vue | 21 ++ .../PropertySectionContent.vue | 21 ++ .../PropertySectionEmptyAction.vue | 31 +++ .../PropertySection/PropertySectionHeader.vue | 21 ++ .../PropertySection/PropertySectionRoot.vue | 77 ++++++ .../PropertySection/PropertySectionTitle.vue | 21 ++ .../src/primitives/PropertySection/context.ts | 5 + .../demo/PropertyPrimitivesDemo.vue | 153 ++++++++++++ .../src/primitives/PropertySection/index.ts | 16 ++ .../src/primitives/PropertySection/types.ts | 49 ++++ .../SegmentedControl/SegmentedControlItem.vue | 62 +++++ .../SegmentedControl/SegmentedControlRoot.vue | 102 ++++++++ .../primitives/SegmentedControl/context.ts | 5 + .../src/primitives/SegmentedControl/index.ts | 13 + .../src/primitives/SegmentedControl/types.ts | 55 +++++ src/components/properties/EffectsSection.vue | 3 +- src/components/properties/FillSection.vue | 3 +- .../properties/PropertyListRoot.vue | 48 ++++ src/components/properties/StrokeSection.vue | 2 +- src/components/ui/SegmentedControl.vue | 25 +- src/components/ui/panel/PanelSection.vue | 56 ++++- src/theme/panel/section.ts | 3 +- tests/e2e/properties/panel.spec.ts | 14 ++ tests/e2e/properties/visibility.spec.ts | 21 ++ 71 files changed, 1709 insertions(+), 437 deletions(-) delete mode 100644 packages/docs/de/programmable/sdk/api/components/bindable-value.md delete mode 100644 packages/docs/de/programmable/sdk/api/components/number-field.md delete mode 100644 packages/docs/es/programmable/sdk/api/components/bindable-value.md delete mode 100644 packages/docs/es/programmable/sdk/api/components/number-field.md delete mode 100644 packages/docs/fr/programmable/sdk/api/components/bindable-value.md delete mode 100644 packages/docs/fr/programmable/sdk/api/components/number-field.md delete mode 100644 packages/docs/it/programmable/sdk/api/components/bindable-value.md delete mode 100644 packages/docs/it/programmable/sdk/api/components/number-field.md delete mode 100644 packages/docs/pl/programmable/sdk/api/components/bindable-value.md delete mode 100644 packages/docs/pl/programmable/sdk/api/components/number-field.md create mode 100644 packages/docs/programmable/sdk/api/components/property-list.data.ts create mode 100644 packages/docs/programmable/sdk/api/components/property-section.data.ts create mode 100644 packages/docs/programmable/sdk/api/components/property-section.md create mode 100644 packages/docs/programmable/sdk/api/components/segmented-control.data.ts create mode 100644 packages/docs/programmable/sdk/api/components/segmented-control.md delete mode 100644 packages/docs/ru/programmable/sdk/api/components/bindable-value.md delete mode 100644 packages/docs/ru/programmable/sdk/api/components/number-field.md create mode 100644 packages/vue/src/controls/property-list/index.ts create mode 100644 packages/vue/src/controls/property-list/use.ts create mode 100644 packages/vue/src/primitives/PropertyList/PropertyListAdd.vue create mode 100644 packages/vue/src/primitives/PropertyList/PropertyListRemove.vue create mode 100644 packages/vue/src/primitives/PropertyList/PropertyListVisibility.vue create mode 100644 packages/vue/src/primitives/PropertyList/types.ts create mode 100644 packages/vue/src/primitives/PropertySection/PropertyPrimitives.stories.ts create mode 100644 packages/vue/src/primitives/PropertySection/PropertySectionActions.vue create mode 100644 packages/vue/src/primitives/PropertySection/PropertySectionContent.vue create mode 100644 packages/vue/src/primitives/PropertySection/PropertySectionEmptyAction.vue create mode 100644 packages/vue/src/primitives/PropertySection/PropertySectionHeader.vue create mode 100644 packages/vue/src/primitives/PropertySection/PropertySectionRoot.vue create mode 100644 packages/vue/src/primitives/PropertySection/PropertySectionTitle.vue create mode 100644 packages/vue/src/primitives/PropertySection/context.ts create mode 100644 packages/vue/src/primitives/PropertySection/demo/PropertyPrimitivesDemo.vue create mode 100644 packages/vue/src/primitives/PropertySection/index.ts create mode 100644 packages/vue/src/primitives/PropertySection/types.ts create mode 100644 packages/vue/src/primitives/SegmentedControl/SegmentedControlItem.vue create mode 100644 packages/vue/src/primitives/SegmentedControl/SegmentedControlRoot.vue create mode 100644 packages/vue/src/primitives/SegmentedControl/context.ts create mode 100644 packages/vue/src/primitives/SegmentedControl/index.ts create mode 100644 packages/vue/src/primitives/SegmentedControl/types.ts create mode 100644 src/components/properties/PropertyListRoot.vue diff --git a/.storybook/main.ts b/.storybook/main.ts index 2f8c48890..c5ab612a2 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -21,7 +21,6 @@ const config: StorybookConfig = { const excludedPluginPrefixes = [ 'copy-canvaskit-wasm', 'open-pencil-automation', - 'raw-text-assets', 'vite-plugin-pwa' ] diff --git a/AGENTS.md b/AGENTS.md index caac8871e..29baf59b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,8 @@ The app editor session (`src/app/editor/session/create.ts`) is a Vue wrapper aro Headless SDK fields compose variable/token binding through `BindingProvider` and the `BindableValue` primitives in `packages/vue/src/controls/binding-provider/` and `packages/vue/src/primitives/BindableValue/`. Keep numeric interaction in `NumberField`; providers own binding lookup, mutation, and undo batching. +Property-panel anatomy in `packages/vue/src/primitives/PropertySection/`, `SegmentedControl/`, and `PropertyList/` is controlled and editor-agnostic. Connect PropertyList events to OpenPencil selection and undo through `useEditorPropertyList()` or an app adapter; never call `useEditor()` from these primitives. + ## Commands - `bun run check` — type-aware lint + typecheck via oxlint + tsgo + architecture checks (run before committing) @@ -88,7 +90,7 @@ Production Cloudflare Pages deploys are intentionally release/manual only: `app. - `CHANGELOG.md` — all user-facing changes, grouped by version. "Unreleased" section at top for in-progress work. - `README.md` — user-facing: features, getting started, CLI, project structure. No implementation details. - `AGENTS.md` (this file) — contributor/agent reference: architecture, conventions, how to release. -- `packages/docs/` — VitePress site deployed at `openpencil.dev`. User guide, SDK, automation, reference, and development docs. +- `packages/docs/` — VitePress site deployed at `openpencil.dev`. User guide, SDK, automation, reference, and development docs. Do not create English placeholder copies under locale directories; until a real translation exists, localized navigation should link to the canonical English page. When adding features, update `CHANGELOG.md` (Unreleased section) and `README.md` (if user-facing). Update `AGENTS.md` when architecture or conventions change. Do not put speculative/internal implementation plans in `packages/docs/**`; VitePress docs are published. Keep temporary plans in ignored `scratch/` or distill durable public direction into the canonical roadmap. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1600b254c..414822339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Standardize Vue SDK and app override type names on the `UI` acronym, including `FontPickerUI`. - Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, and mixed/bound states; remove the superseded ScrubInput API. - Add provider-driven BindableValue primitives for variable and token binding, including detach-on-edit, read-only, edit-variable, mixed-value, and undo-batched interactions. +- Add headless PropertySection, SegmentedControl, and typed PropertyList anatomy, with controlled list events and an undo-aware OpenPencil adapter. - Upgrade Vue SDK documentation with shared Tailwind demos, source-generated component API tables, and type-aware Twoslash examples in VitePress. - Add desktop image drag-and-drop into the Tauri app window. - Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page. diff --git a/packages/docs/.vitepress/config.ts b/packages/docs/.vitepress/config.ts index 5d62bd440..4c7cd1ef4 100644 --- a/packages/docs/.vitepress/config.ts +++ b/packages/docs/.vitepress/config.ts @@ -51,6 +51,7 @@ export default defineConfig({ vite: { resolve: { alias: { + '#docs': fileURLToPath(new URL('.', import.meta.url)), '#vue': fileURLToPath(new URL('../../vue/src', import.meta.url)) } }, diff --git a/packages/docs/.vitepress/sdk-sidebar.ts b/packages/docs/.vitepress/sdk-sidebar.ts index a5f6e2284..c580ac118 100644 --- a/packages/docs/.vitepress/sdk-sidebar.ts +++ b/packages/docs/.vitepress/sdk-sidebar.ts @@ -8,6 +8,8 @@ const SDK_COMPONENT_PAGES = [ { text: 'ToolbarRoot', slug: 'toolbar-root' }, { text: 'ToolbarItem', slug: 'toolbar-item' }, { text: 'PageListRoot', slug: 'page-list-root' }, + { text: 'PropertySection', slug: 'property-section', canonical: true }, + { text: 'SegmentedControl', slug: 'segmented-control', canonical: true }, { text: 'PropertyListRoot', slug: 'property-list-root' }, { text: 'PropertyListItem', slug: 'property-list-item' }, { text: 'ColorPickerRoot', slug: 'color-picker-root' }, @@ -17,8 +19,8 @@ const SDK_COMPONENT_PAGES = [ { text: 'GradientEditorRoot', slug: 'gradient-editor-root' }, { text: 'GradientEditorBar', slug: 'gradient-editor-bar' }, { text: 'GradientEditorStop', slug: 'gradient-editor-stop' }, - { text: 'NumberField', slug: 'number-field' }, - { text: 'BindableValue', slug: 'bindable-value' }, + { text: 'NumberField', slug: 'number-field', canonical: true }, + { text: 'BindableValue', slug: 'bindable-value', canonical: true }, { text: 'LayoutControlsRoot', slug: 'layout-controls-root' }, { text: 'AppearanceControlsRoot', slug: 'appearance-controls-root' }, { text: 'PositionControlsRoot', slug: 'position-controls-root' }, @@ -89,7 +91,7 @@ export const sdkSidebar = (prefix: string): DefaultTheme.SidebarItem[] => [ { text: 'Overview', link: `${prefix}/programmable/sdk/api/components/` }, ...SDK_COMPONENT_PAGES.map((page) => ({ text: page.text, - link: `${prefix}/programmable/sdk/api/components/${page.slug}` + link: `${'canonical' in page ? '' : prefix}/programmable/sdk/api/components/${page.slug}` })) ] }, diff --git a/packages/docs/.vitepress/sdk/component-meta.ts b/packages/docs/.vitepress/sdk/component-meta.ts index a79c73996..23b1c2dad 100644 --- a/packages/docs/.vitepress/sdk/component-meta.ts +++ b/packages/docs/.vitepress/sdk/component-meta.ts @@ -1,7 +1,9 @@ -import { resolve } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { createChecker } from 'vue-component-meta' +import type { Loader } from 'vitepress' export interface SdkComponentPropMeta { name: string @@ -38,9 +40,34 @@ export interface SdkComponentMeta { exposed: SdkComponentExposeMeta[] } -const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)) +function findWorkspaceRoot(start: string): string { + let directory = start + while (true) { + const packagePath = join(directory, 'package.json') + if (existsSync(packagePath)) { + const packageJSON = JSON.parse(readFileSync(packagePath, 'utf8')) as { workspaces?: unknown } + if (Array.isArray(packageJSON.workspaces)) return directory + } + const parent = dirname(directory) + if (parent === directory) throw new Error('Unable to locate workspace root') + directory = parent + } +} + +const repoRoot = findWorkspaceRoot(fileURLToPath(new URL('.', import.meta.url))) const checker = createChecker(resolve(repoRoot, 'packages/vue/tsconfig.json'), { schema: false }) +export interface SdkComponentData { + components: SdkComponentMeta[] +} + +export function defineComponentMetaLoader(sources: string[]): Loader { + return { + watch: sources.map((source) => resolve(repoRoot, source)), + load: () => ({ components: sources.map(readComponentMeta) }) + } +} + export function readComponentMeta(source: string): SdkComponentMeta { const absoluteSource = resolve(repoRoot, source) const meta = checker.getComponentMeta(absoluteSource) diff --git a/packages/docs/de/programmable/sdk/api/components/bindable-value.md b/packages/docs/de/programmable/sdk/api/components/bindable-value.md deleted file mode 100644 index d5a57a497..000000000 --- a/packages/docs/de/programmable/sdk/api/components/bindable-value.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: BindableValue -description: Provider-driven value binding primitives for custom editor controls. ---- - -# BindableValue - -The generated BindableValue API reference and interactive demo currently share one canonical source. - -[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value) diff --git a/packages/docs/de/programmable/sdk/api/components/number-field.md b/packages/docs/de/programmable/sdk/api/components/number-field.md deleted file mode 100644 index 93427ac4e..000000000 --- a/packages/docs/de/programmable/sdk/api/components/number-field.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: NumberField -description: Headless numeric field with scrubbing, expressions, and keyboard stepping. ---- - -# NumberField - -The generated NumberField API reference and interactive demo currently share one canonical source. - -[Open the NumberField reference](/programmable/sdk/api/components/number-field) diff --git a/packages/docs/es/programmable/sdk/api/components/bindable-value.md b/packages/docs/es/programmable/sdk/api/components/bindable-value.md deleted file mode 100644 index d5a57a497..000000000 --- a/packages/docs/es/programmable/sdk/api/components/bindable-value.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: BindableValue -description: Provider-driven value binding primitives for custom editor controls. ---- - -# BindableValue - -The generated BindableValue API reference and interactive demo currently share one canonical source. - -[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value) diff --git a/packages/docs/es/programmable/sdk/api/components/number-field.md b/packages/docs/es/programmable/sdk/api/components/number-field.md deleted file mode 100644 index 93427ac4e..000000000 --- a/packages/docs/es/programmable/sdk/api/components/number-field.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: NumberField -description: Headless numeric field with scrubbing, expressions, and keyboard stepping. ---- - -# NumberField - -The generated NumberField API reference and interactive demo currently share one canonical source. - -[Open the NumberField reference](/programmable/sdk/api/components/number-field) diff --git a/packages/docs/fr/programmable/sdk/api/components/bindable-value.md b/packages/docs/fr/programmable/sdk/api/components/bindable-value.md deleted file mode 100644 index d5a57a497..000000000 --- a/packages/docs/fr/programmable/sdk/api/components/bindable-value.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: BindableValue -description: Provider-driven value binding primitives for custom editor controls. ---- - -# BindableValue - -The generated BindableValue API reference and interactive demo currently share one canonical source. - -[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value) diff --git a/packages/docs/fr/programmable/sdk/api/components/number-field.md b/packages/docs/fr/programmable/sdk/api/components/number-field.md deleted file mode 100644 index 93427ac4e..000000000 --- a/packages/docs/fr/programmable/sdk/api/components/number-field.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: NumberField -description: Headless numeric field with scrubbing, expressions, and keyboard stepping. ---- - -# NumberField - -The generated NumberField API reference and interactive demo currently share one canonical source. - -[Open the NumberField reference](/programmable/sdk/api/components/number-field) diff --git a/packages/docs/it/programmable/sdk/api/components/bindable-value.md b/packages/docs/it/programmable/sdk/api/components/bindable-value.md deleted file mode 100644 index d5a57a497..000000000 --- a/packages/docs/it/programmable/sdk/api/components/bindable-value.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: BindableValue -description: Provider-driven value binding primitives for custom editor controls. ---- - -# BindableValue - -The generated BindableValue API reference and interactive demo currently share one canonical source. - -[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value) diff --git a/packages/docs/it/programmable/sdk/api/components/number-field.md b/packages/docs/it/programmable/sdk/api/components/number-field.md deleted file mode 100644 index 93427ac4e..000000000 --- a/packages/docs/it/programmable/sdk/api/components/number-field.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: NumberField -description: Headless numeric field with scrubbing, expressions, and keyboard stepping. ---- - -# NumberField - -The generated NumberField API reference and interactive demo currently share one canonical source. - -[Open the NumberField reference](/programmable/sdk/api/components/number-field) diff --git a/packages/docs/package.json b/packages/docs/package.json index fcabf9ad9..506babbfb 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -4,6 +4,9 @@ "license": "MIT", "private": true, "type": "module", + "imports": { + "#docs/*": "./.vitepress/*" + }, "scripts": { "dev": "vitepress dev", "build": "vitepress build", diff --git a/packages/docs/pl/programmable/sdk/api/components/bindable-value.md b/packages/docs/pl/programmable/sdk/api/components/bindable-value.md deleted file mode 100644 index d5a57a497..000000000 --- a/packages/docs/pl/programmable/sdk/api/components/bindable-value.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: BindableValue -description: Provider-driven value binding primitives for custom editor controls. ---- - -# BindableValue - -The generated BindableValue API reference and interactive demo currently share one canonical source. - -[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value) diff --git a/packages/docs/pl/programmable/sdk/api/components/number-field.md b/packages/docs/pl/programmable/sdk/api/components/number-field.md deleted file mode 100644 index 93427ac4e..000000000 --- a/packages/docs/pl/programmable/sdk/api/components/number-field.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: NumberField -description: Headless numeric field with scrubbing, expressions, and keyboard stepping. ---- - -# NumberField - -The generated NumberField API reference and interactive demo currently share one canonical source. - -[Open the NumberField reference](/programmable/sdk/api/components/number-field) diff --git a/packages/docs/programmable/sdk/api/advanced/use-property-list.md b/packages/docs/programmable/sdk/api/advanced/use-property-list.md index 0922f5a71..d82c2acd4 100644 --- a/packages/docs/programmable/sdk/api/advanced/use-property-list.md +++ b/packages/docs/programmable/sdk/api/advanced/use-property-list.md @@ -7,7 +7,9 @@ description: Primitive context helper for PropertyListRoot descendants. `usePropertyList()` reads the local property-list context provided by `PropertyListRoot`. -Use it inside descendants that need current items, mixed-state info, or row-level handlers for fills, strokes, or effects. +Use it inside descendants that need controlled items, mixed-state information, or row-level handlers for fills, strokes, or effects. It does not access an editor. + +OpenPencil editor panels can use `useEditorPropertyList(propKey)` in an adapter component to connect the controlled primitive to selection, undo batching, and multi-node mutations. ## Related APIs diff --git a/packages/docs/programmable/sdk/api/components/bindable-value.data.ts b/packages/docs/programmable/sdk/api/components/bindable-value.data.ts index 5e9a5de55..466606318 100644 --- a/packages/docs/programmable/sdk/api/components/bindable-value.data.ts +++ b/packages/docs/programmable/sdk/api/components/bindable-value.data.ts @@ -1,9 +1,4 @@ -import type { Loader } from 'vitepress' - -import { - readComponentMeta, - type SdkComponentMeta -} from '../../../../.vitepress/sdk/component-meta' +import { defineComponentMetaLoader } from '#docs/sdk/component-meta' const sources = [ 'packages/vue/src/primitives/BindableValue/BindableValueRoot.vue', @@ -11,13 +6,4 @@ const sources = [ 'packages/vue/src/primitives/BindableValue/BindableValuePicker.vue' ] -export interface BindableValueComponentData { - components: SdkComponentMeta[] -} - -export default { - watch: sources.map((source) => `../../../../../../${source}`), - load(): BindableValueComponentData { - return { components: sources.map(readComponentMeta) } - } -} satisfies Loader +export default defineComponentMetaLoader(sources) diff --git a/packages/docs/programmable/sdk/api/components/bindable-value.md b/packages/docs/programmable/sdk/api/components/bindable-value.md index a944ca6ad..8c3820e75 100644 --- a/packages/docs/programmable/sdk/api/components/bindable-value.md +++ b/packages/docs/programmable/sdk/api/components/bindable-value.md @@ -4,7 +4,7 @@ description: Provider-driven value binding primitives for custom editor controls --- diff --git a/packages/docs/programmable/sdk/api/components/index.md b/packages/docs/programmable/sdk/api/components/index.md index 1cb81cd42..ba999b273 100644 --- a/packages/docs/programmable/sdk/api/components/index.md +++ b/packages/docs/programmable/sdk/api/components/index.md @@ -22,6 +22,8 @@ description: Component reference for headless Vue primitives in @open-pencil/vue ## Property panel primitives + + diff --git a/packages/docs/programmable/sdk/api/components/number-field.data.ts b/packages/docs/programmable/sdk/api/components/number-field.data.ts index ff3d85b87..eca21ba07 100644 --- a/packages/docs/programmable/sdk/api/components/number-field.data.ts +++ b/packages/docs/programmable/sdk/api/components/number-field.data.ts @@ -1,9 +1,4 @@ -import type { Loader } from 'vitepress' - -import { - readComponentMeta, - type SdkComponentMeta -} from '../../../../.vitepress/sdk/component-meta' +import { defineComponentMetaLoader } from '#docs/sdk/component-meta' const sources = [ 'packages/vue/src/primitives/NumberField/NumberFieldRoot.vue', @@ -11,13 +6,4 @@ const sources = [ 'packages/vue/src/primitives/NumberField/NumberFieldValue.vue' ] -export interface NumberFieldComponentData { - components: SdkComponentMeta[] -} - -export default { - watch: sources.map((source) => `../../../../../../${source}`), - load(): NumberFieldComponentData { - return { components: sources.map(readComponentMeta) } - } -} satisfies Loader +export default defineComponentMetaLoader(sources) diff --git a/packages/docs/programmable/sdk/api/components/number-field.md b/packages/docs/programmable/sdk/api/components/number-field.md index 51a68a499..23901af72 100644 --- a/packages/docs/programmable/sdk/api/components/number-field.md +++ b/packages/docs/programmable/sdk/api/components/number-field.md @@ -4,7 +4,7 @@ description: Headless numeric field primitives with scrubbing, expressions, and --- diff --git a/packages/docs/programmable/sdk/api/components/property-list-root.md b/packages/docs/programmable/sdk/api/components/property-list-root.md index 7611d12fc..16a7a9e87 100644 --- a/packages/docs/programmable/sdk/api/components/property-list-root.md +++ b/packages/docs/programmable/sdk/api/components/property-list-root.md @@ -1,36 +1,68 @@ --- -title: PropertyListRoot -description: Headless structural primitive for fills, strokes, and effects list UIs. +title: PropertyList +description: Precisely typed headless list anatomy for fills, strokes, and effects. --- -# PropertyListRoot + -`PropertyListRoot` is a headless structural primitive for array-based property editors. +# PropertyList -It is intended for property UIs like: +PropertyList is a controlled, headless list primitive for fills, strokes, and effects. The +`propKey` discriminator gives slots and actions exact `Fill`, `Stroke`, or `Effect` types. Editor +mutation and undo behavior stay in `useEditorPropertyList()` or an application adapter. -- fills -- strokes -- effects +## Anatomy -It provides slot props for: +- `PropertyListRoot` — controlled items, identity, mixed state, and semantic events +- `PropertyListItem` — exact item type plus `data-hidden` and `data-dragging` +- `PropertyListAdd` — adds a typed item +- `PropertyListRemove` — removes an indexed item +- `PropertyListVisibility` — toggles indexed visibility and exposes `aria-pressed` -- current items -- mixed-state detection -- add/remove/update/patch operations -- visibility toggling per item +```vue twoslash + -```vue - -
- -
- -
+ ``` -## Related APIs +See the [PropertySection demo](./property-section) for the shared interactive state matrix. -- [SDK API Overview](../) +## Editor adapter + +OpenPencil panels use `useEditorPropertyList(propKey)` to connect controlled events to selection, +multi-node updates, undo batching, and reordering. Third-party SDK consumers can provide their own +state adapter without an OpenPencil editor context. + +## Generated API reference + + diff --git a/packages/docs/programmable/sdk/api/components/property-list.data.ts b/packages/docs/programmable/sdk/api/components/property-list.data.ts new file mode 100644 index 000000000..4383a3928 --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/property-list.data.ts @@ -0,0 +1,11 @@ +import { defineComponentMetaLoader } from '#docs/sdk/component-meta' + +const sources = [ + 'packages/vue/src/primitives/PropertyList/PropertyListRoot.vue', + 'packages/vue/src/primitives/PropertyList/PropertyListItem.vue', + 'packages/vue/src/primitives/PropertyList/PropertyListAdd.vue', + 'packages/vue/src/primitives/PropertyList/PropertyListRemove.vue', + 'packages/vue/src/primitives/PropertyList/PropertyListVisibility.vue' +] + +export default defineComponentMetaLoader(sources) diff --git a/packages/docs/programmable/sdk/api/components/property-section.data.ts b/packages/docs/programmable/sdk/api/components/property-section.data.ts new file mode 100644 index 000000000..b0c2d89fe --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/property-section.data.ts @@ -0,0 +1,12 @@ +import { defineComponentMetaLoader } from '#docs/sdk/component-meta' + +const sources = [ + 'packages/vue/src/primitives/PropertySection/PropertySectionRoot.vue', + 'packages/vue/src/primitives/PropertySection/PropertySectionHeader.vue', + 'packages/vue/src/primitives/PropertySection/PropertySectionTitle.vue', + 'packages/vue/src/primitives/PropertySection/PropertySectionActions.vue', + 'packages/vue/src/primitives/PropertySection/PropertySectionContent.vue', + 'packages/vue/src/primitives/PropertySection/PropertySectionEmptyAction.vue' +] + +export default defineComponentMetaLoader(sources) diff --git a/packages/docs/programmable/sdk/api/components/property-section.md b/packages/docs/programmable/sdk/api/components/property-section.md new file mode 100644 index 000000000..0a7c99123 --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/property-section.md @@ -0,0 +1,49 @@ +--- +title: PropertySection +description: Headless collapsible anatomy for property-panel sections. +--- + + + +# PropertySection + +PropertySection supplies collapsible section anatomy and canonical open, empty, and disabled state +attributes without imposing presentation. + + + +## Anatomy + +- `PropertySectionRoot` — controlled or uncontrolled Collapsible state +- `PropertySectionHeader` — structural header container +- `PropertySectionTitle` — accessible Collapsible trigger +- `PropertySectionActions` — sibling action area, avoiding nested buttons +- `PropertySectionContent` — collapsible content region +- `PropertySectionEmptyAction` — empty-only action that opens before emitting `activate` + +```vue twoslash + + + +``` + +## Generated API reference + + diff --git a/packages/docs/programmable/sdk/api/components/segmented-control.data.ts b/packages/docs/programmable/sdk/api/components/segmented-control.data.ts new file mode 100644 index 000000000..0469b6258 --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/segmented-control.data.ts @@ -0,0 +1,8 @@ +import { defineComponentMetaLoader } from '#docs/sdk/component-meta' + +const sources = [ + 'packages/vue/src/primitives/SegmentedControl/SegmentedControlRoot.vue', + 'packages/vue/src/primitives/SegmentedControl/SegmentedControlItem.vue' +] + +export default defineComponentMetaLoader(sources) diff --git a/packages/docs/programmable/sdk/api/components/segmented-control.md b/packages/docs/programmable/sdk/api/components/segmented-control.md new file mode 100644 index 000000000..f253c6817 --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/segmented-control.md @@ -0,0 +1,43 @@ +--- +title: SegmentedControl +description: Accessible selection and action-only segmented controls. +--- + + + +# SegmentedControl + +SegmentedControl delegates selection, roving focus, and keyboard behavior to Reka UI. Single and +multiple modes retain selection; action mode emits commands without toggle state. + +## Selection mode + +```vue twoslash + + + +``` + +## Action mode + +Use `mode="action"` for grouped commands such as flip and rotate. Arrow keys move focus, while +Space or Enter emits `action(value)` without leaving a selected segment. + +The complete state matrix is shown in the [PropertySection demo](./property-section). + +## Generated API reference + + diff --git a/packages/docs/programmable/sdk/architecture.md b/packages/docs/programmable/sdk/architecture.md index 593fb1864..7b177a298 100644 --- a/packages/docs/programmable/sdk/architecture.md +++ b/packages/docs/programmable/sdk/architecture.md @@ -28,6 +28,8 @@ This package is organized by domain. - `LayerTree/` - `PageList/` - `PropertyList/` +- `PropertySection/` +- `SegmentedControl/` - `NumberField/` - `Toolbar/` @@ -47,6 +49,7 @@ These contain structural/headless primitives and local helpers. - `useEffectsControls` - `useNodeProps` - `usePropScrub` +- `useEditorPropertyList` ### Variables @@ -82,11 +85,13 @@ Examples: - `PageListRoot` - `PropertyListRoot` +- `PropertySectionRoot` +- `SegmentedControlRoot` - `ToolbarRoot` ### Avoid broad context-dump slots -Prefer focused slot props or direct composable usage over giant `v-slot="ctx"` payloads. +Prefer focused slot props or direct composable usage over giant `v-slot="ctx"` payloads. Controlled primitives such as `PropertyListRoot` emit semantic events; editor selection and undo wiring belongs in an adapter or control composable, not the primitive. ## App vs SDK responsibility diff --git a/packages/docs/programmable/sdk/guides/property-panels.md b/packages/docs/programmable/sdk/guides/property-panels.md index 0e3933cae..9915119da 100644 --- a/packages/docs/programmable/sdk/guides/property-panels.md +++ b/packages/docs/programmable/sdk/guides/property-panels.md @@ -49,19 +49,31 @@ const { x, y, width, height, updateProp, commitProp } = usePosition() ```vue ``` diff --git a/packages/docs/ru/programmable/sdk/api/components/bindable-value.md b/packages/docs/ru/programmable/sdk/api/components/bindable-value.md deleted file mode 100644 index d5a57a497..000000000 --- a/packages/docs/ru/programmable/sdk/api/components/bindable-value.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: BindableValue -description: Provider-driven value binding primitives for custom editor controls. ---- - -# BindableValue - -The generated BindableValue API reference and interactive demo currently share one canonical source. - -[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value) diff --git a/packages/docs/ru/programmable/sdk/api/components/number-field.md b/packages/docs/ru/programmable/sdk/api/components/number-field.md deleted file mode 100644 index 93427ac4e..000000000 --- a/packages/docs/ru/programmable/sdk/api/components/number-field.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: NumberField -description: Headless numeric field with scrubbing, expressions, and keyboard stepping. ---- - -# NumberField - -The generated NumberField API reference and interactive demo currently share one canonical source. - -[Open the NumberField reference](/programmable/sdk/api/components/number-field) diff --git a/packages/vue/README.md b/packages/vue/README.md index 88bc9ebc1..92838fbaf 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -82,6 +82,8 @@ Main structural primitives include: - `LayerTreeRoot` - `PageListRoot` - `PropertyListRoot` +- `PropertySectionRoot` +- `SegmentedControlRoot` - `ToolbarRoot` - `ColorPickerRoot` - `FillPickerRoot` @@ -93,6 +95,8 @@ These components coordinate structure and state, but do not impose app styling. adds pointer scrubbing, Arrow-key stepping, mixed/bound state attributes, and safe arithmetic expressions such as `+10`, `*2`, `50%`, and `12*8+4`. `BindableValue` composes fields with a generic `BindingProvider` and supports detach-on-edit, read-only, and edit-variable policies. +`PropertyListRoot` is controlled and editor-agnostic; OpenPencil panels connect it to selection and +undo through `useEditorPropertyList()`. ## Public API tiers @@ -139,6 +143,11 @@ These are the main APIs most SDK consumers should start with. - `LayerTreeRoot` - `PageListRoot` - `PropertyListRoot` +- `PropertyListItem` +- `PropertyListAdd` / `PropertyListRemove` / `PropertyListVisibility` +- `PropertySectionRoot` / `PropertySectionHeader` / `PropertySectionTitle` +- `PropertySectionActions` / `PropertySectionContent` / `PropertySectionEmptyAction` +- `SegmentedControlRoot` / `SegmentedControlItem` - `ToolbarRoot` - `NumberFieldRoot` - `NumberFieldInput` @@ -156,6 +165,7 @@ These are the main APIs most SDK consumers should start with. These exports are intentionally public, but they are lower-level or more specialized. - `useNodeProps()` +- `useEditorPropertyList()` - `useSceneComputed()` - `useColorVariableBinding()` - `provideBindingProvider()` diff --git a/packages/vue/src/controls/property-list/index.ts b/packages/vue/src/controls/property-list/index.ts new file mode 100644 index 000000000..5ea949658 --- /dev/null +++ b/packages/vue/src/controls/property-list/index.ts @@ -0,0 +1 @@ +export { useEditorPropertyList } from '#vue/controls/property-list/use' diff --git a/packages/vue/src/controls/property-list/use.ts b/packages/vue/src/controls/property-list/use.ts new file mode 100644 index 000000000..ec521df81 --- /dev/null +++ b/packages/vue/src/controls/property-list/use.ts @@ -0,0 +1,163 @@ +import { computed } from 'vue' + +import type { SceneNode } from '@open-pencil/scene-graph' + +import { useNodeProps } from '#vue/controls/node-props/use' +import { useUndoBatch } from '#vue/controls/undo-batch/use' +import { useEditor } from '#vue/editor/context' +import { useSceneComputed } from '#vue/internal/scene-computed/use' +import type { + PropertyListActions, + PropertyListItemFor, + PropertyListKey, + PropertyListPatchFor +} from '#vue/primitives/PropertyList/types.ts' + +function moveItem(items: T[], fromIndex: number, toIndex: number): T[] { + if (fromIndex === toIndex || fromIndex < 0 || fromIndex >= items.length) return items + const next = [...items] + const moved = next.splice(fromIndex, 1)[0] + if (moved === undefined) return items + next.splice(Math.max(0, Math.min(toIndex, next.length)), 0, moved) + return next +} + +export function useEditorPropertyList(propKey: K) { + const editor = useEditor() + const { isArrayMixed } = useNodeProps() + const batch = useUndoBatch(editor.undo) + const selectedNodes = useSceneComputed(() => { + void editor.state.sceneVersion + return editor.getSelectedNodes() + }) + const activeNode = useSceneComputed(() => { + void editor.state.sceneVersion + return selectedNodes.value[0] ?? null + }) + const isMulti = computed(() => selectedNodes.value.length > 1) + const active = computed(() => selectedNodes.value.length > 0) + const isMixed = computed(() => isArrayMixed(propKey)) + const items = useSceneComputed[]>(() => { + void editor.state.sceneVersion + if (isMixed.value) return [] + return (activeNode.value?.[propKey] ?? []) as PropertyListItemFor[] + }) + + function targetNodes(): SceneNode[] { + if (isMulti.value) return selectedNodes.value + return activeNode.value ? [activeNode.value] : [] + } + + function propArray(node: SceneNode): PropertyListItemFor[] { + return node[propKey] as PropertyListItemFor[] + } + + function updateArray(node: SceneNode, value: PropertyListItemFor[], label: string) { + editor.updateNodeWithUndo(node.id, { [propKey]: value } as Partial, label) + } + + function add(item: PropertyListItemFor) { + batch.flush() + const nodes = targetNodes() + const label = isMulti.value ? `Set ${propKey}` : `Add ${propKey}` + const apply = () => { + for (const node of nodes) { + const nextItem = structuredClone(item) + const next = isMulti.value ? [nextItem] : [...propArray(node), nextItem] + updateArray(node, next, label) + } + } + if (nodes.length > 1) editor.undo.runBatch(label, apply) + else apply() + } + + function remove(index: number) { + batch.flush() + const nodes = targetNodes() + const label = `Remove ${propKey}` + const apply = () => { + for (const node of nodes) { + updateArray( + node, + propArray(node).filter((_, itemIndex) => itemIndex !== index), + label + ) + } + } + if (nodes.length > 1) editor.undo.runBatch(label, apply) + else apply() + } + + function update(index: number, item: PropertyListItemFor) { + const nodes = targetNodes() + if (nodes.length === 0) return + batch.ensure( + `update:${propKey}:${index}:${nodes.map((node) => node.id).join(',')}`, + `Change ${propKey}` + ) + for (const node of nodes) { + const next = [...propArray(node)] + next[index] = structuredClone(item) + updateArray(node, next, `Change ${propKey}`) + } + } + + function patch(index: number, changes: PropertyListPatchFor) { + const nodes = targetNodes() + if (nodes.length === 0) return + batch.ensure( + `patch:${propKey}:${index}:${nodes.map((node) => node.id).join(',')}`, + `Change ${propKey}` + ) + for (const node of nodes) { + const current = propArray(node)[index] + if (!current) continue + const next = [...propArray(node)] + next[index] = { ...current, ...structuredClone(changes) } + updateArray(node, next, `Change ${propKey}`) + } + } + + function toggleVisibility(index: number) { + batch.flush() + const nodes = targetNodes() + if (nodes.length === 0) return + const apply = () => { + for (const node of nodes) { + const liveNode = editor.getNode(node.id) + if (!liveNode) continue + const current = propArray(liveNode)[index] + if (!current) continue + const next = [...propArray(liveNode)] + next[index] = { ...current, visible: !current.visible } + updateArray(liveNode, next, `Toggle ${propKey} visibility`) + } + } + if (nodes.length > 1) editor.undo.runBatch(`Toggle ${propKey} visibility`, apply) + else apply() + } + + function reorder(fromIndex: number, toIndex: number) { + batch.flush() + const nodes = targetNodes() + if (nodes.length === 0 || fromIndex === toIndex) return + const apply = () => { + for (const node of nodes) { + updateArray(node, moveItem(propArray(node), fromIndex, toIndex), `Reorder ${propKey}`) + } + } + if (nodes.length > 1) editor.undo.runBatch(`Reorder ${propKey}`, apply) + else apply() + } + + const actions: PropertyListActions = { + add, + remove, + update, + patch, + toggleVisibility, + reorder + } + + return { items, isMixed, isMulti, active, activeNode, actions } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 1f21939da..be7177032 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -141,8 +141,63 @@ export type { LayoutControlsContext } from '#vue/primitives/LayoutControls' export { AppearanceControlsRoot } from '#vue/primitives/AppearanceControls' export { PageListRoot } from '#vue/primitives/PageList' export { PositionControlsRoot } from '#vue/primitives/PositionControls' -export { PropertyListRoot, PropertyListItem, usePropertyList } from '#vue/primitives/PropertyList' -export type { PropertyListContext } from '#vue/primitives/PropertyList' +export { useEditorPropertyList } from '#vue/controls/property-list' +export { + PropertyListRoot, + PropertyListItem, + PropertyListAdd, + PropertyListRemove, + PropertyListVisibility, + usePropertyList +} from '#vue/primitives/PropertyList' +export type { + PropertyListActions, + PropertyListContext, + PropertyListIdentity, + PropertyListItemActions, + PropertyListItemFor, + PropertyListItemMap, + PropertyListItemSlotProps, + PropertyListKey, + PropertyListPartProps, + PropertyListPatchFor, + PropertyListRootProps, + PropertyListRootSlotProps, + PropertyListRootSlots +} from '#vue/primitives/PropertyList' +export { + PropertySectionRoot, + PropertySectionHeader, + PropertySectionTitle, + PropertySectionActions, + PropertySectionContent, + PropertySectionEmptyAction, + usePropertySection +} from '#vue/primitives/PropertySection' +export type { + PropertySectionActionAPI, + PropertySectionContext, + PropertySectionPartProps, + PropertySectionRootProps, + PropertySectionRootSlots, + PropertySectionSlotProps, + PropertySectionStateAttrs +} from '#vue/primitives/PropertySection' +export { + SegmentedControlRoot, + SegmentedControlItem, + useSegmentedControl +} from '#vue/primitives/SegmentedControl' +export type { + SegmentedControlContext, + SegmentedControlItemProps, + SegmentedControlItemSlotProps, + SegmentedControlItemSlots, + SegmentedControlMode, + SegmentedControlOrientation, + SegmentedControlRootProps, + SegmentedControlRootSlots +} from '#vue/primitives/SegmentedControl' export { BindableValueRoot, BindableValueTrigger, diff --git a/packages/vue/src/primitives/BindableValue/types.ts b/packages/vue/src/primitives/BindableValue/types.ts index 6f15c3190..e87f70ba4 100644 --- a/packages/vue/src/primitives/BindableValue/types.ts +++ b/packages/vue/src/primitives/BindableValue/types.ts @@ -1,4 +1,5 @@ -import type { Component, ComputedRef, Ref, VNode } from 'vue' +import type { PrimitiveProps } from 'reka-ui' +import type { ComputedRef, Ref, VNode } from 'vue' import type { Variable } from '@open-pencil/scene-graph' @@ -10,12 +11,7 @@ import type { BoundEditPolicy } from '#vue/controls/binding-provider/types' -export interface BindableValueTriggerProps { - /** Element or component rendered by the trigger. @default 'button' */ - as?: string | Component - /** Merge trigger behavior into the single child element. @default false */ - asChild?: boolean -} +export type BindableValueTriggerProps = PrimitiveProps export interface BindableValueRootProps { /** Binding implementation. Falls back to the nearest injected provider. */ diff --git a/packages/vue/src/primitives/PropertyList/PropertyListAdd.vue b/packages/vue/src/primitives/PropertyList/PropertyListAdd.vue new file mode 100644 index 000000000..2c5a93884 --- /dev/null +++ b/packages/vue/src/primitives/PropertyList/PropertyListAdd.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/vue/src/primitives/PropertyList/PropertyListItem.vue b/packages/vue/src/primitives/PropertyList/PropertyListItem.vue index ab1f4402d..49f45c1a5 100644 --- a/packages/vue/src/primitives/PropertyList/PropertyListItem.vue +++ b/packages/vue/src/primitives/PropertyList/PropertyListItem.vue @@ -1,39 +1,91 @@ - diff --git a/packages/vue/src/primitives/PropertyList/PropertyListRemove.vue b/packages/vue/src/primitives/PropertyList/PropertyListRemove.vue new file mode 100644 index 000000000..d2120737f --- /dev/null +++ b/packages/vue/src/primitives/PropertyList/PropertyListRemove.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/vue/src/primitives/PropertyList/PropertyListRoot.vue b/packages/vue/src/primitives/PropertyList/PropertyListRoot.vue index 31a32030c..4db092e0e 100644 --- a/packages/vue/src/primitives/PropertyList/PropertyListRoot.vue +++ b/packages/vue/src/primitives/PropertyList/PropertyListRoot.vue @@ -1,187 +1,72 @@ - diff --git a/packages/vue/src/primitives/PropertyList/PropertyListVisibility.vue b/packages/vue/src/primitives/PropertyList/PropertyListVisibility.vue new file mode 100644 index 000000000..9c45bcfef --- /dev/null +++ b/packages/vue/src/primitives/PropertyList/PropertyListVisibility.vue @@ -0,0 +1,42 @@ + + + diff --git a/packages/vue/src/primitives/PropertyList/context.ts b/packages/vue/src/primitives/PropertyList/context.ts index 44adcb8b3..88ff344c6 100644 --- a/packages/vue/src/primitives/PropertyList/context.ts +++ b/packages/vue/src/primitives/PropertyList/context.ts @@ -1,32 +1,27 @@ -import { type ComputedRef, type InjectionKey, inject, provide } from 'vue' +import { inject, provide } from 'vue' +import type { InjectionKey } from 'vue' -import type { Editor } from '@open-pencil/core/editor' -import type { SceneNode } from '@open-pencil/scene-graph' +import type { PropertyListContext, PropertyListKey } from './types' -export type ArrayPropKey = 'fills' | 'strokes' | 'effects' +const PROPERTY_LIST_KEY: InjectionKey = Symbol('PropertyList') -export interface PropertyListContext { - editor: Editor - propKey: ArrayPropKey - items: ComputedRef - isMixed: ComputedRef - activeNode: ComputedRef - isMulti: ComputedRef - add: (defaults: T) => void - remove: (index: number) => void - update: (index: number, item: T) => void - patch: (index: number, changes: Partial) => void - toggleVisibility: (index: number) => void +export function providePropertyList(context: PropertyListContext) { + provide(PROPERTY_LIST_KEY, context as PropertyListContext) } -const PROPERTY_LIST_KEY: InjectionKey = Symbol('property-list') - -export function providePropertyList(ctx: PropertyListContext) { - provide(PROPERTY_LIST_KEY, ctx as PropertyListContext) +export function usePropertyList(): PropertyListContext { + const context = inject(PROPERTY_LIST_KEY) + if (!context) + throw new Error('[open-pencil] PropertyList part must be used inside PropertyListRoot') + return context as PropertyListContext } -export function usePropertyList(): PropertyListContext { - const ctx = inject(PROPERTY_LIST_KEY) - if (!ctx) throw new Error('[open-pencil] usePropertyList() called outside ') - return ctx as PropertyListContext +export function usePropertyListPart(propKey: K): PropertyListContext { + const context = usePropertyList() + if (context.propKey !== propKey) { + throw new Error( + `[open-pencil] PropertyList part propKey must match PropertyListRoot (${propKey})` + ) + } + return context } diff --git a/packages/vue/src/primitives/PropertyList/index.ts b/packages/vue/src/primitives/PropertyList/index.ts index 68bd746a6..cb1881f19 100644 --- a/packages/vue/src/primitives/PropertyList/index.ts +++ b/packages/vue/src/primitives/PropertyList/index.ts @@ -1,4 +1,21 @@ export { default as PropertyListRoot } from '#vue/primitives/PropertyList/PropertyListRoot.vue' export { default as PropertyListItem } from '#vue/primitives/PropertyList/PropertyListItem.vue' +export { default as PropertyListAdd } from '#vue/primitives/PropertyList/PropertyListAdd.vue' +export { default as PropertyListRemove } from '#vue/primitives/PropertyList/PropertyListRemove.vue' +export { default as PropertyListVisibility } from '#vue/primitives/PropertyList/PropertyListVisibility.vue' export { usePropertyList } from '#vue/primitives/PropertyList/context' -export type { PropertyListContext } from '#vue/primitives/PropertyList/context' +export type { + PropertyListActions, + PropertyListContext, + PropertyListIdentity, + PropertyListItemActions, + PropertyListItemFor, + PropertyListItemMap, + PropertyListItemSlotProps, + PropertyListKey, + PropertyListPartProps, + PropertyListPatchFor, + PropertyListRootProps, + PropertyListRootSlotProps, + PropertyListRootSlots +} from '#vue/primitives/PropertyList/types.ts' diff --git a/packages/vue/src/primitives/PropertyList/types.ts b/packages/vue/src/primitives/PropertyList/types.ts new file mode 100644 index 000000000..1690c54d4 --- /dev/null +++ b/packages/vue/src/primitives/PropertyList/types.ts @@ -0,0 +1,86 @@ +import type { Component, ComputedRef, VNode } from 'vue' + +import type { Effect, Fill, Stroke } from '@open-pencil/scene-graph' + +export interface PropertyListItemMap { + fills: Fill + strokes: Stroke + effects: Effect +} + +export type PropertyListKey = keyof PropertyListItemMap +export type PropertyListItemFor = PropertyListItemMap[K] +export type PropertyListPatchFor = Partial> +export type PropertyListIdentity = string | number + +export interface PropertyListActions { + add(item: PropertyListItemFor): void + remove(index: number): void + update(index: number, item: PropertyListItemFor): void + patch(index: number, changes: PropertyListPatchFor): void + toggleVisibility(index: number): void + reorder(fromIndex: number, toIndex: number): void +} + +export interface PropertyListContext { + propKey: K + items: ComputedRef[]> + isMixed: ComputedRef + disabled: ComputedRef + keyOf(item: PropertyListItemFor, index: number): PropertyListIdentity + actions: PropertyListActions +} + +export interface PropertyListRootProps { + /** Discriminator that provides exact Fill, Stroke, or Effect types to slots and actions. */ + propKey: K + /** Controlled list items. */ + items: PropertyListItemFor[] + /** Marks values across the current selection as inconsistent. @default false */ + mixed?: boolean + /** Prevents item actions. @default false */ + disabled?: boolean + /** Stable identity for keyed rows. Defaults to the item index. */ + getKey?: (item: PropertyListItemFor, index: number) => PropertyListIdentity + /** Optional accessible label exposed to consumers. */ + label?: string +} + +export interface PropertyListRootSlotProps { + items: PropertyListItemFor[] + isMixed: boolean + disabled: boolean + keyOf(item: PropertyListItemFor, index: number): PropertyListIdentity + actions: PropertyListActions +} + +export interface PropertyListRootSlots { + default?(props: PropertyListRootSlotProps): VNode[] +} + +export interface PropertyListItemActions { + update(item: PropertyListItemFor): void + patch(changes: PropertyListPatchFor): void + remove(): void + toggleVisibility(): void +} + +export interface PropertyListPartProps { + /** Must match the nearest PropertyListRoot and preserves generic inference. */ + propKey: K + /** Element or component rendered by this part. @default 'button' */ + as?: string | Component + /** Merge behavior into the single child element. @default false */ + asChild?: boolean + /** Prevent activation. @default false */ + disabled?: boolean +} + +export interface PropertyListItemSlotProps { + item: PropertyListItemFor | undefined + index: number + hidden: boolean + dragging: boolean + disabled: boolean + actions: PropertyListItemActions +} diff --git a/packages/vue/src/primitives/PropertySection/PropertyPrimitives.stories.ts b/packages/vue/src/primitives/PropertySection/PropertyPrimitives.stories.ts new file mode 100644 index 000000000..fa9c3dafc --- /dev/null +++ b/packages/vue/src/primitives/PropertySection/PropertyPrimitives.stories.ts @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { expect, userEvent, within } from 'storybook/test' + +import PropertyPrimitivesDemo from './demo/PropertyPrimitivesDemo.vue' + +const meta = { + title: 'Vue SDK/Primitives/Property Primitives', + component: PropertyPrimitivesDemo, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'Headless PropertySection, SegmentedControl, and typed PropertyList composition states.' + } + } + } +} satisfies Meta + +export default meta +type Story = StoryObj + +export const StateMatrix: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + const layer = canvas.getByRole('button', { name: 'Layer' }) + await expect(canvas.getByText('Collapsible content')).toBeVisible() + await userEvent.click(layer) + await expect(layer).toHaveAttribute('data-state', 'closed') + + await userEvent.click(canvas.getByRole('button', { name: 'Add first effect' })) + await expect(canvas.getByText('Drop shadow')).toBeVisible() + + const center = canvas.getByRole('button', { name: 'center' }) + await userEvent.click(center) + await expect(center).toHaveAttribute('data-state', 'on') + await userEvent.keyboard('{ArrowRight}{Enter}') + await expect(canvas.getByRole('button', { name: 'right' })).toHaveAttribute('data-state', 'on') + + await userEvent.click(canvas.getByRole('button', { name: 'rotate-90' })) + await expect(canvas.getByText('Action: rotate-90')).toBeVisible() + + const hiddenItem = canvas.getByText('Fill 2').closest('[data-slot="item"]') + await expect(hiddenItem).toHaveAttribute('data-hidden') + await userEvent.click(canvas.getByRole('button', { name: 'Show' })) + await expect(hiddenItem).not.toHaveAttribute('data-hidden') + + await userEvent.click(canvas.getByRole('button', { name: 'Add fill' })) + await expect(canvas.getByText('Fill 3')).toBeVisible() + } +} diff --git a/packages/vue/src/primitives/PropertySection/PropertySectionActions.vue b/packages/vue/src/primitives/PropertySection/PropertySectionActions.vue new file mode 100644 index 000000000..b9a744bd2 --- /dev/null +++ b/packages/vue/src/primitives/PropertySection/PropertySectionActions.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/vue/src/primitives/PropertySection/PropertySectionContent.vue b/packages/vue/src/primitives/PropertySection/PropertySectionContent.vue new file mode 100644 index 000000000..c2375897e --- /dev/null +++ b/packages/vue/src/primitives/PropertySection/PropertySectionContent.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/vue/src/primitives/PropertySection/PropertySectionEmptyAction.vue b/packages/vue/src/primitives/PropertySection/PropertySectionEmptyAction.vue new file mode 100644 index 000000000..ad086b2c0 --- /dev/null +++ b/packages/vue/src/primitives/PropertySection/PropertySectionEmptyAction.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/vue/src/primitives/PropertySection/PropertySectionHeader.vue b/packages/vue/src/primitives/PropertySection/PropertySectionHeader.vue new file mode 100644 index 000000000..eed186700 --- /dev/null +++ b/packages/vue/src/primitives/PropertySection/PropertySectionHeader.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/vue/src/primitives/PropertySection/PropertySectionRoot.vue b/packages/vue/src/primitives/PropertySection/PropertySectionRoot.vue new file mode 100644 index 000000000..3b3079ff4 --- /dev/null +++ b/packages/vue/src/primitives/PropertySection/PropertySectionRoot.vue @@ -0,0 +1,77 @@ + + + diff --git a/packages/vue/src/primitives/PropertySection/PropertySectionTitle.vue b/packages/vue/src/primitives/PropertySection/PropertySectionTitle.vue new file mode 100644 index 000000000..dc089a51d --- /dev/null +++ b/packages/vue/src/primitives/PropertySection/PropertySectionTitle.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/vue/src/primitives/PropertySection/context.ts b/packages/vue/src/primitives/PropertySection/context.ts new file mode 100644 index 000000000..f2c024992 --- /dev/null +++ b/packages/vue/src/primitives/PropertySection/context.ts @@ -0,0 +1,5 @@ +import { createContext } from '#vue/internal/create-context' +import type { PropertySectionContext } from '#vue/primitives/PropertySection/types' + +export const [usePropertySection, providePropertySection] = + createContext('PropertySection') diff --git a/packages/vue/src/primitives/PropertySection/demo/PropertyPrimitivesDemo.vue b/packages/vue/src/primitives/PropertySection/demo/PropertyPrimitivesDemo.vue new file mode 100644 index 000000000..b0237466b --- /dev/null +++ b/packages/vue/src/primitives/PropertySection/demo/PropertyPrimitivesDemo.vue @@ -0,0 +1,153 @@ + + + diff --git a/packages/vue/src/primitives/PropertySection/index.ts b/packages/vue/src/primitives/PropertySection/index.ts new file mode 100644 index 000000000..29fbc3c6b --- /dev/null +++ b/packages/vue/src/primitives/PropertySection/index.ts @@ -0,0 +1,16 @@ +export { default as PropertySectionRoot } from '#vue/primitives/PropertySection/PropertySectionRoot.vue' +export { default as PropertySectionHeader } from '#vue/primitives/PropertySection/PropertySectionHeader.vue' +export { default as PropertySectionTitle } from '#vue/primitives/PropertySection/PropertySectionTitle.vue' +export { default as PropertySectionActions } from '#vue/primitives/PropertySection/PropertySectionActions.vue' +export { default as PropertySectionContent } from '#vue/primitives/PropertySection/PropertySectionContent.vue' +export { default as PropertySectionEmptyAction } from '#vue/primitives/PropertySection/PropertySectionEmptyAction.vue' +export { usePropertySection } from '#vue/primitives/PropertySection/context' +export type { + PropertySectionActionAPI, + PropertySectionContext, + PropertySectionPartProps, + PropertySectionRootProps, + PropertySectionRootSlots, + PropertySectionSlotProps, + PropertySectionStateAttrs +} from '#vue/primitives/PropertySection/types' diff --git a/packages/vue/src/primitives/PropertySection/types.ts b/packages/vue/src/primitives/PropertySection/types.ts new file mode 100644 index 000000000..a7d02e048 --- /dev/null +++ b/packages/vue/src/primitives/PropertySection/types.ts @@ -0,0 +1,49 @@ +import type { PrimitiveProps } from 'reka-ui' +import type { ComputedRef, VNode } from 'vue' + +export interface PropertySectionRootProps { + /** Controlled expanded state. */ + open?: boolean + /** Initial expanded state when uncontrolled. @default true */ + defaultOpen?: boolean + /** Marks the section as having no current items. @default false */ + empty?: boolean + /** Prevents the section from being toggled. @default false */ + disabled?: boolean + /** Keep collapsed content mounted in the DOM. @default false */ + unmountOnHide?: boolean +} + +export type PropertySectionPartProps = PrimitiveProps + +export interface PropertySectionStateAttrs { + 'data-state': 'open' | 'closed' + 'data-empty'?: '' + 'data-disabled'?: '' +} + +export interface PropertySectionActionAPI { + open(): void + close(): void + toggle(): void +} + +export interface PropertySectionSlotProps { + open: boolean + empty: boolean + stateAttrs: PropertySectionStateAttrs + actions: PropertySectionActionAPI +} + +export interface PropertySectionRootSlots { + default(props: PropertySectionSlotProps): VNode[] +} + +export interface PropertySectionContext { + open: ComputedRef + empty: ComputedRef + disabled: ComputedRef + stateAttrs: ComputedRef + slotProps: ComputedRef + actions: PropertySectionActionAPI +} diff --git a/packages/vue/src/primitives/SegmentedControl/SegmentedControlItem.vue b/packages/vue/src/primitives/SegmentedControl/SegmentedControlItem.vue new file mode 100644 index 000000000..d6d12d755 --- /dev/null +++ b/packages/vue/src/primitives/SegmentedControl/SegmentedControlItem.vue @@ -0,0 +1,62 @@ + + + diff --git a/packages/vue/src/primitives/SegmentedControl/SegmentedControlRoot.vue b/packages/vue/src/primitives/SegmentedControl/SegmentedControlRoot.vue new file mode 100644 index 000000000..72fb54049 --- /dev/null +++ b/packages/vue/src/primitives/SegmentedControl/SegmentedControlRoot.vue @@ -0,0 +1,102 @@ + + + diff --git a/packages/vue/src/primitives/SegmentedControl/context.ts b/packages/vue/src/primitives/SegmentedControl/context.ts new file mode 100644 index 000000000..7ce968990 --- /dev/null +++ b/packages/vue/src/primitives/SegmentedControl/context.ts @@ -0,0 +1,5 @@ +import { createContext } from '#vue/internal/create-context' +import type { SegmentedControlContext } from '#vue/primitives/SegmentedControl/types' + +export const [useSegmentedControl, provideSegmentedControl] = + createContext('SegmentedControl') diff --git a/packages/vue/src/primitives/SegmentedControl/index.ts b/packages/vue/src/primitives/SegmentedControl/index.ts new file mode 100644 index 000000000..30ec5bc07 --- /dev/null +++ b/packages/vue/src/primitives/SegmentedControl/index.ts @@ -0,0 +1,13 @@ +export { default as SegmentedControlRoot } from '#vue/primitives/SegmentedControl/SegmentedControlRoot.vue' +export { default as SegmentedControlItem } from '#vue/primitives/SegmentedControl/SegmentedControlItem.vue' +export { useSegmentedControl } from '#vue/primitives/SegmentedControl/context' +export type { + SegmentedControlContext, + SegmentedControlItemProps, + SegmentedControlItemSlotProps, + SegmentedControlItemSlots, + SegmentedControlMode, + SegmentedControlOrientation, + SegmentedControlRootProps, + SegmentedControlRootSlots +} from '#vue/primitives/SegmentedControl/types' diff --git a/packages/vue/src/primitives/SegmentedControl/types.ts b/packages/vue/src/primitives/SegmentedControl/types.ts new file mode 100644 index 000000000..b7d9710d8 --- /dev/null +++ b/packages/vue/src/primitives/SegmentedControl/types.ts @@ -0,0 +1,55 @@ +import type { Component, ComputedRef, VNode } from 'vue' + +export type SegmentedControlMode = 'single' | 'multiple' | 'action' +export type SegmentedControlOrientation = 'horizontal' | 'vertical' + +export interface SegmentedControlRootProps { + /** Selection behavior or stateless action behavior. @default 'single' */ + mode?: SegmentedControlMode + /** Controlled selected value or values. */ + modelValue?: string | string[] + /** Arrow-key navigation axis. @default 'horizontal' */ + orientation?: SegmentedControlOrientation + /** Disable every item. @default false */ + disabled?: boolean + /** Require a value in single-selection mode. @default false */ + required?: boolean + /** Enable arrow-key roving focus. @default true */ + rovingFocus?: boolean + /** Wrap keyboard focus at the first and last item. @default true */ + loop?: boolean +} + +export interface SegmentedControlItemProps { + /** Stable selection or action identifier. */ + value: string + /** Disable this item. @default false */ + disabled?: boolean + /** Element or component rendered by this item. @default 'button' */ + as?: string | Component + /** Merge item behavior into the single child element. @default false */ + asChild?: boolean +} + +export interface SegmentedControlItemSlotProps { + value: string + selected: boolean + disabled: boolean + mode: SegmentedControlMode +} + +export interface SegmentedControlRootSlots { + default(props: { mode: SegmentedControlMode; modelValue: string | string[] | undefined }): VNode[] +} + +export interface SegmentedControlItemSlots { + default(props: SegmentedControlItemSlotProps): VNode[] +} + +export interface SegmentedControlContext { + mode: ComputedRef + modelValue: ComputedRef + disabled: ComputedRef + selected(value: string): boolean + activate(value: string): void +} diff --git a/src/components/properties/EffectsSection.vue b/src/components/properties/EffectsSection.vue index 420f6981c..6fb43efc3 100644 --- a/src/components/properties/EffectsSection.vue +++ b/src/components/properties/EffectsSection.vue @@ -5,7 +5,8 @@ import NumberField from '@/components/inputs/NumberField.vue' import IconButton from '@/components/ui/IconButton.vue' import PanelSection from '@/components/ui/panel/PanelSection.vue' import Tip from '@/components/ui/Tip.vue' -import { PropertyListRoot, vTestId, useEffectsControls, useI18n } from '@open-pencil/vue' +import PropertyListRoot from '@/components/properties/PropertyListRoot.vue' +import { vTestId, useEffectsControls, useI18n } from '@open-pencil/vue' import { colorToCSS } from '@open-pencil/core/color' diff --git a/src/components/properties/FillSection.vue b/src/components/properties/FillSection.vue index 89f319fbb..d0f11c3bd 100644 --- a/src/components/properties/FillSection.vue +++ b/src/components/properties/FillSection.vue @@ -1,8 +1,9 @@ + + diff --git a/src/components/properties/StrokeSection.vue b/src/components/properties/StrokeSection.vue index 94e4227a6..c9c430f6d 100644 --- a/src/components/properties/StrokeSection.vue +++ b/src/components/properties/StrokeSection.vue @@ -3,7 +3,6 @@ import { ref } from 'vue' import { applySolidStrokeColor, - PropertyListRoot, useColorVariableBinding, useStrokeControls, useOkHCL, @@ -11,6 +10,7 @@ import { } from '@open-pencil/vue' import ColorStyleRow from '@/components/properties/ColorStyleRow.vue' +import PropertyListRoot from '@/components/properties/PropertyListRoot.vue' import { boundVariableColor } from '@/components/properties/color-style-row' import AppSelect from '@/components/ui/AppSelect.vue' import ColorInput from '@/components/ColorPicker/ColorInput.vue' diff --git a/src/components/ui/SegmentedControl.vue b/src/components/ui/SegmentedControl.vue index 18bfcfb7f..516bd5603 100644 --- a/src/components/ui/SegmentedControl.vue +++ b/src/components/ui/SegmentedControl.vue @@ -28,6 +28,7 @@ export interface SegmentedControlSlots { diff --git a/src/components/ui/panel/PanelSection.vue b/src/components/ui/panel/PanelSection.vue index 68d97d1d4..cd0d6d88c 100644 --- a/src/components/ui/panel/PanelSection.vue +++ b/src/components/ui/panel/PanelSection.vue @@ -7,6 +7,9 @@ import type { PanelSectionTheme } from '@/theme/panel/section' export interface PanelSectionProps { label: string + open?: boolean + defaultOpen?: boolean + empty?: boolean class?: ClassValue ui?: ComponentUI } @@ -14,31 +17,62 @@ export interface PanelSectionProps { export interface PanelSectionSlots { default(): VNode[] actions?(): VNode[] + emptyAction?(): VNode[] } diff --git a/src/theme/panel/section.ts b/src/theme/panel/section.ts index 111142835..336032c64 100644 --- a/src/theme/panel/section.ts +++ b/src/theme/panel/section.ts @@ -2,7 +2,8 @@ const panelSectionTheme = { slots: { root: 'border-b border-border px-panel-x py-panel-y text-surface', header: 'mb-panel grid min-w-0 items-center gap-panel', - title: 'min-w-0 truncate text-[11px] leading-none font-semibold text-surface', + title: + 'min-w-0 cursor-pointer truncate border-0 bg-transparent p-0 text-left text-[11px] leading-none font-semibold text-surface', actions: 'flex h-control w-panel-rail shrink-0 items-center justify-end gap-0.5 [&_[data-slot=icon-button]]:size-control [&_[data-slot=icon-button]]:rounded-panel', body: 'min-w-0' diff --git a/tests/e2e/properties/panel.spec.ts b/tests/e2e/properties/panel.spec.ts index 7c0e9c828..00e3d2f19 100644 --- a/tests/e2e/properties/panel.spec.ts +++ b/tests/e2e/properties/panel.spec.ts @@ -4,6 +4,20 @@ import { getPageChildren, getSelectedNode } from '#tests/helpers/store' const editor = useEditorSetup() +test('property sections collapse and reopen from their title', async () => { + await editor.canvas.clearCanvas() + await editor.canvas.drawRect(200, 200, 80, 80) + + const title = editor.page.getByRole('button', { name: 'Appearance' }) + const blendMode = editor.page.getByTestId('appearance-blend-mode') + await expect(blendMode).toBeVisible() + await title.click() + await expect(blendMode).toBeHidden() + await expect(title).toHaveAttribute('data-state', 'closed') + await title.click() + await expect(blendMode).toBeVisible() +}) + test('NumberField drag changes X position', async () => { await editor.canvas.clearCanvas() await editor.canvas.drawRect(100, 100, 80, 80) diff --git a/tests/e2e/properties/visibility.spec.ts b/tests/e2e/properties/visibility.spec.ts index 301eab73f..a7bd98fb1 100644 --- a/tests/e2e/properties/visibility.spec.ts +++ b/tests/e2e/properties/visibility.spec.ts @@ -70,6 +70,27 @@ test('stroke visibility supports repeat click and undo redo', async () => { ).toBe(true) }) +test('multi-selection list add is one undo step', async () => { + await editor.canvas.clearCanvas() + await editor.canvas.drawRect(100, 100, 80, 80) + await editor.canvas.drawRect(240, 100, 80, 80) + await editor.canvas.pressKey('Meta+a') + + const strokeCounts = () => + editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + return [...store.state.selectedIds].map((id) => store.getNode(id)?.strokes.length ?? -1) + }) + + await editor.page.getByTestId('stroke-section-add').click() + await editor.canvas.waitForRender() + expect(await strokeCounts()).toEqual([1, 1]) + + await editor.canvas.undo() + expect(await strokeCounts()).toEqual([0, 0]) +}) + test('appearance visibility supports repeat click and undo redo in one step', async () => { const visibilityButton = editor.page.getByTestId('appearance-visibility') await expect(visibilityButton).toBeVisible()