refactor(vue): type property list slots

This commit is contained in:
Danila Poyarkov 2026-06-30 18:10:17 +03:00
parent dabeb3e92c
commit 65f57f721d
7 changed files with 106 additions and 78 deletions

View file

@ -24,7 +24,8 @@ export const DEFAULT_STROKE: Stroke = {
align: 'CENTER'
}
export function updateAlign(editor: Editor, align: Stroke['align'], activeNode: SceneNode) {
export function updateAlign(editor: Editor, align: Stroke['align'], activeNode: SceneNode | null) {
if (!activeNode) return
const strokes = activeNode.strokes.map((s) => ({ ...s, align }))
editor.updateNodeWithUndo(activeNode.id, { strokes }, 'Change stroke align')
}
@ -54,8 +55,38 @@ export function currentSides(activeNode: SceneNode | null): StrokeSides {
return 'CUSTOM'
}
export function dashState(stroke: Stroke | undefined): { dash: number; gap: number; on: boolean } {
const pattern = stroke?.dashPattern
if (!pattern || pattern.length === 0) return { dash: 6, gap: 6, on: false }
const dash = pattern[0]
return { dash, gap: pattern[1] ?? dash, on: true }
}
export function toggleDash(stroke: Stroke | undefined): Partial<Stroke> {
const { dash, gap, on } = dashState(stroke)
return { dashPattern: on ? [] : [Math.max(dash, 1), Math.max(gap, 1)] }
}
export function setDash(stroke: Stroke | undefined, value: number): Partial<Stroke> {
const { gap } = dashState(stroke)
return { dashPattern: [Math.max(1, value), gap] }
}
export function setGap(stroke: Stroke | undefined, value: number): Partial<Stroke> {
const { dash } = dashState(stroke)
return { dashPattern: [dash, Math.max(1, value)] }
}
export function borderWeight(activeNode: SceneNode | null, side: (typeof BORDER_SIDES)[number]) {
if (!activeNode) return 0
const key = `border${side[0].toUpperCase()}${side.slice(1)}Weight` as keyof SceneNode
const value = activeNode[key]
return typeof value === 'number' ? value : 0
}
export function createStrokeSideActions(editor: Editor, sideMenuOpen: Ref<boolean>) {
function selectSide(side: StrokeSides, activeNode: SceneNode) {
function selectSide(side: StrokeSides, activeNode: SceneNode | null) {
if (!activeNode) return
const weight = activeNode.strokes.length > 0 ? activeNode.strokes[0].weight : 1
if (side === 'ALL') {
editor.updateNodeWithUndo(
@ -108,8 +139,9 @@ export function createStrokeSideActions(editor: Editor, sideMenuOpen: Ref<boolea
function updateBorderWeight(
side: (typeof BORDER_SIDES)[number],
value: number,
activeNode: SceneNode
activeNode: SceneNode | null
) {
if (!activeNode) return
const key = `border${side[0].toUpperCase()}${side.slice(1)}Weight` as keyof SceneNode
editor.updateNodeWithUndo(
activeNode.id,

View file

@ -4,9 +4,14 @@ import {
BORDER_SIDES,
DEFAULT_STROKE,
SIDE_OPTIONS,
borderWeight,
createStrokeSideActions,
currentAlign,
currentSides,
dashState,
setDash,
setGap,
toggleDash,
updateAlign
} from '#vue/controls/stroke/helpers'
import { useEditor } from '#vue/editor/context'
@ -38,6 +43,11 @@ export function useStrokeControls() {
updateAlign: updateAlign.bind(null, store),
currentAlign,
currentSides,
dashState,
toggleDash,
setDash,
setGap,
borderWeight,
selectSide,
updateBorderWeight
}

View file

@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts" generic="K extends ArrayPropKey">
import { computed } from 'vue'
import { useEditor } from '#vue/editor/context'
@ -8,20 +8,28 @@ import { useSceneComputed } from '#vue/internal/scene-computed/use'
import { providePropertyList } from '#vue/primitives/PropertyList/context'
import type { Effect, Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
import type { ArrayPropKey } from '#vue/primitives/PropertyList/context'
type ArrayItemFor<T extends ArrayPropKey> = T extends 'fills'
? Fill
: T extends 'strokes'
? Stroke
: Effect
type ArrayPropKey = 'fills' | 'strokes' | 'effects'
type ArrayItemType = Fill | Stroke | Effect
type PropertyListItem = ArrayItemFor<K>
type PropertyListPatch = Partial<PropertyListItem>
const { propKey } = defineProps<{
propKey: ArrayPropKey
propKey: K
label?: string
}>()
const emit = defineEmits<{
add: [item: ArrayItemType]
add: [item: PropertyListItem]
remove: [index: number]
update: [index: number, item: ArrayItemType]
patch: [index: number, changes: Record<string, unknown>]
update: [index: number, item: PropertyListItem]
patch: [index: number, changes: PropertyListPatch]
toggleVisibility: [index: number]
}>()
@ -42,10 +50,10 @@ const active = computed(() => selectedNodes.value.length > 0)
const isMixed = computed(() => isArrayMixed(propKey))
const items = useSceneComputed(() => {
const items = useSceneComputed<PropertyListItem[]>(() => {
void editor.state.sceneVersion
if (isMixed.value) return []
return (activeNode.value?.[propKey] ?? []) as ArrayItemType[]
return (activeNode.value?.[propKey] ?? []) as PropertyListItem[]
})
function targetNodes(): SceneNode[] {
@ -53,11 +61,15 @@ function targetNodes(): SceneNode[] {
return activeNode.value ? [activeNode.value] : []
}
function add(defaults: ArrayItemType) {
function propArray(node: SceneNode): PropertyListItem[] {
return node[propKey] as PropertyListItem[]
}
function add(defaults: PropertyListItem) {
batch.flush()
emit('add', defaults)
for (const n of targetNodes()) {
const arr = isMulti.value ? [defaults] : [...n[propKey], defaults]
const arr = isMulti.value ? [defaults] : [...propArray(n), defaults]
editor.updateNodeWithUndo(
n.id,
{ [propKey]: arr } as Partial<SceneNode>,
@ -73,35 +85,35 @@ function remove(index: number) {
editor.updateNodeWithUndo(
n.id,
{
[propKey]: (n[propKey] as ArrayItemType[]).filter((_, i) => i !== index)
[propKey]: propArray(n).filter((_, i) => i !== index)
} as Partial<SceneNode>,
`Remove ${propKey}`
)
}
}
function update(index: number, item: ArrayItemType) {
function update(index: number, item: PropertyListItem) {
emit('update', index, item)
const nodes = targetNodes()
if (nodes.length === 0) return
const key = `update:${propKey}:${index}:${nodes.map((n) => n.id).join(',')}`
batch.ensure(key, `Change ${propKey}`)
for (const n of nodes) {
const arr = [...n[propKey]] as ArrayItemType[]
const arr = [...propArray(n)]
arr[index] = item
editor.updateNodeWithUndo(n.id, { [propKey]: arr } as Partial<SceneNode>, `Change ${propKey}`)
}
}
function patch(index: number, changes: Record<string, unknown>) {
function patch(index: number, changes: PropertyListPatch) {
emit('patch', index, changes)
const nodes = targetNodes()
if (nodes.length === 0) return
const key = `patch:${propKey}:${index}:${nodes.map((n) => n.id).join(',')}`
batch.ensure(key, `Change ${propKey}`)
for (const n of nodes) {
const arr = [...n[propKey]] as ArrayItemType[]
arr[index] = { ...arr[index], ...changes } as ArrayItemType
const arr = [...propArray(n)]
arr[index] = { ...arr[index], ...changes } as PropertyListItem
editor.updateNodeWithUndo(n.id, { [propKey]: arr } as Partial<SceneNode>, `Change ${propKey}`)
}
}
@ -121,7 +133,7 @@ function toggleVisibility(index: number) {
newArr[index] = { ...newArr[index], visible: !arr[index].visible }
editor.updateNodeWithUndo(
n.id,
{ [propKey]: newArr } as Partial<SceneNode>,
{ [propKey]: newArr as ArrayItemType[] } as Partial<SceneNode>,
`Toggle ${propKey} visibility`
)
}
@ -151,6 +163,16 @@ providePropertyList({
patch,
toggleVisibility
})
defineSlots<{
default?: (props: {
items: PropertyListItem[]
isMixed: boolean
isMulti: boolean
activeNode: SceneNode | null
actions: typeof actions
}) => unknown
}>()
</script>
<template>

View file

@ -3,7 +3,7 @@ import { type ComputedRef, type InjectionKey, inject, provide } from 'vue'
import type { Editor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/scene-graph'
type ArrayPropKey = 'fills' | 'strokes' | 'effects'
export type ArrayPropKey = 'fills' | 'strokes' | 'effects'
export interface PropertyListContext<T = unknown> {
editor: Editor

View file

@ -35,7 +35,7 @@ const { panels } = useI18n()
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedEffectsHelp }}</p>
<div
v-for="(effect, i) in items as Effect[]"
v-for="(effect, i) in items"
:key="`${i}:${effect.visible ? 'visible' : 'hidden'}`"
data-test-id="effect-item"
:data-test-index="i"

View file

@ -63,7 +63,7 @@ function updateFillHex(
</template>
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedFillsHelp }}</p>
<ColorStyleRow
v-for="(fill, i) in items as Fill[]"
v-for="(fill, i) in items"
:key="`${i}:${fill.visible ? 'visible' : 'hidden'}`"
:item="fill"
:index="i"

View file

@ -40,7 +40,8 @@ function updateStrokeColor(
patch(index, applySolidStrokeColor(color))
}
function onToggleSides(activeNode: SceneNode) {
function onToggleSides(activeNode: SceneNode | null) {
if (!activeNode) return
const next = !expandedSides.value
expandedSides.value = next
if (next && !activeNode.independentStrokeWeights) {
@ -51,34 +52,11 @@ function onToggleSides(activeNode: SceneNode) {
borderRightWeight: weight,
borderBottomWeight: weight,
borderLeftWeight: weight
} as SceneNode)
})
} else if (!next && activeNode.independentStrokeWeights) {
strokeCtx.selectSide('ALL', activeNode)
}
}
type StrokePatch = (i: number, partial: Partial<Stroke>) => void
function dashState(stroke: Stroke | undefined): { dash: number; gap: number; on: boolean } {
const p = stroke?.dashPattern
if (!p || p.length === 0) return { dash: 6, gap: 6, on: false }
return { dash: p[0] ?? 6, gap: p[1] ?? p[0] ?? 6, on: true }
}
function toggleDash(stroke: Stroke | undefined, patch: StrokePatch) {
const { dash, gap, on } = dashState(stroke)
patch(0, { dashPattern: on ? [] : [Math.max(dash, 1), Math.max(gap, 1)] })
}
function setDash(stroke: Stroke | undefined, patch: StrokePatch, value: number) {
const { gap } = dashState(stroke)
patch(0, { dashPattern: [Math.max(1, value), gap] })
}
function setGap(stroke: Stroke | undefined, patch: StrokePatch, value: number) {
const { dash } = dashState(stroke)
patch(0, { dashPattern: [dash, Math.max(1, value)] })
}
</script>
<template>
@ -101,7 +79,7 @@ function setGap(stroke: Stroke | undefined, patch: StrokePatch, value: number) {
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedStrokesHelp }}</p>
<ColorStyleRow
v-for="(stroke, i) in items as Stroke[]"
v-for="(stroke, i) in items"
:key="`${i}:${stroke.visible ? 'visible' : 'hidden'}`"
:item="stroke"
:index="i"
@ -142,23 +120,20 @@ function setGap(stroke: Stroke | undefined, patch: StrokePatch, value: number) {
/>
</ColorStyleRow>
<div
v-if="!isMixed && (items as unknown[]).length > 0"
class="mt-1 flex items-center gap-1.5"
>
<div v-if="!isMixed && items.length > 0" class="mt-1 flex items-center gap-1.5">
<AppSelect
class="w-[72px]"
:label="panels.strokeType"
:model-value="strokeCtx.currentAlign(activeNode)"
:options="strokeCtx.alignOptions"
@update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode!)"
@update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode)"
/>
<Tip :label="panels.strokeWeight">
<ScrubInput
v-if="!expandedSides"
class="flex-1"
icon="W"
:model-value="activeNode!.strokes[0]?.weight ?? 1"
:model-value="items[0]?.weight ?? 1"
:min="0"
@update:model-value="actions.patch(0, { weight: $event })"
/>
@ -169,68 +144,57 @@ function setGap(stroke: Stroke | undefined, patch: StrokePatch, value: number) {
class="size-[26px] shrink-0"
:active="expandedSides"
data-test-id="stroke-sides-toggle"
@click="onToggleSides(activeNode!)"
@click="onToggleSides(activeNode)"
>
<icon-lucide-grid-2x2 class="size-3.5" />
</IconButton>
</div>
<div
v-if="!isMixed && (items as unknown[]).length > 0"
class="mt-1.5 flex items-center gap-1.5"
>
<div v-if="!isMixed && items.length > 0" class="mt-1.5 flex items-center gap-1.5">
<IconButton
:label="panels.strokeDash"
size="md"
class="shrink-0"
:active="dashState((items as Stroke[])[0]).on"
:active="strokeCtx.dashState(items[0]).on"
data-test-id="stroke-dash-toggle"
@click="toggleDash((items as Stroke[])[0], actions.patch)"
@click="actions.patch(0, strokeCtx.toggleDash(items[0]))"
>
<span class="flex items-center gap-0.5">
<icon-lucide-minus class="size-2.5" />
<icon-lucide-minus class="size-2.5" />
</span>
</IconButton>
<template v-if="dashState((items as Stroke[])[0]).on">
<template v-if="strokeCtx.dashState(items[0]).on">
<ScrubInput
class="flex-1"
icon="D"
:model-value="(items as Stroke[])[0].dashPattern?.[0] ?? 6"
:model-value="items[0]?.dashPattern?.[0] ?? 6"
:min="1"
data-test-id="stroke-dash-length"
@update:model-value="setDash((items as Stroke[])[0], actions.patch, $event)"
@update:model-value="actions.patch(0, strokeCtx.setDash(items[0], $event))"
/>
<ScrubInput
class="flex-1"
icon="G"
:model-value="
(items as Stroke[])[0].dashPattern?.[1] ??
(items as Stroke[])[0].dashPattern?.[0] ??
6
"
:model-value="items[0]?.dashPattern?.[1] ?? items[0]?.dashPattern?.[0] ?? 6"
:min="1"
data-test-id="stroke-dash-gap"
@update:model-value="setGap((items as Stroke[])[0], actions.patch, $event)"
@update:model-value="actions.patch(0, strokeCtx.setGap(items[0], $event))"
/>
</template>
</div>
<div
v-if="!isMixed && (items as unknown[]).length > 0 && expandedSides"
v-if="!isMixed && items.length > 0 && expandedSides"
class="mt-1.5 grid grid-cols-2 gap-1.5"
>
<ScrubInput
v-for="side in strokeCtx.borderSides"
:key="side"
:label="side[0].toUpperCase()"
:model-value="
activeNode![
`border${side[0].toUpperCase()}${side.slice(1)}Weight` as keyof SceneNode
] as number
"
:model-value="strokeCtx.borderWeight(activeNode, side)"
:min="0"
@update:model-value="strokeCtx.updateBorderWeight(side, $event, activeNode!)"
@update:model-value="strokeCtx.updateBorderWeight(side, $event, activeNode)"
/>
</div>
</PanelSection>