Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions src/renderer/nodes/SkiaCardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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. */
Expand Down
28 changes: 20 additions & 8 deletions src/renderer/nodes/SkiaTextRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: []},
Expand Down Expand Up @@ -223,7 +234,7 @@ export function SkiaTextRenderer({node, colorScheme, offsetX, offsetY}: Props) {
}

return (
<Group clip={rect(node.x + offsetX, node.y + offsetY, node.width, node.height)}>
<Group clip={clipRegion}>
<Group transform={[
{translateX: labelCx}, {translateY: labelCy},
{rotate: labelRotation * DEG_TO_RAD},
Expand Down Expand Up @@ -302,14 +313,15 @@ export function SkiaTextRenderer({node, colorScheme, offsetX, offsetY}: Props) {
);
}

// Clip the rendered text to the card's world bounds — long body content
// would otherwise bleed past the card edge into adjacent nodes (#167).
// Top-level-return placement is load-bearing: <Group clip> 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:
// <Group clip> 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 (
<Group clip={rect(node.x + offsetX, node.y + offsetY, node.width, node.height)}>
<Group clip={clipRegion}>
{content}
</Group>
);
Expand Down
74 changes: 74 additions & 0 deletions src/renderer/nodes/__tests__/shapes.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});
78 changes: 78 additions & 0 deletions src/renderer/nodes/shapes.ts
Original file line number Diff line number Diff line change
@@ -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. `<Group clip>` 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 `<Oval>` 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;
}
}
47 changes: 28 additions & 19 deletions src/renderer/useCanvasPicture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ----------

Expand Down Expand Up @@ -245,8 +246,6 @@ async function loadImageCache(uris: string[]): Promise<ImageCache> {
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).
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
// `<Group clip>` 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) {
Expand Down Expand Up @@ -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';
Expand Down
Loading