diff --git a/apps/web/src/canvas/skia/__tests__/focus-fit.test.ts b/apps/web/src/canvas/skia/__tests__/focus-fit.test.ts index df1b30ee8..1ee73683e 100644 --- a/apps/web/src/canvas/skia/__tests__/focus-fit.test.ts +++ b/apps/web/src/canvas/skia/__tests__/focus-fit.test.ts @@ -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 }], }), ]; diff --git a/apps/web/src/canvas/skia/__tests__/skia-interaction.test.ts b/apps/web/src/canvas/skia/__tests__/skia-interaction.test.ts index 976b1c521..e98543eb5 100644 --- a/apps/web/src/canvas/skia/__tests__/skia-interaction.test.ts +++ b/apps/web/src/canvas/skia/__tests__/skia-interaction.test.ts @@ -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); }); diff --git a/apps/web/src/canvas/skia/focus-fit.ts b/apps/web/src/canvas/skia/focus-fit.ts index 5142088c1..8103456bc 100644 --- a/apps/web/src/canvas/skia/focus-fit.ts +++ b/apps/web/src/canvas/skia/focus-fit.ts @@ -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; diff --git a/apps/web/src/canvas/skia/skia-engine.ts b/apps/web/src/canvas/skia/skia-engine.ts index 3f9ab5645..c9d266374 100644 --- a/apps/web/src/canvas/skia/skia-engine.ts +++ b/apps/web/src/canvas/skia/skia-engine.ts @@ -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; diff --git a/apps/web/src/canvas/skia/skia-interaction.ts b/apps/web/src/canvas/skia/skia-interaction.ts index 5be6fedf4..c27581830 100644 --- a/apps/web/src/canvas/skia/skia-interaction.ts +++ b/apps/web/src/canvas/skia/skia-interaction.ts @@ -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 }; } diff --git a/apps/web/src/utils/global-export.ts b/apps/web/src/utils/global-export.ts index 270d12368..70ae50898 100644 --- a/apps/web/src/utils/global-export.ts +++ b/apps/web/src/utils/global-export.ts @@ -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; diff --git a/packages/pen-renderer/src/__tests__/document-flattener.test.ts b/packages/pen-renderer/src/__tests__/document-flattener.test.ts index ee6f61848..09ce5f03c 100644 --- a/packages/pen-renderer/src/__tests__/document-flattener.test.ts +++ b/packages/pen-renderer/src/__tests__/document-flattener.test.ts @@ -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); }); }); diff --git a/packages/pen-renderer/src/document-flattener.ts b/packages/pen-renderer/src/document-flattener.ts index a10678127..a376d41cf 100644 --- a/packages/pen-renderer/src/document-flattener.ts +++ b/packages/pen-renderer/src/document-flattener.ts @@ -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, ); diff --git a/packages/pen-renderer/src/node-renderer.ts b/packages/pen-renderer/src/node-renderer.ts index d34921582..f9a25592e 100644 --- a/packages/pen-renderer/src/node-renderer.ts +++ b/packages/pen-renderer/src/node-renderer.ts @@ -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(); } // --------------------------------------------------------------------------- diff --git a/packages/pen-renderer/src/renderer.ts b/packages/pen-renderer/src/renderer.ts index f1cf62782..7d3fe7b7d 100644 --- a/packages/pen-renderer/src/renderer.ts +++ b/packages/pen-renderer/src/renderer.ts @@ -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; diff --git a/packages/pen-renderer/src/types.ts b/packages/pen-renderer/src/types.ts index f25f2f9e1..067d68458 100644 --- a/packages/pen-renderer/src/types.ts +++ b/packages/pen-renderer/src/types.ts @@ -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. */