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:
parent
d19c05f48e
commit
2f8d799a5f
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -136,6 +136,15 @@
|
|||
"create": "作成",
|
||||
"createNumberVariable": "{value} から数値変数を作成",
|
||||
"strokeDash": "破線",
|
||||
"strokeCap": "線端",
|
||||
"strokeCapButt": "フラット線端",
|
||||
"strokeCapRound": "丸型線端",
|
||||
"strokeCapSquare": "角型線端",
|
||||
"strokeJoin": "線の結合",
|
||||
"strokeJoinMiter": "マイター結合",
|
||||
"strokeJoinBevel": "ベベル結合",
|
||||
"strokeJoinRound": "ラウンド結合",
|
||||
"strokeMiterLimit": "マイター制限",
|
||||
"add": "追加",
|
||||
"variants": "バリアント",
|
||||
"gapAuto": "間隔自動",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,15 @@
|
|||
"create": "Создать",
|
||||
"createNumberVariable": "Создать числовую переменную из {value}",
|
||||
"strokeDash": "Пунктирная обводка",
|
||||
"strokeCap": "Конец обводки",
|
||||
"strokeCapButt": "Плоский конец",
|
||||
"strokeCapRound": "Круглый конец",
|
||||
"strokeCapSquare": "Квадратный конец",
|
||||
"strokeJoin": "Соединение обводки",
|
||||
"strokeJoinMiter": "Острое соединение",
|
||||
"strokeJoinBevel": "Скошенное соединение",
|
||||
"strokeJoinRound": "Круглое соединение",
|
||||
"strokeMiterLimit": "Предел острого соединения",
|
||||
"add": "Добавить",
|
||||
"variants": "Варианты",
|
||||
"gapAuto": "Авто отступ",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,15 @@
|
|||
"create": "创建",
|
||||
"createNumberVariable": "从 {value} 创建数字变量",
|
||||
"strokeDash": "虚线描边",
|
||||
"strokeCap": "描边端点",
|
||||
"strokeCapButt": "平直端点",
|
||||
"strokeCapRound": "圆形端点",
|
||||
"strokeCapSquare": "方形端点",
|
||||
"strokeJoin": "描边连接",
|
||||
"strokeJoinMiter": "尖角连接",
|
||||
"strokeJoinBevel": "斜角连接",
|
||||
"strokeJoinRound": "圆角连接",
|
||||
"strokeMiterLimit": "尖角限制",
|
||||
"add": "添加",
|
||||
"variants": "变体",
|
||||
"gapAuto": "自动间距",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)]'
|
||||
|
|
|
|||
80
tests/e2e/canvas/stroke-geometry-visual.spec.ts
Normal file
80
tests/e2e/canvas/stroke-geometry-visual.spec.ts
Normal 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 |
103
tests/e2e/properties/stroke-geometry.spec.ts
Normal file
103
tests/e2e/properties/stroke-geometry.spec.ts
Normal 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'])
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
58
tests/engine/io/fig/export/stroke-geometry.test.ts
Normal file
58
tests/engine/io/fig/export/stroke-geometry.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
103
tests/engine/vue/controls/stroke.test.ts
Normal file
103
tests/engine/vue/controls/stroke.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue