1.2.1: strokes scale with the layer (incl. inside SVG-asset placements)

- strokeCfg now defaults to strokeScaleEnabled:true so stroked rect /
  circle / text outlines grow proportionally when the layer is scaled
  (Konva default; previously hard-pinned to false).
- polygonConfig and clipPolygonConfig had inline stroke blocks that
  also forced strokeScaleEnabled:false. Flipped both to true so
  freeform / polygon outlines scale alongside the shape.
- SVG-asset placements use a dedicated svgChildConfig wrapper +
  pre-multiplied strokeWidth in svgStackForAsset: each child's stroke
  is bumped by the placement's geometric-mean scale and rendered with
  strokeScaleEnabled:false (Konva resets the ctx to identity before
  stroking) so the on-screen width lands at the right pixel count
  regardless of how Konva propagates parent transforms to nested
  stroke rendering. Konva-mounted tests cover outer-scale 1×, 3× and
  non-uniform 4×/1×.

UI chrome (transformer borders, vertex / tangent handles, hit-shape
outlines, animation-path ghosts, ghost-locked layer indicators) keeps
strokeScaleEnabled:false intentionally — those should stay at constant
pixel width regardless of zoom.
This commit is contained in:
th committed 2026-04-25 22:45:01 +02:00
1 parent d03fbe4ecb
commit 3bc329813c
4 files changed
+213 -13

No files matched your search

+63 -11
View File
@@ -540,6 +540,18 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false }
}
/** SVG-asset child wrapper. Same listening/drag config as button
* children, but also pins `strokeScaleEnabled: false`. svgStackForAsset
* pre-multiplies each child's strokeWidth by the outer placement
* scale; combined with strokeScaleEnabled:false (Konva resets the
* ctx transform to identity before stroking), the on-screen stroke
* width comes out at the right number of pixels regardless of
* whether Konva propagates the parent group's scale to nested
* stroke rendering. */
function svgChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false, strokeScaleEnabled: false }
}
/** Invisible Konva rect that covers the SVG asset's canvas rect.
* Children are non-listening (buttonChildConfig) so without a hit
* surface the layer would fall through to the stage and stay
@@ -577,9 +589,18 @@ function buttonStackForTime(
/** Static render stack for an SVG asset placed in the main scene.
* All child keyframes live at t=0 by construction, so there's no
* per-state lookup — just sample each visible layer once. */
* per-state lookup — just sample each visible layer once.
*
* `outerScale` is the placement layer's scale factor; child stroke
* widths get pre-multiplied by it so the rendered outline grows
* proportionally with the outer scale. Konva's strokeScaleEnabled
* only honours the *node's own* scale, not the cumulative parent
* transform — without this pre-multiply, strokes inside a scaled
* SVG asset would render at constant pixel width even though every
* shape inside grows. */
function svgStackForAsset(
asset: SvgAsset,
outerScale = 1,
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
const out: Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> = []
const layers = asset.layers.slice().reverse()
@@ -588,7 +609,18 @@ function svgStackForAsset(
if (l.editorHidden) continue
const a = assetMap.value.get(l.assetId)
if (!a) continue
out.push({ layer: l, asset: a, props: sampleLayer(l, 0) })
const props = sampleLayer(l, 0)
// Resolve effective stroke width from props ?? asset, then scale.
// strokeCfg looks at props.strokeWidth first; setting it here
// overrides the asset fallback for this render only.
const baseSw =
props.strokeWidth ??
('strokeWidth' in a ? (a.strokeWidth as number | undefined) : undefined) ??
0
if (outerScale !== 1 && baseSw > 0) {
props.strokeWidth = baseSw * outerScale
}
out.push({ layer: l, asset: a, props })
}
return out
}
@@ -1198,7 +1230,12 @@ function strokeCfg(
if (strokeWidth <= 0) return { strokeWidth: 0, stroke: undefined }
const stroke = (props.stroke ?? asset.stroke) || '#000000'
if (stroke === 'transparent') return { strokeWidth: 0, stroke: undefined }
return { stroke, strokeWidth, strokeScaleEnabled: false }
// Strokes scale with the layer (Konva default). Set explicitly:
// Konva's bounding-box / hit / cache paths can default to the
// shape's previous attribute value when the prop is reactively
// omitted, so `strokeScaleEnabled: true` lands on the node either
// way after the first render.
return { stroke, strokeWidth, strokeScaleEnabled: true }
}
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
@@ -1385,7 +1422,12 @@ function polygonConfig(
if (!layer.isMask && stroke && strokeWidth > 0 && stroke !== 'transparent') {
cfg.stroke = stroke
cfg.strokeWidth = strokeWidth
cfg.strokeScaleEnabled = false
// Default Konva behaviour: stroke scales with the node's own
// scale. Previously hard-pinned to false, which left freeform /
// polygon outlines at constant pixel width when the user scaled
// the layer. svgChildConfig overrides this back to false for
// SVG-asset placements (which pre-multiply strokeWidth instead).
cfg.strokeScaleEnabled = true
} else {
cfg.stroke = undefined
cfg.strokeWidth = 0
@@ -1607,7 +1649,11 @@ function clipPolygonConfig(item: RenderedClipLayer) {
if (stroke && strokeWidth > 0) {
cfg.stroke = stroke
cfg.strokeWidth = strokeWidth
cfg.strokeScaleEnabled = false
// Match the regular polygonConfig: strokes scale with the
// shape's transform so a scaled clip-instance polygon's outline
// grows proportionally instead of staying at constant pixel
// width.
cfg.strokeScaleEnabled = true
}
return cfg
}
@@ -4077,28 +4123,34 @@ function onResizeHandlePointerUp(e: PointerEvent) {
:config="svgHitCatcherConfig(svgAssetFor(entry.item.asset)!, entry.item.layer)"
/>
<template
v-for="(sub, sli) in svgStackForAsset(svgAssetFor(entry.item.asset)!)"
v-for="(sub, sli) in svgStackForAsset(
svgAssetFor(entry.item.asset)!,
Math.sqrt(
Math.abs(entry.item.props.scaleX || 1) *
Math.abs(entry.item.props.scaleY || 1),
),
)"
:key="`${entry.item.layer.id}:${sub.layer.id}:${sli}`"
>
<v-rect
v-if="sub.asset.kind === 'rect'"
:config="buttonChildConfig(rectConfig(sub.layer, sub.asset, sub.props))"
:config="svgChildConfig(rectConfig(sub.layer, sub.asset, sub.props))"
/>
<v-circle
v-else-if="sub.asset.kind === 'circle'"
:config="buttonChildConfig(circleConfig(sub.layer, sub.asset, sub.props))"
:config="svgChildConfig(circleConfig(sub.layer, sub.asset, sub.props))"
/>
<v-text
v-else-if="sub.asset.kind === 'text'"
:config="buttonChildConfig(textConfig(sub.layer, sub.asset, sub.props))"
:config="svgChildConfig(textConfig(sub.layer, sub.asset, sub.props))"
/>
<v-line
v-else-if="sub.asset.kind === 'polygon'"
:config="buttonChildConfig(polygonConfig(sub.layer, sub.asset, sub.props))"
:config="svgChildConfig(polygonConfig(sub.layer, sub.asset, sub.props))"
/>
<v-image
v-else-if="sub.asset.kind === 'image' && imageConfig(sub.layer, sub.asset, sub.props)"
:config="buttonChildConfig(imageConfig(sub.layer, sub.asset, sub.props)!)"
:config="svgChildConfig(imageConfig(sub.layer, sub.asset, sub.props)!)"
/>
</template>
</v-group>