diff --git a/CHANGELOG.md b/CHANGELOG.md index b3bc65535..80cd51bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 1356f4a4b..2b05a4faa 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -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 } diff --git a/packages/core/src/canvas/strokes.ts b/packages/core/src/canvas/strokes.ts index 0794c3f39..b74b482fe 100644 --- a/packages/core/src/canvas/strokes.ts +++ b/packages/core/src/canvas/strokes.ts @@ -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) diff --git a/packages/core/src/figma-api/proxy.ts b/packages/core/src/figma-api/proxy.ts index 4b063b823..b1b6bc817 100644 --- a/packages/core/src/figma-api/proxy.ts +++ b/packages/core/src/figma-api/proxy.ts @@ -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 { diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 2d0aa8b7f..f0ee07ab2 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -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 } } diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/core/src/kiwi/fig/node-change/export-node.ts index 64089f046..a240581bc 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/core/src/kiwi/fig/node-change/export-node.ts @@ -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 = { diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index c8aed67dd..0b3fb0561 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -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. | diff --git a/packages/docs/programmable/sdk/api/composables/use-stroke-controls.md b/packages/docs/programmable/sdk/api/composables/use-stroke-controls.md index d98c89fc7..770eb4af0 100644 --- a/packages/docs/programmable/sdk/api/composables/use-stroke-controls.md +++ b/packages/docs/programmable/sdk/api/composables/use-stroke-controls.md @@ -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) diff --git a/packages/vue/src/controls/stroke/helpers.ts b/packages/vue/src/controls/stroke/helpers.ts index de23b8359..f05b989c6 100644 --- a/packages/vue/src/controls/stroke/helpers.ts +++ b/packages/vue/src/controls/stroke/helpers.ts @@ -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 + merged: (key: K) => MixedValue +} + +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 +): StrokeGeometryActions { + const originalMiterLimits = new Map() + + 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 })) diff --git a/packages/vue/src/controls/stroke/use.ts b/packages/vue/src/controls/stroke/use.ts index 319846bf7..7c2db5a71 100644 --- a/packages/vue/src/controls/stroke/use.ts +++ b/packages/vue/src/controls/stroke/use.ts @@ -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, diff --git a/packages/vue/src/i18n/locales/de/panels.json b/packages/vue/src/i18n/locales/de/panels.json index 31da4514a..e0cdbc522 100644 --- a/packages/vue/src/i18n/locales/de/panels.json +++ b/packages/vue/src/i18n/locales/de/panels.json @@ -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", diff --git a/packages/vue/src/i18n/locales/es/panels.json b/packages/vue/src/i18n/locales/es/panels.json index 823e8909e..89bf3dcb2 100644 --- a/packages/vue/src/i18n/locales/es/panels.json +++ b/packages/vue/src/i18n/locales/es/panels.json @@ -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", diff --git a/packages/vue/src/i18n/locales/fr/panels.json b/packages/vue/src/i18n/locales/fr/panels.json index b0d436f73..41f698057 100644 --- a/packages/vue/src/i18n/locales/fr/panels.json +++ b/packages/vue/src/i18n/locales/fr/panels.json @@ -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 d’onglet", "add": "Ajouter", "variants": "Variantes", "gapAuto": "Espacement auto", diff --git a/packages/vue/src/i18n/locales/it/panels.json b/packages/vue/src/i18n/locales/it/panels.json index ba9f6944e..97ecce591 100644 --- a/packages/vue/src/i18n/locales/it/panels.json +++ b/packages/vue/src/i18n/locales/it/panels.json @@ -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", diff --git a/packages/vue/src/i18n/locales/ja/panels.json b/packages/vue/src/i18n/locales/ja/panels.json index e1eb016be..6520ad619 100644 --- a/packages/vue/src/i18n/locales/ja/panels.json +++ b/packages/vue/src/i18n/locales/ja/panels.json @@ -136,6 +136,15 @@ "create": "作成", "createNumberVariable": "{value} から数値変数を作成", "strokeDash": "破線", + "strokeCap": "線端", + "strokeCapButt": "フラット線端", + "strokeCapRound": "丸型線端", + "strokeCapSquare": "角型線端", + "strokeJoin": "線の結合", + "strokeJoinMiter": "マイター結合", + "strokeJoinBevel": "ベベル結合", + "strokeJoinRound": "ラウンド結合", + "strokeMiterLimit": "マイター制限", "add": "追加", "variants": "バリアント", "gapAuto": "間隔自動", diff --git a/packages/vue/src/i18n/locales/pl/panels.json b/packages/vue/src/i18n/locales/pl/panels.json index ef916d571..5ae6daa58 100644 --- a/packages/vue/src/i18n/locales/pl/panels.json +++ b/packages/vue/src/i18n/locales/pl/panels.json @@ -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", diff --git a/packages/vue/src/i18n/locales/ru/panels.json b/packages/vue/src/i18n/locales/ru/panels.json index 3011c4b9a..d51e5753b 100644 --- a/packages/vue/src/i18n/locales/ru/panels.json +++ b/packages/vue/src/i18n/locales/ru/panels.json @@ -120,6 +120,15 @@ "create": "Создать", "createNumberVariable": "Создать числовую переменную из {value}", "strokeDash": "Пунктирная обводка", + "strokeCap": "Конец обводки", + "strokeCapButt": "Плоский конец", + "strokeCapRound": "Круглый конец", + "strokeCapSquare": "Квадратный конец", + "strokeJoin": "Соединение обводки", + "strokeJoinMiter": "Острое соединение", + "strokeJoinBevel": "Скошенное соединение", + "strokeJoinRound": "Круглое соединение", + "strokeMiterLimit": "Предел острого соединения", "add": "Добавить", "variants": "Варианты", "gapAuto": "Авто отступ", diff --git a/packages/vue/src/i18n/locales/zh-cn/panels.json b/packages/vue/src/i18n/locales/zh-cn/panels.json index ec4e5e42c..412d250be 100644 --- a/packages/vue/src/i18n/locales/zh-cn/panels.json +++ b/packages/vue/src/i18n/locales/zh-cn/panels.json @@ -120,6 +120,15 @@ "create": "创建", "createNumberVariable": "从 {value} 创建数字变量", "strokeDash": "虚线描边", + "strokeCap": "描边端点", + "strokeCapButt": "平直端点", + "strokeCapRound": "圆形端点", + "strokeCapSquare": "方形端点", + "strokeJoin": "描边连接", + "strokeJoinMiter": "尖角连接", + "strokeJoinBevel": "斜角连接", + "strokeJoinRound": "圆角连接", + "strokeMiterLimit": "尖角限制", "add": "添加", "variants": "变体", "gapAuto": "自动间距", diff --git a/packages/vue/src/i18n/messages/panels.ts b/packages/vue/src/i18n/messages/panels.ts index d5459520b..663bc1773 100644 --- a/packages/vue/src/i18n/messages/panels.ts +++ b/packages/vue/src/i18n/messages/panels.ts @@ -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', diff --git a/src/components/properties/StrokeSection.vue b/src/components/properties/StrokeSection.vue index a34d13a35..f3a7d34ca 100644 --- a/src/components/properties/StrokeSection.vue +++ b/src/components/properties/StrokeSection.vue @@ -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) { + + + + + + + + + + + + + + + + + + + +
{ + 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') +}) diff --git a/tests/e2e/canvas/stroke-geometry-visual.spec.ts-snapshots/stroke-caps-joins-miter-limits-openpencil-darwin.png b/tests/e2e/canvas/stroke-geometry-visual.spec.ts-snapshots/stroke-caps-joins-miter-limits-openpencil-darwin.png new file mode 100644 index 000000000..61a326ea0 Binary files /dev/null and b/tests/e2e/canvas/stroke-geometry-visual.spec.ts-snapshots/stroke-caps-joins-miter-limits-openpencil-darwin.png differ diff --git a/tests/e2e/properties/stroke-geometry.spec.ts b/tests/e2e/properties/stroke-geometry.spec.ts new file mode 100644 index 000000000..f0656772f --- /dev/null +++ b/tests/e2e/properties/stroke-geometry.spec.ts @@ -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']) +}) diff --git a/tests/engine/figma/api/stroke/details.test.ts b/tests/engine/figma/api/stroke/details.test.ts index db62d33b1..e204dcc83 100644 --- a/tests/engine/figma/api/stroke/details.test.ts +++ b/tests/engine/figma/api/stroke/details.test.ts @@ -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', () => { diff --git a/tests/engine/io/fig/export/stroke-geometry.test.ts b/tests/engine/io/fig/export/stroke-geometry.test.ts new file mode 100644 index 000000000..df46e2514 --- /dev/null +++ b/tests/engine/io/fig/export/stroke-geometry.test.ts @@ -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') + }) +}) diff --git a/tests/engine/io/fig/import/legacy/strokes.test.ts b/tests/engine/io/fig/import/legacy/strokes.test.ts index 40e3b7211..1e7c91162 100644 --- a/tests/engine/io/fig/import/legacy/strokes.test.ts +++ b/tests/engine/io/fig/import/legacy/strokes.test.ts @@ -22,12 +22,14 @@ describe('fig-import: stroke options', () => { strokeWeight: 3, strokeAlign: 'CENTER', strokeCap: 'ROUND', - strokeJoin: 'BEVEL' + strokeJoin: 'BEVEL', + miterLimit: 9 } as Partial) ]) 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', () => { diff --git a/tests/engine/vue/controls/stroke.test.ts b/tests/engine/vue/controls/stroke.test.ts new file mode 100644 index 000000000..973718427 --- /dev/null +++ b/tests/engine/vue/controls/stroke.test.ts @@ -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 = {}) { + const graph = makeSceneGraph() + const node = graph.createNode('RECTANGLE', firstPageId(graph), { + strokes: [{ ...DEFAULT_STROKE }], + ...overrides + }) + return { graph, node } +} + +function merged(nodes: SceneNode[]) { + return (key: K): MixedValue => { + 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) + }) +})