diff --git a/src/renderer/nodes/SkiaCardRenderer.tsx b/src/renderer/nodes/SkiaCardRenderer.tsx index f77a479..765970b 100644 --- a/src/renderer/nodes/SkiaCardRenderer.tsx +++ b/src/renderer/nodes/SkiaCardRenderer.tsx @@ -7,13 +7,13 @@ import type {CanvasNode} from '../../core'; import type {EnrichedTextNode} from '../extensions/cssclasses'; import {getNodeColors, type ColorScheme} from '../theme'; import {devFlags} from '../devFlags'; +import {parallelogramPath} from './shapes'; interface Props { node: CanvasNode; colorScheme: ColorScheme; } -const PARALLELOGRAM_SKEW = 0.2; const DEG_TO_RAD = Math.PI / 180; /** @@ -85,15 +85,7 @@ function makeSideBorderPath(x: number, y: number, w: number, h: number, r: numbe } function makeParallelogramPath(x: number, y: number, w: number, h: number, direction: 'left' | 'right') { - const skew = w * PARALLELOGRAM_SKEW; - if (direction === 'left') { - return Skia.Path.MakeFromSVGString( - `M ${x + skew} ${y} L ${x + w} ${y} L ${x + w - skew} ${y + h} L ${x} ${y + h} Z` - ); - } - return Skia.Path.MakeFromSVGString( - `M ${x} ${y} L ${x + w - skew} ${y} L ${x + w} ${y + h} L ${x + skew} ${y + h} Z` - ); + return Skia.Path.MakeFromSVGString(parallelogramPath(x, y, w, h, direction)); } /** Compute gradient start/end points for a given angle within a rect. */ diff --git a/src/renderer/nodes/SkiaTextRenderer.tsx b/src/renderer/nodes/SkiaTextRenderer.tsx index 4767916..b7c9342 100644 --- a/src/renderer/nodes/SkiaTextRenderer.tsx +++ b/src/renderer/nodes/SkiaTextRenderer.tsx @@ -9,6 +9,7 @@ import {hasCallouts, parseCallouts, getHeader, getFooter, getLabels, getCentered import type {ColorScheme} from '../theme'; import {parseToSegments, toPlainText} from '../markdown'; import {buildParagraph, getParagraphColours} from '../paragraphBuilder'; +import {shapeClipPath} from './shapes'; const DEG_TO_RAD = Math.PI / 180; const ZONE_HEIGHT = 28; @@ -69,6 +70,16 @@ export function SkiaTextRenderer({node, colorScheme, offsetX, offsetY}: Props) { const rotateCard = enriched.renderProps?.rotateCard; const shape = enriched.renderProps?.shape; + // Clip text to the card outline so it can't bleed past the edge into + // neighbouring nodes (#167). For circle / parallelogram cards, clip to the + // actual shape path rather than the bounding rect, so text also respects the + // curved / slanted edges (#53). Falls back to the bounding rect for + // rectangular and unshaped cards. World coords — survives the rotation Group. + const clipX = node.x + offsetX; + const clipY = node.y + offsetY; + const shapeClip = shapeClipPath(shape, clipX, clipY, node.width, node.height); + const clipRegion = shapeClip ?? rect(clipX, clipY, node.width, node.height); + // Parse callout zones (header, footer, labels) from text content const {bodyText, callouts} = useMemo( () => hasCallouts(rawContent) ? parseCallouts(rawContent) : {bodyText: rawContent, callouts: []}, @@ -223,7 +234,7 @@ export function SkiaTextRenderer({node, colorScheme, offsetX, offsetY}: Props) { } return ( - + inside an - // element array (the parent's children list) is a no-op per #96. The clip - // is in world coords so it survives the rotation Group above it. Intra- - // card scroll is tracked separately at #168. + // Clip the rendered text to the card outline — long body content would + // otherwise bleed past the card edge into adjacent nodes (#167), and on + // circle / parallelogram cards past the curved / slanted edge (#53; see + // `clipRegion` above). Top-level-return placement is load-bearing: + // inside an element array (the parent's children list) is a + // no-op per #96. The clip is in world coords so it survives the rotation + // Group above it. Intra-card scroll is tracked separately at #168. return ( - + {content} ); diff --git a/src/renderer/nodes/__tests__/shapes.test.ts b/src/renderer/nodes/__tests__/shapes.test.ts new file mode 100644 index 0000000..2a1fe4a --- /dev/null +++ b/src/renderer/nodes/__tests__/shapes.test.ts @@ -0,0 +1,74 @@ +import { + parallelogramPath, + ovalPath, + shapeClipPath, + PARALLELOGRAM_SKEW, +} from '../shapes'; + +describe('parallelogramPath', () => { + it('closes the path (Z) and has four corners', () => { + const d = parallelogramPath(0, 0, 100, 50, 'left'); + expect(d.trim().endsWith('Z')).toBe(true); + // M + 3×L = 4 vertices + expect((d.match(/[ML]/g) ?? []).length).toBe(4); + }); + + it('left skews the top edge right by w*SKEW; bottom-left sits at x', () => { + const d = parallelogramPath(0, 0, 100, 50, 'left'); + // top-left vertex starts at x + skew + expect(d).toContain(`M ${100 * PARALLELOGRAM_SKEW} 0`); + // bottom-left vertex returns to x=0 + expect(d).toContain('L 0 50'); + }); + + it('right is the mirror of left (top-left at x, bottom-left at x+skew)', () => { + const d = parallelogramPath(0, 0, 100, 50, 'right'); + expect(d.startsWith('M 0 0')).toBe(true); + expect(d).toContain(`L ${100 * PARALLELOGRAM_SKEW} 50`); + }); + + it('respects the x/y origin offset', () => { + const d = parallelogramPath(10, 20, 100, 50, 'left'); + // top-left = (x + skew, y) = (30, 20) + expect(d).toContain(`M ${10 + 100 * PARALLELOGRAM_SKEW} 20`); + }); +}); + +describe('ovalPath', () => { + it('produces two elliptical arcs and closes', () => { + const d = ovalPath(0, 0, 100, 80); + expect((d.match(/A/g) ?? []).length).toBe(2); + expect(d.trim().endsWith('Z')).toBe(true); + }); + + it('uses half-width/half-height as the arc radii', () => { + const d = ovalPath(0, 0, 100, 80); + // rx=50 ry=40 appear in each arc command + expect(d).toContain('A 50 40'); + }); + + it('starts at the left-middle of the box, accounting for origin', () => { + const d = ovalPath(10, 20, 100, 80); + // start = (x, y + h/2) = (10, 60) + expect(d.startsWith('M 10 60')).toBe(true); + }); +}); + +describe('shapeClipPath', () => { + it('returns null for rectangular / unset / unknown shapes', () => { + expect(shapeClipPath(undefined, 0, 0, 10, 10)).toBeNull(); + expect(shapeClipPath('rectangle', 0, 0, 10, 10)).toBeNull(); + expect(shapeClipPath('pill', 0, 0, 10, 10)).toBeNull(); + }); + + it('maps circle to an oval path', () => { + expect(shapeClipPath('circle', 0, 0, 100, 80)).toBe(ovalPath(0, 0, 100, 80)); + }); + + it('maps parallelogram-left / -right to the matching parallelogram path', () => { + expect(shapeClipPath('parallelogram-left', 0, 0, 100, 50)) + .toBe(parallelogramPath(0, 0, 100, 50, 'left')); + expect(shapeClipPath('parallelogram-right', 0, 0, 100, 50)) + .toBe(parallelogramPath(0, 0, 100, 50, 'right')); + }); +}); diff --git a/src/renderer/nodes/shapes.ts b/src/renderer/nodes/shapes.ts new file mode 100644 index 0000000..ebd9105 --- /dev/null +++ b/src/renderer/nodes/shapes.ts @@ -0,0 +1,78 @@ +// Shared node-shape geometry, expressed as SVG path strings. +// +// Single source of truth for the parallelogram and circle/oval outlines used +// by `cc-shape-*` cards. Three call sites consume these: +// +// 1. SkiaCardRenderer — draws the shape fill (live tree). +// 2. useCanvasPicture — draws the same fill in the Picture overlay. +// 3. SkiaTextRenderer — clips body/label text to the shape outline so text +// doesn't bleed past a slanted parallelogram or curved circle edge (#53). +// +// Keeping the maths here means the clip outline can never silently drift from +// the fill outline. `` and the imperative `canvas.clipPath` both +// accept an SVG path string, so returning strings (rather than allocating an +// SkPath) keeps this dependency-free and works for every consumer. + +/** Horizontal skew of a parallelogram, as a fraction of its width. The top + * edge is shifted right by `w * SKEW`; the bottom edge left by the same. */ +export const PARALLELOGRAM_SKEW = 0.2; + +/** + * SVG path for a parallelogram filling the node's bounds. + * `left` leans the top edge right (╱-leaning); `right` mirrors it. + */ +export function parallelogramPath( + x: number, + y: number, + w: number, + h: number, + direction: 'left' | 'right', +): string { + const skew = w * PARALLELOGRAM_SKEW; + return direction === 'left' + ? `M ${x + skew} ${y} L ${x + w} ${y} L ${x + w - skew} ${y + h} L ${x} ${y + h} Z` + : `M ${x} ${y} L ${x + w - skew} ${y} L ${x + w} ${y + h} L ${x + skew} ${y + h} Z`; +} + +/** + * SVG path for an ellipse inscribed in the node's bounds — a true circle when + * the box is square, a stretched ellipse otherwise (matches the `` fill + * and Canvas Candy's `border-radius: 50%` convention). Two half-arcs sweep the + * full perimeter. + */ +export function ovalPath(x: number, y: number, w: number, h: number): string { + const rx = w / 2; + const ry = h / 2; + const cy = y + ry; + const left = x; + const right = x + w; + // Start at the left edge, arc over the top to the right edge, then back + // under the bottom. Sweep flag 1 = clockwise. + return `M ${left} ${cy} A ${rx} ${ry} 0 1 1 ${right} ${cy} A ${rx} ${ry} 0 1 1 ${left} ${cy} Z`; +} + +/** The `cc-shape-*` values that have a non-rectangular outline to clip to. */ +export type ClippableShape = 'circle' | 'parallelogram-left' | 'parallelogram-right'; + +/** + * SVG clip path for a node's shape, or `null` for rectangular / unset shapes + * (callers fall back to a plain bounding-rect clip in that case). + */ +export function shapeClipPath( + shape: string | undefined, + x: number, + y: number, + w: number, + h: number, +): string | null { + switch (shape) { + case 'circle': + return ovalPath(x, y, w, h); + case 'parallelogram-left': + return parallelogramPath(x, y, w, h, 'left'); + case 'parallelogram-right': + return parallelogramPath(x, y, w, h, 'right'); + default: + return null; + } +} diff --git a/src/renderer/useCanvasPicture.ts b/src/renderer/useCanvasPicture.ts index dd023d8..25e787a 100644 --- a/src/renderer/useCanvasPicture.ts +++ b/src/renderer/useCanvasPicture.ts @@ -8,6 +8,7 @@ import {getNodeColors, type ColorScheme} from './theme'; import {parseToSegments, toPlainText} from './markdown'; import {buildParagraph, getParagraphColours} from './paragraphBuilder'; import {resolveFileUri} from './utils/resolveFileUri'; +import {shapeClipPath, parallelogramPath} from './nodes/shapes'; // ---------- Fonts (duplicated from individual renderers — shared via Skia's internal cache) ---------- @@ -245,8 +246,6 @@ async function loadImageCache(uris: string[]): Promise { return cache; } -const PARALLELOGRAM_SKEW = 0.2; - // Mirrors `makeSideBorderPath` in SkiaCardRenderer.tsx — see that file for // the per-side geometry rationale. The two paths must stay in sync (Picture // recording vs live tree). @@ -278,13 +277,7 @@ function makeSideBorderPath(x: number, y: number, w: number, h: number, r: numbe } function makeParaPath(x: number, y: number, w: number, h: number, dir: 'left' | 'right') { - const skew = w * PARALLELOGRAM_SKEW; - const path = Skia.Path.MakeFromSVGString( - dir === 'left' - ? `M ${x + skew} ${y} L ${x + w} ${y} L ${x + w - skew} ${y + h} L ${x} ${y + h} Z` - : `M ${x} ${y} L ${x + w - skew} ${y} L ${x + w} ${y + h} L ${x + skew} ${y + h} Z` - ); - return path; + return Skia.Path.MakeFromSVGString(parallelogramPath(x, y, w, h, dir)); } function drawCard(canvas: SkCanvas, node: CanvasNode, colorScheme: ColorScheme) { @@ -497,17 +490,34 @@ function drawTextNode(canvas: SkCanvas, node: TextNode, colorScheme: ColorScheme const isDark = colorScheme === 'dark'; - // Clip the text-node output to its world bounds — mirrors the + // Clip the text-node output to the card outline — mirrors the // `` wrapper at the bottom of `SkiaTextRenderer` so the Picture - // overlay produces the same clipped result during pinch (#167). Every - // return path below MUST be preceded by `canvas.restore()` to keep the - // save stack balanced. + // overlay produces the same clipped result during pinch (#167). For + // circle / parallelogram cards, clip to the shape path so text respects the + // curved / slanted edge (#53); otherwise the bounding rect. Every return + // path below MUST be preceded by `canvas.restore()` to keep the save stack + // balanced. + const shape = enriched.renderProps?.shape; + const shapeClip = shapeClipPath(shape, node.x, node.y, node.width, node.height); canvas.save(); - canvas.clipRect( - {x: node.x, y: node.y, width: node.width, height: node.height}, - 1 /* ClipOp.Intersect */, - true, - ); + if (shapeClip) { + const clipPath = Skia.Path.MakeFromSVGString(shapeClip); + if (clipPath) { + canvas.clipPath(clipPath, 1 /* ClipOp.Intersect */, true); + } else { + canvas.clipRect( + {x: node.x, y: node.y, width: node.width, height: node.height}, + 1 /* ClipOp.Intersect */, + true, + ); + } + } else { + canvas.clipRect( + {x: node.x, y: node.y, width: node.width, height: node.height}, + 1 /* ClipOp.Intersect */, + true, + ); + } // Side labels — draw rotated text for label-only nodes if (labels.length > 0 && !bodyText.trim() && !header && !footer) { @@ -587,7 +597,6 @@ function drawTextNode(canvas: SkCanvas, node: TextNode, colorScheme: ColorScheme return; } - const shape = enriched.renderProps?.shape; const centerText = enriched.renderProps?.textAlign === 'center' || centered != null || shape === 'circle' || shape === 'parallelogram-left' || shape === 'parallelogram-right';