@hypen-space/web 0.5.2 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/canvas/accessibility.d.ts +118 -11
- package/dist/canvas/accessibility.js +470 -108
- package/dist/canvas/accessibility.js.map +1 -1
- package/dist/canvas/dispatch.d.ts +24 -0
- package/dist/canvas/dispatch.js +62 -0
- package/dist/canvas/dispatch.js.map +1 -0
- package/dist/canvas/editing.d.ts +178 -0
- package/dist/canvas/editing.js +590 -0
- package/dist/canvas/editing.js.map +1 -0
- package/dist/canvas/events.d.ts +28 -23
- package/dist/canvas/events.js +76 -128
- package/dist/canvas/events.js.map +1 -1
- package/dist/canvas/focus.d.ts +78 -0
- package/dist/canvas/focus.js +182 -0
- package/dist/canvas/focus.js.map +1 -0
- package/dist/canvas/index.d.ts +3 -1
- package/dist/canvas/index.js +3 -1
- package/dist/canvas/index.js.map +1 -1
- package/dist/canvas/paint.d.ts +16 -3
- package/dist/canvas/paint.js +283 -41
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/renderer.d.ts +20 -1
- package/dist/canvas/renderer.js +181 -50
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/selection.js +31 -177
- package/dist/canvas/selection.js.map +1 -1
- package/dist/canvas/text-geometry.d.ts +72 -0
- package/dist/canvas/text-geometry.js +244 -0
- package/dist/canvas/text-geometry.js.map +1 -0
- package/dist/canvas/text.js +42 -9
- package/dist/canvas/text.js.map +1 -1
- package/dist/canvas/types.d.ts +7 -1
- package/dist/canvas/variants.d.ts +4 -1
- package/dist/canvas/variants.js +22 -3
- package/dist/canvas/variants.js.map +1 -1
- package/dist/dom/a11y-styles.d.ts +20 -0
- package/dist/dom/a11y-styles.js +61 -0
- package/dist/dom/a11y-styles.js.map +1 -0
- package/dist/dom/applicators/aria.d.ts +19 -0
- package/dist/dom/applicators/aria.js +36 -0
- package/dist/dom/applicators/aria.js.map +1 -0
- package/dist/dom/applicators/events.d.ts +7 -0
- package/dist/dom/applicators/events.js +35 -8
- package/dist/dom/applicators/events.js.map +1 -1
- package/dist/dom/applicators/index.js +4 -0
- package/dist/dom/applicators/index.js.map +1 -1
- package/dist/dom/components/hypenapp.d.ts +25 -1
- package/dist/dom/components/hypenapp.js +213 -245
- package/dist/dom/components/hypenapp.js.map +1 -1
- package/dist/dom/components/index.js +7 -0
- package/dist/dom/components/index.js.map +1 -1
- package/dist/dom/components/spinner.js +24 -10
- package/dist/dom/components/spinner.js.map +1 -1
- package/dist/dom/components/tabs.d.ts +27 -0
- package/dist/dom/components/tabs.js +69 -0
- package/dist/dom/components/tabs.js.map +1 -0
- package/dist/dom/components/visuallyhidden.d.ts +9 -0
- package/dist/dom/components/visuallyhidden.js +26 -0
- package/dist/dom/components/visuallyhidden.js.map +1 -0
- package/dist/dom/operability.d.ts +100 -0
- package/dist/dom/operability.js +298 -0
- package/dist/dom/operability.js.map +1 -0
- package/dist/dom/renderer.d.ts +98 -5
- package/dist/dom/renderer.js +398 -63
- package/dist/dom/renderer.js.map +1 -1
- package/dist/dom/route-focus.d.ts +42 -0
- package/dist/dom/route-focus.js +88 -0
- package/dist/dom/route-focus.js.map +1 -0
- package/dist/dom/semantics.d.ts +35 -0
- package/dist/dom/semantics.js +184 -0
- package/dist/dom/semantics.js.map +1 -0
- package/package.json +2 -2
- package/src/canvas/QUICKSTART.md +4 -4
- package/src/canvas/README.md +55 -29
- package/src/canvas/accessibility.ts +507 -115
- package/src/canvas/dispatch.ts +78 -0
- package/src/canvas/editing.ts +697 -0
- package/src/canvas/events.ts +89 -142
- package/src/canvas/focus.ts +216 -0
- package/src/canvas/index.ts +8 -1
- package/src/canvas/paint.ts +339 -42
- package/src/canvas/renderer.ts +218 -70
- package/src/canvas/selection.ts +52 -210
- package/src/canvas/text-geometry.ts +311 -0
- package/src/canvas/text.ts +43 -9
- package/src/canvas/types.ts +14 -1
- package/src/canvas/variants.ts +24 -5
- package/src/dom/a11y-styles.ts +65 -0
- package/src/dom/applicators/aria.ts +41 -0
- package/src/dom/applicators/events.ts +38 -7
- package/src/dom/applicators/index.ts +5 -0
- package/src/dom/components/hypenapp.ts +244 -272
- package/src/dom/components/index.ts +7 -0
- package/src/dom/components/spinner.ts +31 -13
- package/src/dom/components/tabs.ts +76 -0
- package/src/dom/components/visuallyhidden.ts +30 -0
- package/src/dom/operability.ts +312 -0
- package/src/dom/renderer.ts +455 -63
- package/src/dom/route-focus.ts +98 -0
- package/src/dom/semantics.ts +199 -0
- package/dist/canvas/input.d.ts +0 -76
- package/dist/canvas/input.js +0 -207
- package/dist/canvas/input.js.map +0 -1
- package/src/canvas/input.ts +0 -251
package/src/canvas/paint.ts
CHANGED
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
* Drawing virtual nodes to canvas
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { VirtualNode, PainterFunction } from "./types.js";
|
|
8
|
-
import { renderText } from "./text.js";
|
|
7
|
+
import type { VirtualNode, PainterFunction, Rectangle } from "./types.js";
|
|
8
|
+
import { measureText, renderText } from "./text.js";
|
|
9
9
|
import { ScrollManager, isScrollable } from "./scroll.js";
|
|
10
10
|
import { getVisibleChildren, VIRTUALIZE_THRESHOLD } from "./virtualize.js";
|
|
11
11
|
import type { SelectionManager } from "./selection.js";
|
|
12
|
+
import type { TextEditController } from "./editing.js";
|
|
13
|
+
import {
|
|
14
|
+
SELECTION_HIGHLIGHT_COLOR,
|
|
15
|
+
offsetToCaretRect,
|
|
16
|
+
rangeToRects,
|
|
17
|
+
} from "./text-geometry.js";
|
|
12
18
|
import { cssLengthToPx } from "./utils.js";
|
|
13
19
|
|
|
14
20
|
/**
|
|
@@ -20,6 +26,16 @@ export function setSelectionManager(mgr: SelectionManager | null): void {
|
|
|
20
26
|
activeSelectionManager = mgr;
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Module-level reference to the active TextEditController so paintInput can
|
|
31
|
+
* render the edited value, selection highlight, composition underline, and
|
|
32
|
+
* blinking caret. Same late-binding pattern as the SelectionManager hook.
|
|
33
|
+
*/
|
|
34
|
+
let activeTextEditor: TextEditController | null = null;
|
|
35
|
+
export function setTextEditor(editor: TextEditController | null): void {
|
|
36
|
+
activeTextEditor = editor;
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
/**
|
|
24
40
|
* Custom painters registry
|
|
25
41
|
*/
|
|
@@ -33,23 +49,170 @@ export function registerPainter(type: string, painter: PainterFunction): void {
|
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
/**
|
|
36
|
-
*
|
|
52
|
+
* Per-font character advance widths, used by the manual letter-spacing path
|
|
53
|
+
* so each glyph is shaped once per font instead of once per frame.
|
|
37
54
|
*/
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
const charAdvanceCache = new Map<string, Map<string, number>>();
|
|
56
|
+
const MAX_CHAR_ADVANCE_FONTS = 32;
|
|
40
57
|
|
|
41
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Clear cached per-character advances. Must be called when web fonts finish
|
|
60
|
+
* loading: the cache is keyed by CSS font string, which is identical before
|
|
61
|
+
* and after a webfont replaces its fallback, so advances measured against
|
|
62
|
+
* the fallback font would otherwise poison letter-spaced rendering
|
|
63
|
+
* permanently.
|
|
64
|
+
*/
|
|
65
|
+
export function clearCharAdvanceCache(): void {
|
|
66
|
+
charAdvanceCache.clear();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function charAdvance(ctx: CanvasRenderingContext2D, font: string, ch: string): number {
|
|
70
|
+
let perFont = charAdvanceCache.get(font);
|
|
71
|
+
if (!perFont) {
|
|
72
|
+
perFont = new Map();
|
|
73
|
+
charAdvanceCache.set(font, perFont);
|
|
74
|
+
evictMap(charAdvanceCache, MAX_CHAR_ADVANCE_FONTS);
|
|
75
|
+
}
|
|
76
|
+
let width = perFont.get(ch);
|
|
77
|
+
if (width === undefined) {
|
|
78
|
+
width = ctx.measureText(ch).width;
|
|
79
|
+
perFont.set(ch, width);
|
|
80
|
+
}
|
|
81
|
+
return width;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Component types whose painters set sticky canvas state (textAlign,
|
|
86
|
+
* textBaseline, lineCap, shadows, …) that must not leak to siblings — they
|
|
87
|
+
* need a scoping save/restore pair. Containers, text, and spacers (and
|
|
88
|
+
* every unknown type, which routes to paintContainer) only touch state each
|
|
89
|
+
* draw call re-sets before use, so they paint without one.
|
|
90
|
+
*/
|
|
91
|
+
const STATE_UNSAFE_TYPES = new Set([
|
|
92
|
+
"button",
|
|
93
|
+
"input",
|
|
94
|
+
"textarea",
|
|
95
|
+
"image",
|
|
96
|
+
"divider",
|
|
97
|
+
"separator",
|
|
98
|
+
"checkbox",
|
|
99
|
+
"radio",
|
|
100
|
+
"switch",
|
|
101
|
+
"toggle",
|
|
102
|
+
"slider",
|
|
103
|
+
"progress",
|
|
104
|
+
"progressbar",
|
|
105
|
+
"spinner",
|
|
106
|
+
"loading",
|
|
107
|
+
"card",
|
|
108
|
+
"badge",
|
|
109
|
+
"avatar",
|
|
110
|
+
"icon",
|
|
111
|
+
"link",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/** Whether any transform-related prop is present on the node. */
|
|
115
|
+
function hasTransformProps(props: Record<string, any>): boolean {
|
|
116
|
+
return (
|
|
117
|
+
props.transform !== undefined ||
|
|
118
|
+
props.translateX !== undefined ||
|
|
119
|
+
props.translateY !== undefined ||
|
|
120
|
+
props.rotate !== undefined ||
|
|
121
|
+
props.scale !== undefined ||
|
|
122
|
+
props.scaleX !== undefined ||
|
|
123
|
+
props.scaleY !== undefined ||
|
|
124
|
+
props.skew !== undefined ||
|
|
125
|
+
props.skewX !== undefined ||
|
|
126
|
+
props.skewY !== undefined
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Extra pixels a node's painting may reach beyond its layout box. */
|
|
131
|
+
const CULL_SLACK = 8;
|
|
132
|
+
|
|
133
|
+
/** How far a shadow can bleed outside the box it is attached to. */
|
|
134
|
+
function shadowExtent(shadow: any): number {
|
|
135
|
+
if (typeof shadow === "string") {
|
|
136
|
+
const parts = shadow.trim().split(/\s+/);
|
|
137
|
+
const ox = Math.abs(parseFloat(parts[0])) || 0;
|
|
138
|
+
const oy = Math.abs(parseFloat(parts[1])) || 0;
|
|
139
|
+
const blur = Math.abs(parseFloat(parts[2])) || 0;
|
|
140
|
+
return Math.max(ox, oy) + blur;
|
|
141
|
+
}
|
|
142
|
+
if (shadow && typeof shadow === "object") {
|
|
143
|
+
return (
|
|
144
|
+
Math.max(Math.abs(shadow.offsetX || 0), Math.abs(shadow.offsetY || 0)) +
|
|
145
|
+
Math.abs(shadow.blur || 0)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether the whole subtree rooted at `node` can be skipped when repainting
|
|
153
|
+
* only `cull`. Conservative: nodes with transforms or custom painters are
|
|
154
|
+
* never culled (they can draw anywhere), the box is expanded by shadow
|
|
155
|
+
* bleed + slack, and a node with children is only culled when it clips them.
|
|
156
|
+
*/
|
|
157
|
+
function canCullSubtree(node: VirtualNode, cull: Rectangle): boolean {
|
|
158
|
+
const layout = node.layout!;
|
|
159
|
+
if (hasTransformProps(node.props)) return false;
|
|
160
|
+
if (customPainters.has(node.type.toLowerCase())) return false;
|
|
161
|
+
|
|
162
|
+
const shadow = node.props.shadow || node.props.boxShadow || node.props.textShadow;
|
|
163
|
+
const margin = CULL_SLACK + (shadow ? shadowExtent(shadow) : 0);
|
|
164
|
+
if (
|
|
165
|
+
layout.x - margin < cull.x + cull.width &&
|
|
166
|
+
layout.x + layout.width + margin > cull.x &&
|
|
167
|
+
layout.y - margin < cull.y + cull.height &&
|
|
168
|
+
layout.y + layout.height + margin > cull.y
|
|
169
|
+
) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (node.children.length === 0) return true;
|
|
174
|
+
const overflow = node.props.overflow;
|
|
175
|
+
return overflow === "hidden" || overflow === "scroll" || overflow === "auto";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Paint a virtual node and its children.
|
|
180
|
+
*
|
|
181
|
+
* `cull` (optional) is the dirty region being repainted — subtrees that
|
|
182
|
+
* provably cannot reach it are skipped entirely.
|
|
183
|
+
*/
|
|
184
|
+
export function paintNode(
|
|
185
|
+
ctx: CanvasRenderingContext2D,
|
|
186
|
+
node: VirtualNode,
|
|
187
|
+
cull?: Rectangle | null,
|
|
188
|
+
): void {
|
|
189
|
+
if (!node.visible || !node.layout) return;
|
|
190
|
+
if (cull && canCullSubtree(node, cull)) return;
|
|
191
|
+
|
|
192
|
+
const type = node.type.toLowerCase();
|
|
193
|
+
const customPainter = customPainters.get(type);
|
|
194
|
+
const needsTransform = hasTransformProps(node.props);
|
|
195
|
+
const needsSave =
|
|
196
|
+
customPainter !== undefined ||
|
|
197
|
+
needsTransform ||
|
|
198
|
+
node.opacity < 1 ||
|
|
199
|
+
STATE_UNSAFE_TYPES.has(type);
|
|
200
|
+
|
|
201
|
+
if (needsSave) {
|
|
202
|
+
ctx.save();
|
|
42
203
|
|
|
43
|
-
|
|
44
|
-
|
|
204
|
+
// Apply transforms
|
|
205
|
+
if (needsTransform) {
|
|
206
|
+
applyTransforms(ctx, node);
|
|
207
|
+
}
|
|
45
208
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
209
|
+
// Apply opacity
|
|
210
|
+
if (node.opacity < 1) {
|
|
211
|
+
ctx.globalAlpha = node.opacity;
|
|
212
|
+
}
|
|
49
213
|
}
|
|
50
214
|
|
|
51
215
|
// Check for custom painter
|
|
52
|
-
const customPainter = customPainters.get(node.type.toLowerCase());
|
|
53
216
|
if (customPainter) {
|
|
54
217
|
customPainter(ctx, node);
|
|
55
218
|
ctx.restore();
|
|
@@ -57,7 +220,7 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
|
|
|
57
220
|
}
|
|
58
221
|
|
|
59
222
|
// Default painting based on type
|
|
60
|
-
switch (
|
|
223
|
+
switch (type) {
|
|
61
224
|
case "column":
|
|
62
225
|
case "row":
|
|
63
226
|
case "stack":
|
|
@@ -70,6 +233,7 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
|
|
|
70
233
|
paintButton(ctx, node);
|
|
71
234
|
break;
|
|
72
235
|
case "input":
|
|
236
|
+
case "textarea":
|
|
73
237
|
paintInput(ctx, node);
|
|
74
238
|
break;
|
|
75
239
|
case "image":
|
|
@@ -127,14 +291,26 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
|
|
|
127
291
|
paintContainer(ctx, node);
|
|
128
292
|
}
|
|
129
293
|
|
|
130
|
-
|
|
294
|
+
if (needsSave) {
|
|
295
|
+
ctx.restore();
|
|
296
|
+
}
|
|
131
297
|
|
|
132
298
|
// Apply scroll translation for scrollable containers
|
|
133
299
|
const ss = node.scrollState;
|
|
300
|
+
let childCull = cull ?? null;
|
|
134
301
|
if (ss && (ss.scrollX !== 0 || ss.scrollY !== 0)) {
|
|
135
302
|
ctx.save();
|
|
136
303
|
ctx.translate(-ss.scrollX, -ss.scrollY);
|
|
137
304
|
(node as any)._scrollRestore = true;
|
|
305
|
+
// Children paint in scrolled space — shift the cull region to match.
|
|
306
|
+
if (childCull) {
|
|
307
|
+
childCull = {
|
|
308
|
+
x: childCull.x + ss.scrollX,
|
|
309
|
+
y: childCull.y + ss.scrollY,
|
|
310
|
+
width: childCull.width,
|
|
311
|
+
height: childCull.height,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
138
314
|
}
|
|
139
315
|
|
|
140
316
|
// Paint children -- use windowed rendering for large scrollable lists
|
|
@@ -158,14 +334,21 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
|
|
|
158
334
|
// tree (Story uses this for its close button) gets covered by the
|
|
159
335
|
// following in-flow Image. Push absolute kids to the end so they land
|
|
160
336
|
// on top — same effect as `z-index: auto` painting absolute after flow.
|
|
161
|
-
|
|
162
|
-
const overlayKids: VirtualNode[] = [];
|
|
337
|
+
let hasOverlays = false;
|
|
163
338
|
for (const child of childrenToPaint) {
|
|
164
|
-
if (child.props.position === "absolute")
|
|
165
|
-
|
|
339
|
+
if (child.props.position === "absolute") {
|
|
340
|
+
hasOverlays = true;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
paintNode(ctx, child, childCull);
|
|
344
|
+
}
|
|
345
|
+
if (hasOverlays) {
|
|
346
|
+
for (const child of childrenToPaint) {
|
|
347
|
+
if (child.props.position === "absolute") {
|
|
348
|
+
paintNode(ctx, child, childCull);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
166
351
|
}
|
|
167
|
-
for (const child of flowKids) paintNode(ctx, child);
|
|
168
|
-
for (const child of overlayKids) paintNode(ctx, child);
|
|
169
352
|
|
|
170
353
|
// Undo scroll translation before drawing scrollbars
|
|
171
354
|
if ((node as any)._scrollRestore) {
|
|
@@ -299,14 +482,15 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
299
482
|
if (letterSpacing !== 0) {
|
|
300
483
|
// Manual letter spacing
|
|
301
484
|
ctx.fillStyle = color;
|
|
302
|
-
|
|
485
|
+
const font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
486
|
+
ctx.font = font;
|
|
303
487
|
ctx.textAlign = "left";
|
|
304
488
|
ctx.textBaseline = "top";
|
|
305
489
|
|
|
306
490
|
let currentX = x;
|
|
307
491
|
for (let i = 0; i < text.length; i++) {
|
|
308
492
|
ctx.fillText(text[i], currentX, y);
|
|
309
|
-
currentX += ctx
|
|
493
|
+
currentX += charAdvance(ctx, font, text[i]);
|
|
310
494
|
// Add letter spacing after each character except the last
|
|
311
495
|
if (i < text.length - 1) {
|
|
312
496
|
currentX += letterSpacing;
|
|
@@ -350,11 +534,18 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
350
534
|
textOverflow
|
|
351
535
|
);
|
|
352
536
|
|
|
353
|
-
// Text decoration
|
|
537
|
+
// Text decoration — reuse the wrapped metrics renderText just resolved
|
|
538
|
+
// (cache hit) instead of re-shaping the raw, unwrapped string.
|
|
354
539
|
if (textDecoration !== "none") {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
540
|
+
const metrics = measureText(
|
|
541
|
+
ctx,
|
|
542
|
+
text,
|
|
543
|
+
{ fontSize, fontWeight, fontFamily, lineHeight },
|
|
544
|
+
layout.contentWidth,
|
|
545
|
+
maxLines,
|
|
546
|
+
textOverflow,
|
|
547
|
+
);
|
|
548
|
+
applyTextDecoration(ctx, textDecoration, color, x, y, metrics.width, fontSize);
|
|
358
549
|
}
|
|
359
550
|
}
|
|
360
551
|
|
|
@@ -423,6 +614,18 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
423
614
|
ctx.stroke();
|
|
424
615
|
}
|
|
425
616
|
|
|
617
|
+
// Keyboard focus ring. Focus lives on the node's (unrendered) mirror
|
|
618
|
+
// element in the canvas fallback content, so the UA can't draw a ring at
|
|
619
|
+
// the painted box — paint one so keyboard users can see where they are.
|
|
620
|
+
if (node.focused) {
|
|
621
|
+
ctx.save();
|
|
622
|
+
ctx.strokeStyle = props.focusRingColor || "#007bff";
|
|
623
|
+
ctx.lineWidth = 2;
|
|
624
|
+
drawRoundedRect(ctx, x - 2, y - 2, width + 4, height + 4, radius + 2);
|
|
625
|
+
ctx.stroke();
|
|
626
|
+
ctx.restore();
|
|
627
|
+
}
|
|
628
|
+
|
|
426
629
|
// Paint children (typically Text)
|
|
427
630
|
for (const child of node.children) {
|
|
428
631
|
paintNode(ctx, child);
|
|
@@ -455,24 +658,76 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
455
658
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
456
659
|
ctx.stroke();
|
|
457
660
|
|
|
458
|
-
|
|
459
|
-
|
|
661
|
+
const isSingleLine = node.type.toLowerCase() === "input";
|
|
662
|
+
|
|
663
|
+
// While editing, the mirror element (via TextEditController) is the value
|
|
664
|
+
// source — paint stays ahead of the engine's SetProp echo.
|
|
665
|
+
const edit = activeTextEditor?.getStateFor(node) ?? null;
|
|
666
|
+
|
|
667
|
+
const value = edit ? edit.value : (props.value || "");
|
|
460
668
|
const placeholder = props.placeholder || "";
|
|
461
669
|
const text = value || placeholder;
|
|
462
670
|
const textColor = value ? (props.color || "#000000") : "#999999";
|
|
463
671
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
672
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
673
|
+
const fontWeight = props.fontWeight || "normal";
|
|
674
|
+
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
675
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
469
676
|
|
|
677
|
+
if (!edit) {
|
|
678
|
+
if (text) {
|
|
679
|
+
renderText(
|
|
680
|
+
ctx,
|
|
681
|
+
text,
|
|
682
|
+
layout.x + layout.contentX,
|
|
683
|
+
layout.y + layout.contentY,
|
|
684
|
+
layout.contentWidth,
|
|
685
|
+
layout.contentHeight,
|
|
686
|
+
{
|
|
687
|
+
color: textColor,
|
|
688
|
+
fontSize,
|
|
689
|
+
fontWeight,
|
|
690
|
+
fontFamily,
|
|
691
|
+
textAlign: "left",
|
|
692
|
+
verticalAlign: isSingleLine ? "middle" : "top",
|
|
693
|
+
lineHeight,
|
|
694
|
+
}
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ---- Editing visuals: clip → selection highlight → text → composition
|
|
701
|
+
// ---- underline → caret. Geometry comes from the edit controller so
|
|
702
|
+
// ---- pointer mapping and painting share identical math (incl. scrollX).
|
|
703
|
+
ctx.save();
|
|
704
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
705
|
+
ctx.clip();
|
|
706
|
+
|
|
707
|
+
const g = activeTextEditor!.geometry(
|
|
708
|
+
layout.x + layout.contentX - edit.scrollX,
|
|
709
|
+
layout.y + layout.contentY,
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
// Selection highlight behind the text
|
|
713
|
+
if (edit.selStart !== edit.selEnd) {
|
|
714
|
+
const from = Math.min(edit.selStart, edit.selEnd);
|
|
715
|
+
const to = Math.max(edit.selStart, edit.selEnd);
|
|
716
|
+
ctx.fillStyle = SELECTION_HIGHLIGHT_COLOR;
|
|
717
|
+
for (const r of rangeToRects(ctx, g, from, to)) {
|
|
718
|
+
ctx.fillRect(r.x, r.y, r.width, r.height);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (text) {
|
|
470
723
|
renderText(
|
|
471
724
|
ctx,
|
|
472
|
-
|
|
473
|
-
|
|
725
|
+
value || placeholder,
|
|
726
|
+
g.contentX,
|
|
474
727
|
layout.y + layout.contentY,
|
|
475
|
-
|
|
728
|
+
// Single-line inputs never wrap — the clip + scrollX pan handle
|
|
729
|
+
// overflow. Textareas wrap at content width like static paint.
|
|
730
|
+
isSingleLine ? Number.POSITIVE_INFINITY : layout.contentWidth,
|
|
476
731
|
layout.contentHeight,
|
|
477
732
|
{
|
|
478
733
|
color: textColor,
|
|
@@ -480,11 +735,34 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
480
735
|
fontWeight,
|
|
481
736
|
fontFamily,
|
|
482
737
|
textAlign: "left",
|
|
483
|
-
verticalAlign: "middle",
|
|
738
|
+
verticalAlign: isSingleLine ? "middle" : "top",
|
|
484
739
|
lineHeight,
|
|
485
740
|
}
|
|
486
741
|
);
|
|
487
742
|
}
|
|
743
|
+
|
|
744
|
+
// IME composition underline (dashed, under the composing range)
|
|
745
|
+
if (edit.compStart !== null && edit.compEnd !== null && edit.compEnd > edit.compStart) {
|
|
746
|
+
ctx.strokeStyle = props.color || "#000000";
|
|
747
|
+
ctx.lineWidth = 1;
|
|
748
|
+
ctx.setLineDash([2, 2]);
|
|
749
|
+
for (const r of rangeToRects(ctx, g, edit.compStart, edit.compEnd)) {
|
|
750
|
+
ctx.beginPath();
|
|
751
|
+
ctx.moveTo(r.x, r.y + r.height - 2);
|
|
752
|
+
ctx.lineTo(r.x + r.width, r.y + r.height - 2);
|
|
753
|
+
ctx.stroke();
|
|
754
|
+
}
|
|
755
|
+
ctx.setLineDash([]);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Caret (hidden while a range is selected, blinks via the edit timer)
|
|
759
|
+
if (edit.caretVisible && edit.selStart === edit.selEnd) {
|
|
760
|
+
const caret = offsetToCaretRect(ctx, g, edit.selEnd);
|
|
761
|
+
ctx.fillStyle = props.color || "#000000";
|
|
762
|
+
ctx.fillRect(caret.x, caret.y + 1, 1.5, caret.height - 2);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
ctx.restore();
|
|
488
766
|
}
|
|
489
767
|
|
|
490
768
|
/**
|
|
@@ -648,8 +926,10 @@ function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
648
926
|
imagePending.delete(src);
|
|
649
927
|
imageFailCache.delete(src);
|
|
650
928
|
evictMap(imageCache, MAX_IMAGE_CACHE_SIZE);
|
|
651
|
-
// Request a repaint so the loaded image is drawn
|
|
652
|
-
|
|
929
|
+
// Request a repaint so the loaded image is drawn. `layout: true`
|
|
930
|
+
// because the decoded intrinsic size can change the image's box
|
|
931
|
+
// (see getImageNaturalAspect in layout.ts).
|
|
932
|
+
canvas.dispatchEvent(new CustomEvent("hypen:redraw", { detail: { layout: true } }));
|
|
653
933
|
};
|
|
654
934
|
img.onerror = () => {
|
|
655
935
|
imageInFlight.delete(src);
|
|
@@ -793,6 +1073,15 @@ function resolveBackgroundPaint(props: Record<string, any>): string | undefined
|
|
|
793
1073
|
return undefined;
|
|
794
1074
|
}
|
|
795
1075
|
|
|
1076
|
+
/**
|
|
1077
|
+
* Parsed gradients keyed by (source string, box geometry). CanvasGradient
|
|
1078
|
+
* objects are not bound to a specific context, so identical fills across
|
|
1079
|
+
* frames — the common case, since layout geometry is stable — skip the
|
|
1080
|
+
* regex parse and createLinearGradient/createRadialGradient entirely.
|
|
1081
|
+
*/
|
|
1082
|
+
const gradientCache = new Map<string, CanvasGradient | string>();
|
|
1083
|
+
const MAX_GRADIENT_CACHE_SIZE = 256;
|
|
1084
|
+
|
|
796
1085
|
function resolveCanvasPaint(
|
|
797
1086
|
ctx: CanvasRenderingContext2D,
|
|
798
1087
|
paint: string,
|
|
@@ -801,7 +1090,15 @@ function resolveCanvasPaint(
|
|
|
801
1090
|
width: number,
|
|
802
1091
|
height: number,
|
|
803
1092
|
): CanvasGradient | string {
|
|
804
|
-
|
|
1093
|
+
if (!paint.includes("gradient(")) return paint;
|
|
1094
|
+
const key = `${paint}|${x}|${y}|${width}|${height}`;
|
|
1095
|
+
let resolved = gradientCache.get(key);
|
|
1096
|
+
if (resolved === undefined) {
|
|
1097
|
+
resolved = parseGradient(ctx, paint, x, y, width, height);
|
|
1098
|
+
gradientCache.set(key, resolved);
|
|
1099
|
+
evictMap(gradientCache, MAX_GRADIENT_CACHE_SIZE);
|
|
1100
|
+
}
|
|
1101
|
+
return resolved;
|
|
805
1102
|
}
|
|
806
1103
|
|
|
807
1104
|
/**
|
|
@@ -1268,7 +1565,7 @@ function paintSlider(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1268
1565
|
|
|
1269
1566
|
// Support gradient fills
|
|
1270
1567
|
if (typeof fillColor === "string" && fillColor.includes("gradient")) {
|
|
1271
|
-
ctx.fillStyle =
|
|
1568
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, trackY, fillWidth, trackHeight);
|
|
1272
1569
|
} else {
|
|
1273
1570
|
ctx.fillStyle = fillColor;
|
|
1274
1571
|
}
|
|
@@ -1323,7 +1620,7 @@ function paintProgress(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1323
1620
|
// Support gradient fills
|
|
1324
1621
|
const fillColor = props.fillColor || props.color || "#007bff";
|
|
1325
1622
|
if (typeof fillColor === "string" && fillColor.includes("gradient")) {
|
|
1326
|
-
ctx.fillStyle =
|
|
1623
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, y, fillWidth, height);
|
|
1327
1624
|
} else {
|
|
1328
1625
|
ctx.fillStyle = fillColor;
|
|
1329
1626
|
}
|