refactor(vue): type property list slots
This commit is contained in:
parent
dabeb3e92c
commit
65f57f721d
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue