fix(renderer): RenderNode.clipRect → clipStack so each ancestor rrect is preserved
Single ClipInfo can't faithfully encode `(rrect ∩ rrect)` whenever one rect cuts inside the other's corner. The previous fix collapsed nested clips into one ClipInfo and dropped one side's rounded corner — which meant a rounded modal containing rounded cards would silently lose either the modal's rounding or the card's rounding at paint time. Fix: replace the single `RenderNode.clipRect: ClipInfo | undefined` with `clipStack: ClipInfo[]`. Flatten time accumulates a stack from outer-most ancestor down to the immediate clip-introducing parent. Paint time pushes each entry as its own canvas.save+clipRect/clipRRect — Skia's clip stack intersects them naturally, so each level's rounded corner is enforced independently. Touched: - types.ts: export ClipInfo, replace clipRect with clipStack - document-flattener.ts: thread `clipStack: ClipInfo[]` through recursion; push to a copy when isRootFrame || explicitClip - node-renderer.ts paint: loop over clipStack, push N save+clip ops, pop the same N at the end - renderer.ts (root frame label loop) + skia-engine.ts (root frame label loop) + focus-fit.ts (auto-fit excludes clipped descendants) + global-export.ts (page bounds): all check clipStack.length instead of truthy single field - skia-interaction.ts: drag/resize/rotate snapshots store and restore clipStack arrays (deep-cloned per entry) - Tests updated + 1 new test: rounded modal containing rounded card preserves both rrects on the inner content's clip stack
This commit is contained in:
parent
220a92c501
commit
4aa59677be
|
|
@ -65,7 +65,7 @@ describe('focus fit helpers', () => {
|
|||
absY: 60,
|
||||
absW: 120,
|
||||
absH: 90,
|
||||
clipRect: { x: 0, y: 0, w: 1000, h: 800, rx: 0 },
|
||||
clipStack: [{ x: 0, y: 0, w: 1000, h: 800, rx: 0 }],
|
||||
}),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ describe('SkiaInteractionManager continuous interaction commits', () => {
|
|||
absY: 60,
|
||||
absW: 200,
|
||||
absH: 120,
|
||||
clipRect: { x: 45, y: 55, w: 210, h: 130, rx: 8 },
|
||||
clipStack: [{ x: 45, y: 55, w: 210, h: 130, rx: 8 }],
|
||||
};
|
||||
const engine = createEngineStub([renderNode]);
|
||||
const manager = new SkiaInteractionManager(
|
||||
|
|
@ -347,7 +347,7 @@ describe('SkiaInteractionManager continuous interaction commits', () => {
|
|||
|
||||
expect(renderNode.absX).toBe(70);
|
||||
expect(renderNode.absY).toBe(75);
|
||||
expect(renderNode.clipRect).toMatchObject({ x: 65, y: 70, w: 210, h: 130, rx: 8 });
|
||||
expect(renderNode.clipStack).toEqual([{ x: 65, y: 70, w: 210, h: 130, rx: 8 }]);
|
||||
expect(engine.rebuildCount).toBeGreaterThan(0);
|
||||
expect(engine.dirtyCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ export function getFocusBounds(
|
|||
const selectedSet = new Set(selectedIds);
|
||||
const selectedNodes = renderNodes.filter((rn) => selectedSet.has(rn.node.id));
|
||||
const targetNodes =
|
||||
selectedNodes.length > 0 ? selectedNodes : renderNodes.filter((rn) => !rn.clipRect);
|
||||
selectedNodes.length > 0
|
||||
? selectedNodes
|
||||
: renderNodes.filter((rn) => rn.clipStack === undefined || rn.clipStack.length === 0);
|
||||
|
||||
if (targetNodes.length === 0) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -421,7 +421,8 @@ export class SkiaEngine {
|
|||
canvas.scale(dpr, dpr);
|
||||
for (const rn of this.renderNodes) {
|
||||
if (!rn.node.name) continue;
|
||||
const isRootFrame = rn.node.type === 'frame' && !rn.clipRect;
|
||||
const isRootFrame =
|
||||
rn.node.type === 'frame' && (rn.clipStack === undefined || rn.clipStack.length === 0);
|
||||
const isReusable = this.reusableIds.has(rn.node.id);
|
||||
const isInstance = this.instanceIds.has(rn.node.id);
|
||||
if (!isRootFrame && !isReusable && !isInstance) continue;
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ interface RenderNodeSnapshot {
|
|||
absY: number;
|
||||
absW: number;
|
||||
absH: number;
|
||||
clipRect?: { x: number; y: number; w: number; h: number; rx: number };
|
||||
clipStack?: { x: number; y: number; w: number; h: number; rx: number }[];
|
||||
}
|
||||
|
||||
interface PreviewRect {
|
||||
|
|
@ -523,7 +523,9 @@ export class SkiaInteractionManager {
|
|||
absY: rn.absY,
|
||||
absW: rn.absW,
|
||||
absH: rn.absH,
|
||||
...(rn.clipRect ? { clipRect: { ...rn.clipRect } } : {}),
|
||||
...(rn.clipStack && rn.clipStack.length > 0
|
||||
? { clipStack: rn.clipStack.map((c) => ({ ...c })) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return snapshots;
|
||||
|
|
@ -647,20 +649,15 @@ export class SkiaInteractionManager {
|
|||
rootRn.absY = nextRootRect.y;
|
||||
rootRn.absW = nextRootRect.w;
|
||||
rootRn.absH = nextRootRect.h;
|
||||
rootRn.clipRect = rootSnapshot.clipRect
|
||||
? {
|
||||
rootRn.clipStack = rootSnapshot.clipStack
|
||||
? rootSnapshot.clipStack.map((clip) => ({
|
||||
...this.scalePreviewRect(
|
||||
{
|
||||
x: rootSnapshot.clipRect.x,
|
||||
y: rootSnapshot.clipRect.y,
|
||||
w: rootSnapshot.clipRect.w,
|
||||
h: rootSnapshot.clipRect.h,
|
||||
},
|
||||
{ x: clip.x, y: clip.y, w: clip.w, h: clip.h },
|
||||
sourceRect,
|
||||
nextRootRect,
|
||||
),
|
||||
rx: rootSnapshot.clipRect.rx * Math.min(scaleX, scaleY),
|
||||
}
|
||||
rx: clip.rx * Math.min(scaleX, scaleY),
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
for (const [id, snapshot] of previewNodes) {
|
||||
|
|
@ -677,20 +674,15 @@ export class SkiaInteractionManager {
|
|||
rn.absY = scaled.y;
|
||||
rn.absW = scaled.w;
|
||||
rn.absH = scaled.h;
|
||||
rn.clipRect = snapshot.clipRect
|
||||
? {
|
||||
rn.clipStack = snapshot.clipStack
|
||||
? snapshot.clipStack.map((clip) => ({
|
||||
...this.scalePreviewRect(
|
||||
{
|
||||
x: snapshot.clipRect.x,
|
||||
y: snapshot.clipRect.y,
|
||||
w: snapshot.clipRect.w,
|
||||
h: snapshot.clipRect.h,
|
||||
},
|
||||
{ x: clip.x, y: clip.y, w: clip.w, h: clip.h },
|
||||
sourceRect,
|
||||
nextRootRect,
|
||||
),
|
||||
rx: snapshot.clipRect.rx * Math.min(scaleX, scaleY),
|
||||
}
|
||||
rx: clip.rx * Math.min(scaleX, scaleY),
|
||||
}))
|
||||
: undefined;
|
||||
}
|
||||
|
||||
|
|
@ -726,7 +718,9 @@ export class SkiaInteractionManager {
|
|||
rootRn.absY = rootSnapshot.absY;
|
||||
rootRn.absW = rootSnapshot.absW;
|
||||
rootRn.absH = rootSnapshot.absH;
|
||||
rootRn.clipRect = rootSnapshot.clipRect ? { ...rootSnapshot.clipRect } : undefined;
|
||||
rootRn.clipStack = rootSnapshot.clipStack
|
||||
? rootSnapshot.clipStack.map((c) => ({ ...c }))
|
||||
: undefined;
|
||||
|
||||
const centerX = rootSnapshot.absX + rootSnapshot.absW / 2;
|
||||
const centerY = rootSnapshot.absY + rootSnapshot.absH / 2;
|
||||
|
|
@ -750,21 +744,16 @@ export class SkiaInteractionManager {
|
|||
rn.absY = rotated.y;
|
||||
rn.absW = rotated.w;
|
||||
rn.absH = rotated.h;
|
||||
rn.clipRect = snapshot.clipRect
|
||||
? {
|
||||
rn.clipStack = snapshot.clipStack
|
||||
? snapshot.clipStack.map((clip) => ({
|
||||
...this.rotatePreviewRect(
|
||||
{
|
||||
x: snapshot.clipRect.x,
|
||||
y: snapshot.clipRect.y,
|
||||
w: snapshot.clipRect.w,
|
||||
h: snapshot.clipRect.h,
|
||||
},
|
||||
{ x: clip.x, y: clip.y, w: clip.w, h: clip.h },
|
||||
centerX,
|
||||
centerY,
|
||||
angleDelta,
|
||||
),
|
||||
rx: snapshot.clipRect.rx,
|
||||
}
|
||||
rx: clip.rx,
|
||||
}))
|
||||
: undefined;
|
||||
}
|
||||
|
||||
|
|
@ -919,12 +908,12 @@ export class SkiaInteractionManager {
|
|||
if (this.dragAllIds!.has(rn.node.id)) {
|
||||
rn.absX += incrDx;
|
||||
rn.absY += incrDy;
|
||||
if (rn.clipRect) {
|
||||
rn.clipRect = {
|
||||
...rn.clipRect,
|
||||
x: rn.clipRect.x + incrDx,
|
||||
y: rn.clipRect.y + incrDy,
|
||||
};
|
||||
if (rn.clipStack && rn.clipStack.length > 0) {
|
||||
rn.clipStack = rn.clipStack.map((c) => ({
|
||||
...c,
|
||||
x: c.x + incrDx,
|
||||
y: c.y + incrDy,
|
||||
}));
|
||||
}
|
||||
rn.node = { ...rn.node, x: rn.absX, y: rn.absY };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ function renderPageToImage(
|
|||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
for (const rn of renderNodes) {
|
||||
if (rn.clipRect) continue;
|
||||
if (rn.clipStack && rn.clipStack.length > 0) continue;
|
||||
if (rn.absX < minX) minX = rn.absX;
|
||||
if (rn.absY < minY) minY = rn.absY;
|
||||
if (rn.absX + rn.absW > maxX) maxX = rn.absX + rn.absW;
|
||||
|
|
|
|||
|
|
@ -223,9 +223,9 @@ describe('flattenToRenderNodes — dimension consistency', () => {
|
|||
expect(t2.absY).toBeGreaterThan(t1.absY + t1.absH);
|
||||
});
|
||||
|
||||
it('root frame clipRect matches absW/absH, not a divergent nodeW/nodeH', () => {
|
||||
// Root frame (depth=0) creates a clipRect for its children.
|
||||
// clipRect must use the same dimensions as the RenderNode's absW/absH.
|
||||
it('root frame clip matches absW/absH, not a divergent nodeW/nodeH', () => {
|
||||
// Root frame (depth=0) creates a clipStack entry for its children.
|
||||
// The clip rect must use the same dimensions as the RenderNode's absW/absH.
|
||||
const root = frame({
|
||||
id: 'root',
|
||||
width: 400,
|
||||
|
|
@ -239,20 +239,21 @@ describe('flattenToRenderNodes — dimension consistency', () => {
|
|||
const rootRN = nodes.find((rn) => rn.node.id === 'root')!;
|
||||
const t1 = nodes.find((rn) => rn.node.id === 't1')!;
|
||||
|
||||
// Root frame itself has no clipRect (it IS the clip source)
|
||||
expect(rootRN.clipRect).toBeUndefined();
|
||||
// Root frame itself has empty clip stack (it IS the clip source for children)
|
||||
expect(rootRN.clipStack).toBeUndefined();
|
||||
|
||||
// Child inherits root's clip — must match root's rendered dimensions
|
||||
expect(t1.clipRect).toBeDefined();
|
||||
expect(t1.clipRect!.w).toBe(rootRN.absW);
|
||||
expect(t1.clipRect!.h).toBe(rootRN.absH);
|
||||
expect(t1.clipRect!.x).toBe(rootRN.absX);
|
||||
expect(t1.clipRect!.y).toBe(rootRN.absY);
|
||||
// Child inherits root's clip — single entry on the stack
|
||||
expect(t1.clipStack).toBeDefined();
|
||||
expect(t1.clipStack!.length).toBe(1);
|
||||
expect(t1.clipStack![0].w).toBe(rootRN.absW);
|
||||
expect(t1.clipStack![0].h).toBe(rootRN.absH);
|
||||
expect(t1.clipStack![0].x).toBe(rootRN.absX);
|
||||
expect(t1.clipStack![0].y).toBe(rootRN.absY);
|
||||
});
|
||||
|
||||
it('root frame clipRect matches absW/absH for frame without explicit height', () => {
|
||||
it('root frame clip matches absW/absH for frame without explicit height', () => {
|
||||
// Frame with fit_content height — getNodeHeight computes from children.
|
||||
// clipRect.h must equal the RenderNode's absH, not a stale fallback.
|
||||
// The artboard clip must equal the RenderNode's absH, not a stale fallback.
|
||||
const root = frame({
|
||||
id: 'root',
|
||||
width: 375,
|
||||
|
|
@ -266,16 +267,15 @@ describe('flattenToRenderNodes — dimension consistency', () => {
|
|||
const rootRN = nodes.find((rn) => rn.node.id === 'root')!;
|
||||
const t1 = nodes.find((rn) => rn.node.id === 't1')!;
|
||||
|
||||
// absH should be computed from content, not 100 fallback
|
||||
expect(rootRN.absH).toBeGreaterThan(0);
|
||||
|
||||
// clipRect must match absH
|
||||
expect(t1.clipRect).toBeDefined();
|
||||
expect(t1.clipRect!.h).toBe(rootRN.absH);
|
||||
expect(t1.clipRect!.w).toBe(rootRN.absW);
|
||||
expect(t1.clipStack).toBeDefined();
|
||||
expect(t1.clipStack!.length).toBe(1);
|
||||
expect(t1.clipStack![0].h).toBe(rootRN.absH);
|
||||
expect(t1.clipStack![0].w).toBe(rootRN.absW);
|
||||
});
|
||||
|
||||
it('nested frame with clipContent clips its descendants using its own bounds/radius', () => {
|
||||
it('nested frame with clipContent appends its own clip on top of ancestor stack', () => {
|
||||
const root = frame({
|
||||
id: 'root',
|
||||
width: 400,
|
||||
|
|
@ -298,20 +298,67 @@ describe('flattenToRenderNodes — dimension consistency', () => {
|
|||
const card = nodes.find((rn) => rn.node.id === 'card')!;
|
||||
const inner = nodes.find((rn) => rn.node.id === 'inner')!;
|
||||
|
||||
expect(inner.clipRect).toBeDefined();
|
||||
expect(inner.clipRect!.x).toBe(card.absX);
|
||||
expect(inner.clipRect!.y).toBe(card.absY);
|
||||
expect(inner.clipRect!.w).toBe(card.absW);
|
||||
expect(inner.clipRect!.h).toBe(card.absH);
|
||||
expect(inner.clipRect!.rx).toBe(16);
|
||||
// inner has 2-level stack: outer = root frame's clip, then card's own clip
|
||||
expect(inner.clipStack).toBeDefined();
|
||||
expect(inner.clipStack!.length).toBe(2);
|
||||
expect(inner.clipStack![1].x).toBe(card.absX);
|
||||
expect(inner.clipStack![1].y).toBe(card.absY);
|
||||
expect(inner.clipStack![1].w).toBe(card.absW);
|
||||
expect(inner.clipStack![1].h).toBe(card.absH);
|
||||
expect(inner.clipStack![1].rx).toBe(16);
|
||||
});
|
||||
|
||||
it('nested clipContent intersects with ancestor clip (overflowing horizontal scroll row)', () => {
|
||||
it('preserves each ancestor rrect (rounded modal containing rounded card)', () => {
|
||||
// A rounded modal contains a rounded card. Both rrects must be enforced
|
||||
// on inner content — a single ClipInfo can't encode `rrect ∩ rrect` so
|
||||
// each level lives independently on the clipStack.
|
||||
const root = frame({
|
||||
id: 'root',
|
||||
width: 600,
|
||||
height: 600,
|
||||
children: [
|
||||
frame({
|
||||
id: 'modal',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 400,
|
||||
cornerRadius: 24,
|
||||
clipContent: true,
|
||||
children: [
|
||||
frame({
|
||||
id: 'card',
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 360,
|
||||
height: 80,
|
||||
cornerRadius: 12,
|
||||
clipContent: true,
|
||||
children: [text('card-inner', 'Title')],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const nodes = flattenToRenderNodes([root]);
|
||||
const inner = nodes.find((rn) => rn.node.id === 'card-inner')!;
|
||||
|
||||
// Inner sits under [root artboard, modal rrect, card rrect]
|
||||
expect(inner.clipStack).toBeDefined();
|
||||
expect(inner.clipStack!.length).toBe(3);
|
||||
expect(inner.clipStack![0].rx).toBe(0); // root artboard (no cornerRadius)
|
||||
expect(inner.clipStack![1].rx).toBe(24); // modal preserves its rounding
|
||||
expect(inner.clipStack![2].rx).toBe(12); // card preserves its rounding
|
||||
});
|
||||
|
||||
it('overflowing horizontal scroll: 3rd card stack ends with row + card own clip', () => {
|
||||
// Reproduces the food-delivery brief regression: a horizontal scroll row
|
||||
// contains 3 rounded-corner cards (each `clipContent: true`) whose total
|
||||
// width exceeds the row's clip. Without intersect, the third card's
|
||||
// children would clip to the card's own bounds (extending past the row's
|
||||
// right edge) instead of staying inside the visible scroll strip.
|
||||
// width exceeds the row's visible width. The renderer enforces the row's
|
||||
// rectangular clip AND the card's rounded clip independently — the 3rd
|
||||
// card's children paint within `(row clip) ∩ (card3 rrect clip)` even
|
||||
// though card3's bounds extend past the row's right edge.
|
||||
const root = frame({
|
||||
id: 'root',
|
||||
width: 400,
|
||||
|
|
@ -358,24 +405,26 @@ describe('flattenToRenderNodes — dimension consistency', () => {
|
|||
const c3 = nodes.find((rn) => rn.node.id === 'c3')!;
|
||||
const c3inner = nodes.find((rn) => rn.node.id === 'c3-inner')!;
|
||||
|
||||
// Layout sanity: c1 at 0, c2 at 162, c3 at 324; c3 right edge = 474 > 400
|
||||
// Layout sanity: c3 at 324 with right edge 474 > 400 (root width)
|
||||
expect(c3.absX).toBe(324);
|
||||
expect(c3.absW).toBe(150);
|
||||
|
||||
// c3 itself inherits the row's clipRect (rectangular, full row width)
|
||||
expect(c3.clipRect).toBeDefined();
|
||||
expect(c3.clipRect!.x).toBe(0);
|
||||
expect(c3.clipRect!.w).toBe(400);
|
||||
// c3 RN paints under [root, row] — 2 ancestors
|
||||
expect(c3.clipStack).toBeDefined();
|
||||
expect(c3.clipStack!.length).toBe(2);
|
||||
// Row clip is full row width (400) at row position
|
||||
expect(c3.clipStack![1].w).toBe(400);
|
||||
|
||||
// c3-inner's clipRect MUST be intersect(c3 bounds, row clip), not c3 alone
|
||||
// c3 bounds {x:324, w:150} ∩ row clip {x:0, w:400} = {x:324, w:76}
|
||||
// (without intersect, this would be {x:324, w:150} and the inner text
|
||||
// would paint past the row's right edge)
|
||||
expect(c3inner.clipRect).toBeDefined();
|
||||
expect(c3inner.clipRect!.x).toBe(324);
|
||||
expect(c3inner.clipRect!.w).toBe(76);
|
||||
// Shrunk on x-axis → rounded corners would no longer follow both
|
||||
// boundaries faithfully, so intersect drops to rectangular clip.
|
||||
expect(c3inner.clipRect!.rx).toBe(0);
|
||||
// c3-inner sits under [root, row, c3] — 3 levels, every rrect preserved
|
||||
expect(c3inner.clipStack).toBeDefined();
|
||||
expect(c3inner.clipStack!.length).toBe(3);
|
||||
// Row clip is enforced as its OWN entry — not collapsed into c3's
|
||||
expect(c3inner.clipStack![1].x).toBe(0);
|
||||
expect(c3inner.clipStack![1].w).toBe(400);
|
||||
expect(c3inner.clipStack![1].rx).toBe(0);
|
||||
// c3 clip is enforced as its OWN entry — keeps its own bounds + rrect
|
||||
expect(c3inner.clipStack![2].x).toBe(324);
|
||||
expect(c3inner.clipStack![2].w).toBe(150);
|
||||
expect(c3inner.clipStack![2].rx).toBe(16);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
cssFontFamily,
|
||||
} from '@zseven-w/pen-core';
|
||||
import { wrapLine } from './paint-utils.js';
|
||||
import type { RenderNode } from './types.js';
|
||||
import type { ClipInfo, RenderNode } from './types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-measure text widths using Canvas 2D (browser fonts)
|
||||
|
|
@ -124,14 +124,6 @@ export function premeasureTextHeights(nodes: PenNode[]): PenNode[] {
|
|||
// Flatten document tree -> absolute-positioned RenderNode list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ClipInfo {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
rx: number;
|
||||
}
|
||||
|
||||
function sizeToNumber(val: number | string | undefined, fallback: number): number {
|
||||
if (typeof val === 'number') return val;
|
||||
if (typeof val === 'string') {
|
||||
|
|
@ -149,36 +141,13 @@ function cornerRadiusVal(cr: number | [number, number, number, number] | undefin
|
|||
return cr[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Intersect two axis-aligned clip rectangles. If `inner` (the new clip we
|
||||
* want to apply) gets cut by `outer` (the ancestor clip we must respect),
|
||||
* drop the rounded corners — a single ClipInfo can only encode one rounded
|
||||
* rect, but the true `(rrect ∩ rect)` whenever the rect cuts inside the
|
||||
* rrect's corner is non-trivial. Rectangular fallback is the safe option.
|
||||
*
|
||||
* `outer.rx` is irrelevant here because `outer` is already in effect from
|
||||
* earlier draws — its rounded shape will continue to clip transparently
|
||||
* via the ambient canvas clip stack at paint time.
|
||||
*/
|
||||
function intersectClip(inner: ClipInfo, outer: ClipInfo): ClipInfo {
|
||||
const x = Math.max(inner.x, outer.x);
|
||||
const y = Math.max(inner.y, outer.y);
|
||||
const x2 = Math.min(inner.x + inner.w, outer.x + outer.w);
|
||||
const y2 = Math.min(inner.y + inner.h, outer.y + outer.h);
|
||||
const w = Math.max(0, x2 - x);
|
||||
const h = Math.max(0, y2 - y);
|
||||
const innerWasCut = w < inner.w || h < inner.h;
|
||||
const rx = innerWasCut ? 0 : inner.rx;
|
||||
return { x, y, w, h, rx };
|
||||
}
|
||||
|
||||
export function flattenToRenderNodes(
|
||||
nodes: PenNode[],
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
parentAvailW?: number,
|
||||
parentAvailH?: number,
|
||||
clipCtx?: ClipInfo,
|
||||
clipStack: ClipInfo[] = [],
|
||||
depth = 0,
|
||||
): RenderNode[] {
|
||||
const result: RenderNode[] = [];
|
||||
|
|
@ -247,7 +216,7 @@ export function flattenToRenderNodes(
|
|||
absY,
|
||||
absW,
|
||||
absH,
|
||||
clipRect: clipCtx,
|
||||
clipStack: clipStack.length > 0 ? clipStack.slice() : undefined,
|
||||
});
|
||||
|
||||
// Recurse into children
|
||||
|
|
@ -265,20 +234,19 @@ export function flattenToRenderNodes(
|
|||
layout && layout !== 'none' ? computeLayoutPositions(resolved, children) : children;
|
||||
|
||||
// Clipping — root frames always clip like artboards. Nested containers
|
||||
// clip only when clipContent is enabled. When a nested clip is
|
||||
// introduced, intersect it with the inherited ancestor clip — otherwise
|
||||
// a card with `clipContent: true` (e.g. for rounded image masking) would
|
||||
// let its own children paint outside the row that's supposed to clip
|
||||
// the whole horizontal scroll strip, since the card's bounds extend
|
||||
// past the row's right edge in an overflowing row.
|
||||
let childClip = clipCtx;
|
||||
// clip only when clipContent is enabled. Each level pushes its own
|
||||
// ClipInfo onto the stack rather than intersecting into a single
|
||||
// ClipInfo, so each ancestor's rounded corner can be enforced
|
||||
// independently at paint time (a single rrect can't encode `(rrect ∩
|
||||
// rrect)` faithfully when one rect cuts inside the other's corner).
|
||||
let childClipStack = clipStack;
|
||||
const isRootFrame = node.type === 'frame' && depth === 0;
|
||||
const explicitClip = 'clipContent' in resolved && resolved.clipContent === true;
|
||||
if (isRootFrame || explicitClip) {
|
||||
const crRaw = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0;
|
||||
const cr = Math.min(crRaw, nodeH / 2);
|
||||
const own: ClipInfo = { x: absX, y: absY, w: nodeW, h: nodeH, rx: cr };
|
||||
childClip = clipCtx ? intersectClip(own, clipCtx) : own;
|
||||
childClipStack = [...clipStack, own];
|
||||
}
|
||||
|
||||
const childRNs = flattenToRenderNodes(
|
||||
|
|
@ -287,7 +255,7 @@ export function flattenToRenderNodes(
|
|||
absY,
|
||||
childAvailW,
|
||||
childAvailH,
|
||||
childClip,
|
||||
childClipStack,
|
||||
depth + 1,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -480,7 +480,7 @@ export class SkiaNodeRenderer {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
drawNode(canvas: Canvas, rn: RenderNode) {
|
||||
const { node, absX, absY, absW, absH, clipRect } = rn;
|
||||
const { node, absX, absY, absW, absH, clipStack } = rn;
|
||||
const ck = this.ck;
|
||||
const opacity = typeof node.opacity === 'number' ? node.opacity : 1;
|
||||
|
||||
|
|
@ -490,27 +490,33 @@ export class SkiaNodeRenderer {
|
|||
this.textRenderer.zoom = this.zoom;
|
||||
this.textRenderer.devicePixelRatio = this.devicePixelRatio;
|
||||
|
||||
// Apply clipping from parent frame
|
||||
let clipped = false;
|
||||
if (clipRect) {
|
||||
canvas.save();
|
||||
clipped = true;
|
||||
if (clipRect.rx > 0) {
|
||||
canvas.clipRRect(
|
||||
ck.RRectXY(
|
||||
ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h),
|
||||
clipRect.rx,
|
||||
clipRect.rx,
|
||||
),
|
||||
ck.ClipOp.Intersect,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
canvas.clipRect(
|
||||
ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h),
|
||||
ck.ClipOp.Intersect,
|
||||
true,
|
||||
);
|
||||
// Apply ancestor-to-self clip stack. Each ancestor with `clipContent: true`
|
||||
// (and the root frame artboard) pushed a ClipInfo onto this stack at
|
||||
// flatten time. Pushing each as a separate canvas.save+clip preserves
|
||||
// every level's rounded corner — a single ClipInfo can't encode the
|
||||
// intersection of two rrects when one cuts inside the other's corner.
|
||||
let clipsPushed = 0;
|
||||
if (clipStack && clipStack.length > 0) {
|
||||
for (const clip of clipStack) {
|
||||
canvas.save();
|
||||
clipsPushed++;
|
||||
if (clip.rx > 0) {
|
||||
canvas.clipRRect(
|
||||
ck.RRectXY(
|
||||
ck.LTRBRect(clip.x, clip.y, clip.x + clip.w, clip.y + clip.h),
|
||||
clip.rx,
|
||||
clip.rx,
|
||||
),
|
||||
ck.ClipOp.Intersect,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
canvas.clipRect(
|
||||
ck.LTRBRect(clip.x, clip.y, clip.x + clip.w, clip.y + clip.h),
|
||||
ck.ClipOp.Intersect,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -569,7 +575,7 @@ export class SkiaNodeRenderer {
|
|||
|
||||
if (rotation !== 0) canvas.restore();
|
||||
if (flipX || flipY) canvas.restore();
|
||||
if (clipped) canvas.restore();
|
||||
for (let i = 0; i < clipsPushed; i++) canvas.restore();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ export class PenRenderer {
|
|||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
for (const rn of this.renderNodes) {
|
||||
if (rn.clipRect) continue;
|
||||
if (rn.clipStack && rn.clipStack.length > 0) continue;
|
||||
minX = Math.min(minX, rn.absX);
|
||||
minY = Math.min(minY, rn.absY);
|
||||
maxX = Math.max(maxX, rn.absX + rn.absW);
|
||||
|
|
@ -330,7 +330,8 @@ export class PenRenderer {
|
|||
// Draw frame labels for root frames + reusable + instances
|
||||
for (const rn of this.renderNodes) {
|
||||
if (!rn.node.name) continue;
|
||||
const isRootFrame = rn.node.type === 'frame' && !rn.clipRect;
|
||||
const isRootFrame =
|
||||
rn.node.type === 'frame' && (rn.clipStack === undefined || rn.clipStack.length === 0);
|
||||
const isReusable = this.reusableIds.has(rn.node.id);
|
||||
const isInstance = this.instanceIds.has(rn.node.id);
|
||||
if (!isRootFrame && !isReusable && !isInstance) continue;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,33 @@ import type { PenNode } from '@zseven-w/pen-types';
|
|||
|
||||
export type { ViewportState } from '@zseven-w/pen-types';
|
||||
|
||||
export interface ClipInfo {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
rx: number;
|
||||
}
|
||||
|
||||
export interface RenderNode {
|
||||
node: PenNode;
|
||||
absX: number;
|
||||
absY: number;
|
||||
absW: number;
|
||||
absH: number;
|
||||
clipRect?: { x: number; y: number; w: number; h: number; rx: number };
|
||||
/**
|
||||
* Ancestor-to-self stack of clip rectangles to apply when painting this
|
||||
* node. Stored as an array (not a single intersected ClipInfo) so each
|
||||
* level's rounded corner can be enforced independently — a single rrect
|
||||
* can't encode `(rrect ∩ rrect)` faithfully when one rect cuts inside
|
||||
* the other's corner. The renderer pushes a canvas clip per entry and
|
||||
* pops them in reverse before drawing the next node.
|
||||
*
|
||||
* Outer-most ancestor first; the entry derived from the immediate
|
||||
* `clipContent: true` parent (or the root frame artboard) is last.
|
||||
* Empty array (or undefined) means the node paints unclipped.
|
||||
*/
|
||||
clipStack?: ClipInfo[];
|
||||
}
|
||||
|
||||
/** Injectable icon lookup function for resolving icon names to SVG path data. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue