@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/dist/canvas/paint.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Drawing virtual nodes to canvas
|
|
5
5
|
*/
|
|
6
|
-
import { renderText } from "./text.js";
|
|
6
|
+
import { measureText, renderText } from "./text.js";
|
|
7
7
|
import { ScrollManager, isScrollable } from "./scroll.js";
|
|
8
8
|
import { getVisibleChildren, VIRTUALIZE_THRESHOLD } from "./virtualize.js";
|
|
9
|
+
import { SELECTION_HIGHLIGHT_COLOR, offsetToCaretRect, rangeToRects, } from "./text-geometry.js";
|
|
9
10
|
import { cssLengthToPx } from "./utils.js";
|
|
10
11
|
/**
|
|
11
12
|
* Module-level reference to the active SelectionManager so paintText
|
|
@@ -15,6 +16,15 @@ let activeSelectionManager = null;
|
|
|
15
16
|
export function setSelectionManager(mgr) {
|
|
16
17
|
activeSelectionManager = mgr;
|
|
17
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Module-level reference to the active TextEditController so paintInput can
|
|
21
|
+
* render the edited value, selection highlight, composition underline, and
|
|
22
|
+
* blinking caret. Same late-binding pattern as the SelectionManager hook.
|
|
23
|
+
*/
|
|
24
|
+
let activeTextEditor = null;
|
|
25
|
+
export function setTextEditor(editor) {
|
|
26
|
+
activeTextEditor = editor;
|
|
27
|
+
}
|
|
18
28
|
/**
|
|
19
29
|
* Custom painters registry
|
|
20
30
|
*/
|
|
@@ -26,27 +36,156 @@ export function registerPainter(type, painter) {
|
|
|
26
36
|
customPainters.set(type.toLowerCase(), painter);
|
|
27
37
|
}
|
|
28
38
|
/**
|
|
29
|
-
*
|
|
39
|
+
* Per-font character advance widths, used by the manual letter-spacing path
|
|
40
|
+
* so each glyph is shaped once per font instead of once per frame.
|
|
41
|
+
*/
|
|
42
|
+
const charAdvanceCache = new Map();
|
|
43
|
+
const MAX_CHAR_ADVANCE_FONTS = 32;
|
|
44
|
+
/**
|
|
45
|
+
* Clear cached per-character advances. Must be called when web fonts finish
|
|
46
|
+
* loading: the cache is keyed by CSS font string, which is identical before
|
|
47
|
+
* and after a webfont replaces its fallback, so advances measured against
|
|
48
|
+
* the fallback font would otherwise poison letter-spaced rendering
|
|
49
|
+
* permanently.
|
|
50
|
+
*/
|
|
51
|
+
export function clearCharAdvanceCache() {
|
|
52
|
+
charAdvanceCache.clear();
|
|
53
|
+
}
|
|
54
|
+
function charAdvance(ctx, font, ch) {
|
|
55
|
+
let perFont = charAdvanceCache.get(font);
|
|
56
|
+
if (!perFont) {
|
|
57
|
+
perFont = new Map();
|
|
58
|
+
charAdvanceCache.set(font, perFont);
|
|
59
|
+
evictMap(charAdvanceCache, MAX_CHAR_ADVANCE_FONTS);
|
|
60
|
+
}
|
|
61
|
+
let width = perFont.get(ch);
|
|
62
|
+
if (width === undefined) {
|
|
63
|
+
width = ctx.measureText(ch).width;
|
|
64
|
+
perFont.set(ch, width);
|
|
65
|
+
}
|
|
66
|
+
return width;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Component types whose painters set sticky canvas state (textAlign,
|
|
70
|
+
* textBaseline, lineCap, shadows, …) that must not leak to siblings — they
|
|
71
|
+
* need a scoping save/restore pair. Containers, text, and spacers (and
|
|
72
|
+
* every unknown type, which routes to paintContainer) only touch state each
|
|
73
|
+
* draw call re-sets before use, so they paint without one.
|
|
74
|
+
*/
|
|
75
|
+
const STATE_UNSAFE_TYPES = new Set([
|
|
76
|
+
"button",
|
|
77
|
+
"input",
|
|
78
|
+
"textarea",
|
|
79
|
+
"image",
|
|
80
|
+
"divider",
|
|
81
|
+
"separator",
|
|
82
|
+
"checkbox",
|
|
83
|
+
"radio",
|
|
84
|
+
"switch",
|
|
85
|
+
"toggle",
|
|
86
|
+
"slider",
|
|
87
|
+
"progress",
|
|
88
|
+
"progressbar",
|
|
89
|
+
"spinner",
|
|
90
|
+
"loading",
|
|
91
|
+
"card",
|
|
92
|
+
"badge",
|
|
93
|
+
"avatar",
|
|
94
|
+
"icon",
|
|
95
|
+
"link",
|
|
96
|
+
]);
|
|
97
|
+
/** Whether any transform-related prop is present on the node. */
|
|
98
|
+
function hasTransformProps(props) {
|
|
99
|
+
return (props.transform !== undefined ||
|
|
100
|
+
props.translateX !== undefined ||
|
|
101
|
+
props.translateY !== undefined ||
|
|
102
|
+
props.rotate !== undefined ||
|
|
103
|
+
props.scale !== undefined ||
|
|
104
|
+
props.scaleX !== undefined ||
|
|
105
|
+
props.scaleY !== undefined ||
|
|
106
|
+
props.skew !== undefined ||
|
|
107
|
+
props.skewX !== undefined ||
|
|
108
|
+
props.skewY !== undefined);
|
|
109
|
+
}
|
|
110
|
+
/** Extra pixels a node's painting may reach beyond its layout box. */
|
|
111
|
+
const CULL_SLACK = 8;
|
|
112
|
+
/** How far a shadow can bleed outside the box it is attached to. */
|
|
113
|
+
function shadowExtent(shadow) {
|
|
114
|
+
if (typeof shadow === "string") {
|
|
115
|
+
const parts = shadow.trim().split(/\s+/);
|
|
116
|
+
const ox = Math.abs(parseFloat(parts[0])) || 0;
|
|
117
|
+
const oy = Math.abs(parseFloat(parts[1])) || 0;
|
|
118
|
+
const blur = Math.abs(parseFloat(parts[2])) || 0;
|
|
119
|
+
return Math.max(ox, oy) + blur;
|
|
120
|
+
}
|
|
121
|
+
if (shadow && typeof shadow === "object") {
|
|
122
|
+
return (Math.max(Math.abs(shadow.offsetX || 0), Math.abs(shadow.offsetY || 0)) +
|
|
123
|
+
Math.abs(shadow.blur || 0));
|
|
124
|
+
}
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether the whole subtree rooted at `node` can be skipped when repainting
|
|
129
|
+
* only `cull`. Conservative: nodes with transforms or custom painters are
|
|
130
|
+
* never culled (they can draw anywhere), the box is expanded by shadow
|
|
131
|
+
* bleed + slack, and a node with children is only culled when it clips them.
|
|
30
132
|
*/
|
|
31
|
-
|
|
133
|
+
function canCullSubtree(node, cull) {
|
|
134
|
+
const layout = node.layout;
|
|
135
|
+
if (hasTransformProps(node.props))
|
|
136
|
+
return false;
|
|
137
|
+
if (customPainters.has(node.type.toLowerCase()))
|
|
138
|
+
return false;
|
|
139
|
+
const shadow = node.props.shadow || node.props.boxShadow || node.props.textShadow;
|
|
140
|
+
const margin = CULL_SLACK + (shadow ? shadowExtent(shadow) : 0);
|
|
141
|
+
if (layout.x - margin < cull.x + cull.width &&
|
|
142
|
+
layout.x + layout.width + margin > cull.x &&
|
|
143
|
+
layout.y - margin < cull.y + cull.height &&
|
|
144
|
+
layout.y + layout.height + margin > cull.y) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
if (node.children.length === 0)
|
|
148
|
+
return true;
|
|
149
|
+
const overflow = node.props.overflow;
|
|
150
|
+
return overflow === "hidden" || overflow === "scroll" || overflow === "auto";
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Paint a virtual node and its children.
|
|
154
|
+
*
|
|
155
|
+
* `cull` (optional) is the dirty region being repainted — subtrees that
|
|
156
|
+
* provably cannot reach it are skipped entirely.
|
|
157
|
+
*/
|
|
158
|
+
export function paintNode(ctx, node, cull) {
|
|
32
159
|
if (!node.visible || !node.layout)
|
|
33
160
|
return;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
161
|
+
if (cull && canCullSubtree(node, cull))
|
|
162
|
+
return;
|
|
163
|
+
const type = node.type.toLowerCase();
|
|
164
|
+
const customPainter = customPainters.get(type);
|
|
165
|
+
const needsTransform = hasTransformProps(node.props);
|
|
166
|
+
const needsSave = customPainter !== undefined ||
|
|
167
|
+
needsTransform ||
|
|
168
|
+
node.opacity < 1 ||
|
|
169
|
+
STATE_UNSAFE_TYPES.has(type);
|
|
170
|
+
if (needsSave) {
|
|
171
|
+
ctx.save();
|
|
172
|
+
// Apply transforms
|
|
173
|
+
if (needsTransform) {
|
|
174
|
+
applyTransforms(ctx, node);
|
|
175
|
+
}
|
|
176
|
+
// Apply opacity
|
|
177
|
+
if (node.opacity < 1) {
|
|
178
|
+
ctx.globalAlpha = node.opacity;
|
|
179
|
+
}
|
|
40
180
|
}
|
|
41
181
|
// Check for custom painter
|
|
42
|
-
const customPainter = customPainters.get(node.type.toLowerCase());
|
|
43
182
|
if (customPainter) {
|
|
44
183
|
customPainter(ctx, node);
|
|
45
184
|
ctx.restore();
|
|
46
185
|
return;
|
|
47
186
|
}
|
|
48
187
|
// Default painting based on type
|
|
49
|
-
switch (
|
|
188
|
+
switch (type) {
|
|
50
189
|
case "column":
|
|
51
190
|
case "row":
|
|
52
191
|
case "stack":
|
|
@@ -59,6 +198,7 @@ export function paintNode(ctx, node) {
|
|
|
59
198
|
paintButton(ctx, node);
|
|
60
199
|
break;
|
|
61
200
|
case "input":
|
|
201
|
+
case "textarea":
|
|
62
202
|
paintInput(ctx, node);
|
|
63
203
|
break;
|
|
64
204
|
case "image":
|
|
@@ -115,13 +255,25 @@ export function paintNode(ctx, node) {
|
|
|
115
255
|
default:
|
|
116
256
|
paintContainer(ctx, node);
|
|
117
257
|
}
|
|
118
|
-
|
|
258
|
+
if (needsSave) {
|
|
259
|
+
ctx.restore();
|
|
260
|
+
}
|
|
119
261
|
// Apply scroll translation for scrollable containers
|
|
120
262
|
const ss = node.scrollState;
|
|
263
|
+
let childCull = cull ?? null;
|
|
121
264
|
if (ss && (ss.scrollX !== 0 || ss.scrollY !== 0)) {
|
|
122
265
|
ctx.save();
|
|
123
266
|
ctx.translate(-ss.scrollX, -ss.scrollY);
|
|
124
267
|
node._scrollRestore = true;
|
|
268
|
+
// Children paint in scrolled space — shift the cull region to match.
|
|
269
|
+
if (childCull) {
|
|
270
|
+
childCull = {
|
|
271
|
+
x: childCull.x + ss.scrollX,
|
|
272
|
+
y: childCull.y + ss.scrollY,
|
|
273
|
+
width: childCull.width,
|
|
274
|
+
height: childCull.height,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
125
277
|
}
|
|
126
278
|
// Paint children -- use windowed rendering for large scrollable lists
|
|
127
279
|
const shouldVirtualize = isScrollable(node) &&
|
|
@@ -141,18 +293,21 @@ export function paintNode(ctx, node) {
|
|
|
141
293
|
// tree (Story uses this for its close button) gets covered by the
|
|
142
294
|
// following in-flow Image. Push absolute kids to the end so they land
|
|
143
295
|
// on top — same effect as `z-index: auto` painting absolute after flow.
|
|
144
|
-
|
|
145
|
-
const overlayKids = [];
|
|
296
|
+
let hasOverlays = false;
|
|
146
297
|
for (const child of childrenToPaint) {
|
|
147
|
-
if (child.props.position === "absolute")
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
298
|
+
if (child.props.position === "absolute") {
|
|
299
|
+
hasOverlays = true;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
paintNode(ctx, child, childCull);
|
|
303
|
+
}
|
|
304
|
+
if (hasOverlays) {
|
|
305
|
+
for (const child of childrenToPaint) {
|
|
306
|
+
if (child.props.position === "absolute") {
|
|
307
|
+
paintNode(ctx, child, childCull);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
151
310
|
}
|
|
152
|
-
for (const child of flowKids)
|
|
153
|
-
paintNode(ctx, child);
|
|
154
|
-
for (const child of overlayKids)
|
|
155
|
-
paintNode(ctx, child);
|
|
156
311
|
// Undo scroll translation before drawing scrollbars
|
|
157
312
|
if (node._scrollRestore) {
|
|
158
313
|
ctx.restore();
|
|
@@ -272,13 +427,14 @@ function paintText(ctx, node) {
|
|
|
272
427
|
if (letterSpacing !== 0) {
|
|
273
428
|
// Manual letter spacing
|
|
274
429
|
ctx.fillStyle = color;
|
|
275
|
-
|
|
430
|
+
const font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
431
|
+
ctx.font = font;
|
|
276
432
|
ctx.textAlign = "left";
|
|
277
433
|
ctx.textBaseline = "top";
|
|
278
434
|
let currentX = x;
|
|
279
435
|
for (let i = 0; i < text.length; i++) {
|
|
280
436
|
ctx.fillText(text[i], currentX, y);
|
|
281
|
-
currentX += ctx
|
|
437
|
+
currentX += charAdvance(ctx, font, text[i]);
|
|
282
438
|
// Add letter spacing after each character except the last
|
|
283
439
|
if (i < text.length - 1) {
|
|
284
440
|
currentX += letterSpacing;
|
|
@@ -308,11 +464,11 @@ function paintText(ctx, node) {
|
|
|
308
464
|
verticalAlign: "top",
|
|
309
465
|
lineHeight,
|
|
310
466
|
}, maxLines, textOverflow);
|
|
311
|
-
// Text decoration
|
|
467
|
+
// Text decoration — reuse the wrapped metrics renderText just resolved
|
|
468
|
+
// (cache hit) instead of re-shaping the raw, unwrapped string.
|
|
312
469
|
if (textDecoration !== "none") {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
applyTextDecoration(ctx, textDecoration, color, x, y, textWidth, fontSize);
|
|
470
|
+
const metrics = measureText(ctx, text, { fontSize, fontWeight, fontFamily, lineHeight }, layout.contentWidth, maxLines, textOverflow);
|
|
471
|
+
applyTextDecoration(ctx, textDecoration, color, x, y, metrics.width, fontSize);
|
|
316
472
|
}
|
|
317
473
|
}
|
|
318
474
|
// Reset shadow
|
|
@@ -374,6 +530,17 @@ function paintButton(ctx, node) {
|
|
|
374
530
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
375
531
|
ctx.stroke();
|
|
376
532
|
}
|
|
533
|
+
// Keyboard focus ring. Focus lives on the node's (unrendered) mirror
|
|
534
|
+
// element in the canvas fallback content, so the UA can't draw a ring at
|
|
535
|
+
// the painted box — paint one so keyboard users can see where they are.
|
|
536
|
+
if (node.focused) {
|
|
537
|
+
ctx.save();
|
|
538
|
+
ctx.strokeStyle = props.focusRingColor || "#007bff";
|
|
539
|
+
ctx.lineWidth = 2;
|
|
540
|
+
drawRoundedRect(ctx, x - 2, y - 2, width + 4, height + 4, radius + 2);
|
|
541
|
+
ctx.stroke();
|
|
542
|
+
ctx.restore();
|
|
543
|
+
}
|
|
377
544
|
// Paint children (typically Text)
|
|
378
545
|
for (const child of node.children) {
|
|
379
546
|
paintNode(ctx, child);
|
|
@@ -401,26 +568,82 @@ function paintInput(ctx, node) {
|
|
|
401
568
|
ctx.lineWidth = borderWidth;
|
|
402
569
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
403
570
|
ctx.stroke();
|
|
404
|
-
|
|
405
|
-
|
|
571
|
+
const isSingleLine = node.type.toLowerCase() === "input";
|
|
572
|
+
// While editing, the mirror element (via TextEditController) is the value
|
|
573
|
+
// source — paint stays ahead of the engine's SetProp echo.
|
|
574
|
+
const edit = activeTextEditor?.getStateFor(node) ?? null;
|
|
575
|
+
const value = edit ? edit.value : (props.value || "");
|
|
406
576
|
const placeholder = props.placeholder || "";
|
|
407
577
|
const text = value || placeholder;
|
|
408
578
|
const textColor = value ? (props.color || "#000000") : "#999999";
|
|
579
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
580
|
+
const fontWeight = props.fontWeight || "normal";
|
|
581
|
+
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
582
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
583
|
+
if (!edit) {
|
|
584
|
+
if (text) {
|
|
585
|
+
renderText(ctx, text, layout.x + layout.contentX, layout.y + layout.contentY, layout.contentWidth, layout.contentHeight, {
|
|
586
|
+
color: textColor,
|
|
587
|
+
fontSize,
|
|
588
|
+
fontWeight,
|
|
589
|
+
fontFamily,
|
|
590
|
+
textAlign: "left",
|
|
591
|
+
verticalAlign: isSingleLine ? "middle" : "top",
|
|
592
|
+
lineHeight,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// ---- Editing visuals: clip → selection highlight → text → composition
|
|
598
|
+
// ---- underline → caret. Geometry comes from the edit controller so
|
|
599
|
+
// ---- pointer mapping and painting share identical math (incl. scrollX).
|
|
600
|
+
ctx.save();
|
|
601
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
602
|
+
ctx.clip();
|
|
603
|
+
const g = activeTextEditor.geometry(layout.x + layout.contentX - edit.scrollX, layout.y + layout.contentY);
|
|
604
|
+
// Selection highlight behind the text
|
|
605
|
+
if (edit.selStart !== edit.selEnd) {
|
|
606
|
+
const from = Math.min(edit.selStart, edit.selEnd);
|
|
607
|
+
const to = Math.max(edit.selStart, edit.selEnd);
|
|
608
|
+
ctx.fillStyle = SELECTION_HIGHLIGHT_COLOR;
|
|
609
|
+
for (const r of rangeToRects(ctx, g, from, to)) {
|
|
610
|
+
ctx.fillRect(r.x, r.y, r.width, r.height);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
409
613
|
if (text) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
renderText(ctx, text, layout.x + layout.contentX, layout.y + layout.contentY, layout.contentWidth, layout.contentHeight, {
|
|
614
|
+
renderText(ctx, value || placeholder, g.contentX, layout.y + layout.contentY,
|
|
615
|
+
// Single-line inputs never wrap — the clip + scrollX pan handle
|
|
616
|
+
// overflow. Textareas wrap at content width like static paint.
|
|
617
|
+
isSingleLine ? Number.POSITIVE_INFINITY : layout.contentWidth, layout.contentHeight, {
|
|
415
618
|
color: textColor,
|
|
416
619
|
fontSize,
|
|
417
620
|
fontWeight,
|
|
418
621
|
fontFamily,
|
|
419
622
|
textAlign: "left",
|
|
420
|
-
verticalAlign: "middle",
|
|
623
|
+
verticalAlign: isSingleLine ? "middle" : "top",
|
|
421
624
|
lineHeight,
|
|
422
625
|
});
|
|
423
626
|
}
|
|
627
|
+
// IME composition underline (dashed, under the composing range)
|
|
628
|
+
if (edit.compStart !== null && edit.compEnd !== null && edit.compEnd > edit.compStart) {
|
|
629
|
+
ctx.strokeStyle = props.color || "#000000";
|
|
630
|
+
ctx.lineWidth = 1;
|
|
631
|
+
ctx.setLineDash([2, 2]);
|
|
632
|
+
for (const r of rangeToRects(ctx, g, edit.compStart, edit.compEnd)) {
|
|
633
|
+
ctx.beginPath();
|
|
634
|
+
ctx.moveTo(r.x, r.y + r.height - 2);
|
|
635
|
+
ctx.lineTo(r.x + r.width, r.y + r.height - 2);
|
|
636
|
+
ctx.stroke();
|
|
637
|
+
}
|
|
638
|
+
ctx.setLineDash([]);
|
|
639
|
+
}
|
|
640
|
+
// Caret (hidden while a range is selected, blinks via the edit timer)
|
|
641
|
+
if (edit.caretVisible && edit.selStart === edit.selEnd) {
|
|
642
|
+
const caret = offsetToCaretRect(ctx, g, edit.selEnd);
|
|
643
|
+
ctx.fillStyle = props.color || "#000000";
|
|
644
|
+
ctx.fillRect(caret.x, caret.y + 1, 1.5, caret.height - 2);
|
|
645
|
+
}
|
|
646
|
+
ctx.restore();
|
|
424
647
|
}
|
|
425
648
|
/**
|
|
426
649
|
* Image cache for canvas renderer
|
|
@@ -572,8 +795,10 @@ function paintImage(ctx, node) {
|
|
|
572
795
|
imagePending.delete(src);
|
|
573
796
|
imageFailCache.delete(src);
|
|
574
797
|
evictMap(imageCache, MAX_IMAGE_CACHE_SIZE);
|
|
575
|
-
// Request a repaint so the loaded image is drawn
|
|
576
|
-
|
|
798
|
+
// Request a repaint so the loaded image is drawn. `layout: true`
|
|
799
|
+
// because the decoded intrinsic size can change the image's box
|
|
800
|
+
// (see getImageNaturalAspect in layout.ts).
|
|
801
|
+
canvas.dispatchEvent(new CustomEvent("hypen:redraw", { detail: { layout: true } }));
|
|
577
802
|
};
|
|
578
803
|
img.onerror = () => {
|
|
579
804
|
imageInFlight.delete(src);
|
|
@@ -697,8 +922,25 @@ function resolveBackgroundPaint(props) {
|
|
|
697
922
|
}
|
|
698
923
|
return undefined;
|
|
699
924
|
}
|
|
925
|
+
/**
|
|
926
|
+
* Parsed gradients keyed by (source string, box geometry). CanvasGradient
|
|
927
|
+
* objects are not bound to a specific context, so identical fills across
|
|
928
|
+
* frames — the common case, since layout geometry is stable — skip the
|
|
929
|
+
* regex parse and createLinearGradient/createRadialGradient entirely.
|
|
930
|
+
*/
|
|
931
|
+
const gradientCache = new Map();
|
|
932
|
+
const MAX_GRADIENT_CACHE_SIZE = 256;
|
|
700
933
|
function resolveCanvasPaint(ctx, paint, x, y, width, height) {
|
|
701
|
-
|
|
934
|
+
if (!paint.includes("gradient("))
|
|
935
|
+
return paint;
|
|
936
|
+
const key = `${paint}|${x}|${y}|${width}|${height}`;
|
|
937
|
+
let resolved = gradientCache.get(key);
|
|
938
|
+
if (resolved === undefined) {
|
|
939
|
+
resolved = parseGradient(ctx, paint, x, y, width, height);
|
|
940
|
+
gradientCache.set(key, resolved);
|
|
941
|
+
evictMap(gradientCache, MAX_GRADIENT_CACHE_SIZE);
|
|
942
|
+
}
|
|
943
|
+
return resolved;
|
|
702
944
|
}
|
|
703
945
|
/**
|
|
704
946
|
* Parse gradient string and create canvas gradient
|
|
@@ -1100,7 +1342,7 @@ function paintSlider(ctx, node) {
|
|
|
1100
1342
|
const fillColor = props.fillColor || props.color || "#007bff";
|
|
1101
1343
|
// Support gradient fills
|
|
1102
1344
|
if (typeof fillColor === "string" && fillColor.includes("gradient")) {
|
|
1103
|
-
ctx.fillStyle =
|
|
1345
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, trackY, fillWidth, trackHeight);
|
|
1104
1346
|
}
|
|
1105
1347
|
else {
|
|
1106
1348
|
ctx.fillStyle = fillColor;
|
|
@@ -1146,7 +1388,7 @@ function paintProgress(ctx, node) {
|
|
|
1146
1388
|
// Support gradient fills
|
|
1147
1389
|
const fillColor = props.fillColor || props.color || "#007bff";
|
|
1148
1390
|
if (typeof fillColor === "string" && fillColor.includes("gradient")) {
|
|
1149
|
-
ctx.fillStyle =
|
|
1391
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, y, fillWidth, height);
|
|
1150
1392
|
}
|
|
1151
1393
|
else {
|
|
1152
1394
|
ctx.fillStyle = fillColor;
|