diff --git a/client/src/components/Studio/TextEffectsView.tsx b/client/src/components/Studio/TextEffectsView.tsx
index 016b403a3..d1b3a78b7 100644
--- a/client/src/components/Studio/TextEffectsView.tsx
+++ b/client/src/components/Studio/TextEffectsView.tsx
@@ -6,7 +6,7 @@ import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn, bookTextPaintFill }
import { fontStack } from './bookFonts';
import { photoSrc } from './bookRender';
-import { paintParts } from './paint/paintRender';
+import { paintParts, type PaintParts } from './paint/paintParts';
import {
caseStyle,
curveAnchor,
@@ -146,6 +146,28 @@ interface GlyphPaint {
strokeWidth?: number;
}
+/** A gradient or pattern in the letters, laid out over the frame as a shape's paint is over its box. */
+function lettersPaint(el: BookTextElement, id: string): PaintParts | null {
+ const paint = bookTextPaintFill(el);
+ return paint ? paintParts(paint, { x: 0, y: 0, w: el.frame.w, h: el.frame.h }, id) : null;
+}
+
+/** A pattern's ground and its marks, clipped to the letters. */
+function PatternLetters({ parts, w, h, clipId }: { parts: PaintParts; w: number; h: number; clipId: string }) {
+ const pattern = parts.pattern;
+ if (!pattern) return null;
+ return (
+
+ {parts.fill !== 'none' && }
+
+ {pattern.layers.map((layer, i) => (
+
+ ))}
+
+
+ );
+}
+
function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: CSSProperties; big: boolean }) {
const reactId = useId();
const pathId = svgId('tp-', reactId);
@@ -156,10 +178,8 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
const lines = bookDrawnLines(el);
const curve = el.curve ? bookTextCurvePath(el.curve, w, h, fontMm) : null;
const photo = el.fillPaint?.type === 'photo' ? el.fillPaint : null;
- // A gradient or pattern in the letters, laid out over the frame as a shape's paint is over its box.
- const fillPaint = bookTextPaintFill(el);
- const letterPaint = fillPaint ? paintParts(fillPaint, { x: 0, y: 0, w, h }, paintId) : null;
- const pattern = letterPaint?.pattern ?? null;
+ const letterPaint = lettersPaint(el, paintId);
+ const pattern = letterPaint ? letterPaint.pattern : null;
const hollow = isHollow(el);
const { outline, shadow } = el;
@@ -218,17 +238,7 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
const cover = photoCover(w, h, photo);
layers.push();
} else if (pattern && letterPaint) {
- // The pattern's ground and its marks, clipped to the letters.
- layers.push(
-
- {letterPaint.fill !== 'none' && }
-
- {pattern.layers.map((layer, i) => (
-
- ))}
-
-
- );
+ layers.push();
} else {
layers.push(glyphs({ fill: letterPaint ? letterPaint.fill : (letterFill(el) ?? el.color) }, 'fill'));
}
diff --git a/client/src/components/Studio/paint/paintParts.tsx b/client/src/components/Studio/paint/paintParts.tsx
new file mode 100644
index 000000000..8727ec7e7
--- /dev/null
+++ b/client/src/components/Studio/paint/paintParts.tsx
@@ -0,0 +1,88 @@
+import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
+import type { ReactNode } from 'react';
+import { linearLine, r2 } from './paintGeometry';
+import { patternGeometry, type PatternBox } from './patterns';
+
+/**
+ * A paint as SVG parts over a box (paintRender.tsx explains why the ids come
+ * from useId): kept apart from the components that draw them, so a shape and
+ * the letters of a text share it without the component file exporting a helper.
+ */
+
+function Stops({ stops }: { stops: readonly BookPaintStop[] }) {
+ return (
+ <>
+ {sortedStops(stops).map((s, i) => (
+
+ ))}
+ >
+ );
+}
+
+/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
+export interface PaintParts {
+ defs: ReactNode;
+ fill: string;
+ fillOpacity?: number;
+ /** A pattern's geometry, to draw clipped to the outline over its fill. */
+ pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
+}
+
+/**
+ * What drawing a paint over a box takes: the gradient definition and the fill
+ * to point at it, or a pattern's geometry to draw clipped to the outline over
+ * its ground. A shape's outline and the letters of a text both draw from it.
+ */
+export function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
+ switch (paint.type) {
+ case 'solid':
+ return {
+ defs: null,
+ fill: paint.color,
+ fillOpacity: stopAlpha(paint) < 1 ? stopAlpha(paint) : undefined,
+ pattern: null,
+ };
+ case 'linear': {
+ const line = linearLine(paint.angle, box);
+ return {
+ defs: (
+
+
+
+ ),
+ fill: `url(#${id})`,
+ pattern: null,
+ };
+ }
+ case 'radial':
+ return {
+ defs: (
+
+
+
+ ),
+ fill: `url(#${id})`,
+ pattern: null,
+ };
+ case 'pattern': {
+ const geo = patternGeometry(paint, box);
+ return {
+ defs: null,
+ fill: paint.bg ?? 'none',
+ pattern: { clipId: `${id}-clip`, layers: geo.layers, transform: geo.transform, ink: paint.fg },
+ };
+ }
+ }
+}
diff --git a/client/src/components/Studio/paint/paintRender.tsx b/client/src/components/Studio/paint/paintRender.tsx
index 95b28640c..dc9060436 100644
--- a/client/src/components/Studio/paint/paintRender.tsx
+++ b/client/src/components/Studio/paint/paintRender.tsx
@@ -1,7 +1,8 @@
-import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
-import { useMemo, type ReactNode, type SVGProps } from 'react';
-import { linearLine, r2 } from './paintGeometry';
-import { patternGeometry, type PatternBox } from './patterns';
+import type { BookPaint } from '@trek/shared';
+import { useMemo, type SVGProps } from 'react';
+import { r2 } from './paintGeometry';
+import { paintParts } from './paintParts';
+import type { PatternBox } from './patterns';
import { usePaintId } from './usePaintId';
/**
@@ -25,84 +26,6 @@ import { usePaintId } from './usePaintId';
* element's own id is kept in front only to make the markup readable.
*/
-function Stops({ stops }: { stops: readonly BookPaintStop[] }) {
- return (
- <>
- {sortedStops(stops).map((s, i) => (
-
- ))}
- >
- );
-}
-
-/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
-export interface PaintParts {
- defs: ReactNode;
- fill: string;
- fillOpacity?: number;
- /** A pattern's geometry, to draw clipped to the outline over its fill. */
- pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
-}
-
-/**
- * What drawing a paint over a box takes: the gradient definition and the fill
- * to point at it, or a pattern's geometry to draw clipped to the outline over
- * its ground. A shape's outline and the letters of a text both draw from it.
- */
-export function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
- switch (paint.type) {
- case 'solid':
- return {
- defs: null,
- fill: paint.color,
- fillOpacity: stopAlpha(paint) < 1 ? stopAlpha(paint) : undefined,
- pattern: null,
- };
- case 'linear': {
- const line = linearLine(paint.angle, box);
- return {
- defs: (
-
-
-
- ),
- fill: `url(#${id})`,
- pattern: null,
- };
- }
- case 'radial':
- return {
- defs: (
-
-
-
- ),
- fill: `url(#${id})`,
- pattern: null,
- };
- case 'pattern': {
- const geo = patternGeometry(paint, box);
- return {
- defs: null,
- fill: paint.bg ?? 'none',
- pattern: { clipId: `${id}-clip`, layers: geo.layers, transform: geo.transform, ink: paint.fg },
- };
- }
- }
-}
-
type OutlineProps = Omit, 'fill' | 'd'>;
/**