openpencil/src/components/properties/StrokeSection.vue
Danila Poyarkov 0071c32544 feat(vue): add responsive property grids
- Add a headless PropertyGrid primitive with themed app composition

- Keep composite paint details full-width and show remove actions consistently

- Tighten color picker and Design-panel field sizing without clipping values
2026-07-26 12:42:50 +03:00

339 lines
12 KiB
Vue

<script setup lang="ts">
import { ref } from 'vue'
import {
applySolidStrokeColor,
BindableValueRoot,
MIXED,
useColorBindingProvider,
useI18n,
useOkHCL,
useStrokeControls
} 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'
const strokeCtx = useStrokeControls()
const { advancedActive, cap, join, miterLimit } = strokeCtx
const colorProvider = useColorBindingProvider()
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
strokeCtx.selectSide('CUSTOM', {
...activeNode,
borderTopWeight: weight,
borderRightWeight: weight,
borderBottomWeight: weight,
borderLeftWeight: weight
})
} else if (!next && activeNode.independentStrokeWeights) {
strokeCtx.selectSide('ALL', activeNode)
}
}
</script>
<template>
<PropertyListRoot
v-slot="{ items, isMixed, activeNode, selectedNodeIds, flush, actions }"
prop-key="strokes"
:label="panels.stroke"
>
<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" />
<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"
:remove-label="panels.removeStroke"
>
<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">
<AppSelect
:label="panels.strokeType"
:ui="{ trigger: 'w-[88px] flex-none' }"
:model-value="strokeCtx.currentAlign(activeNode)"
:options="strokeCtx.alignOptions"
data-property="stroke-align"
@update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode)"
/>
<Tip :label="panels.strokeWeight">
<NumberField
v-if="!expandedSides"
class="flex-1"
icon="W"
:model-value="items[0]?.weight ?? 1"
:min="0"
data-property="stroke-weight"
@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)"
>
<icon-lucide-layout-grid class="size-3.5" />
</IconButton>
</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
class="flex-1"
icon="D"
:model-value="items[0]?.dashPattern?.[0] ?? 6"
:min="1"
data-property="stroke-dash-length"
@update:model-value="actions.patch(0, strokeCtx.setDash(items[0], $event))"
/>
<NumberField
class="flex-1"
icon="G"
:model-value="items[0]?.dashPattern?.[1] ?? items[0]?.dashPattern?.[0] ?? 6"
:min="1"
data-property="stroke-dash-gap"
@update:model-value="actions.patch(0, strokeCtx.setGap(items[0], $event))"
/>
</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>
<div
v-if="!isMixed && items.length > 0 && expandedSides"
class="mt-1.5 grid grid-cols-2 gap-1.5"
>
<NumberField
v-for="side in strokeCtx.borderSides"
:key="side"
:label="side[0].toUpperCase()"
:model-value="strokeCtx.borderWeight(activeNode, side)"
:min="0"
:data-property="`stroke-${side}-weight`"
@update:model-value="strokeCtx.updateBorderWeight(side, $event, activeNode)"
/>
</div>
</PanelSection>
</PropertyListRoot>
</template>