openpencil/src/components/properties/StrokeSection.vue

339 lines
12 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { ref } from 'vue'
2026-03-28 17:33:35 +00:00
import {
applySolidStrokeColor,
BindableValueRoot,
MIXED,
useColorBindingProvider,
useI18n,
2026-03-28 17:33:35 +00:00
useOkHCL,
useStrokeControls
2026-03-28 17:33:35 +00:00
} from '@open-pencil/vue'
import ColorPicker from '@/components/ColorPicker/ColorPicker.vue'
import NumberField from '@/components/inputs/NumberField.vue'
import PropertyItemRow from '@/components/properties/item-list/PropertyItemRow.vue'
import PaintField from '@/components/properties/paint/PaintField.vue'
import PaintValue from '@/components/properties/paint/PaintValue.vue'
import {
applyPaintMutation,
cancelPaintMutation,
commitPaintMutation,
paintBindingTargets
} from '@/components/properties/paint/binding'
import { createStrokeOkhclAdapter } from '@/components/properties/paint/okhcl'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import VariableBindingPicker from '@/components/properties/binding/VariableBindingPicker.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import FillSwatch from '@/components/ui/FillSwatch.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 PanelSection from '@/components/ui/panel/PanelSection.vue'
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import Tip from '@/components/ui/Tip.vue'
import { colorToHexRaw } from '@open-pencil/core/color'
import type { Color, Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
import type { BindableValueActions } from '@open-pencil/vue'
2026-03-23 14:42:58 +00:00
const strokeCtx = useStrokeControls()
const { advancedActive, cap, join, miterLimit } = strokeCtx
const colorProvider = useColorBindingProvider()
2026-03-28 17:33:35 +00:00
const okhcl = useOkHCL()
const { panels, dialogs } = useI18n()
const expandedSides = ref(false)
function strokePreview(stroke: Stroke, color: Color): Fill {
return {
type: 'SOLID',
color,
opacity: stroke.opacity,
visible: stroke.visible
}
}
function updateStrokeColor(
binding: BindableValueActions<Color>,
flush: () => void,
color: Color,
patch: (changes: Partial<Stroke>) => void,
commit: boolean
) {
if (!applyPaintMutation(binding, flush, () => patch(applySolidStrokeColor(color)))) return
if (commit) commitPaintMutation(binding)
}
function setCap(value: string) {
if (value === 'NONE' || value === 'ROUND' || value === 'SQUARE') {
strokeCtx.setCap(value)
}
}
function setJoin(value: string) {
if (value === 'MITER' || value === 'BEVEL' || value === 'ROUND') {
strokeCtx.setJoin(value)
}
}
function onToggleSides(activeNode: SceneNode | null) {
if (!activeNode) return
const next = !expandedSides.value
expandedSides.value = next
if (next && !activeNode.independentStrokeWeights) {
const weight = activeNode.strokes[0]?.weight ?? 1
2026-03-28 17:33:35 +00:00
strokeCtx.selectSide('CUSTOM', {
...activeNode,
borderTopWeight: weight,
borderRightWeight: weight,
borderBottomWeight: weight,
borderLeftWeight: weight
})
} else if (!next && activeNode.independentStrokeWeights) {
strokeCtx.selectSide('ALL', activeNode)
}
}
</script>
<template>
2026-03-23 14:42:58 +00:00
<PropertyListRoot
v-slot="{ items, isMixed, activeNode, selectedNodeIds, flush, actions }"
2026-03-23 14:42:58 +00:00
prop-key="strokes"
2026-03-25 15:47:23 +00:00
:label="panels.stroke"
2026-03-23 14:42:58 +00:00
>
<PanelSection :label="panels.stroke" :empty="!isMixed && items.length === 0">
<template #actions>
<IconButton :label="panels.addStroke" @click="actions.add(strokeCtx.defaultStroke)">
<icon-lucide-plus class="size-3.5" />
</IconButton>
</template>
<SharedStyleField kind="stroke" :label="panels.strokeStyle" />
2026-03-25 15:47:23 +00:00
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedStrokesHelp }}</p>
<PropertyItemRow
v-for="(stroke, index) in items"
:key="`${index}:${stroke.visible ? 'visible' : 'hidden'}`"
prop-key="strokes"
:index="index"
:visibility-label="panels.toggleVisibility"
2026-05-19 12:41:38 +00:00
:remove-label="panels.removeStroke"
2026-03-23 14:42:58 +00:00
>
<BindableValueRoot
v-slot="binding"
:provider="colorProvider"
:targets="paintBindingTargets(selectedNodeIds, 'strokes', index)"
:value="stroke.color"
batch-label="Change stroke color"
>
<PaintField
:opacity="stroke.opacity"
:opacity-label="panels.opacity"
@update:opacity="actions.patch(index, { opacity: $event })"
>
<template #preview>
<ColorPicker
:color="binding.resolvedValue ?? stroke.color"
:okhcl="createStrokeOkhclAdapter(okhcl, activeNode, index)"
@update="
updateStrokeColor(
binding.actions,
flush,
$event,
(changes) => actions.patch(index, changes),
false
)
"
@open-change="!$event && commitPaintMutation(binding.actions)"
@cancel="cancelPaintMutation(binding.actions)"
>
<template #trigger>
<button
type="button"
:aria-label="panels.stroke"
class="size-4 shrink-0 cursor-pointer rounded-sm border-0 bg-transparent p-0"
>
<FillSwatch
:fill="strokePreview(stroke, binding.resolvedValue ?? stroke.color)"
class="size-full"
/>
</button>
</template>
</ColorPicker>
</template>
<template #value>
<PaintValue
:color="stroke.color"
:resolved-color="binding.resolvedValue"
:variable-name="binding.variable?.name"
:label="panels.stroke"
@update="
updateStrokeColor(
binding.actions,
flush,
$event,
(changes) => actions.patch(index, changes),
true
)
"
/>
</template>
<template #binding>
<VariableBindingPicker
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:detach-label="panels.detachVariable"
:create-label="
panels.createColorVariable({ value: `#${colorToHexRaw(stroke.color)}` })
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
/>
</template>
</PaintField>
</BindableValueRoot>
</PropertyItemRow>
<div v-if="!isMixed && items.length > 0" class="mt-1 flex items-center gap-1.5">
2026-05-22 18:24:02 +00:00
<AppSelect
:label="panels.strokeType"
:ui="{ trigger: 'w-[88px] flex-none' }"
2026-05-22 18:24:02 +00:00
:model-value="strokeCtx.currentAlign(activeNode)"
:options="strokeCtx.alignOptions"
data-property="stroke-align"
@update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode)"
2026-05-22 18:24:02 +00:00
/>
<Tip :label="panels.strokeWeight">
<NumberField
2026-05-22 18:24:02 +00:00
v-if="!expandedSides"
class="flex-1"
icon="W"
:model-value="items[0]?.weight ?? 1"
2026-05-22 18:24:02 +00:00
:min="0"
data-property="stroke-weight"
2026-05-22 18:24:02 +00:00
@update:model-value="actions.patch(0, { weight: $event })"
/>
</Tip>
<IconButton
:label="panels.strokeSides"
size="md"
class="size-[26px] shrink-0"
:active="expandedSides"
data-property="stroke-sides"
@click="onToggleSides(activeNode)"
>
2026-06-30 16:13:29 +00:00
<icon-lucide-layout-grid class="size-3.5" />
</IconButton>
2026-03-23 14:42:58 +00:00
</div>
<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="strokeCtx.dashState(items[0]).on"
data-property="stroke-dash"
@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="strokeCtx.dashState(items[0]).on">
<NumberField
fix(vector): render dashPattern strokes correctly on closed crescents Vector nodes with stroke.dashPattern were rendering as solid lines, and even when the dash was applied through brute outline-stroke conversion, closed crescent shapes (e.g. annular wedges) showed two parallel dashed arcs instead of a single arc along the centerline. Root cause: - drawVectorPathStrokes converted stroke to a fill outline before drawing, leaving no place for PathEffect.MakeDash to apply. - A closed crescent is a single closed path traced around both the outer and inner arcs, so dashing the path produces two visible arcs. Fix: - packages/core/src/canvas/scene.ts: in drawVectorPathStrokes, when the stroke has a dashPattern, skip the outline conversion and draw with strokePaint + PathEffect.MakeDash directly. In renderShapeUncached, dashed VECTOR nodes with a vectorNetwork now route through a new centerline path instead of the precomputed strokeGeometry. - packages/core/src/vector/index.ts: new vectorNetworkToCenterlinePath. For closed thin crescents (cycle of even length where pairwise opposite vertex distance is small relative to perimeter and consistent), detect the two cap segments via vertex direction-change angles, split the cycle into outer/inner subchains, pair vertices by angle, and emit a smooth Path.addArc when the midpoints fit a circle (fitCircleArc with 3-point construction + radius tolerance), falling back to a midpoint polyline otherwise. Open paths use the existing chain walk. JSX prop wiring so users can author dashed Vectors: - packages/core/src/design-jsx/tree.ts: add strokeDash?: number[] | boolean. - packages/core/src/design-jsx/renderer.ts: applyStrokeOverrides reads strokeDash and writes Stroke.dashPattern; true expands to [w*2, w*2]. - packages/core/src/io/formats/jsx/export.ts: solidStroke serializes dashPattern back to strokeDash on JSX export. Editor UI: - src/components/properties/StrokeSection.vue: dash toggle button plus ScrubInputs for dash and gap length, writing strokes[0].dashPattern. bun run check passes. Pre-existing test failures (drop shadow / auto layout) are unrelated to this change and reproduce on a clean upstream/master.
2026-04-30 06:12:59 +00:00
class="flex-1"
icon="D"
:model-value="items[0]?.dashPattern?.[0] ?? 6"
fix(vector): render dashPattern strokes correctly on closed crescents Vector nodes with stroke.dashPattern were rendering as solid lines, and even when the dash was applied through brute outline-stroke conversion, closed crescent shapes (e.g. annular wedges) showed two parallel dashed arcs instead of a single arc along the centerline. Root cause: - drawVectorPathStrokes converted stroke to a fill outline before drawing, leaving no place for PathEffect.MakeDash to apply. - A closed crescent is a single closed path traced around both the outer and inner arcs, so dashing the path produces two visible arcs. Fix: - packages/core/src/canvas/scene.ts: in drawVectorPathStrokes, when the stroke has a dashPattern, skip the outline conversion and draw with strokePaint + PathEffect.MakeDash directly. In renderShapeUncached, dashed VECTOR nodes with a vectorNetwork now route through a new centerline path instead of the precomputed strokeGeometry. - packages/core/src/vector/index.ts: new vectorNetworkToCenterlinePath. For closed thin crescents (cycle of even length where pairwise opposite vertex distance is small relative to perimeter and consistent), detect the two cap segments via vertex direction-change angles, split the cycle into outer/inner subchains, pair vertices by angle, and emit a smooth Path.addArc when the midpoints fit a circle (fitCircleArc with 3-point construction + radius tolerance), falling back to a midpoint polyline otherwise. Open paths use the existing chain walk. JSX prop wiring so users can author dashed Vectors: - packages/core/src/design-jsx/tree.ts: add strokeDash?: number[] | boolean. - packages/core/src/design-jsx/renderer.ts: applyStrokeOverrides reads strokeDash and writes Stroke.dashPattern; true expands to [w*2, w*2]. - packages/core/src/io/formats/jsx/export.ts: solidStroke serializes dashPattern back to strokeDash on JSX export. Editor UI: - src/components/properties/StrokeSection.vue: dash toggle button plus ScrubInputs for dash and gap length, writing strokes[0].dashPattern. bun run check passes. Pre-existing test failures (drop shadow / auto layout) are unrelated to this change and reproduce on a clean upstream/master.
2026-04-30 06:12:59 +00:00
:min="1"
data-property="stroke-dash-length"
@update:model-value="actions.patch(0, strokeCtx.setDash(items[0], $event))"
/>
<NumberField
fix(vector): render dashPattern strokes correctly on closed crescents Vector nodes with stroke.dashPattern were rendering as solid lines, and even when the dash was applied through brute outline-stroke conversion, closed crescent shapes (e.g. annular wedges) showed two parallel dashed arcs instead of a single arc along the centerline. Root cause: - drawVectorPathStrokes converted stroke to a fill outline before drawing, leaving no place for PathEffect.MakeDash to apply. - A closed crescent is a single closed path traced around both the outer and inner arcs, so dashing the path produces two visible arcs. Fix: - packages/core/src/canvas/scene.ts: in drawVectorPathStrokes, when the stroke has a dashPattern, skip the outline conversion and draw with strokePaint + PathEffect.MakeDash directly. In renderShapeUncached, dashed VECTOR nodes with a vectorNetwork now route through a new centerline path instead of the precomputed strokeGeometry. - packages/core/src/vector/index.ts: new vectorNetworkToCenterlinePath. For closed thin crescents (cycle of even length where pairwise opposite vertex distance is small relative to perimeter and consistent), detect the two cap segments via vertex direction-change angles, split the cycle into outer/inner subchains, pair vertices by angle, and emit a smooth Path.addArc when the midpoints fit a circle (fitCircleArc with 3-point construction + radius tolerance), falling back to a midpoint polyline otherwise. Open paths use the existing chain walk. JSX prop wiring so users can author dashed Vectors: - packages/core/src/design-jsx/tree.ts: add strokeDash?: number[] | boolean. - packages/core/src/design-jsx/renderer.ts: applyStrokeOverrides reads strokeDash and writes Stroke.dashPattern; true expands to [w*2, w*2]. - packages/core/src/io/formats/jsx/export.ts: solidStroke serializes dashPattern back to strokeDash on JSX export. Editor UI: - src/components/properties/StrokeSection.vue: dash toggle button plus ScrubInputs for dash and gap length, writing strokes[0].dashPattern. bun run check passes. Pre-existing test failures (drop shadow / auto layout) are unrelated to this change and reproduce on a clean upstream/master.
2026-04-30 06:12:59 +00:00
class="flex-1"
icon="G"
:model-value="items[0]?.dashPattern?.[1] ?? items[0]?.dashPattern?.[0] ?? 6"
fix(vector): render dashPattern strokes correctly on closed crescents Vector nodes with stroke.dashPattern were rendering as solid lines, and even when the dash was applied through brute outline-stroke conversion, closed crescent shapes (e.g. annular wedges) showed two parallel dashed arcs instead of a single arc along the centerline. Root cause: - drawVectorPathStrokes converted stroke to a fill outline before drawing, leaving no place for PathEffect.MakeDash to apply. - A closed crescent is a single closed path traced around both the outer and inner arcs, so dashing the path produces two visible arcs. Fix: - packages/core/src/canvas/scene.ts: in drawVectorPathStrokes, when the stroke has a dashPattern, skip the outline conversion and draw with strokePaint + PathEffect.MakeDash directly. In renderShapeUncached, dashed VECTOR nodes with a vectorNetwork now route through a new centerline path instead of the precomputed strokeGeometry. - packages/core/src/vector/index.ts: new vectorNetworkToCenterlinePath. For closed thin crescents (cycle of even length where pairwise opposite vertex distance is small relative to perimeter and consistent), detect the two cap segments via vertex direction-change angles, split the cycle into outer/inner subchains, pair vertices by angle, and emit a smooth Path.addArc when the midpoints fit a circle (fitCircleArc with 3-point construction + radius tolerance), falling back to a midpoint polyline otherwise. Open paths use the existing chain walk. JSX prop wiring so users can author dashed Vectors: - packages/core/src/design-jsx/tree.ts: add strokeDash?: number[] | boolean. - packages/core/src/design-jsx/renderer.ts: applyStrokeOverrides reads strokeDash and writes Stroke.dashPattern; true expands to [w*2, w*2]. - packages/core/src/io/formats/jsx/export.ts: solidStroke serializes dashPattern back to strokeDash on JSX export. Editor UI: - src/components/properties/StrokeSection.vue: dash toggle button plus ScrubInputs for dash and gap length, writing strokes[0].dashPattern. bun run check passes. Pre-existing test failures (drop shadow / auto layout) are unrelated to this change and reproduce on a clean upstream/master.
2026-04-30 06:12:59 +00:00
:min="1"
data-property="stroke-dash-gap"
@update:model-value="actions.patch(0, strokeCtx.setGap(items[0], $event))"
/>
fix(vector): render dashPattern strokes correctly on closed crescents Vector nodes with stroke.dashPattern were rendering as solid lines, and even when the dash was applied through brute outline-stroke conversion, closed crescent shapes (e.g. annular wedges) showed two parallel dashed arcs instead of a single arc along the centerline. Root cause: - drawVectorPathStrokes converted stroke to a fill outline before drawing, leaving no place for PathEffect.MakeDash to apply. - A closed crescent is a single closed path traced around both the outer and inner arcs, so dashing the path produces two visible arcs. Fix: - packages/core/src/canvas/scene.ts: in drawVectorPathStrokes, when the stroke has a dashPattern, skip the outline conversion and draw with strokePaint + PathEffect.MakeDash directly. In renderShapeUncached, dashed VECTOR nodes with a vectorNetwork now route through a new centerline path instead of the precomputed strokeGeometry. - packages/core/src/vector/index.ts: new vectorNetworkToCenterlinePath. For closed thin crescents (cycle of even length where pairwise opposite vertex distance is small relative to perimeter and consistent), detect the two cap segments via vertex direction-change angles, split the cycle into outer/inner subchains, pair vertices by angle, and emit a smooth Path.addArc when the midpoints fit a circle (fitCircleArc with 3-point construction + radius tolerance), falling back to a midpoint polyline otherwise. Open paths use the existing chain walk. JSX prop wiring so users can author dashed Vectors: - packages/core/src/design-jsx/tree.ts: add strokeDash?: number[] | boolean. - packages/core/src/design-jsx/renderer.ts: applyStrokeOverrides reads strokeDash and writes Stroke.dashPattern; true expands to [w*2, w*2]. - packages/core/src/io/formats/jsx/export.ts: solidStroke serializes dashPattern back to strokeDash on JSX export. Editor UI: - src/components/properties/StrokeSection.vue: dash toggle button plus ScrubInputs for dash and gap length, writing strokes[0].dashPattern. bun run check passes. Pre-existing test failures (drop shadow / auto layout) are unrelated to this change and reproduce on a clean upstream/master.
2026-04-30 06:12:59 +00:00
</template>
</div>
<PanelGrid v-if="advancedActive" :columns="3" class="mt-1.5">
<PanelFieldGroup :label="panels.strokeCap">
<SegmentedControl
:model-value="cap === MIXED ? 'MIXED' : cap"
:options="strokeCtx.capOptions"
:label="panels.strokeCap"
data-property="stroke-cap"
@update:model-value="setCap"
>
<template #option="{ option }">
<Tip :label="option.label">
<icon-lucide-minus v-if="option.value === 'NONE'" class="size-3" />
<icon-lucide-circle v-else-if="option.value === 'ROUND'" class="size-2.5" />
<icon-lucide-square v-else class="size-2.5" />
</Tip>
</template>
</SegmentedControl>
</PanelFieldGroup>
<PanelFieldGroup :label="panels.strokeJoin">
<SegmentedControl
:model-value="join === MIXED ? 'MIXED' : join"
:options="strokeCtx.joinOptions"
:label="panels.strokeJoin"
data-property="stroke-join"
@update:model-value="setJoin"
>
<template #option="{ option }">
<Tip :label="option.label">
<icon-lucide-corner-up-right v-if="option.value === 'MITER'" class="size-3" />
<icon-lucide-triangle v-else-if="option.value === 'BEVEL'" class="size-2.5" />
<icon-lucide-circle v-else class="size-2.5" />
</Tip>
</template>
</SegmentedControl>
</PanelFieldGroup>
<PanelFieldGroup :label="panels.strokeMiterLimit">
<NumberField
:model-value="miterLimit"
:min="1"
data-property="stroke-miter-limit"
:aria-label="panels.strokeMiterLimit"
@update:model-value="strokeCtx.updateMiterLimit"
@commit="strokeCtx.commitMiterLimit"
>
<template #icon>
<icon-lucide-triangle-right class="size-3" />
</template>
</NumberField>
</PanelFieldGroup>
</PanelGrid>
2026-03-23 14:42:58 +00:00
<div
v-if="!isMixed && items.length > 0 && expandedSides"
2026-03-23 14:42:58 +00:00
class="mt-1.5 grid grid-cols-2 gap-1.5"
>
<NumberField
2026-03-23 14:42:58 +00:00
v-for="side in strokeCtx.borderSides"
:key="side"
:label="side[0].toUpperCase()"
:model-value="strokeCtx.borderWeight(activeNode, side)"
2026-03-23 14:42:58 +00:00
:min="0"
:data-property="`stroke-${side}-weight`"
@update:model-value="strokeCtx.updateBorderWeight(side, $event, activeNode)"
/>
</div>
</PanelSection>
2026-03-23 14:42:58 +00:00
</PropertyListRoot>
</template>