packages/core/src/lib/components/text.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import { resolveTexturePaint } from '../textures/texture-protocol.js';
2 import { definePrimitive } from '../define-primitive.js';
3 import { materializeTextChildren } from '../element/children.js';
4 import { GLOBAL_STATE } from '../global-state.js';
5 import type {
6 PibblEventFocusOptions,
7 PibblEventHandlers,
8 PibblEventParticipationOptions,
9 } from '../events/types.js';
10 import {
11 resolveOptionalLength,
12 stripLayoutParticipation,
13 } from '../style/resolve-built-in.js';
14 import { registerOpaqueStyleProperties } from '../style/resolve-dispatch.js';
15 import type { ResolvedTextStyle, TextStyle } from '../style/types.js';
16 import {
17 CanvasMeasurementService,
18 MeasureResult,
19 RenderingContext2D,
20 } from '../types.js';
21 import type { Constraints } from '../layout/types.js';
22 import type { PibblRenderTransaction, PibblTextInput } from '../types.js';
23 import type { PibblPhysics2DPropExtension } from '../types.js';
24 import { resolveLength } from '../style/resolve-length.js';
25 import { wireCommonEvents } from '../util/wire-common-events.js';
26 import { pushClippingPath } from '../util/push-clipping-path.js';
27 import { resolveTextClipBounds, resolveTextLayout } from './text-layout.js';
28 import { withReceiverInputProvision } from '../signals/input-provision.js';
29
30 /**
31 * Authored inputs for Text, including the declared data and presentation options.
32 *
33 * @see {@link PibblEventHandlers}
34 * @see {@link PibblEventParticipationOptions}
35 * @see {@link PibblPhysics2DPropExtension}
36 * @see {@link PibblTextInput}
37 * @see {@link Text}
38 */
39 export interface TextProps
40 extends
41 PibblEventHandlers,
42 PibblEventFocusOptions,
43 PibblEventParticipationOptions,
44 PibblPhysics2DPropExtension {
45 /** Descendant content or the callback that supplies it. See {@link PibblTextInput}. */
46 children?: PibblTextInput;
47 }
48
49 const materializedTextTransactions = new WeakMap<
50 PibblRenderTransaction,
51 WeakMap<object, string>
52 >();
53
54 function renderText(
55 props: TextProps,
56 style: Readonly<ResolvedTextStyle>,
57 ctx: RenderingContext2D,
58 ) {
59 const text = materializeTextContent(props);
60 const {
61 font = '13px sans-serif',
62 textBaseline = 'top',
63 textAlign = 'left',
64 fill,
65 stroke,
66 strokeWidth = 0,
67 opacity,
68 cursor,
69 } = style;
70
71 if (opacity !== undefined) {
72 ctx.globalAlpha = ctx.globalAlpha * opacity;
73 }
74
75 ctx.font = font;
76 ctx.textBaseline = textBaseline;
77 ctx.textAlign = textAlign;
78
79 const layout = resolveTextLayout(
80 value => ctx.measureText(value),
81 text,
82 style,
83 );
84 const clipBounds =
85 layout.clipBounds ?? resolveTextClipBounds(style, ctx.canvas.height);
86
87 let releaseClip: (() => void) | undefined;
88 if (clipBounds) {
89 const clipPath = new Path2D();
90 clipPath.rect(
91 clipBounds.x,
92 clipBounds.y,
93 clipBounds.width,
94 clipBounds.height,
95 );
96 releaseClip = pushClippingPath(ctx, clipPath);
97 }
98
99 try {
100 const textBounds = props.pointerEvents === 'none' ? undefined : new Path2D();
101
102 for (const line of layout.lines) {
103 if (stroke && strokeWidth) {
104 ctx.strokeStyle = resolveTexturePaint(stroke, ctx, layout.bounds ?? line.bounds);
105 ctx.lineWidth = strokeWidth;
106 ctx.strokeText(line.text, line.x, line.y, line.squishWidth);
107 }
108
109 if (fill) {
110 ctx.fillStyle = resolveTexturePaint(fill, ctx, layout.bounds ?? line.bounds);
111 ctx.fillText(line.text, line.x, line.y, line.squishWidth);
112 }
113
114 if (textBounds) {
115 textBounds.moveTo(line.bounds.x, line.bounds.y);
116 textBounds.lineTo(line.bounds.x + line.bounds.width, line.bounds.y);
117 textBounds.lineTo(
118 line.bounds.x + line.bounds.width,
119 line.bounds.y + line.bounds.height,
120 );
121 textBounds.lineTo(line.bounds.x, line.bounds.y + line.bounds.height);
122 textBounds.closePath();
123 }
124 }
125
126 wireCommonEvents(
127 props,
128 textBounds,
129 true,
130 strokeWidth,
131 cursor,
132 layout.bounds,
133 );
134 } finally {
135 releaseClip?.();
136 }
137 }
138
139 /**
140 * Draws Canvas text from textual children using the resolved font, wrapping, fitting, and paint
141 * style.
142 *
143 * @param props - Authored component inputs, supplied through JSX or createElement. See the linked
144 * props and style types.
145 * @throws When called directly; Pibbl mounts this component through JSX or createElement.
146 *
147 * @see {@link TextProps}
148 * @see {@link TextStyle}
149 */
150 export const Text = definePrimitive<
151 TextProps,
152 TextStyle,
153 TextStyle,
154 ResolvedTextStyle
155 >(renderText, {
156 childInput: 'text',
157 resolveStyle: (style, context) => ({
158 ...stripLayoutParticipation(style),
159 x: 0,
160 y: 0,
161 width: resolveOptionalLength(
162 style.width,
163 context.percentageBasis.width,
164 'width',
165 context,
166 ),
167 height: resolveOptionalLength(
168 style.height,
169 context.percentageBasis.height,
170 'height',
171 context,
172 ),
173 }) as ResolvedTextStyle,
174 measure: ({ props, style, constraints, service }) =>
175 measureTextStyle(
176 materializeTextContent(props),
177 style,
178 constraints,
179 service,
180 ),
181 });
182 registerOpaqueStyleProperties(Text, [
183 'font',
184 'fill',
185 'stroke',
186 'cursor',
187 'fit',
188 'textBaseline',
189 'textAlign',
190 ]);
191
192 function measureTextStyle(
193 text: string,
194 style: Readonly<TextStyle>,
195 constraints: Readonly<Constraints>,
196 service: CanvasMeasurementService,
197 ): MeasureResult {
198 const context = {
199 component: 'Text',
200 algorithm: 'measurement',
201 constraints,
202 };
203 const specifiedWidth = style.width === undefined ? undefined :
204 resolveLength(style.width, constraints.maxWidth, 'width', context);
205 const specifiedHeight = style.height === undefined ? undefined :
206 resolveLength(style.height, constraints.maxHeight, 'height', context);
207 const layoutWidth = specifiedWidth ?? (
208 style.wrap && Number.isFinite(constraints.maxWidth) ?
209 constraints.maxWidth :
210 undefined
211 );
212 const font = style.font ?? '13px sans-serif';
213 const layout = resolveTextLayout(
214 value => service.measureText(value, font),
215 text,
216 {
217 ...style,
218 x: 0,
219 y: 0,
220 width: layoutWidth,
221 height: specifiedHeight,
222 },
223 );
224 return {
225 status: 'measured',
226 size: {
227 width: specifiedWidth ?? (style.wrap && layoutWidth !== undefined ?
228 layoutWidth :
229 layout.bounds?.width ?? 0),
230 height: specifiedHeight ?? (style.wrap ?
231 layout.lines.length * (style.lineHeight ?? 13) :
232 layout.bounds?.height ?? 0),
233 },
234 };
235 }
236
237 function materializeTextContent(props: Readonly<TextProps>): string {
238 const transaction = GLOBAL_STATE.renderTransaction;
239 const receiver = GLOBAL_STATE.primitiveReceiverToken;
240 if (!transaction || !receiver) {
241 return materializeTextChildren(props.children, 'Text');
242 }
243
244 let receivers = materializedTextTransactions.get(transaction);
245 if (!receivers) {
246 receivers = new WeakMap();
247 materializedTextTransactions.set(transaction, receivers);
248 }
249 const cached = receivers.get(receiver);
250 if (cached !== undefined) return cached;
251 const materialized = withReceiverInputProvision(
252 transaction,
253 receiver,
254 () => materializeTextChildren(props.children, 'Text'),
255 );
256 receivers.set(receiver, materialized);
257 return materialized;
258 }
259
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.