feat(app): migrate position and appearance panels
- Move independent-corner presentation and batched mutations into AppearanceControls - Rebuild Position and Appearance with aligned panel grids and semantic locators - Replace type text headers with compact node icons and add visual coverage
This commit is contained in:
parent
1d60d3a407
commit
12031b76c9
|
|
@ -18,6 +18,7 @@
|
|||
- 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.
|
||||
- Refine variable-bound number fields with a quiet identity pill, one picker affordance, an accessible variable combobox, and non-destructive focus behavior.
|
||||
- Redesign Position and Appearance controls with aligned panel grids, SDK-owned independent-corner state, and compact type-icon selection headers.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
|
||||
|
||||
export default defineComponentMetaLoader([
|
||||
'packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue'
|
||||
])
|
||||
|
|
@ -1,13 +1,29 @@
|
|||
---
|
||||
title: AppearanceControlsRoot
|
||||
description: Headless root primitive for opacity, visibility, and corner-radius controls.
|
||||
description: Headless root primitive for opacity, visibility, blend mode, and corner-radius controls.
|
||||
---
|
||||
|
||||
<script setup lang="ts">
|
||||
import { data } from './appearance-controls-root.data'
|
||||
</script>
|
||||
|
||||
# AppearanceControlsRoot
|
||||
|
||||
`AppearanceControlsRoot` exposes the slot contract returned by `useAppearance()` as a structural primitive.
|
||||
`AppearanceControlsRoot` exposes the slot contract returned by `useAppearance()` as a structural
|
||||
primitive. Use it when you want reusable appearance controls with custom presentation.
|
||||
|
||||
Use it when you want reusable appearance controls with custom presentation.
|
||||
The root owns selection-derived presentation decisions, including `showIndependentCorners`.
|
||||
That state becomes active when the selected node explicitly uses independent corners or when an
|
||||
imported node contains unequal corner values with a stale uniform flag. Consumers should render
|
||||
from this state rather than maintaining a parallel local expansion ref.
|
||||
|
||||
Multi-node independent-corner toggles and per-corner commits are grouped into one undo entry.
|
||||
|
||||
## Generated API reference
|
||||
|
||||
The following tables are extracted from the Vue source and JSDoc during the documentation build.
|
||||
|
||||
<SdkComponentAPI :components="data.components" />
|
||||
|
||||
## Related APIs
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ It exposes selection-derived UI state for:
|
|||
- visibility
|
||||
- opacity
|
||||
- corner radius
|
||||
- independent corner radii
|
||||
- independent corner radii, including imported unequal-corner state
|
||||
- blend mode
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ const {
|
|||
visibilityState,
|
||||
opacityPercent,
|
||||
cornerRadiusValue,
|
||||
showIndependentCorners,
|
||||
toggleVisibility,
|
||||
toggleIndependentCorners,
|
||||
} = useAppearance()
|
||||
|
|
@ -49,6 +51,10 @@ appearance.updateCornerProp('topLeftRadius', 12)
|
|||
appearance.commitCornerProp('topLeftRadius', 12, 8)
|
||||
```
|
||||
|
||||
Render the per-corner editor from `showIndependentCorners`. It accounts for both the explicit
|
||||
scene-node flag and imported nodes whose corner values differ. Multi-selection toggles and commits
|
||||
are grouped into one undo entry.
|
||||
|
||||
## Related APIs
|
||||
|
||||
- [SDK API Overview](../)
|
||||
|
|
|
|||
|
|
@ -96,8 +96,10 @@ adds pointer scrubbing, Arrow-key stepping, mixed/bound state attributes, and sa
|
|||
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.
|
||||
Focusing a bound NumberField is non-destructive; the configured policy begins only on the first
|
||||
value mutation. `PropertyListRoot` is controlled and editor-agnostic; OpenPencil panels connect it to selection and
|
||||
undo through `useEditorPropertyList()`.
|
||||
value mutation. `AppearanceControlsRoot` exposes selection-derived independent-corner presentation
|
||||
state so consumers do not need parallel expansion heuristics. `PropertyListRoot` is controlled and
|
||||
editor-agnostic; OpenPencil panels connect it to selection and undo through
|
||||
`useEditorPropertyList()`.
|
||||
|
||||
## Public API tiers
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { ComputedRef } from 'vue'
|
|||
import type { Editor } from '@open-pencil/core/editor'
|
||||
import type { BlendMode, SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import type { CornerRadiusKey } from '#vue/controls/appearance/types'
|
||||
import { MIXED, type MixedValue } from '#vue/controls/node-props/use'
|
||||
|
||||
const CORNER_RADIUS_TYPES = new Set([
|
||||
|
|
@ -25,6 +26,14 @@ type AppearanceActionOptions = AppearanceStateOptions & {
|
|||
editor: Editor
|
||||
}
|
||||
|
||||
function hasUnequalCorners(node: SceneNode) {
|
||||
return !(
|
||||
node.topLeftRadius === node.topRightRadius &&
|
||||
node.topLeftRadius === node.bottomRightRadius &&
|
||||
node.topLeftRadius === node.bottomLeftRadius
|
||||
)
|
||||
}
|
||||
|
||||
export function createAppearanceState({ node, nodes, isMulti, merged }: AppearanceStateOptions) {
|
||||
const hasCornerRadius = computed(() => {
|
||||
if (isMulti.value) return nodes.value.every((n) => CORNER_RADIUS_TYPES.has(n.type))
|
||||
|
|
@ -36,6 +45,12 @@ export function createAppearanceState({ node, nodes, isMulti, merged }: Appearan
|
|||
return node.value?.independentCorners ?? false
|
||||
})
|
||||
|
||||
const showIndependentCorners = computed(() => {
|
||||
if (isMulti.value) return false
|
||||
const selected = node.value
|
||||
return selected ? selected.independentCorners || hasUnequalCorners(selected) : false
|
||||
})
|
||||
|
||||
const cornerRadiusValue = computed(() => {
|
||||
if (isMulti.value) return merged('cornerRadius')
|
||||
return node.value?.cornerRadius ?? 0
|
||||
|
|
@ -60,6 +75,7 @@ export function createAppearanceState({ node, nodes, isMulti, merged }: Appearan
|
|||
return {
|
||||
hasCornerRadius,
|
||||
independentCorners,
|
||||
showIndependentCorners,
|
||||
cornerRadiusValue,
|
||||
opacityPercent,
|
||||
blendModeValue,
|
||||
|
|
@ -106,40 +122,51 @@ export function createAppearanceActions({ editor, node, nodes, isMulti }: Appear
|
|||
|
||||
function toggleIndependentCorners() {
|
||||
const selected = node.value
|
||||
const singleTarget = selected ? [selected] : []
|
||||
const targets = isMulti.value ? nodes.value : singleTarget
|
||||
for (const n of targets) {
|
||||
if (n.independentCorners) {
|
||||
const uniform = n.topLeftRadius
|
||||
editor.updateNodeWithUndo(
|
||||
n.id,
|
||||
{
|
||||
independentCorners: false,
|
||||
cornerRadius: uniform,
|
||||
topLeftRadius: uniform,
|
||||
topRightRadius: uniform,
|
||||
bottomRightRadius: uniform,
|
||||
bottomLeftRadius: uniform
|
||||
} as Partial<SceneNode>,
|
||||
'Uniform corner radius'
|
||||
)
|
||||
} else {
|
||||
editor.updateNodeWithUndo(
|
||||
n.id,
|
||||
{
|
||||
independentCorners: true,
|
||||
topLeftRadius: n.cornerRadius,
|
||||
topRightRadius: n.cornerRadius,
|
||||
bottomRightRadius: n.cornerRadius,
|
||||
bottomLeftRadius: n.cornerRadius
|
||||
} as Partial<SceneNode>,
|
||||
'Independent corner radii'
|
||||
)
|
||||
const targets = isMulti.value ? [...nodes.value] : []
|
||||
if (!isMulti.value && selected) targets.push(selected)
|
||||
if (targets.length === 0) return
|
||||
const makeIndependent = !targets.every(
|
||||
(target) => target.independentCorners || hasUnequalCorners(target)
|
||||
)
|
||||
|
||||
editor.undo.runBatch(
|
||||
makeIndependent ? 'Independent corner radii' : 'Uniform corner radius',
|
||||
() => {
|
||||
for (const target of targets) {
|
||||
if (makeIndependent) {
|
||||
if (target.independentCorners) continue
|
||||
editor.updateNodeWithUndo(
|
||||
target.id,
|
||||
{
|
||||
independentCorners: true,
|
||||
topLeftRadius: target.cornerRadius,
|
||||
topRightRadius: target.cornerRadius,
|
||||
bottomRightRadius: target.cornerRadius,
|
||||
bottomLeftRadius: target.cornerRadius
|
||||
} as Partial<SceneNode>,
|
||||
'Independent corner radii'
|
||||
)
|
||||
} else {
|
||||
const uniform = target.topLeftRadius
|
||||
editor.updateNodeWithUndo(
|
||||
target.id,
|
||||
{
|
||||
independentCorners: false,
|
||||
cornerRadius: uniform,
|
||||
topLeftRadius: uniform,
|
||||
topRightRadius: uniform,
|
||||
bottomRightRadius: uniform,
|
||||
bottomLeftRadius: uniform
|
||||
} as Partial<SceneNode>,
|
||||
'Uniform corner radius'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function updateCornerProp(key: string, value: number) {
|
||||
function updateCornerProp(key: CornerRadiusKey, value: number) {
|
||||
if (isMulti.value) {
|
||||
for (const n of nodes.value) editor.updateNode(n.id, { [key]: value })
|
||||
} else {
|
||||
|
|
@ -148,11 +175,13 @@ export function createAppearanceActions({ editor, node, nodes, isMulti }: Appear
|
|||
}
|
||||
}
|
||||
|
||||
function commitCornerProp(key: string, _value: number, previous: number) {
|
||||
function commitCornerProp(key: CornerRadiusKey, _value: number, previous: number) {
|
||||
if (isMulti.value) {
|
||||
for (const n of nodes.value) {
|
||||
editor.commitNodeUpdate(n.id, { [key]: previous } as Partial<SceneNode>, `Change ${key}`)
|
||||
}
|
||||
editor.undo.runBatch(`Change ${key}`, () => {
|
||||
for (const n of nodes.value) {
|
||||
editor.commitNodeUpdate(n.id, { [key]: previous } as Partial<SceneNode>, `Change ${key}`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const n = node.value
|
||||
if (n) {
|
||||
|
|
|
|||
5
packages/vue/src/controls/appearance/types.ts
Normal file
5
packages/vue/src/controls/appearance/types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type CornerRadiusKey =
|
||||
| 'topLeftRadius'
|
||||
| 'topRightRadius'
|
||||
| 'bottomRightRadius'
|
||||
| 'bottomLeftRadius'
|
||||
|
|
@ -63,7 +63,7 @@ export function createNodePropSelectionState(store: Editor) {
|
|||
return store.getSelectedNodes()
|
||||
})
|
||||
const isMulti = computed(() => nodes.value.length > 1)
|
||||
const active = computed(() => node.value || isMulti.value)
|
||||
const active = computed(() => node.value !== null || isMulti.value)
|
||||
const activeNode = computed(() => node.value ?? (nodes.value[0] as SceneNode | undefined) ?? null)
|
||||
|
||||
function merged<K extends keyof SceneNode>(key: K): MixedValue<SceneNode[K]> {
|
||||
|
|
|
|||
|
|
@ -139,6 +139,12 @@ export type { LayerDragInstruction, LayerTreeContext, LayerNode } from '#vue/pri
|
|||
export { LayoutControlsRoot, useLayoutControlsContext } from '#vue/primitives/LayoutControls'
|
||||
export type { LayoutControlsContext } from '#vue/primitives/LayoutControls'
|
||||
export { AppearanceControlsRoot } from '#vue/primitives/AppearanceControls'
|
||||
export type {
|
||||
AppearanceControlsActions,
|
||||
AppearanceControlsRootSlotProps,
|
||||
AppearanceControlsRootSlots
|
||||
} from '#vue/primitives/AppearanceControls'
|
||||
export type { CornerRadiusKey } from '#vue/controls/appearance/types'
|
||||
export { PageListRoot } from '#vue/primitives/PageList'
|
||||
export { PositionControlsRoot } from '#vue/primitives/PositionControls'
|
||||
export { useEditorPropertyList } from '#vue/controls/property-list'
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { useAppearance } from '#vue/controls/appearance/use'
|
||||
import type { AppearanceControlsRootSlots } from '#vue/primitives/AppearanceControls/types'
|
||||
|
||||
const ctx = useAppearance()
|
||||
defineSlots<AppearanceControlsRootSlots>()
|
||||
const actions = {
|
||||
updateProp: ctx.updateProp,
|
||||
commitProp: ctx.commitProp,
|
||||
setBlendMode: ctx.setBlendMode,
|
||||
toggleVisibility: ctx.toggleVisibility,
|
||||
toggleIndependentCorners: ctx.toggleIndependentCorners,
|
||||
updateCornerProp: ctx.updateCornerProp,
|
||||
|
|
@ -19,8 +22,10 @@ const actions = {
|
|||
:active="ctx.active.value"
|
||||
:has-corner-radius="ctx.hasCornerRadius.value"
|
||||
:independent-corners="ctx.independentCorners.value"
|
||||
:show-independent-corners="ctx.showIndependentCorners.value"
|
||||
:corner-radius-value="ctx.cornerRadiusValue.value"
|
||||
:opacity-percent="ctx.opacityPercent.value"
|
||||
:blend-mode-value="ctx.blendModeValue.value"
|
||||
:visibility-state="ctx.visibilityState.value"
|
||||
:actions="actions"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1 +1,6 @@
|
|||
export { default as AppearanceControlsRoot } from '#vue/primitives/AppearanceControls/AppearanceControlsRoot.vue'
|
||||
export type {
|
||||
AppearanceControlsActions,
|
||||
AppearanceControlsRootSlotProps,
|
||||
AppearanceControlsRootSlots
|
||||
} from '#vue/primitives/AppearanceControls/types'
|
||||
|
|
|
|||
35
packages/vue/src/primitives/AppearanceControls/types.ts
Normal file
35
packages/vue/src/primitives/AppearanceControls/types.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { VNode } from 'vue'
|
||||
|
||||
import type { BlendMode, SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import type { CornerRadiusKey } from '#vue/controls/appearance/types'
|
||||
import type { MixedValue } from '#vue/controls/node-props/use'
|
||||
|
||||
export interface AppearanceControlsActions {
|
||||
updateProp(key: string, value: number): void
|
||||
commitProp(key: string, value: number, previous: number): void
|
||||
setBlendMode(value: BlendMode): void
|
||||
toggleVisibility(): void
|
||||
toggleIndependentCorners(): void
|
||||
updateCornerProp(key: CornerRadiusKey, value: number): void
|
||||
commitCornerProp(key: CornerRadiusKey, value: number, previous: number): void
|
||||
}
|
||||
|
||||
export interface AppearanceControlsRootSlotProps {
|
||||
node: SceneNode | null
|
||||
isMulti: boolean
|
||||
active: boolean
|
||||
hasCornerRadius: boolean
|
||||
independentCorners: MixedValue<boolean>
|
||||
showIndependentCorners: boolean
|
||||
cornerRadiusValue: MixedValue<number>
|
||||
opacityPercent: MixedValue<number>
|
||||
blendModeValue: MixedValue<BlendMode>
|
||||
visibilityState: 'visible' | 'hidden' | 'mixed'
|
||||
actions: AppearanceControlsActions
|
||||
}
|
||||
|
||||
export interface AppearanceControlsRootSlots {
|
||||
/** Complete selection-derived appearance state and mutation actions. */
|
||||
default(props: AppearanceControlsRootSlotProps): VNode[]
|
||||
}
|
||||
|
|
@ -3,6 +3,10 @@ import { computed, ref } from 'vue'
|
|||
|
||||
import { useI18n, useSelectionState, useEditorCommands } from '@open-pencil/vue'
|
||||
|
||||
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'
|
||||
|
|
@ -25,9 +29,10 @@ const { getCommand } = useEditorCommands()
|
|||
const goToMainComponent = getCommand('selection.goToMainComponent')
|
||||
const detachInstance = getCommand('selection.detachInstance')
|
||||
const isComponentType = computed(() => {
|
||||
const t = node.value?.type
|
||||
return t === 'COMPONENT' || t === 'COMPONENT_SET' || t === 'INSTANCE'
|
||||
const type = node.value?.type
|
||||
return type ? COMPONENT_TYPES.has(type) : false
|
||||
})
|
||||
const selectedIcon = computed(() => (node.value ? nodeIcon(node.value) : undefined))
|
||||
const { panels } = useI18n()
|
||||
</script>
|
||||
|
||||
|
|
@ -38,16 +43,17 @@ const { panels } = useI18n()
|
|||
data-test-id="design-panel-multi"
|
||||
class="scrollbar-thin flex-1 overflow-x-hidden overflow-y-auto pb-4"
|
||||
>
|
||||
<div
|
||||
data-test-id="design-multi-header"
|
||||
class="flex items-center gap-1.5 border-b border-border px-3 py-2"
|
||||
>
|
||||
<span class="text-[11px] text-muted">{{ panels.mixed }}</span>
|
||||
<span class="text-xs font-semibold">{{
|
||||
panels.layersCount({ count: String(multiCount) })
|
||||
}}</span>
|
||||
<SelectionActionsControl :show-boolean-operations="showBooleanOperations" />
|
||||
</div>
|
||||
<PanelHeader>
|
||||
<template #icon>
|
||||
<icon-lucide-layers-3 class="size-panel-icon" 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>
|
||||
<PositionSection />
|
||||
<AppearanceSection />
|
||||
<FillSection />
|
||||
|
|
@ -62,16 +68,19 @@ const { panels } = useI18n()
|
|||
data-test-id="design-panel-single"
|
||||
class="scrollbar-thin flex-1 overflow-x-hidden overflow-y-auto pb-4"
|
||||
>
|
||||
<div
|
||||
data-test-id="design-node-header"
|
||||
class="flex items-center gap-1.5 border-b border-border px-3 py-2"
|
||||
>
|
||||
<span class="text-[11px]" :class="isComponentType ? 'text-component' : 'text-muted'">{{
|
||||
node.type
|
||||
}}</span>
|
||||
<span class="text-xs font-semibold">{{ node.name }}</span>
|
||||
<SelectionActionsControl />
|
||||
</div>
|
||||
<PanelHeader :component="isComponentType">
|
||||
<template #icon>
|
||||
<Tip :label="node.type">
|
||||
<span role="img" :aria-label="node.type" class="contents">
|
||||
<component :is="selectedIcon" class="size-panel-icon" />
|
||||
</span>
|
||||
</Tip>
|
||||
</template>
|
||||
<span role="heading" aria-level="2">{{ node.name }}</span>
|
||||
<template #actions>
|
||||
<SelectionActionsControl />
|
||||
</template>
|
||||
</PanelHeader>
|
||||
|
||||
<!-- Component actions -->
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,233 +1,226 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { MIXED, useAppearance, useI18n } from '@open-pencil/vue'
|
||||
import { AppearanceControlsRoot, MIXED, useI18n } from '@open-pencil/vue'
|
||||
|
||||
import NumberField from '@/components/inputs/NumberField.vue'
|
||||
import VariableNumberField from '@/components/properties/VariableNumberField.vue'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import IconButton from '@/components/ui/IconButton.vue'
|
||||
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
|
||||
import PanelGrid from '@/components/ui/panel/PanelGrid.vue'
|
||||
import PanelRail from '@/components/ui/panel/PanelRail.vue'
|
||||
import PanelSection from '@/components/ui/panel/PanelSection.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
|
||||
import type { BlendMode } from '@open-pencil/scene-graph'
|
||||
|
||||
const { panels } = useI18n()
|
||||
|
||||
type BlendModeSelectValue = BlendMode | 'MIXED'
|
||||
|
||||
const blendModeOptions = computed<Array<{ value: BlendModeSelectValue; label: string }>>(() => {
|
||||
const options: Array<{ value: BlendModeSelectValue; label: string }> = [
|
||||
{ value: 'PASS_THROUGH', label: panels.value.blendModePassThrough },
|
||||
{ value: 'NORMAL', label: panels.value.blendModeNormal },
|
||||
{ value: 'DARKEN', label: panels.value.blendModeDarken },
|
||||
{ value: 'MULTIPLY', label: panels.value.blendModeMultiply },
|
||||
{ value: 'COLOR_BURN', label: panels.value.blendModeColorBurn },
|
||||
{ value: 'LIGHTEN', label: panels.value.blendModeLighten },
|
||||
{ value: 'SCREEN', label: panels.value.blendModeScreen },
|
||||
{ value: 'COLOR_DODGE', label: panels.value.blendModeColorDodge },
|
||||
{ value: 'OVERLAY', label: panels.value.blendModeOverlay },
|
||||
{ value: 'SOFT_LIGHT', label: panels.value.blendModeSoftLight },
|
||||
{ value: 'HARD_LIGHT', label: panels.value.blendModeHardLight },
|
||||
{ value: 'DIFFERENCE', label: panels.value.blendModeDifference },
|
||||
{ value: 'EXCLUSION', label: panels.value.blendModeExclusion },
|
||||
{ value: 'HUE', label: panels.value.blendModeHue },
|
||||
{ value: 'SATURATION', label: panels.value.blendModeSaturation },
|
||||
{ value: 'COLOR', label: panels.value.blendModeColor },
|
||||
{ value: 'LUMINOSITY', label: panels.value.blendModeLuminosity }
|
||||
]
|
||||
return blendModeValue.value === MIXED
|
||||
? [{ value: 'MIXED', label: panels.value.mixed }, ...options]
|
||||
: options
|
||||
})
|
||||
const {
|
||||
node,
|
||||
isMulti,
|
||||
active,
|
||||
hasCornerRadius,
|
||||
independentCorners,
|
||||
cornerRadiusValue,
|
||||
opacityPercent,
|
||||
blendModeValue,
|
||||
visibilityState,
|
||||
setBlendMode,
|
||||
updateProp,
|
||||
commitProp,
|
||||
toggleVisibility,
|
||||
toggleIndependentCorners,
|
||||
updateCornerProp,
|
||||
commitCornerProp
|
||||
} = useAppearance()
|
||||
const baseBlendModeOptions = computed<Array<{ value: BlendModeSelectValue; label: string }>>(() => [
|
||||
{ value: 'PASS_THROUGH', label: panels.value.blendModePassThrough },
|
||||
{ value: 'NORMAL', label: panels.value.blendModeNormal },
|
||||
{ value: 'DARKEN', label: panels.value.blendModeDarken },
|
||||
{ value: 'MULTIPLY', label: panels.value.blendModeMultiply },
|
||||
{ value: 'COLOR_BURN', label: panels.value.blendModeColorBurn },
|
||||
{ value: 'LIGHTEN', label: panels.value.blendModeLighten },
|
||||
{ value: 'SCREEN', label: panels.value.blendModeScreen },
|
||||
{ value: 'COLOR_DODGE', label: panels.value.blendModeColorDodge },
|
||||
{ value: 'OVERLAY', label: panels.value.blendModeOverlay },
|
||||
{ value: 'SOFT_LIGHT', label: panels.value.blendModeSoftLight },
|
||||
{ value: 'HARD_LIGHT', label: panels.value.blendModeHardLight },
|
||||
{ value: 'DIFFERENCE', label: panels.value.blendModeDifference },
|
||||
{ value: 'EXCLUSION', label: panels.value.blendModeExclusion },
|
||||
{ value: 'HUE', label: panels.value.blendModeHue },
|
||||
{ value: 'SATURATION', label: panels.value.blendModeSaturation },
|
||||
{ value: 'COLOR', label: panels.value.blendModeColor },
|
||||
{ value: 'LUMINOSITY', label: panels.value.blendModeLuminosity }
|
||||
])
|
||||
|
||||
const manualExpanded = ref<boolean | null>(null)
|
||||
|
||||
const showIndependentCorners = computed(() => {
|
||||
if (manualExpanded.value !== null) return manualExpanded.value
|
||||
if (independentCorners.value === true) return true
|
||||
const n = node.value
|
||||
if (!n) return false
|
||||
return !(
|
||||
n.topLeftRadius === n.topRightRadius &&
|
||||
n.topLeftRadius === n.bottomRightRadius &&
|
||||
n.topLeftRadius === n.bottomLeftRadius
|
||||
)
|
||||
})
|
||||
|
||||
function onToggleCorners() {
|
||||
manualExpanded.value = !showIndependentCorners.value
|
||||
toggleIndependentCorners()
|
||||
function blendModeOptions(value: BlendMode | typeof MIXED) {
|
||||
return value === MIXED
|
||||
? [{ value: 'MIXED' as const, label: panels.value.mixed }, ...baseBlendModeOptions.value]
|
||||
: baseBlendModeOptions.value
|
||||
}
|
||||
|
||||
const blendModeSelectValue = computed<BlendModeSelectValue>({
|
||||
get: () => (blendModeValue.value === MIXED ? 'MIXED' : blendModeValue.value),
|
||||
set: (value) => {
|
||||
if (value !== 'MIXED') setBlendMode(value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PanelSection v-if="active" :label="panels.appearance">
|
||||
<template #actions>
|
||||
<IconButton
|
||||
:label="panels.toggleVisibility"
|
||||
:active="visibilityState === 'hidden'"
|
||||
data-test-id="appearance-visibility"
|
||||
@click="toggleVisibility"
|
||||
>
|
||||
<icon-lucide-eye v-if="visibilityState === 'visible'" class="size-3.5" />
|
||||
<icon-lucide-eye-off v-else-if="visibilityState === 'hidden'" class="size-3.5" />
|
||||
<icon-lucide-eye v-else class="size-3.5 opacity-50" />
|
||||
</IconButton>
|
||||
</template>
|
||||
<AppearanceControlsRoot
|
||||
v-slot="{
|
||||
node,
|
||||
isMulti,
|
||||
active,
|
||||
hasCornerRadius,
|
||||
independentCorners,
|
||||
showIndependentCorners,
|
||||
cornerRadiusValue,
|
||||
opacityPercent,
|
||||
blendModeValue,
|
||||
visibilityState,
|
||||
actions
|
||||
}"
|
||||
>
|
||||
<PanelSection v-if="active" :label="panels.appearance">
|
||||
<template #actions>
|
||||
<IconButton
|
||||
:label="panels.toggleVisibility"
|
||||
:active="visibilityState === 'hidden'"
|
||||
@click="actions.toggleVisibility"
|
||||
>
|
||||
<icon-lucide-eye v-if="visibilityState === 'visible'" class="size-3.5" />
|
||||
<icon-lucide-eye-off v-else-if="visibilityState === 'hidden'" class="size-3.5" />
|
||||
<icon-lucide-eye v-else class="size-3.5 opacity-50" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-[minmax(0,3fr)_minmax(0,2fr)] gap-1.5">
|
||||
<Tip :label="panels.blendMode">
|
||||
<AppSelect
|
||||
v-model="blendModeSelectValue"
|
||||
class="w-full"
|
||||
:label="panels.blendMode"
|
||||
:options="blendModeOptions"
|
||||
<PanelGrid columns="two">
|
||||
<PanelFieldGroup :label="panels.blendMode">
|
||||
<AppSelect
|
||||
:model-value="blendModeValue === MIXED ? 'MIXED' : blendModeValue"
|
||||
class="w-full"
|
||||
:label="panels.blendMode"
|
||||
:options="blendModeOptions(blendModeValue)"
|
||||
@update:model-value="
|
||||
(value: BlendModeSelectValue) => value !== 'MIXED' && actions.setBlendMode(value)
|
||||
"
|
||||
/>
|
||||
</PanelFieldGroup>
|
||||
|
||||
<PanelFieldGroup :label="panels.opacity">
|
||||
<VariableNumberField
|
||||
v-if="node && !isMulti"
|
||||
suffix="%"
|
||||
:aria-label="panels.opacity"
|
||||
:model-value="opacityPercent"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:node-id="node.id"
|
||||
binding-path="opacity"
|
||||
@update:model-value="actions.updateProp('opacity', $event / 100)"
|
||||
@commit="(v: number, p: number) => actions.commitProp('opacity', v / 100, p / 100)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-blend class="size-3" />
|
||||
</template>
|
||||
</VariableNumberField>
|
||||
<NumberField
|
||||
v-else
|
||||
suffix="%"
|
||||
data-property="opacity"
|
||||
:aria-label="panels.opacity"
|
||||
:model-value="opacityPercent"
|
||||
:min="0"
|
||||
:max="100"
|
||||
@update:model-value="actions.updateProp('opacity', $event / 100)"
|
||||
@commit="(v: number, p: number) => actions.commitProp('opacity', v / 100, p / 100)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-blend class="size-3" />
|
||||
</template>
|
||||
</NumberField>
|
||||
</PanelFieldGroup>
|
||||
</PanelGrid>
|
||||
|
||||
<PanelGrid
|
||||
v-if="hasCornerRadius && !showIndependentCorners"
|
||||
columns="fill-rail"
|
||||
class="mt-panel"
|
||||
>
|
||||
<PanelFieldGroup :label="panels.radius">
|
||||
<VariableNumberField
|
||||
v-if="node && !isMulti"
|
||||
:aria-label="panels.radius"
|
||||
:model-value="cornerRadiusValue"
|
||||
:min="0"
|
||||
:node-id="node.id"
|
||||
binding-path="cornerRadius"
|
||||
@update:model-value="actions.updateProp('cornerRadius', $event)"
|
||||
@commit="(v: number, p: number) => actions.commitProp('cornerRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-square-round-corner class="size-3" />
|
||||
</template>
|
||||
</VariableNumberField>
|
||||
<NumberField
|
||||
v-else
|
||||
data-property="cornerRadius"
|
||||
:aria-label="panels.radius"
|
||||
:model-value="cornerRadiusValue"
|
||||
:min="0"
|
||||
@update:model-value="actions.updateProp('cornerRadius', $event)"
|
||||
@commit="(v: number, p: number) => actions.commitProp('cornerRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-square-round-corner class="size-3" />
|
||||
</template>
|
||||
</NumberField>
|
||||
</PanelFieldGroup>
|
||||
<PanelRail>
|
||||
<IconButton
|
||||
:label="panels.independentCornerRadii"
|
||||
size="md"
|
||||
:active="independentCorners === true"
|
||||
@click="actions.toggleIndependentCorners"
|
||||
>
|
||||
<icon-lucide-square-round-corner class="size-3" />
|
||||
</IconButton>
|
||||
</PanelRail>
|
||||
</PanelGrid>
|
||||
|
||||
<PanelGrid
|
||||
v-else-if="hasCornerRadius && !isMulti && node"
|
||||
columns="two-rail"
|
||||
class="mt-panel"
|
||||
data-corner-grid
|
||||
>
|
||||
<VariableNumberField
|
||||
label="TL"
|
||||
:model-value="node.topLeftRadius"
|
||||
:min="0"
|
||||
:node-id="node.id"
|
||||
binding-path="topLeftRadius"
|
||||
@update:model-value="actions.updateCornerProp('topLeftRadius', $event)"
|
||||
@commit="(v: number, p: number) => actions.commitCornerProp('topLeftRadius', v, p)"
|
||||
/>
|
||||
</Tip>
|
||||
|
||||
<Tip :label="panels.opacity">
|
||||
<VariableNumberField
|
||||
v-if="node"
|
||||
suffix="%"
|
||||
:model-value="opacityPercent"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:node-id="node.id"
|
||||
binding-path="opacity"
|
||||
@update:model-value="updateProp('opacity', $event / 100)"
|
||||
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-blend class="size-3" />
|
||||
</template>
|
||||
</VariableNumberField>
|
||||
<NumberField
|
||||
v-else
|
||||
suffix="%"
|
||||
:model-value="opacityPercent"
|
||||
:min="0"
|
||||
:max="100"
|
||||
@update:model-value="updateProp('opacity', $event / 100)"
|
||||
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-blend class="size-3" />
|
||||
</template>
|
||||
</NumberField>
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
<div v-if="hasCornerRadius" class="mt-1.5 flex gap-1.5">
|
||||
<Tip :label="panels.radius">
|
||||
<VariableNumberField
|
||||
v-if="!showIndependentCorners && node"
|
||||
:model-value="cornerRadiusValue"
|
||||
label="TR"
|
||||
:model-value="node.topRightRadius"
|
||||
:min="0"
|
||||
:node-id="node.id"
|
||||
binding-path="cornerRadius"
|
||||
@update:model-value="updateProp('cornerRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
binding-path="topRightRadius"
|
||||
@update:model-value="actions.updateCornerProp('topRightRadius', $event)"
|
||||
@commit="(v: number, p: number) => actions.commitCornerProp('topRightRadius', v, p)"
|
||||
/>
|
||||
<PanelRail>
|
||||
<IconButton
|
||||
:label="panels.independentCornerRadii"
|
||||
size="md"
|
||||
active
|
||||
@click="actions.toggleIndependentCorners"
|
||||
>
|
||||
<icon-lucide-square-round-corner class="size-3" />
|
||||
</template>
|
||||
</VariableNumberField>
|
||||
<NumberField
|
||||
v-else-if="!showIndependentCorners"
|
||||
:model-value="cornerRadiusValue"
|
||||
</IconButton>
|
||||
</PanelRail>
|
||||
<VariableNumberField
|
||||
label="BL"
|
||||
:model-value="node.bottomLeftRadius"
|
||||
:min="0"
|
||||
@update:model-value="updateProp('cornerRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-square-round-corner class="size-3" />
|
||||
</template>
|
||||
</NumberField>
|
||||
</Tip>
|
||||
|
||||
<IconButton
|
||||
:label="panels.independentCornerRadii"
|
||||
size="md"
|
||||
class="size-[26px] shrink-0"
|
||||
:active="showIndependentCorners"
|
||||
data-test-id="independent-corners-toggle"
|
||||
@click="onToggleCorners"
|
||||
>
|
||||
<icon-lucide-square-round-corner class="size-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasCornerRadius && showIndependentCorners && !isMulti && node"
|
||||
data-test-id="independent-corners-grid"
|
||||
class="mt-1.5 grid grid-cols-2 gap-1.5"
|
||||
>
|
||||
<VariableNumberField
|
||||
data-test-id="corner-tl-input"
|
||||
label="TL"
|
||||
:model-value="node.topLeftRadius"
|
||||
:min="0"
|
||||
:node-id="node.id"
|
||||
binding-path="topLeftRadius"
|
||||
@update:model-value="updateCornerProp('topLeftRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitCornerProp('topLeftRadius', v, p)"
|
||||
/>
|
||||
<VariableNumberField
|
||||
data-test-id="corner-tr-input"
|
||||
label="TR"
|
||||
:model-value="node.topRightRadius"
|
||||
:min="0"
|
||||
:node-id="node.id"
|
||||
binding-path="topRightRadius"
|
||||
@update:model-value="updateCornerProp('topRightRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitCornerProp('topRightRadius', v, p)"
|
||||
/>
|
||||
<VariableNumberField
|
||||
data-test-id="corner-bl-input"
|
||||
label="BL"
|
||||
:model-value="node.bottomLeftRadius"
|
||||
:min="0"
|
||||
:node-id="node.id"
|
||||
binding-path="bottomLeftRadius"
|
||||
@update:model-value="updateCornerProp('bottomLeftRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitCornerProp('bottomLeftRadius', v, p)"
|
||||
/>
|
||||
<VariableNumberField
|
||||
data-test-id="corner-br-input"
|
||||
label="BR"
|
||||
:model-value="node.bottomRightRadius"
|
||||
:min="0"
|
||||
:node-id="node.id"
|
||||
binding-path="bottomRightRadius"
|
||||
@update:model-value="updateCornerProp('bottomRightRadius', $event)"
|
||||
@commit="(v: number, p: number) => commitCornerProp('bottomRightRadius', v, p)"
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
:node-id="node.id"
|
||||
binding-path="bottomLeftRadius"
|
||||
@update:model-value="actions.updateCornerProp('bottomLeftRadius', $event)"
|
||||
@commit="(v: number, p: number) => actions.commitCornerProp('bottomLeftRadius', v, p)"
|
||||
/>
|
||||
<VariableNumberField
|
||||
label="BR"
|
||||
:model-value="node.bottomRightRadius"
|
||||
:min="0"
|
||||
:node-id="node.id"
|
||||
binding-path="bottomRightRadius"
|
||||
@update:model-value="actions.updateCornerProp('bottomRightRadius', $event)"
|
||||
@commit="(v: number, p: number) => actions.commitCornerProp('bottomRightRadius', v, p)"
|
||||
/>
|
||||
<PanelRail />
|
||||
</PanelGrid>
|
||||
</PanelSection>
|
||||
</AppearanceControlsRoot>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { PositionControlsRoot, useI18n } from '@open-pencil/vue'
|
||||
|
||||
import { useEditorStore } from '@/app/editor/active-store'
|
||||
import NumberField from '@/components/inputs/NumberField.vue'
|
||||
import IconButton from '@/components/ui/IconButton.vue'
|
||||
import PanelRow from '@/components/ui/panel/PanelRow.vue'
|
||||
import PanelGrid from '@/components/ui/panel/PanelGrid.vue'
|
||||
import PanelSection from '@/components/ui/panel/PanelSection.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import { useEditorStore } from '@/app/editor/active-store'
|
||||
import { PositionControlsRoot, useI18n } from '@open-pencil/vue'
|
||||
|
||||
const { panels } = useI18n()
|
||||
const store = useEditorStore()
|
||||
|
|
@ -15,8 +16,8 @@ function handleAlign(
|
|||
axis: 'horizontal' | 'vertical',
|
||||
pos: 'min' | 'center' | 'max'
|
||||
) {
|
||||
const es = store.state.nodeEditState
|
||||
if (es && es.selectedVertexIndices.size >= 2) {
|
||||
const editState = store.state.nodeEditState
|
||||
if (editState && editState.selectedVertexIndices.size >= 2) {
|
||||
store.nodeEditAlignVertices(axis, pos)
|
||||
} else {
|
||||
nodeAlign(axis, pos)
|
||||
|
|
@ -28,13 +29,12 @@ function handleAlign(
|
|||
<PositionControlsRoot
|
||||
v-slot="{ active, isMulti, xValue, yValue, wValue, hValue, rotationValue, actions }"
|
||||
>
|
||||
<PanelSection v-if="active" :label="panels.position" data-test-id="position-section">
|
||||
<PanelRow class="mb-1.5 gap-2">
|
||||
<PanelRow gap="sm">
|
||||
<PanelSection v-if="active" :label="panels.position">
|
||||
<div role="toolbar" :aria-label="panels.position" class="mb-panel flex justify-between">
|
||||
<div class="flex gap-0.5">
|
||||
<IconButton
|
||||
:label="panels.alignLeft"
|
||||
size="md"
|
||||
data-test-id="position-align-left"
|
||||
@click="handleAlign(actions.align, 'horizontal', 'min')"
|
||||
>
|
||||
<icon-lucide-align-start-vertical class="size-3.5" />
|
||||
|
|
@ -42,7 +42,6 @@ function handleAlign(
|
|||
<IconButton
|
||||
:label="panels.alignCenterHorizontally"
|
||||
size="md"
|
||||
data-test-id="position-align-center-h"
|
||||
@click="handleAlign(actions.align, 'horizontal', 'center')"
|
||||
>
|
||||
<icon-lucide-align-center-vertical class="size-3.5" />
|
||||
|
|
@ -50,17 +49,15 @@ function handleAlign(
|
|||
<IconButton
|
||||
:label="panels.alignRight"
|
||||
size="md"
|
||||
data-test-id="position-align-right"
|
||||
@click="handleAlign(actions.align, 'horizontal', 'max')"
|
||||
>
|
||||
<icon-lucide-align-end-vertical class="size-3.5" />
|
||||
</IconButton>
|
||||
</PanelRow>
|
||||
<PanelRow gap="sm">
|
||||
</div>
|
||||
<div class="flex gap-0.5">
|
||||
<IconButton
|
||||
:label="panels.alignTop"
|
||||
size="md"
|
||||
data-test-id="position-align-top"
|
||||
@click="handleAlign(actions.align, 'vertical', 'min')"
|
||||
>
|
||||
<icon-lucide-align-start-horizontal class="size-3.5" />
|
||||
|
|
@ -68,7 +65,6 @@ function handleAlign(
|
|||
<IconButton
|
||||
:label="panels.alignCenterVertically"
|
||||
size="md"
|
||||
data-test-id="position-align-center-v"
|
||||
@click="handleAlign(actions.align, 'vertical', 'center')"
|
||||
>
|
||||
<icon-lucide-align-center-horizontal class="size-3.5" />
|
||||
|
|
@ -76,19 +72,19 @@ function handleAlign(
|
|||
<IconButton
|
||||
:label="panels.alignBottom"
|
||||
size="md"
|
||||
data-test-id="position-align-bottom"
|
||||
@click="handleAlign(actions.align, 'vertical', 'max')"
|
||||
>
|
||||
<icon-lucide-align-end-horizontal class="size-3.5" />
|
||||
</IconButton>
|
||||
</PanelRow>
|
||||
</PanelRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PanelRow cols="two">
|
||||
<PanelGrid columns="two">
|
||||
<Tip :label="panels.xAxis">
|
||||
<NumberField
|
||||
icon="X"
|
||||
data-property="x"
|
||||
:aria-label="panels.xAxis"
|
||||
:model-value="xValue"
|
||||
@update:model-value="actions.updateProp('x', $event)"
|
||||
@commit="(v: number, p: number) => actions.commitProp('x', v, p)"
|
||||
|
|
@ -98,18 +94,20 @@ function handleAlign(
|
|||
<NumberField
|
||||
icon="Y"
|
||||
data-property="y"
|
||||
:aria-label="panels.yAxis"
|
||||
:model-value="yValue"
|
||||
@update:model-value="actions.updateProp('y', $event)"
|
||||
@commit="(v: number, p: number) => actions.commitProp('y', v, p)"
|
||||
/>
|
||||
</Tip>
|
||||
</PanelRow>
|
||||
</PanelGrid>
|
||||
|
||||
<PanelRow v-if="isMulti" cols="two" class="mt-1.5">
|
||||
<PanelGrid v-if="isMulti" columns="two" class="mt-panel">
|
||||
<Tip :label="panels.width">
|
||||
<NumberField
|
||||
icon="W"
|
||||
data-property="width"
|
||||
:aria-label="panels.width"
|
||||
:model-value="wValue"
|
||||
:min="1"
|
||||
@update:model-value="actions.updateProp('width', $event)"
|
||||
|
|
@ -120,18 +118,18 @@ function handleAlign(
|
|||
<NumberField
|
||||
icon="H"
|
||||
data-property="height"
|
||||
:aria-label="panels.height"
|
||||
:model-value="hValue"
|
||||
:min="1"
|
||||
@update:model-value="actions.updateProp('height', $event)"
|
||||
@commit="(v: number, p: number) => actions.commitProp('height', v, p)"
|
||||
/>
|
||||
</Tip>
|
||||
</PanelRow>
|
||||
</PanelGrid>
|
||||
|
||||
<PanelRow cols="fill" class="mt-1.5">
|
||||
<div class="mt-panel grid grid-cols-[minmax(0,1fr)_repeat(3,var(--spacing-control))] gap-0.5">
|
||||
<Tip :label="panels.rotation">
|
||||
<NumberField
|
||||
class="flex-1"
|
||||
suffix="°"
|
||||
data-property="rotation"
|
||||
:aria-label="panels.rotation"
|
||||
|
|
@ -146,34 +144,16 @@ function handleAlign(
|
|||
</template>
|
||||
</NumberField>
|
||||
</Tip>
|
||||
<IconButton
|
||||
:label="panels.flipHorizontal"
|
||||
size="md"
|
||||
class="shrink-0"
|
||||
data-test-id="position-flip-horizontal"
|
||||
@click="actions.flip('horizontal')"
|
||||
>
|
||||
<IconButton :label="panels.flipHorizontal" size="md" @click="actions.flip('horizontal')">
|
||||
<icon-lucide-flip-horizontal-2 class="size-3.5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
:label="panels.flipVertical"
|
||||
size="md"
|
||||
class="shrink-0"
|
||||
data-test-id="position-flip-vertical"
|
||||
@click="actions.flip('vertical')"
|
||||
>
|
||||
<IconButton :label="panels.flipVertical" size="md" @click="actions.flip('vertical')">
|
||||
<icon-lucide-flip-vertical-2 class="size-3.5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
:label="panels.rotate90"
|
||||
size="md"
|
||||
class="shrink-0"
|
||||
data-test-id="position-rotate-90"
|
||||
@click="actions.rotate(90)"
|
||||
>
|
||||
<IconButton :label="panels.rotate90" size="md" @click="actions.rotate(90)">
|
||||
<icon-lucide-rotate-cw-square class="size-3.5" />
|
||||
</IconButton>
|
||||
</PanelRow>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</PositionControlsRoot>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|||
import { TooltipProvider } from 'reka-ui'
|
||||
import { expect, userEvent, within } from 'storybook/test'
|
||||
import { ref } from 'vue'
|
||||
import MoreIcon from '~icons/lucide/ellipsis'
|
||||
import EyeIcon from '~icons/lucide/eye'
|
||||
import LinkIcon from '~icons/lucide/link'
|
||||
import RotateIcon from '~icons/lucide/rotate-ccw'
|
||||
import SquareIcon from '~icons/lucide/square'
|
||||
|
||||
import AppInput from '@/components/ui/AppInput.vue'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
|
|
@ -13,6 +15,7 @@ import SegmentedControl from '@/components/ui/SegmentedControl.vue'
|
|||
|
||||
import PanelFieldGroup from './PanelFieldGroup.vue'
|
||||
import PanelGrid from './PanelGrid.vue'
|
||||
import PanelHeader from './PanelHeader.vue'
|
||||
import PanelRail from './PanelRail.vue'
|
||||
import PanelSection from './PanelSection.vue'
|
||||
|
||||
|
|
@ -40,12 +43,15 @@ export const StateMatrix: Story = {
|
|||
EyeIcon,
|
||||
IconButton,
|
||||
LinkIcon,
|
||||
MoreIcon,
|
||||
PanelFieldGroup,
|
||||
PanelGrid,
|
||||
PanelHeader,
|
||||
PanelRail,
|
||||
PanelSection,
|
||||
RotateIcon,
|
||||
SegmentedControl,
|
||||
SquareIcon,
|
||||
TooltipProvider
|
||||
},
|
||||
setup() {
|
||||
|
|
@ -80,10 +86,13 @@ export const StateMatrix: Story = {
|
|||
template: `
|
||||
<TooltipProvider>
|
||||
<div class="w-[320px] overflow-hidden rounded-lg border border-border bg-panel shadow-xl">
|
||||
<header class="border-b border-border px-panel-x py-panel-y">
|
||||
<p class="text-xs font-semibold">Properties</p>
|
||||
<p class="mt-1 text-[11px] text-muted">Panel foundation states</p>
|
||||
</header>
|
||||
<PanelHeader>
|
||||
<template #icon><SquareIcon class="size-panel-icon" /></template>
|
||||
<span role="heading" aria-level="2">Rectangle</span>
|
||||
<template #actions>
|
||||
<IconButton label="Selection actions"><MoreIcon class="size-panel-icon" /></IconButton>
|
||||
</template>
|
||||
</PanelHeader>
|
||||
|
||||
<PanelSection label="Layout">
|
||||
<template #actions>
|
||||
|
|
|
|||
47
src/components/ui/panel/PanelHeader.vue
Normal file
47
src/components/ui/panel/PanelHeader.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
import type { VNode } from 'vue'
|
||||
import type { ClassValue } from 'tailwind-variants'
|
||||
|
||||
import type { ComponentUI } from '@/components/ui/types'
|
||||
import type { PanelHeaderTheme } from '@/theme/panel/header'
|
||||
|
||||
export interface PanelHeaderProps {
|
||||
component?: boolean
|
||||
class?: ClassValue
|
||||
ui?: ComponentUI<PanelHeaderTheme>
|
||||
}
|
||||
|
||||
export interface PanelHeaderSlots {
|
||||
icon?(): VNode[]
|
||||
default(): VNode[]
|
||||
actions?(): VNode[]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
|
||||
import theme from '@/theme/panel/header'
|
||||
|
||||
const { component = false, class: className, ui } = defineProps<PanelHeaderProps>()
|
||||
const slots = defineSlots<PanelHeaderSlots>()
|
||||
const styles = tv(theme)({ component })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
data-slot="root"
|
||||
:data-component="component ? '' : undefined"
|
||||
:class="styles.root({ class: [ui?.root, className] })"
|
||||
>
|
||||
<div data-slot="icon" :class="styles.icon({ class: ui?.icon })">
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
<div data-slot="title" :class="styles.title({ class: ui?.title })">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="slots.actions" data-slot="actions" :class="styles.actions({ class: ui?.actions })">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export { default as PanelFieldGroup } from './PanelFieldGroup.vue'
|
||||
export { default as PanelGrid } from './PanelGrid.vue'
|
||||
export { default as PanelHeader } from './PanelHeader.vue'
|
||||
export { default as PanelRail } from './PanelRail.vue'
|
||||
export { default as PanelRow } from './PanelRow.vue'
|
||||
export { default as PanelSection } from './PanelSection.vue'
|
||||
|
|
|
|||
24
src/theme/panel/header.ts
Normal file
24
src/theme/panel/header.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const panelHeaderTheme = {
|
||||
slots: {
|
||||
root: 'grid min-w-0 grid-cols-[var(--spacing-panel-icon)_minmax(0,1fr)_auto] items-center gap-panel border-b border-border px-panel-x py-panel-y text-surface',
|
||||
icon: 'flex size-panel-icon items-center justify-center text-muted',
|
||||
title: 'min-w-0 truncate text-xs font-semibold text-surface',
|
||||
actions:
|
||||
'flex min-w-0 items-center justify-end gap-0.5 [&_[data-slot=icon-button]]:size-control [&_[data-slot=icon-button]]:rounded-panel'
|
||||
},
|
||||
variants: {
|
||||
component: {
|
||||
true: {
|
||||
icon: 'text-component',
|
||||
title: 'text-component'
|
||||
},
|
||||
false: {}
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
component: false
|
||||
}
|
||||
}
|
||||
|
||||
export type PanelHeaderTheme = typeof panelHeaderTheme
|
||||
export default panelHeaderTheme
|
||||
|
|
@ -43,8 +43,9 @@ async function selectDemoCard(page: Parameters<typeof test>[0]['page'], canvas:
|
|||
})
|
||||
await canvas.waitForRender()
|
||||
|
||||
await expect(page.getByTestId('design-panel-single')).toBeVisible()
|
||||
await expect(page.getByTestId('design-node-header')).toContainText('Card')
|
||||
const designPanel = page.getByTestId('design-panel-single')
|
||||
await expect(designPanel).toBeVisible()
|
||||
await expect(designPanel.getByRole('heading', { name: 'Card' })).toBeVisible()
|
||||
}
|
||||
|
||||
async function getSelectedFill(page: Parameters<typeof test>[0]['page']) {
|
||||
|
|
|
|||
|
|
@ -52,9 +52,8 @@ test('create component from selection (⌘⌥K)', async () => {
|
|||
componentId = selectedId
|
||||
})
|
||||
|
||||
test('component shows purple label in design panel', async () => {
|
||||
const header = editor.page.getByTestId('design-node-header')
|
||||
await expect(header).toContainText('COMPONENT')
|
||||
test('component shows its type icon in the design panel', async () => {
|
||||
await expect(editor.page.getByRole('img', { name: 'COMPONENT' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('component visible in layers panel', async () => {
|
||||
|
|
@ -103,8 +102,7 @@ test('instance shows INSTANCE type in design panel', async () => {
|
|||
}, instance.id)
|
||||
await editor.canvas.waitForRender()
|
||||
|
||||
const header = editor.page.getByTestId('design-node-header')
|
||||
await expect(header).toContainText('INSTANCE')
|
||||
await expect(editor.page.getByRole('img', { name: 'INSTANCE' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('instance has "Go to Main Component" button', async () => {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,6 @@ function designPanel() {
|
|||
return editor.page.getByTestId('design-panel-single')
|
||||
}
|
||||
|
||||
function nodeHeader() {
|
||||
return editor.page.getByTestId('design-node-header')
|
||||
}
|
||||
|
||||
function fillSection() {
|
||||
return editor.page.getByTestId('fill-section')
|
||||
}
|
||||
|
|
@ -21,7 +17,7 @@ function strokeSection() {
|
|||
}
|
||||
|
||||
function positionSection() {
|
||||
return editor.page.getByTestId('position-section')
|
||||
return propertySection(editor.page, 'Position')
|
||||
}
|
||||
|
||||
function effectsSection() {
|
||||
|
|
@ -73,8 +69,9 @@ test('selecting a rectangle shows design panel with type and name', async () =>
|
|||
await editor.canvas.waitForRender()
|
||||
|
||||
await expect(designPanel()).toBeVisible()
|
||||
await expect(nodeHeader()).toContainText('RECTANGLE')
|
||||
await expect(nodeHeader()).toContainText('Rectangle')
|
||||
await expect(designPanel().getByRole('img', { name: 'RECTANGLE' })).toBeVisible()
|
||||
await expect(designPanel().getByRole('heading', { name: 'Rectangle' })).toBeVisible()
|
||||
await expect(designPanel()).toHaveScreenshot('design-panel-position-appearance.png')
|
||||
})
|
||||
|
||||
test('position section shows X, Y, rotation inputs', async () => {
|
||||
|
|
@ -246,7 +243,9 @@ test('mask action toggles mask section and mask type control', async () => {
|
|||
})
|
||||
|
||||
test('visibility toggle in appearance section works', async () => {
|
||||
const visBtn = editor.page.getByTestId('appearance-visibility')
|
||||
const visBtn = propertySection(editor.page, 'Appearance').getByRole('button', {
|
||||
name: 'Toggle visibility'
|
||||
})
|
||||
await expect(visBtn).toBeVisible()
|
||||
|
||||
const id = await getSelectedId()
|
||||
|
|
@ -393,10 +392,10 @@ test('multi-select shows mixed header and boolean operations', async () => {
|
|||
await editor.canvas.selectAll()
|
||||
await editor.canvas.waitForRender()
|
||||
|
||||
const multiHeader = editor.page.getByTestId('design-multi-header')
|
||||
const multiHeader = editor.page
|
||||
.getByTestId('design-panel-multi')
|
||||
.getByRole('heading', { name: /layers/ })
|
||||
await expect(multiHeader).toBeVisible()
|
||||
await expect(multiHeader).toContainText('Mixed')
|
||||
await expect(multiHeader).toContainText('layers')
|
||||
|
||||
const booleanOperations = editor.page.getByTestId('boolean-operations-trigger')
|
||||
await booleanOperations.hover()
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -1,6 +1,7 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
import { propertySection } from '#tests/helpers/properties'
|
||||
|
||||
let page: Page
|
||||
let canvas: CanvasHelper
|
||||
|
|
@ -48,14 +49,16 @@ test('independent corners toggle shows per-corner inputs', async () => {
|
|||
expect(flags?.type).toBe('FRAME')
|
||||
expect(flags?.independentCorners).toBe(false)
|
||||
|
||||
const toggle = page.getByTestId('independent-corners-toggle')
|
||||
const toggle = propertySection(page, 'Appearance').getByRole('button', {
|
||||
name: 'Independent corner radii'
|
||||
})
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await toggle.click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
expect((await getSelectedNodeFlags())?.independentCorners).toBe(true)
|
||||
const grid = page.getByTestId('independent-corners-grid')
|
||||
const grid = page.locator('[data-corner-grid]')
|
||||
await expect(grid).toBeVisible()
|
||||
const cornerInputs = grid.getByRole('spinbutton')
|
||||
expect(await cornerInputs.count()).toBe(4)
|
||||
|
|
@ -65,6 +68,34 @@ test('independent corners toggle shows per-corner inputs', async () => {
|
|||
await expect(grid).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('multi-selection independent corners toggle is one undo step', async () => {
|
||||
await canvas.clearCanvas()
|
||||
await drawFrame(80, 80, 100, 70)
|
||||
await drawFrame(240, 80, 100, 70)
|
||||
await canvas.pressKey('Meta+a')
|
||||
await canvas.waitForRender()
|
||||
|
||||
const independentStates = () =>
|
||||
page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
return [...store.state.selectedIds].map(
|
||||
(id) => store.graph.getNode(id)?.independentCorners ?? null
|
||||
)
|
||||
})
|
||||
|
||||
const toggle = propertySection(page, 'Appearance').getByRole('button', {
|
||||
name: 'Independent corner radii'
|
||||
})
|
||||
await toggle.click()
|
||||
await canvas.waitForRender()
|
||||
expect(await independentStates()).toEqual([true, true])
|
||||
|
||||
await canvas.pressKey('Meta+z')
|
||||
await canvas.waitForRender()
|
||||
expect(await independentStates()).toEqual([false, false])
|
||||
})
|
||||
|
||||
test('stroke sides toggle shows per-side weight inputs', async () => {
|
||||
await drawFrame(300, 50, 120, 80)
|
||||
await canvas.waitForRender()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ function xField() {
|
|||
|
||||
async function editField(field: ReturnType<typeof xField>) {
|
||||
await field.click()
|
||||
return field.getByRole('spinbutton', { name: 'X' })
|
||||
return field.getByRole('spinbutton', { name: 'X Axis' })
|
||||
}
|
||||
|
||||
async function numericFieldValue(field: ReturnType<typeof xField>): Promise<number> {
|
||||
|
|
@ -30,7 +30,7 @@ test('NumberField commits arithmetic and relative expressions', async () => {
|
|||
let input = await editField(field)
|
||||
await expect(field).not.toHaveAttribute('role')
|
||||
await expect(input).toHaveAttribute('role', 'spinbutton')
|
||||
await expect(input).toHaveAttribute('aria-label', 'X')
|
||||
await expect(input).toHaveAttribute('aria-label', 'X Axis')
|
||||
await expect(field.getByRole('spinbutton')).toHaveCount(1)
|
||||
await input.fill('*2')
|
||||
await input.press('Enter')
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ test('corner radius uniform sets cornerRadius', async () => {
|
|||
const scrubContainer = propertyField(editor.page, 'cornerRadius')
|
||||
await scrubContainer.click()
|
||||
await editor.canvas.waitForRender()
|
||||
const input = scrubContainer.getByRole('spinbutton', { name: 'cornerRadius' })
|
||||
const input = scrubContainer.getByRole('spinbutton', { name: 'Radius' })
|
||||
await input.fill('12')
|
||||
await input.press('Enter')
|
||||
await editor.canvas.waitForRender()
|
||||
|
|
@ -52,13 +52,15 @@ test('corner radius uniform sets cornerRadius', async () => {
|
|||
})
|
||||
|
||||
test('independent corners toggle shows four corner inputs', async () => {
|
||||
await editor.page.getByTestId('independent-corners-toggle').click()
|
||||
await propertySection(editor.page, 'Appearance')
|
||||
.getByRole('button', { name: 'Independent corner radii' })
|
||||
.click()
|
||||
await editor.canvas.waitForRender()
|
||||
|
||||
await expect(editor.page.getByTestId('corner-tl-input')).toBeVisible()
|
||||
await expect(editor.page.getByTestId('corner-tr-input')).toBeVisible()
|
||||
await expect(editor.page.getByTestId('corner-br-input')).toBeVisible()
|
||||
await expect(editor.page.getByTestId('corner-bl-input')).toBeVisible()
|
||||
await expect(propertyField(editor.page, 'topLeftRadius')).toBeVisible()
|
||||
await expect(propertyField(editor.page, 'topRightRadius')).toBeVisible()
|
||||
await expect(propertyField(editor.page, 'bottomRightRadius')).toBeVisible()
|
||||
await expect(propertyField(editor.page, 'bottomLeftRadius')).toBeVisible()
|
||||
editor.canvas.assertNoErrors()
|
||||
})
|
||||
|
||||
|
|
@ -246,7 +248,7 @@ test('bound NumberField detach edit is one undo step', async () => {
|
|||
})
|
||||
|
||||
await field.click({ position: { x: 40, y: 13 } })
|
||||
const input = field.getByRole('spinbutton', { name: 'cornerRadius' })
|
||||
const input = field.getByRole('spinbutton', { name: 'Radius' })
|
||||
await input.press('Tab')
|
||||
expect(await readState()).toEqual({ radius: 0, binding: 'Radius/default' })
|
||||
await editor.canvas.pressKey('Meta+z')
|
||||
|
|
@ -285,7 +287,7 @@ test('alignment buttons align nodes to same X', async () => {
|
|||
await editor.canvas.pressKey('Meta+a')
|
||||
await editor.canvas.waitForRender()
|
||||
|
||||
await editor.page.getByTestId('position-align-left').click()
|
||||
await propertySection(editor.page, 'Position').getByRole('button', { name: 'Align left' }).click()
|
||||
await editor.canvas.waitForRender()
|
||||
|
||||
const children = await getPageChildren(editor.page)
|
||||
|
|
@ -298,7 +300,9 @@ test('flip horizontal sets flipX', async () => {
|
|||
await editor.canvas.clearCanvas()
|
||||
await editor.canvas.drawRect(200, 200, 80, 80)
|
||||
|
||||
await editor.page.getByTestId('position-flip-horizontal').click()
|
||||
await propertySection(editor.page, 'Position')
|
||||
.getByRole('button', { name: 'Flip horizontal' })
|
||||
.click()
|
||||
await editor.canvas.waitForRender()
|
||||
|
||||
const node = await getSelectedNode(editor.page)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
import { propertySection } from '#tests/helpers/properties'
|
||||
import { getSelectedNode } from '#tests/helpers/store'
|
||||
|
||||
const editor = useEditorSetup()
|
||||
|
|
@ -92,7 +93,9 @@ test('multi-selection list add is one undo step', async () => {
|
|||
})
|
||||
|
||||
test('appearance visibility supports repeat click and undo redo in one step', async () => {
|
||||
const visibilityButton = editor.page.getByTestId('appearance-visibility')
|
||||
const visibilityButton = propertySection(editor.page, 'Appearance').getByRole('button', {
|
||||
name: 'Toggle visibility'
|
||||
})
|
||||
await expect(visibilityButton).toBeVisible()
|
||||
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').visible).toBe(true)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const editor = useEditorSetupWithClear('/?test')
|
|||
test('draw section in full editor without browser errors', async () => {
|
||||
await editor.canvas.drawSection(100, 100, 240, 160)
|
||||
|
||||
await expect(editor.page.getByTestId('design-node-header')).toContainText('SECTION')
|
||||
await expect(editor.page.getByRole('img', { name: 'SECTION' })).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return editor.page.evaluate(() => {
|
||||
|
|
|
|||
64
tests/engine/vue/controls/appearance.test.ts
Normal file
64
tests/engine/vue/controls/appearance.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
import type { MixedValue } from '@open-pencil/vue'
|
||||
|
||||
import { createAppearanceState } from '#vue/controls/appearance/helpers'
|
||||
|
||||
import { createRect, firstPageId, makeSceneGraph } from '#tests/helpers/scene'
|
||||
|
||||
function appearanceState(node: SceneNode, multi = false) {
|
||||
const selected = ref<SceneNode | null>(node)
|
||||
const nodes = ref<SceneNode[]>([node])
|
||||
const isMulti = ref(multi)
|
||||
|
||||
function merged<K extends keyof SceneNode>(key: K): MixedValue<SceneNode[K]> {
|
||||
const current = selected.value
|
||||
if (!current) throw new Error('Expected selected node')
|
||||
return current[key]
|
||||
}
|
||||
|
||||
return createAppearanceState({
|
||||
node: computed(() => selected.value),
|
||||
nodes: computed(() => nodes.value),
|
||||
isMulti: computed(() => isMulti.value),
|
||||
merged
|
||||
})
|
||||
}
|
||||
|
||||
function rectangle() {
|
||||
const graph = makeSceneGraph()
|
||||
return createRect(graph, firstPageId(graph))
|
||||
}
|
||||
|
||||
describe('appearance control state', () => {
|
||||
test('keeps equal uniform corners collapsed', () => {
|
||||
const state = appearanceState(rectangle())
|
||||
expect(state.showIndependentCorners.value).toBe(false)
|
||||
})
|
||||
|
||||
test('expands corners when the explicit independent flag is set', () => {
|
||||
const node = rectangle()
|
||||
node.independentCorners = true
|
||||
const state = appearanceState(node)
|
||||
expect(state.showIndependentCorners.value).toBe(true)
|
||||
})
|
||||
|
||||
test('expands imported unequal corners when the explicit flag is stale', () => {
|
||||
const node = rectangle()
|
||||
node.independentCorners = false
|
||||
node.topLeftRadius = 4
|
||||
node.topRightRadius = 12
|
||||
const state = appearanceState(node)
|
||||
expect(state.showIndependentCorners.value).toBe(true)
|
||||
})
|
||||
|
||||
test('leaves the per-corner editor collapsed for multi-selection', () => {
|
||||
const node = rectangle()
|
||||
node.independentCorners = true
|
||||
const state = appearanceState(node, true)
|
||||
expect(state.showIndependentCorners.value).toBe(false)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue