feat(editor): add stroke geometry controls

- Add mixed-selection cap, join, and miter-limit controls with one-step undo
- Apply node miter limits across CanvasKit stroke paths and vector outline caches
- Preserve imported and edited stroke geometry through Plugin API and .fig roundtrips
This commit is contained in:
Danila Poyarkov 2026-07-17 19:01:42 +03:00
parent d19c05f48e
commit 2f8d799a5f
28 changed files with 676 additions and 37 deletions

View file

@ -15,6 +15,7 @@
- Refine Design panel foundations with 26px controls, consistently aligned action rails, shared Tailwind themes, and Storybook component states.
- Scale the Layers panel to 5,000-node documents with virtualized rows, indexed updates, scroll-to-selection, range selection, and focus-aware themed states.
- Add Figma-style horizontal and vertical constraint controls with pin interactions, mixed-selection editing, undo, and responsive frame resizing.
- Add mixed-selection stroke cap, join, and miter-limit controls with CanvasKit rendering and `.fig` roundtrip support.
- Standardize Vue SDK and app override type names on the `UI` acronym, including `FontPickerUI`.
- Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, and mixed/bound states; remove the superseded ScrubInput API.
- Add provider-driven BindableValue primitives for variable and token binding, including detach-on-edit, read-only, edit-variable, mixed-value, and undo-batched interactions.

View file

@ -16,6 +16,7 @@ import { renderMaskedChildIds } from './masks'
import type { SkiaRenderer, RenderOverlays } from './renderer'
import { makeSmoothRRectPath, nodeHasRadius, nodeHasSmoothCorners } from './shapes'
import {
configureStrokePaint,
drawDashedRRectWithSolidCorners,
drawStyledRRectStroke,
getStrokeCapEntity,
@ -301,9 +302,7 @@ export function renderSection(
drawVisibleFills(r, node, graph, () => canvas.drawRRect(rrect, r.fillPaint))
forVisibleStrokes(r, node, graph, (stroke, color) => {
r.strokePaint.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a))
r.strokePaint.setStrokeWidth(stroke.weight)
r.strokePaint.setAlphaf(stroke.opacity)
configureStrokePaint(r, node, stroke, color)
if (node.independentStrokeWeights) r.drawIndividualSideStrokes(canvas, node, stroke.align)
else r.drawRRectStrokeWithAlign(canvas, rrect, node, stroke)
@ -441,6 +440,7 @@ function drawVectorPathStrokes(
vectorPaths: Path[],
stroke: SceneNode['strokes'][0],
sc: Color,
miterLimit: number,
outlineCacheKey?: string
): void {
const dash = stroke.dashPattern
@ -450,6 +450,7 @@ function drawVectorPathStrokes(
r.strokePaint.setStrokeWidth(stroke.weight)
r.strokePaint.setStrokeCap(getStrokeCapEntity(r, stroke.cap ?? 'NONE'))
r.strokePaint.setStrokeJoin(getStrokeJoinEntity(r, stroke.join ?? 'MITER'))
r.strokePaint.setStrokeMiter(miterLimit)
r.strokePaint.setShader(null)
const effect = r.ck.PathEffect.MakeDash(dash, 0)
r.strokePaint.setPathEffect(effect)
@ -460,7 +461,7 @@ function drawVectorPathStrokes(
}
const strokeOpts = {
width: stroke.weight,
miter_limit: 4,
miter_limit: miterLimit,
cap: getStrokeCapEntity(r, stroke.cap ?? 'NONE'),
join: getStrokeJoinEntity(r, stroke.join ?? 'MITER')
}
@ -489,16 +490,7 @@ function drawRegularStroke(
stroke: SceneNode['strokes'][0],
sc: Color
): void {
r.strokePaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
r.strokePaint.setStrokeWidth(stroke.weight)
r.strokePaint.setAlphaf(stroke.opacity)
if (stroke.cap) {
r.strokePaint.setStrokeCap(getStrokeCapEntity(r, stroke.cap))
}
if (stroke.join) {
r.strokePaint.setStrokeJoin(getStrokeJoinEntity(r, stroke.join))
}
configureStrokePaint(r, node, stroke, sc)
if (stroke.dashPattern && stroke.dashPattern.length > 0) {
r.strokePaint.setPathEffect(r.ck.PathEffect.MakeDash(stroke.dashPattern, 0))
} else {
@ -531,13 +523,14 @@ function drawNodeStroke(
node.type === 'VECTOR' &&
!node.fills.some((fill) => fill.visible)
if (shouldStrokeVectorCenterline) {
const outlineKey = `${node.id}|${stroke.weight}|${stroke.cap ?? 'NONE'}|${stroke.join ?? 'MITER'}`
drawVectorPathStrokes(r, canvas, vectorStroke, stroke, sc, outlineKey)
const outlineKey = `${node.id}|${stroke.weight}|${stroke.cap ?? node.strokeCap}|${stroke.join ?? node.strokeJoin}|${node.strokeMiterLimit}`
drawVectorPathStrokes(r, canvas, vectorStroke, stroke, sc, node.strokeMiterLimit, outlineKey)
return
}
if (!sg) {
if (vectorPaths) drawVectorPathStrokes(r, canvas, vectorPaths, stroke, sc)
else drawRegularStroke(r, canvas, node, rect, hasRadius, stroke, sc)
if (vectorPaths) {
drawVectorPathStrokes(r, canvas, vectorPaths, stroke, sc, node.strokeMiterLimit)
} else drawRegularStroke(r, canvas, node, rect, hasRadius, stroke, sc)
return
}
if (stroke.align !== 'INSIDE') {
@ -587,7 +580,7 @@ export function renderShapeUncached(
node.vectorNetwork
) {
const centerline = vectorNetworkToCenterlinePath(r.ck, node.vectorNetwork)
drawVectorPathStrokes(r, canvas, [centerline], stroke, color)
drawVectorPathStrokes(r, canvas, [centerline], stroke, color, node.strokeMiterLimit)
centerline.delete()
return
}

View file

@ -55,7 +55,8 @@ export function drawDashedRRectWithSolidCorners(
r.strokePaint.setStrokeWidth(stroke.weight)
r.strokePaint.setAlphaf(stroke.opacity)
r.strokePaint.setStrokeCap(r.ck.StrokeCap.Butt)
r.strokePaint.setStrokeJoin(getStrokeJoinEntity(r, stroke.join))
r.strokePaint.setStrokeJoin(getStrokeJoinEntity(r, stroke.join ?? node.strokeJoin))
r.strokePaint.setStrokeMiter(node.strokeMiterLimit)
r.strokePaint.setPathEffect(null)
canvas.drawArc(
@ -95,6 +96,20 @@ export function drawDashedRRectWithSolidCorners(
r.strokePaint.setPathEffect(null)
}
export function configureStrokePaint(
r: SkiaRenderer,
node: SceneNode,
stroke: Stroke,
color: Color
): void {
r.strokePaint.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a))
r.strokePaint.setStrokeWidth(stroke.weight)
r.strokePaint.setAlphaf(stroke.opacity)
r.strokePaint.setStrokeCap(getStrokeCapEntity(r, stroke.cap ?? node.strokeCap))
r.strokePaint.setStrokeJoin(getStrokeJoinEntity(r, stroke.join ?? node.strokeJoin))
r.strokePaint.setStrokeMiter(node.strokeMiterLimit)
}
export function drawStyledRRectStroke(
r: SkiaRenderer,
canvas: Canvas,
@ -105,11 +120,7 @@ export function drawStyledRRectStroke(
dashPhase = 0
): void {
const dash = stroke.dashPattern ?? []
r.strokePaint.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a))
r.strokePaint.setStrokeWidth(stroke.weight)
r.strokePaint.setAlphaf(stroke.opacity)
r.strokePaint.setStrokeCap(getStrokeCapEntity(r, stroke.cap))
r.strokePaint.setStrokeJoin(getStrokeJoinEntity(r, stroke.join))
configureStrokePaint(r, node, stroke, color)
r.strokePaint.setPathEffect(dash.length > 0 ? r.ck.PathEffect.MakeDash(dash, dashPhase) : null)
r.drawRRectStrokeWithAlign(canvas, rrect, node, stroke)
r.strokePaint.setPathEffect(null)

View file

@ -149,7 +149,12 @@ export class FigmaNodeProxy {
}
set strokeCap(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeCap: v as SceneNode['strokeCap'] })
const strokeCap = v as SceneNode['strokeCap']
const node = this._raw()
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
strokeCap,
strokes: node.strokes.map((stroke) => ({ ...stroke, cap: strokeCap }))
})
}
get strokeJoin(): string {
@ -157,7 +162,12 @@ export class FigmaNodeProxy {
}
set strokeJoin(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeJoin: v as SceneNode['strokeJoin'] })
const strokeJoin = v as SceneNode['strokeJoin']
const node = this._raw()
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
strokeJoin,
strokes: node.strokes.map((stroke) => ({ ...stroke, join: strokeJoin }))
})
}
get strokeMiterLimit(): number {

View file

@ -517,7 +517,7 @@ function convertVectorAndStrokeProps(nc: NodeChange, blobs: Uint8Array[]) {
borderBottomWeight: (nc.borderBottomWeight ?? 0) as number,
borderLeftWeight: (nc.borderLeftWeight ?? 0) as number,
independentStrokeWeights: (nc.borderStrokeWeightsIndependent ?? false) as boolean,
strokeMiterLimit: DEFAULT_STROKE_MITER_LIMIT
strokeMiterLimit: (nc.miterLimit ?? DEFAULT_STROKE_MITER_LIMIT) as number
}
}

View file

@ -5,6 +5,7 @@ import type { Color, GUID, Matrix, Vector } from '@open-pencil/scene-graph/primi
/* eslint-disable max-lines */
import { bytesToHex } from '#core/bytes/hex'
import { DEFAULT_STROKE_MITER_LIMIT } from '#core/constants'
import {
applyExportSettingsPluginData,
@ -650,8 +651,15 @@ function applyNodeVisualProps(
if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint
if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint
if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap
if (node.strokeJoin !== 'MITER') nc.strokeJoin = node.strokeJoin
if (!node.source.id && node.strokeMiterLimit !== 28.96) nc.miterLimit = node.strokeMiterLimit
if (node.strokeJoin !== 'MITER' || 'strokeJoin' in node.source.fig.rawNodeFields) {
nc.strokeJoin = node.strokeJoin
}
if (
node.strokeMiterLimit !== DEFAULT_STROKE_MITER_LIMIT ||
'miterLimit' in node.source.fig.rawNodeFields
) {
nc.miterLimit = node.strokeMiterLimit
}
if (node.dashPattern.length > 0) nc.dashPattern = node.dashPattern
if (node.arcData) {
nc.arcData = {

View file

@ -141,7 +141,7 @@ Figma's design documentation groups features into these areas:
| Layer/fill/effect blend modes | ✅ | ◐ | — | ✅ | ✅ | Canvas applies node, fill, and common shadow effect blend modes; Figma isolation edge cases remain partial. |
| Opacity | ✅ | ✅ | ✅ | ✅ | ✅ | Node opacity uses save layers in the renderer. |
| Strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Weight, alignment, dashes, and side weights are supported. |
| Stroke caps / joins / miter limit | ✅ | ✅ | ◐ | ✅ | ✅ | Renderer/export support exists; inspector controls are limited. |
| Stroke caps / joins / miter limit | ✅ | ✅ | ✅ | ✅ | ✅ | Inspector controls support mixed cap/join/miter editing; CanvasKit rendering and `.fig` roundtrips preserve miter limits. |
| Effects: shadows and blurs | ✅ | ✅ | ✅ | ✅ | ✅ | `showShadowBehindNode` is rendered but not exposed in UI. |
| Effect styles | ↩ | — | — | ↩ | — | Style IDs round-trip; no style manager. |
| Corner radius | ✅ | ✅ | ✅ | ✅ | ✅ | Uniform and independent radii supported. |

View file

@ -1,6 +1,6 @@
---
title: useStrokeControls
description: Stroke-panel helpers for alignment, side selection, and per-side stroke weights.
description: Stroke-panel state and actions for alignment, sides, caps, joins, and miter limits.
---
# useStrokeControls
@ -13,6 +13,9 @@ It provides:
- side presets like all, top, bottom, left, right, custom
- default stroke data
- helpers for per-side border weights
- mixed-selection cap, join, and miter-limit state
- undo-batched cap and join updates
- preview and commit actions for miter-limit fields
## Usage
@ -42,6 +45,19 @@ strokes.updateAlign('INSIDE', activeNode)
strokes.selectSide('TOP', activeNode)
```
### Edit stroke geometry
```ts
strokes.setCap('ROUND')
strokes.setJoin('BEVEL')
strokes.updateMiterLimit(8)
strokes.commitMiterLimit(8)
```
`advancedActive` is true only when every selected node has at least one stroke. `cap`, `join`, and
`miterLimit` return the shared value or `MIXED` for a mixed selection.
## Related APIs
- [PropertyListRoot](../components/property-list-root)

View file

@ -1,8 +1,10 @@
import type { Ref } from 'vue'
import { computed, type ComputedRef, type Ref } from 'vue'
import { BLACK } from '@open-pencil/core/constants'
import type { Editor } from '@open-pencil/core/editor'
import type { SceneNode, Stroke } from '@open-pencil/scene-graph'
import type { SceneNode, Stroke, StrokeCap, StrokeJoin } from '@open-pencil/scene-graph'
import type { MixedValue } from '#vue/controls/node-props/use'
export type StrokeSides = 'ALL' | 'TOP' | 'BOTTOM' | 'LEFT' | 'RIGHT' | 'CUSTOM'
@ -24,6 +26,82 @@ export const DEFAULT_STROKE: Stroke = {
align: 'CENTER'
}
export interface StrokeGeometryStateInput {
nodes: ComputedRef<SceneNode[]>
merged: <K extends keyof SceneNode>(key: K) => MixedValue<SceneNode[K]>
}
export interface StrokeGeometryActions {
setCap: (value: StrokeCap) => void
setJoin: (value: StrokeJoin) => void
updateMiterLimit: (value: number) => void
commitMiterLimit: (value: number) => void
}
export function createStrokeGeometryState({ nodes, merged }: StrokeGeometryStateInput) {
return {
advancedActive: computed(
() => nodes.value.length > 0 && nodes.value.every((node) => node.strokes.length > 0)
),
cap: computed(() => merged('strokeCap')),
join: computed(() => merged('strokeJoin')),
miterLimit: computed(() => merged('strokeMiterLimit'))
}
}
export function createStrokeGeometryActions(
editor: Editor,
nodes: ComputedRef<SceneNode[]>
): StrokeGeometryActions {
const originalMiterLimits = new Map<string, number>()
function runForSelection(label: string, action: (node: SceneNode) => void) {
const selected = nodes.value
const run = () => selected.forEach(action)
if (selected.length > 1) editor.undo.runBatch(label, run)
else run()
}
function setCap(value: StrokeCap) {
runForSelection('Change stroke cap', (node) => {
editor.updateNodeWithUndo(
node.id,
{ strokeCap: value, strokes: node.strokes.map((stroke) => ({ ...stroke, cap: value })) },
'Change stroke cap'
)
})
}
function setJoin(value: StrokeJoin) {
runForSelection('Change stroke join', (node) => {
editor.updateNodeWithUndo(
node.id,
{ strokeJoin: value, strokes: node.strokes.map((stroke) => ({ ...stroke, join: value })) },
'Change stroke join'
)
})
}
function updateMiterLimit(value: number) {
for (const node of nodes.value) {
if (!originalMiterLimits.has(node.id)) originalMiterLimits.set(node.id, node.strokeMiterLimit)
editor.updateNode(node.id, { strokeMiterLimit: Math.max(1, value) })
}
}
function commitMiterLimit(value: number) {
if (originalMiterLimits.size === 0) updateMiterLimit(value)
runForSelection('Change stroke miter limit', (node) => {
const previous = originalMiterLimits.get(node.id)
if (previous === undefined) return
editor.commitNodeUpdate(node.id, { strokeMiterLimit: previous }, 'Change stroke miter limit')
})
originalMiterLimits.clear()
}
return { setCap, setJoin, updateMiterLimit, commitMiterLimit }
}
export function updateAlign(editor: Editor, align: Stroke['align'], activeNode: SceneNode | null) {
if (!activeNode) return
const strokes = activeNode.strokes.map((s) => ({ ...s, align }))

View file

@ -1,10 +1,13 @@
import { ref } from 'vue'
import { useNodeProps } from '#vue/controls/node-props/use'
import {
BORDER_SIDES,
DEFAULT_STROKE,
SIDE_OPTIONS,
borderWeight,
createStrokeGeometryActions,
createStrokeGeometryState,
createStrokeSideActions,
currentAlign,
currentSides,
@ -20,11 +23,12 @@ import { useI18n } from '#vue/i18n'
/**
* Returns stroke-related helpers for property panels.
*
* This composable provides alignment options, side presets, a default stroke,
* and helpers for per-side border weight editing.
* This composable provides alignment and side helpers plus mixed-selection
* state and undo-aware actions for caps, joins, and miter limits.
*/
export function useStrokeControls() {
const store = useEditor()
const { nodes, merged } = useNodeProps()
const { panels } = useI18n()
const sideMenuOpen = ref(false)
const alignOptions = [
@ -32,10 +36,26 @@ export function useStrokeControls() {
{ value: 'CENTER' as const, label: panels.value.strokeAlignCenter },
{ value: 'OUTSIDE' as const, label: panels.value.strokeAlignOutside }
]
const capOptions = [
{ value: 'NONE' as const, label: panels.value.strokeCapButt },
{ value: 'ROUND' as const, label: panels.value.strokeCapRound },
{ value: 'SQUARE' as const, label: panels.value.strokeCapSquare }
]
const joinOptions = [
{ value: 'MITER' as const, label: panels.value.strokeJoinMiter },
{ value: 'BEVEL' as const, label: panels.value.strokeJoinBevel },
{ value: 'ROUND' as const, label: panels.value.strokeJoinRound }
]
const geometryState = createStrokeGeometryState({ nodes, merged })
const geometryActions = createStrokeGeometryActions(store, nodes)
const { selectSide, updateBorderWeight } = createStrokeSideActions(store, sideMenuOpen)
return {
alignOptions,
capOptions,
joinOptions,
...geometryState,
...geometryActions,
sideOptions: SIDE_OPTIONS,
borderSides: BORDER_SIDES,
sideMenuOpen,

View file

@ -120,6 +120,15 @@
"create": "Erstellen",
"createNumberVariable": "Zahlenvariable aus {value} erstellen",
"strokeDash": "Gestrichelter Strich",
"strokeCap": "Linienende",
"strokeCapButt": "Flaches Ende",
"strokeCapRound": "Rundes Ende",
"strokeCapSquare": "Quadratisches Ende",
"strokeJoin": "Linienverbindung",
"strokeJoinMiter": "Gehrungsverbindung",
"strokeJoinBevel": "Abgeschrägte Verbindung",
"strokeJoinRound": "Runde Verbindung",
"strokeMiterLimit": "Gehrungsgrenze",
"add": "Hinzufügen",
"variants": "Varianten",
"gapAuto": "Automatischer Abstand",

View file

@ -136,6 +136,15 @@
"create": "Crear",
"createNumberVariable": "Crear variable numérica desde {value}",
"strokeDash": "Trazo discontinuo",
"strokeCap": "Extremo del trazo",
"strokeCapButt": "Extremo plano",
"strokeCapRound": "Extremo redondo",
"strokeCapSquare": "Extremo cuadrado",
"strokeJoin": "Unión del trazo",
"strokeJoinMiter": "Unión en inglete",
"strokeJoinBevel": "Unión biselada",
"strokeJoinRound": "Unión redonda",
"strokeMiterLimit": "Límite de inglete",
"add": "Añadir",
"variants": "Variantes",
"gapAuto": "Espaciado auto",

View file

@ -120,6 +120,15 @@
"create": "Créer",
"createNumberVariable": "Créer une variable numérique depuis {value}",
"strokeDash": "Trait en pointillés",
"strokeCap": "Extrémité du trait",
"strokeCapButt": "Extrémité plate",
"strokeCapRound": "Extrémité arrondie",
"strokeCapSquare": "Extrémité carrée",
"strokeJoin": "Jonction du trait",
"strokeJoinMiter": "Jonction en onglet",
"strokeJoinBevel": "Jonction biseautée",
"strokeJoinRound": "Jonction arrondie",
"strokeMiterLimit": "Limite donglet",
"add": "Ajouter",
"variants": "Variantes",
"gapAuto": "Espacement auto",

View file

@ -120,6 +120,15 @@
"create": "Crea",
"createNumberVariable": "Crea variabile numerica da {value}",
"strokeDash": "Tratto tratteggiato",
"strokeCap": "Estremità del tratto",
"strokeCapButt": "Estremità piatta",
"strokeCapRound": "Estremità arrotondata",
"strokeCapSquare": "Estremità quadrata",
"strokeJoin": "Giunzione del tratto",
"strokeJoinMiter": "Giunzione a mitra",
"strokeJoinBevel": "Giunzione smussata",
"strokeJoinRound": "Giunzione arrotondata",
"strokeMiterLimit": "Limite mitra",
"add": "Aggiungi",
"variants": "Varianti",
"gapAuto": "Spaziatura auto",

View file

@ -136,6 +136,15 @@
"create": "作成",
"createNumberVariable": "{value} から数値変数を作成",
"strokeDash": "破線",
"strokeCap": "線端",
"strokeCapButt": "フラット線端",
"strokeCapRound": "丸型線端",
"strokeCapSquare": "角型線端",
"strokeJoin": "線の結合",
"strokeJoinMiter": "マイター結合",
"strokeJoinBevel": "ベベル結合",
"strokeJoinRound": "ラウンド結合",
"strokeMiterLimit": "マイター制限",
"add": "追加",
"variants": "バリアント",
"gapAuto": "間隔自動",

View file

@ -120,6 +120,15 @@
"create": "Utwórz",
"createNumberVariable": "Utwórz zmienną liczbową z {value}",
"strokeDash": "Obrys przerywany",
"strokeCap": "Zakończenie obrysu",
"strokeCapButt": "Płaskie zakończenie",
"strokeCapRound": "Okrągłe zakończenie",
"strokeCapSquare": "Kwadratowe zakończenie",
"strokeJoin": "Łączenie obrysu",
"strokeJoinMiter": "Łączenie ostre",
"strokeJoinBevel": "Łączenie ścięte",
"strokeJoinRound": "Łączenie okrągłe",
"strokeMiterLimit": "Limit łączenia ostrego",
"add": "Dodaj",
"variants": "Warianty",
"gapAuto": "Automatyczny odstep",

View file

@ -120,6 +120,15 @@
"create": "Создать",
"createNumberVariable": "Создать числовую переменную из {value}",
"strokeDash": "Пунктирная обводка",
"strokeCap": "Конец обводки",
"strokeCapButt": "Плоский конец",
"strokeCapRound": "Круглый конец",
"strokeCapSquare": "Квадратный конец",
"strokeJoin": "Соединение обводки",
"strokeJoinMiter": "Острое соединение",
"strokeJoinBevel": "Скошенное соединение",
"strokeJoinRound": "Круглое соединение",
"strokeMiterLimit": "Предел острого соединения",
"add": "Добавить",
"variants": "Варианты",
"gapAuto": "Авто отступ",

View file

@ -120,6 +120,15 @@
"create": "创建",
"createNumberVariable": "从 {value} 创建数字变量",
"strokeDash": "虚线描边",
"strokeCap": "描边端点",
"strokeCapButt": "平直端点",
"strokeCapRound": "圆形端点",
"strokeCapSquare": "方形端点",
"strokeJoin": "描边连接",
"strokeJoinMiter": "尖角连接",
"strokeJoinBevel": "斜角连接",
"strokeJoinRound": "圆角连接",
"strokeMiterLimit": "尖角限制",
"add": "添加",
"variants": "变体",
"gapAuto": "自动间距",

View file

@ -141,6 +141,15 @@ export const panelMessageDefaults = {
mixedEffectsHelp: 'Click + to replace mixed effects',
strokeSides: 'Stroke sides',
strokeDash: 'Dashed stroke',
strokeCap: 'Stroke cap',
strokeCapButt: 'Butt cap',
strokeCapRound: 'Round cap',
strokeCapSquare: 'Square cap',
strokeJoin: 'Stroke join',
strokeJoinMiter: 'Miter join',
strokeJoinBevel: 'Bevel join',
strokeJoinRound: 'Round join',
strokeMiterLimit: 'Miter limit',
strokeAlignInside: 'Inside',
strokeAlignCenter: 'Center',
strokeAlignOutside: 'Outside',

View file

@ -4,6 +4,7 @@ import { ref } from 'vue'
import {
applySolidStrokeColor,
BindableValueRoot,
MIXED,
useColorBindingProvider,
useI18n,
useOkHCL,
@ -27,7 +28,10 @@ import VariableBindingPicker from '@/components/properties/binding/VariableBindi
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'
@ -35,6 +39,7 @@ 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()
@ -60,6 +65,18 @@ function updateStrokeColor(
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
@ -246,6 +263,59 @@ function onToggleSides(activeNode: SceneNode | null) {
</template>
</div>
<PanelGrid v-if="advancedActive" columns="three" class="mt-panel">
<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"

View file

@ -3,6 +3,7 @@ const panelGridTheme = {
variants: {
columns: {
two: 'grid-cols-2',
three: 'grid-cols-3',
'two-rail': 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)_var(--spacing-panel-rail)]',
fill: 'grid-cols-[minmax(0,1fr)]',
'fill-rail': 'grid-cols-[minmax(0,1fr)_var(--spacing-panel-rail)]'

View file

@ -0,0 +1,80 @@
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
test('stroke caps joins and miter limits', async () => {
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageId = store.state.currentPageId
const color = { r: 0.23, g: 0.51, b: 0.96, a: 1 }
const caps = ['NONE', 'ROUND', 'SQUARE'] as const
for (const [index, cap] of caps.entries()) {
store.graph.createNode('VECTOR', pageId, {
name: `${cap} cap visual`,
x: 92 + index * 190,
y: 72,
width: 120,
height: 41,
vectorNetwork: {
vertices: [
{ x: 0, y: 0 },
{ x: 120, y: 40 }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: []
},
strokeCap: cap,
strokes: [
{
color,
weight: 20,
visible: true,
opacity: 1,
align: 'CENTER',
cap
}
]
})
}
const joins = [
{ join: 'MITER' as const, limit: 1 },
{ join: 'MITER' as const, limit: 12 },
{ join: 'BEVEL' as const, limit: 4 },
{ join: 'ROUND' as const, limit: 4 }
]
for (const [index, { join, limit }] of joins.entries()) {
store.graph.createNode('STAR', pageId, {
name: `${join} ${limit} join visual`,
x: 72 + index * 150,
y: 170,
width: 110,
height: 110,
pointCount: 5,
starInnerRadius: 0.18,
strokeJoin: join,
strokeMiterLimit: limit,
strokes: [
{
color: { r: 0.96, g: 0.35, b: 0.12, a: 1 },
weight: 10,
visible: true,
opacity: 1,
align: 'CENTER',
join
}
]
})
}
store.clearSelection()
store.requestRender()
})
await editor.canvas.waitForRender()
editor.canvas.assertNoErrors()
const buffer = await editor.canvas.canvas.screenshot()
expect(buffer).toMatchSnapshot('stroke-caps-joins-miter-limits.png')
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -0,0 +1,103 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { propertySection } from '#tests/helpers/properties'
let page: Page
let canvas: CanvasHelper
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await page.goto('/')
canvas = new CanvasHelper(page)
await canvas.waitForInit()
})
test.afterAll(async () => {
await page.close()
})
async function drawStrokedRectangle(x: number, y: number) {
await canvas.pressKey('r')
await canvas.drag(x, y, x + 120, y + 80)
await canvas.waitForRender()
await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
await canvas.waitForRender()
}
async function selectedStrokeGeometry() {
return page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return [...store.state.selectedIds].map((id) => {
const node = store.graph.getNode(id)
return node
? {
cap: node.strokeCap,
join: node.strokeJoin,
miterLimit: node.strokeMiterLimit,
paintCaps: node.strokes.map((stroke) => stroke.cap),
paintJoins: node.strokes.map((stroke) => stroke.join)
}
: null
})
})
}
test('shows geometry controls only after a stroke is added', async () => {
await canvas.pressKey('r')
await canvas.drag(100, 100, 220, 180)
await canvas.waitForRender()
await expect(page.locator('[data-property="stroke-cap"]')).not.toBeVisible()
await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
await canvas.waitForRender()
await expect(page.locator('[data-property="stroke-cap"]')).toBeVisible()
await expect(page.locator('[data-property="stroke-join"]')).toBeVisible()
await expect(page.locator('[data-property="stroke-miter-limit"]')).toBeVisible()
})
test('updates cap, join, and miter state from compact controls', async () => {
const section = propertySection(page, 'Stroke')
await section.getByRole('button', { name: 'Round cap' }).click()
await section.getByRole('button', { name: 'Bevel join' }).click()
await section.getByRole('spinbutton', { name: 'Miter limit' }).focus()
const miter = section.getByRole('spinbutton', { name: 'Miter limit' })
await miter.fill('12')
await miter.press('Enter')
await canvas.waitForRender()
expect(await selectedStrokeGeometry()).toEqual([
{
cap: 'ROUND',
join: 'BEVEL',
miterLimit: 12,
paintCaps: ['ROUND'],
paintJoins: ['BEVEL']
}
])
await canvas.pressKey('Meta+z')
await canvas.waitForRender()
expect((await selectedStrokeGeometry())[0]?.miterLimit).toBe(4)
})
test('applies mixed multi-selection joins in one undo step', async () => {
await canvas.clearCanvas()
await drawStrokedRectangle(80, 80)
await drawStrokedRectangle(260, 80)
await canvas.pressKey('Meta+a')
await canvas.waitForRender()
const roundJoin = propertySection(page, 'Stroke').getByRole('button', { name: 'Round join' })
await expect(roundJoin).toBeVisible()
await roundJoin.click()
await canvas.waitForRender()
expect((await selectedStrokeGeometry()).map((value) => value?.join)).toEqual(['ROUND', 'ROUND'])
await canvas.pressKey('Meta+z')
await canvas.waitForRender()
expect((await selectedStrokeGeometry()).map((value) => value?.join)).toEqual(['MITER', 'MITER'])
})

View file

@ -18,12 +18,16 @@ describe('stroke details', () => {
test('strokeCap and strokeJoin', () => {
const api = createAPI()
const line = api.createLine()
line.strokes = [
{ color: { r: 0, g: 0, b: 0, a: 1 }, weight: 2, opacity: 1, visible: true, align: 'CENTER' }
]
expect(line.strokeCap).toBe('NONE')
expect(line.strokeJoin).toBe('MITER')
line.strokeCap = 'ROUND'
line.strokeJoin = 'BEVEL'
expect(line.strokeCap).toBe('ROUND')
expect(line.strokeJoin).toBe('BEVEL')
expect(line.strokes[0]).toMatchObject({ cap: 'ROUND', join: 'BEVEL' })
})
test('strokeMiterLimit', () => {

View file

@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize'
function serializeMiterLimit(strokeMiterLimit: number, importedMiterLimit?: number) {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('VECTOR', page.id, { strokeMiterLimit })
if (importedMiterLimit !== undefined) {
graph.updateNode(node.id, {
source: {
...node.source,
id: '1:2',
fig: {
...node.source.fig,
rawNodeFields: { ...node.source.fig.rawNodeFields, miterLimit: importedMiterLimit }
}
}
})
}
const current = graph.getNode(node.id)
if (!current) throw new Error('Expected vector node')
return sceneNodeToKiwi(current, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [])[0]
.miterLimit
}
function serializeImportedDefaultJoin() {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('VECTOR', page.id, { strokeJoin: 'MITER' })
graph.updateNode(node.id, {
source: {
...node.source,
id: '1:2',
fig: {
...node.source.fig,
rawNodeFields: { ...node.source.fig.rawNodeFields, strokeJoin: 'BEVEL' }
}
}
})
const current = graph.getNode(node.id)
if (!current) throw new Error('Expected vector node')
return sceneNodeToKiwi(current, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [])[0]
.strokeJoin
}
describe('Figma stroke geometry export', () => {
test('exports non-default miter limits', () => {
expect(serializeMiterLimit(12)).toBe(12)
})
test('overrides stale imported raw values when edited back to the default', () => {
expect(serializeMiterLimit(4, 9)).toBe(4)
expect(serializeImportedDefaultJoin()).toBe('MITER')
})
})

View file

@ -22,12 +22,14 @@ describe('fig-import: stroke options', () => {
strokeWeight: 3,
strokeAlign: 'CENTER',
strokeCap: 'ROUND',
strokeJoin: 'BEVEL'
strokeJoin: 'BEVEL',
miterLimit: 9
} as Partial<NodeChange>)
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.strokes[0].cap).toBe('ROUND')
expect(n.strokes[0].join).toBe('BEVEL')
expect(n.strokeMiterLimit).toBe(9)
})
test('dash pattern', () => {

View file

@ -0,0 +1,103 @@
import { describe, expect, test } from 'bun:test'
import { computed, ref } from 'vue'
import { createEditor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/scene-graph'
import { MIXED, type MixedValue } from '#vue/controls/node-props/use'
import {
DEFAULT_STROKE,
createStrokeGeometryActions,
createStrokeGeometryState
} from '#vue/controls/stroke/helpers'
import { firstPageId, makeSceneGraph } from '#tests/helpers/scene'
function strokedRect(overrides: Partial<SceneNode> = {}) {
const graph = makeSceneGraph()
const node = graph.createNode('RECTANGLE', firstPageId(graph), {
strokes: [{ ...DEFAULT_STROKE }],
...overrides
})
return { graph, node }
}
function merged(nodes: SceneNode[]) {
return <K extends keyof SceneNode>(key: K): MixedValue<SceneNode[K]> => {
const first = nodes[0]?.[key]
if (first === undefined || nodes.some((node) => node[key] !== first)) return MIXED
return first
}
}
describe('stroke geometry controls', () => {
test('is active only when every selected node has a stroke and reports mixed values', () => {
const { node } = strokedRect()
const other = structuredClone(node)
other.id = 'other'
other.strokeJoin = 'BEVEL'
const nodes = ref([node, other])
const state = createStrokeGeometryState({
nodes: computed(() => nodes.value),
merged: (key) => merged(nodes.value)(key)
})
expect(state.advancedActive.value).toBe(true)
expect(state.cap.value).toBe('NONE')
expect(state.join.value).toBe(MIXED)
nodes.value[1].strokes = []
expect(state.advancedActive.value).toBe(false)
})
test('synchronizes node and paint cap and join with one multi-selection undo', () => {
const graph = makeSceneGraph()
const pageId = firstPageId(graph)
const first = graph.createNode('RECTANGLE', pageId, { strokes: [{ ...DEFAULT_STROKE }] })
const second = graph.createNode('RECTANGLE', pageId, { strokes: [{ ...DEFAULT_STROKE }] })
const editor = createEditor({ graph })
const nodes = computed(() =>
[graph.getNode(first.id), graph.getNode(second.id)].filter(Boolean)
)
const actions = createStrokeGeometryActions(editor, nodes)
actions.setCap('ROUND')
actions.setJoin('BEVEL')
expect(graph.getNode(first.id)).toMatchObject({
strokeCap: 'ROUND',
strokeJoin: 'BEVEL',
strokes: [{ cap: 'ROUND', join: 'BEVEL' }]
})
expect(graph.getNode(second.id)?.strokes[0]).toMatchObject({ cap: 'ROUND', join: 'BEVEL' })
editor.undo.undo()
expect(graph.getNode(first.id)?.strokeJoin).toBe('MITER')
expect(graph.getNode(second.id)?.strokeJoin).toBe('MITER')
expect(graph.getNode(first.id)?.strokeCap).toBe('ROUND')
})
test('previews and commits miter limit as one multi-selection undo step', () => {
const graph = makeSceneGraph()
const pageId = firstPageId(graph)
const first = graph.createNode('RECTANGLE', pageId, { strokes: [{ ...DEFAULT_STROKE }] })
const second = graph.createNode('RECTANGLE', pageId, {
strokes: [{ ...DEFAULT_STROKE }],
strokeMiterLimit: 8
})
const editor = createEditor({ graph })
const nodes = computed(() =>
[graph.getNode(first.id), graph.getNode(second.id)].filter(Boolean)
)
const actions = createStrokeGeometryActions(editor, nodes)
actions.updateMiterLimit(12)
expect(graph.getNode(first.id)?.strokeMiterLimit).toBe(12)
expect(graph.getNode(second.id)?.strokeMiterLimit).toBe(12)
actions.commitMiterLimit(12)
editor.undo.undo()
expect(graph.getNode(first.id)?.strokeMiterLimit).toBe(4)
expect(graph.getNode(second.id)?.strokeMiterLimit).toBe(8)
})
})