StrokeSection: compose from PropertyListRoot (388→180 lines)

Stroke CRUD (add/remove/toggle/update color+weight) now uses
PropertyListRoot from SDK. Stroke-specific logic (sides, align,
border weights) stays in app component as functions taking activeNode.
This commit is contained in:
Danila Poyarkov 2026-03-17 15:08:34 +03:00
parent 8ba8c48a4c
commit 6ffe201bec
2 changed files with 316 additions and 345 deletions

View file

@ -3,6 +3,11 @@ name: Update Homebrew Tap
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v0.10.0)'
required: true
permissions: {}
@ -14,10 +19,11 @@ jobs:
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
TAG="${{ github.event.inputs.tag || github.ref_name }}"
VERSION="${TAG#v}"
TMPDIR=$(mktemp -d)
gh release download "$GITHUB_REF_NAME" \
gh release download "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--pattern 'OpenPencil_aarch64.app.tar.gz' \
--pattern 'OpenPencil_x64.app.tar.gz' \

View file

@ -1,12 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import AppSelect from '@/components/AppSelect.vue'
import { menuContent, menuItem } from '@/components/ui/menu'
import ColorInput from '@/components/ColorInput.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import Tip from '@/components/Tip.vue'
import { useNodeProps } from '@open-pencil/vue'
import { ref } from 'vue'
import {
DropdownMenuRoot,
DropdownMenuTrigger,
@ -15,23 +8,18 @@ import {
DropdownMenuPortal
} from 'reka-ui'
import type { Color, SceneNode, Stroke } from '@open-pencil/core'
import AppSelect from '@/components/AppSelect.vue'
import ColorInput from '@/components/ColorInput.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import Tip from '@/components/Tip.vue'
import { PropertyListRoot, useEditor } from '@open-pencil/vue'
import { menuContent, menuItem } from '@/components/ui/menu'
import type { SceneNode, Stroke } from '@open-pencil/core'
type StrokeSides = 'ALL' | 'TOP' | 'BOTTOM' | 'LEFT' | 'RIGHT' | 'CUSTOM'
const {
store,
isMulti,
active,
activeNode,
targetNodes,
isArrayMixed,
updateArrayItem,
removeArrayItem,
toggleArrayVisibility
} = useNodeProps()
const strokesAreMixed = computed(() => isArrayMixed('strokes'))
const store = useEditor()
const ALIGN_OPTIONS: { value: Stroke['align']; label: string }[] = [
{ value: 'INSIDE', label: 'Inside' },
@ -39,139 +27,6 @@ const ALIGN_OPTIONS: { value: Stroke['align']; label: string }[] = [
{ value: 'OUTSIDE', label: 'Outside' }
]
const currentAlign = computed<Stroke['align']>(() => {
const n = activeNode.value
if (!n || n.strokes.length === 0) return 'CENTER'
return n.strokes[0].align
})
const currentSides = computed<StrokeSides>(() => {
const n = activeNode.value
if (!n || !n.independentStrokeWeights) return 'ALL'
const { borderTopWeight: t, borderRightWeight: r, borderBottomWeight: b, borderLeftWeight: l } = n
const active = [t > 0, r > 0, b > 0, l > 0]
const count = active.filter(Boolean).length
if (count === 4 && t === r && r === b && b === l) return 'ALL'
if (count === 1) {
if (t > 0) return 'TOP'
if (b > 0) return 'BOTTOM'
if (l > 0) return 'LEFT'
if (r > 0) return 'RIGHT'
}
return 'CUSTOM'
})
const hasStrokes = computed(
() => !strokesAreMixed.value && (activeNode.value?.strokes?.length ?? 0) > 0
)
const sideMenuOpen = ref(false)
function updateColor(index: number, color: Color) {
updateArrayItem('strokes', index, { color }, 'Change stroke')
}
function updateWeight(index: number, weight: number) {
updateArrayItem('strokes', index, { weight }, 'Change stroke')
}
function updateAlign(align: Stroke['align']) {
for (const n of targetNodes()) {
const strokes = n.strokes.map((s) => ({ ...s, align }))
store.updateNodeWithUndo(n.id, { strokes }, 'Change stroke align')
}
}
function toggleVisibility(index: number) {
toggleArrayVisibility('strokes', index)
}
function add() {
const stroke: Stroke = {
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'CENTER'
}
for (const n of targetNodes()) {
const strokes = isMulti.value ? [stroke] : [...n.strokes, stroke]
store.updateNodeWithUndo(n.id, { strokes }, isMulti.value ? 'Set stroke' : 'Add stroke')
}
}
function remove(index: number) {
removeArrayItem('strokes', index, 'Remove stroke')
}
function selectSide(side: StrokeSides) {
for (const n of targetNodes()) {
const weight = n.strokes.length > 0 ? n.strokes[0].weight : 1
if (side === 'ALL') {
store.updateNodeWithUndo(
n.id,
{
independentStrokeWeights: false,
borderTopWeight: 0,
borderRightWeight: 0,
borderBottomWeight: 0,
borderLeftWeight: 0
} as Partial<SceneNode>,
'Stroke all sides'
)
} else if (side === 'CUSTOM') {
const current = n.independentStrokeWeights
? {
top: n.borderTopWeight,
right: n.borderRightWeight,
bottom: n.borderBottomWeight,
left: n.borderLeftWeight
}
: { top: weight, right: weight, bottom: weight, left: weight }
store.updateNodeWithUndo(
n.id,
{
independentStrokeWeights: true,
borderTopWeight: current.top,
borderRightWeight: current.right,
borderBottomWeight: current.bottom,
borderLeftWeight: current.left
} as Partial<SceneNode>,
'Custom stroke sides'
)
} else {
store.updateNodeWithUndo(
n.id,
{
independentStrokeWeights: true,
borderTopWeight: side === 'TOP' ? weight : 0,
borderRightWeight: side === 'RIGHT' ? weight : 0,
borderBottomWeight: side === 'BOTTOM' ? weight : 0,
borderLeftWeight: side === 'LEFT' ? weight : 0
} as Partial<SceneNode>,
`Stroke ${side.toLowerCase()} only`
)
}
}
sideMenuOpen.value = false
}
function updateBorderWeight(side: 'top' | 'right' | 'bottom' | 'left', value: number) {
const fieldMap = {
top: 'borderTopWeight',
right: 'borderRightWeight',
bottom: 'borderBottomWeight',
left: 'borderLeftWeight'
} as const
for (const n of targetNodes()) {
store.updateNodeWithUndo(
n.id,
{ [fieldMap[side]]: value } as Partial<SceneNode>,
'Change stroke weight'
)
}
}
const SIDE_OPTIONS: { value: StrokeSides; label: string }[] = [
{ value: 'ALL', label: 'All' },
{ value: 'TOP', label: 'Top' },
@ -183,206 +38,316 @@ const SIDE_OPTIONS: { value: StrokeSides; label: string }[] = [
const BORDER_SIDES = ['top', 'right', 'bottom', 'left'] as const
const borderWeights = computed(() => {
const n = activeNode.value
return {
top: n?.borderTopWeight ?? 0,
right: n?.borderRightWeight ?? 0,
bottom: n?.borderBottomWeight ?? 0,
left: n?.borderLeftWeight ?? 0
const sideMenuOpen = ref(false)
function updateAlign(align: Stroke['align'], activeNode: SceneNode) {
const strokes = activeNode.strokes.map((s) => ({ ...s, align }))
store.updateNodeWithUndo(activeNode.id, { strokes }, 'Change stroke align')
}
function currentAlign(activeNode: SceneNode | null): Stroke['align'] {
if (!activeNode || activeNode.strokes.length === 0) return 'CENTER'
return activeNode.strokes[0].align
}
function currentSides(activeNode: SceneNode | null): StrokeSides {
if (!activeNode?.independentStrokeWeights) return 'ALL'
const {
borderTopWeight: t,
borderRightWeight: r,
borderBottomWeight: b,
borderLeftWeight: l
} = activeNode
const active = [t > 0, r > 0, b > 0, l > 0]
const count = active.filter(Boolean).length
if (count === 4 && t === r && r === b && b === l) return 'ALL'
if (count === 1) {
if (t > 0) return 'TOP'
if (b > 0) return 'BOTTOM'
if (l > 0) return 'LEFT'
if (r > 0) return 'RIGHT'
}
})
return 'CUSTOM'
}
function selectSide(side: StrokeSides, activeNode: SceneNode) {
const weight = activeNode.strokes.length > 0 ? activeNode.strokes[0].weight : 1
if (side === 'ALL') {
store.updateNodeWithUndo(
activeNode.id,
{
independentStrokeWeights: false,
borderTopWeight: 0,
borderRightWeight: 0,
borderBottomWeight: 0,
borderLeftWeight: 0
} as Partial<SceneNode>,
'Stroke all sides'
)
} else if (side === 'CUSTOM') {
const w = activeNode.independentStrokeWeights
? {
top: activeNode.borderTopWeight,
right: activeNode.borderRightWeight,
bottom: activeNode.borderBottomWeight,
left: activeNode.borderLeftWeight
}
: { top: weight, right: weight, bottom: weight, left: weight }
store.updateNodeWithUndo(
activeNode.id,
{
independentStrokeWeights: true,
borderTopWeight: w.top,
borderRightWeight: w.right,
borderBottomWeight: w.bottom,
borderLeftWeight: w.left
} as Partial<SceneNode>,
'Custom stroke sides'
)
} else {
store.updateNodeWithUndo(
activeNode.id,
{
independentStrokeWeights: true,
borderTopWeight: side === 'TOP' ? weight : 0,
borderRightWeight: side === 'RIGHT' ? weight : 0,
borderBottomWeight: side === 'BOTTOM' ? weight : 0,
borderLeftWeight: side === 'LEFT' ? weight : 0
} as Partial<SceneNode>,
`Stroke ${side.toLowerCase()} only`
)
}
sideMenuOpen.value = false
}
function updateBorderWeight(
side: (typeof BORDER_SIDES)[number],
value: number,
activeNode: SceneNode
) {
const key = `border${side[0].toUpperCase()}${side.slice(1)}Weight` as keyof SceneNode
store.updateNodeWithUndo(
activeNode.id,
{ [key]: value } as Partial<SceneNode>,
'Change stroke weight'
)
}
const DEFAULT_STROKE: Stroke = {
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'CENTER'
}
</script>
<template>
<div v-if="active" data-test-id="stroke-section" class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="mb-1 block text-[11px] text-muted">Stroke</label>
<button
data-test-id="stroke-section-add"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="add"
>
+
</button>
</div>
<PropertyListRoot
v-slot="{ items, isMixed, activeNode, add, remove, patch, toggleVisibility }"
prop-key="strokes"
label="Stroke"
>
<div data-test-id="stroke-section" class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="mb-1 block text-[11px] text-muted">Stroke</label>
<button
data-test-id="stroke-section-add"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="add(DEFAULT_STROKE)"
>
+
</button>
</div>
<p v-if="strokesAreMixed" class="text-[11px] text-muted">Click + to replace mixed strokes</p>
<p v-if="isMixed" class="text-[11px] text-muted">Click + to replace mixed strokes</p>
<!-- Color row per stroke -->
<div
v-for="(stroke, i) in strokesAreMixed ? [] : (activeNode?.strokes ?? [])"
:key="i"
data-test-id="stroke-item"
:data-test-index="i"
class="group flex items-center gap-1.5 py-0.5"
>
<ColorInput
class="min-w-0 flex-1"
:color="stroke.color"
editable
@update="updateColor(i, $event)"
/>
<button
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@click="toggleVisibility(i)"
<div
v-for="(stroke, i) in items as Stroke[]"
:key="i"
data-test-id="stroke-item"
:data-test-index="i"
class="group flex items-center gap-1.5 py-0.5"
>
<icon-lucide-eye v-if="stroke.visible" class="size-3.5" />
<icon-lucide-eye-off v-else class="size-3.5" />
</button>
<button
class="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="remove(i)"
>
−
</button>
</div>
<ColorInput
class="min-w-0 flex-1"
:color="stroke.color"
editable
@update="patch(i, { color: $event })"
/>
<button
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@click="toggleVisibility(i)"
>
<icon-lucide-eye v-if="stroke.visible" class="size-3.5" />
<icon-lucide-eye-off v-else class="size-3.5" />
</button>
<button
class="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="remove(i)"
>
−
</button>
</div>
<!-- Stroke details row: [Align ▾] [≡ Weight] [Side selector] -->
<div v-if="hasStrokes" class="mt-1 flex items-center gap-1.5">
<AppSelect
class="w-[72px]"
:model-value="currentAlign"
:options="ALIGN_OPTIONS"
@update:model-value="updateAlign($event as Stroke['align'])"
/>
<ScrubInput
v-if="!activeNode?.independentStrokeWeights"
class="flex-1"
:model-value="activeNode?.strokes[0]?.weight ?? 1"
:min="0"
@update:model-value="updateWeight(0, $event)"
<!-- Stroke details -->
<div
v-if="!isMixed && activeNode && activeNode.strokes.length > 0"
class="mt-1 flex items-center gap-1.5"
>
<template #icon>
<svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<line x1="1" y1="3" x2="11" y2="3" />
<line x1="1" y1="6" x2="11" y2="6" />
<line x1="1" y1="9" x2="11" y2="9" />
</svg>
</template>
</ScrubInput>
<DropdownMenuRoot v-model:open="sideMenuOpen">
<Tip label="Stroke sides">
<DropdownMenuTrigger as-child>
<button
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
:class="{ '!border-accent !text-accent': activeNode?.independentStrokeWeights }"
<AppSelect
class="w-[72px]"
:model-value="currentAlign(activeNode)"
:options="ALIGN_OPTIONS"
@update:model-value="updateAlign($event as Stroke['align'], activeNode)"
/>
<ScrubInput
v-if="!activeNode.independentStrokeWeights"
class="flex-1"
:model-value="activeNode.strokes[0]?.weight ?? 1"
:min="0"
@update:model-value="patch(0, { weight: $event })"
>
<template #icon>
<svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<svg class="size-3.5" viewBox="0 0 14 14" fill="currentColor">
<rect x="1" y="1" width="5" height="5" rx="1" />
<rect x="8" y="1" width="5" height="5" rx="1" />
<rect x="1" y="8" width="5" height="5" rx="1" />
<rect x="8" y="8" width="5" height="5" rx="1" />
</svg>
</button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuPortal>
<DropdownMenuContent
:side-offset="4"
align="end"
:class="menuContent({ class: 'min-w-[140px] rounded-md p-0.5' })"
>
<DropdownMenuItem
v-for="opt in SIDE_OPTIONS"
:key="opt.value"
:class="menuItem({ justify: 'start', class: 'relative px-2' })"
@click="selectSide(opt.value)"
>
<icon-lucide-check
v-if="currentSides === opt.value"
class="absolute left-2 size-3 text-accent"
/>
<span class="flex items-center gap-2 pl-5">
<svg
class="size-3.5"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<template v-if="opt.value === 'ALL'">
<rect x="1" y="1" width="12" height="12" rx="1" />
</template>
<template v-else-if="opt.value === 'CUSTOM'">
<line x1="4" y1="7" x2="10" y2="7" />
<line x1="7" y1="4" x2="7" y2="10" />
</template>
<template v-else>
<rect
x="1"
y="1"
width="12"
height="12"
rx="1"
stroke-opacity="0.3"
stroke-dasharray="2 2"
/>
<line v-if="opt.value === 'TOP'" x1="1" y1="1" x2="13" y2="1" />
<line v-else-if="opt.value === 'BOTTOM'" x1="1" y1="13" x2="13" y2="13" />
<line v-else-if="opt.value === 'LEFT'" x1="1" y1="1" x2="1" y2="13" />
<line v-else-if="opt.value === 'RIGHT'" x1="13" y1="1" x2="13" y2="13" />
</template>
<line x1="1" y1="3" x2="11" y2="3" />
<line x1="1" y1="6" x2="11" y2="6" />
<line x1="1" y1="9" x2="11" y2="9" />
</svg>
</template>
</ScrubInput>
<DropdownMenuRoot v-model:open="sideMenuOpen">
<Tip label="Stroke sides">
<DropdownMenuTrigger as-child>
<button
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
:class="{ '!border-accent !text-accent': activeNode.independentStrokeWeights }"
>
<svg class="size-3.5" viewBox="0 0 14 14" fill="currentColor">
<rect x="1" y="1" width="5" height="5" rx="1" />
<rect x="8" y="1" width="5" height="5" rx="1" />
<rect x="1" y="8" width="5" height="5" rx="1" />
<rect x="8" y="8" width="5" height="5" rx="1" />
</svg>
<span>{{ opt.label }}</span>
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
</button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuPortal>
<DropdownMenuContent
:side-offset="4"
align="end"
:class="menuContent({ class: 'min-w-[140px] rounded-md p-0.5' })"
>
<DropdownMenuItem
v-for="opt in SIDE_OPTIONS"
:key="opt.value"
:class="menuItem({ justify: 'start', class: 'relative px-2' })"
@click="selectSide(opt.value, activeNode)"
>
<icon-lucide-check
v-if="currentSides(activeNode) === opt.value"
class="absolute left-2 size-3 text-accent"
/>
<span class="flex items-center gap-2 pl-5">
<svg
class="size-3.5"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<template v-if="opt.value === 'ALL'">
<rect x="1" y="1" width="12" height="12" rx="1" />
</template>
<template v-else-if="opt.value === 'CUSTOM'">
<line x1="4" y1="7" x2="10" y2="7" />
<line x1="7" y1="4" x2="7" y2="10" />
</template>
<template v-else>
<rect
x="1"
y="1"
width="12"
height="12"
rx="1"
stroke-opacity="0.3"
stroke-dasharray="2 2"
/>
<line v-if="opt.value === 'TOP'" x1="1" y1="1" x2="13" y2="1" />
<line v-else-if="opt.value === 'BOTTOM'" x1="1" y1="13" x2="13" y2="13" />
<line v-else-if="opt.value === 'LEFT'" x1="1" y1="1" x2="1" y2="13" />
<line v-else-if="opt.value === 'RIGHT'" x1="13" y1="1" x2="13" y2="13" />
</template>
</svg>
<span>{{ opt.label }}</span>
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<!-- Individual side weights (2×2 grid) -->
<div
v-if="hasStrokes && activeNode?.independentStrokeWeights"
class="mt-1.5 grid grid-cols-2 gap-1.5"
>
<ScrubInput
v-for="side in BORDER_SIDES"
:key="side"
:model-value="borderWeights[side]"
:min="0"
@update:model-value="updateBorderWeight(side, $event)"
<!-- Individual border weights -->
<div
v-if="
!isMixed &&
activeNode &&
activeNode.strokes.length > 0 &&
activeNode.independentStrokeWeights
"
class="mt-1.5 grid grid-cols-2 gap-1.5"
>
<template #icon>
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke-width="1.5">
<rect
x="1"
y="1"
width="10"
height="10"
rx="1"
stroke="currentColor"
stroke-opacity="0.3"
stroke-dasharray="2 2"
/>
<line v-if="side === 'top'" x1="1" y1="1" x2="11" y2="1" stroke="currentColor" />
<line
v-else-if="side === 'right'"
x1="11"
y1="1"
x2="11"
y2="11"
stroke="currentColor"
/>
<line
v-else-if="side === 'bottom'"
x1="1"
y1="11"
x2="11"
y2="11"
stroke="currentColor"
/>
<line v-else x1="1" y1="1" x2="1" y2="11" stroke="currentColor" />
</svg>
</template>
</ScrubInput>
<ScrubInput
v-for="side in BORDER_SIDES"
:key="side"
:model-value="
activeNode[
`border${side[0].toUpperCase()}${side.slice(1)}Weight` as keyof SceneNode
] as number
"
:min="0"
@update:model-value="updateBorderWeight(side, $event, activeNode)"
>
<template #icon>
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke-width="1.5">
<rect
x="1"
y="1"
width="10"
height="10"
rx="1"
stroke="currentColor"
stroke-opacity="0.3"
stroke-dasharray="2 2"
/>
<line v-if="side === 'top'" x1="1" y1="1" x2="11" y2="1" stroke="currentColor" />
<line
v-else-if="side === 'right'"
x1="11"
y1="1"
x2="11"
y2="11"
stroke="currentColor"
/>
<line
v-else-if="side === 'bottom'"
x1="1"
y1="11"
x2="11"
y2="11"
stroke="currentColor"
/>
<line v-else x1="1" y1="1" x2="1" y2="11" stroke="currentColor" />
</svg>
</template>
</ScrubInput>
</div>
</div>
</div>
</PropertyListRoot>
</template>