@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/selection.ts
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
* triple-click-line, and Ctrl/Cmd+C copy for canvas-rendered text.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { VirtualNode, Point
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
import type { VirtualNode, Point } from "./types.js";
|
|
9
|
+
import {
|
|
10
|
+
SELECTION_HIGHLIGHT_COLOR,
|
|
11
|
+
nodeTextGeometry,
|
|
12
|
+
pointToOffset,
|
|
13
|
+
rangeToRects,
|
|
14
|
+
resolveNodeText,
|
|
15
|
+
} from "./text-geometry.js";
|
|
12
16
|
|
|
13
17
|
// ---------------------------------------------------------------------------
|
|
14
18
|
// Types
|
|
@@ -36,12 +40,6 @@ export interface SelectionRect {
|
|
|
36
40
|
height: number;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
// Selection state
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
|
|
43
|
-
const HIGHLIGHT_COLOR = "rgba(59, 130, 246, 0.35)";
|
|
44
|
-
|
|
45
43
|
// ---------------------------------------------------------------------------
|
|
46
44
|
// SelectionManager
|
|
47
45
|
// ---------------------------------------------------------------------------
|
|
@@ -202,7 +200,7 @@ export class SelectionManager {
|
|
|
202
200
|
private selectWord(pos: TextPosition): void {
|
|
203
201
|
const node = this.findNodeById(pos.nodeId);
|
|
204
202
|
if (!node) return;
|
|
205
|
-
const text =
|
|
203
|
+
const text = resolveNodeText(node);
|
|
206
204
|
const start = wordBoundaryBefore(text, pos.offset);
|
|
207
205
|
const end = wordBoundaryAfter(text, pos.offset);
|
|
208
206
|
this.selection = {
|
|
@@ -214,7 +212,7 @@ export class SelectionManager {
|
|
|
214
212
|
private selectAll(nodeId: string): void {
|
|
215
213
|
const node = this.findNodeById(nodeId);
|
|
216
214
|
if (!node) return;
|
|
217
|
-
const text =
|
|
215
|
+
const text = resolveNodeText(node);
|
|
218
216
|
this.selection = {
|
|
219
217
|
anchor: { nodeId, offset: 0 },
|
|
220
218
|
focus: { nodeId, offset: text.length },
|
|
@@ -225,7 +223,7 @@ export class SelectionManager {
|
|
|
225
223
|
if (!this.selection) return null;
|
|
226
224
|
const node = this.findNodeById(this.selection.anchor.nodeId);
|
|
227
225
|
if (!node) return null;
|
|
228
|
-
const text =
|
|
226
|
+
const text = resolveNodeText(node);
|
|
229
227
|
const [start, end] = normalizeRange(this.selection);
|
|
230
228
|
return text.slice(start, end);
|
|
231
229
|
}
|
|
@@ -245,29 +243,50 @@ export class SelectionManager {
|
|
|
245
243
|
private hitTestTextNode(
|
|
246
244
|
node: VirtualNode,
|
|
247
245
|
point: Point,
|
|
246
|
+
scrollX: number = 0,
|
|
247
|
+
scrollY: number = 0,
|
|
248
248
|
): TextPosition | null {
|
|
249
249
|
if (!node.visible || !node.layout) return null;
|
|
250
250
|
|
|
251
|
+
const layout = node.layout;
|
|
252
|
+
const x = layout.x - scrollX;
|
|
253
|
+
const y = layout.y - scrollY;
|
|
254
|
+
const inBounds =
|
|
255
|
+
point.x >= x && point.x <= x + layout.width &&
|
|
256
|
+
point.y >= y && point.y <= y + layout.height;
|
|
257
|
+
|
|
258
|
+
// A clipping container confines its subtree to its own box — when the
|
|
259
|
+
// point is outside, no descendant text can be visible under it.
|
|
260
|
+
if (!inBounds) {
|
|
261
|
+
const overflow = node.props.overflow;
|
|
262
|
+
if (overflow === "hidden" || overflow === "scroll" || overflow === "auto") {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const childScrollX = scrollX + (node.scrollState?.scrollX ?? 0);
|
|
268
|
+
const childScrollY = scrollY + (node.scrollState?.scrollY ?? 0);
|
|
269
|
+
|
|
251
270
|
// Check children first (front-to-back)
|
|
252
271
|
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
253
|
-
const result = this.hitTestTextNode(node.children[i], point);
|
|
272
|
+
const result = this.hitTestTextNode(node.children[i], point, childScrollX, childScrollY);
|
|
254
273
|
if (result) return result;
|
|
255
274
|
}
|
|
256
275
|
|
|
257
276
|
// Only text nodes are selectable
|
|
258
277
|
if (node.type !== "text") return null;
|
|
259
|
-
|
|
260
|
-
const bounds = getScrollAwareBounds(node);
|
|
261
|
-
if (!bounds) return null;
|
|
262
|
-
if (
|
|
263
|
-
point.x < bounds.x || point.x > bounds.x + bounds.width ||
|
|
264
|
-
point.y < bounds.y || point.y > bounds.y + bounds.height
|
|
265
|
-
) {
|
|
266
|
-
return null;
|
|
267
|
-
}
|
|
278
|
+
if (!inBounds) return null;
|
|
268
279
|
|
|
269
280
|
// Map point to character offset within this text node
|
|
270
|
-
const
|
|
281
|
+
const ctx = this.canvas.getContext("2d");
|
|
282
|
+
if (!ctx) return null;
|
|
283
|
+
const geometry = nodeTextGeometry(
|
|
284
|
+
node,
|
|
285
|
+
resolveNodeText(node),
|
|
286
|
+
x + layout.contentX,
|
|
287
|
+
y + layout.contentY,
|
|
288
|
+
);
|
|
289
|
+
const offset = pointToOffset(ctx, geometry, point);
|
|
271
290
|
return { nodeId: node.id, offset };
|
|
272
291
|
}
|
|
273
292
|
|
|
@@ -285,10 +304,17 @@ export class SelectionManager {
|
|
|
285
304
|
const [start, end] = normalizeRange(this.selection);
|
|
286
305
|
if (start === end) return; // caret only, no highlight
|
|
287
306
|
|
|
288
|
-
const
|
|
307
|
+
const layout = node.layout!;
|
|
308
|
+
const geometry = nodeTextGeometry(
|
|
309
|
+
node,
|
|
310
|
+
resolveNodeText(node),
|
|
311
|
+
layout.x + layout.contentX,
|
|
312
|
+
layout.y + layout.contentY,
|
|
313
|
+
);
|
|
314
|
+
const rects = rangeToRects(ctx, geometry, start, end);
|
|
289
315
|
|
|
290
316
|
ctx.save();
|
|
291
|
-
ctx.fillStyle =
|
|
317
|
+
ctx.fillStyle = SELECTION_HIGHLIGHT_COLOR;
|
|
292
318
|
for (const r of rects) {
|
|
293
319
|
ctx.fillRect(r.x, r.y, r.width, r.height);
|
|
294
320
|
}
|
|
@@ -321,16 +347,6 @@ export class SelectionManager {
|
|
|
321
347
|
// Pure helpers
|
|
322
348
|
// ---------------------------------------------------------------------------
|
|
323
349
|
|
|
324
|
-
function resolveText(node: VirtualNode): string {
|
|
325
|
-
const raw = node.props[0] || node.props.text || "";
|
|
326
|
-
let text = String(raw);
|
|
327
|
-
const tt = node.props.textTransform || "none";
|
|
328
|
-
if (tt === "uppercase") text = text.toUpperCase();
|
|
329
|
-
else if (tt === "lowercase") text = text.toLowerCase();
|
|
330
|
-
else if (tt === "capitalize") text = text.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
331
|
-
return text;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
350
|
function normalizeRange(sel: TextSelection): [number, number] {
|
|
335
351
|
const a = sel.anchor.offset;
|
|
336
352
|
const b = sel.focus.offset;
|
|
@@ -364,180 +380,6 @@ function findById(node: VirtualNode, id: string): VirtualNode | null {
|
|
|
364
380
|
return null;
|
|
365
381
|
}
|
|
366
382
|
|
|
367
|
-
// ---------------------------------------------------------------------------
|
|
368
|
-
// Geometry: map a canvas point to a character offset
|
|
369
|
-
// ---------------------------------------------------------------------------
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Given a point inside a text node's bounds, return the character index
|
|
373
|
-
* that the point falls on.
|
|
374
|
-
*/
|
|
375
|
-
function pointToOffset(
|
|
376
|
-
canvas: HTMLCanvasElement,
|
|
377
|
-
node: VirtualNode,
|
|
378
|
-
bounds: { x: number; y: number; width: number; height: number },
|
|
379
|
-
point: Point,
|
|
380
|
-
): number {
|
|
381
|
-
const ctx = canvas.getContext("2d");
|
|
382
|
-
if (!ctx) return 0;
|
|
383
|
-
|
|
384
|
-
const props = node.props;
|
|
385
|
-
const text = resolveText(node);
|
|
386
|
-
const layout = node.layout!;
|
|
387
|
-
const fontSize = parseFloat(props.fontSize) || 16;
|
|
388
|
-
const fontWeight = props.fontWeight || "normal";
|
|
389
|
-
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
390
|
-
const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
|
|
391
|
-
const textAlign = props.textAlign || "left";
|
|
392
|
-
|
|
393
|
-
const fontStyle: FontStyle = { fontSize, fontWeight, fontFamily, lineHeight };
|
|
394
|
-
const font = createFontString(fontSize, fontWeight, fontFamily);
|
|
395
|
-
|
|
396
|
-
ctx.save();
|
|
397
|
-
ctx.font = font;
|
|
398
|
-
|
|
399
|
-
const metrics = measureText(ctx, text, fontStyle, layout.contentWidth);
|
|
400
|
-
const contentX = bounds.x + layout.contentX;
|
|
401
|
-
const contentY = bounds.y + layout.contentY;
|
|
402
|
-
|
|
403
|
-
// Find which line the point falls on
|
|
404
|
-
const relY = point.y - contentY;
|
|
405
|
-
let lineIndex = Math.floor(relY / metrics.lineHeight);
|
|
406
|
-
lineIndex = Math.max(0, Math.min(lineIndex, metrics.lines.length - 1));
|
|
407
|
-
|
|
408
|
-
// Calculate character offset before this line
|
|
409
|
-
let charsBefore = 0;
|
|
410
|
-
for (let i = 0; i < lineIndex; i++) {
|
|
411
|
-
charsBefore += metrics.lines[i].length;
|
|
412
|
-
// Account for space/newline between lines (word wrap eats the space)
|
|
413
|
-
if (i < metrics.lines.length - 1) {
|
|
414
|
-
const nextLineStart = text.indexOf(metrics.lines[i + 1], charsBefore);
|
|
415
|
-
if (nextLineStart > charsBefore) charsBefore = nextLineStart;
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
const line = metrics.lines[lineIndex];
|
|
420
|
-
|
|
421
|
-
// Calculate line X offset (for text-align)
|
|
422
|
-
let lineX = contentX;
|
|
423
|
-
if (textAlign === "center") {
|
|
424
|
-
const lw = ctx.measureText(line).width;
|
|
425
|
-
lineX = contentX + (layout.contentWidth - lw) / 2;
|
|
426
|
-
} else if (textAlign === "right") {
|
|
427
|
-
const lw = ctx.measureText(line).width;
|
|
428
|
-
lineX = contentX + layout.contentWidth - lw;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// Binary search for the character at point.x
|
|
432
|
-
const relX = point.x - lineX;
|
|
433
|
-
let best = 0;
|
|
434
|
-
for (let i = 0; i <= line.length; i++) {
|
|
435
|
-
const w = ctx.measureText(line.slice(0, i)).width;
|
|
436
|
-
if (w <= relX) {
|
|
437
|
-
best = i;
|
|
438
|
-
} else {
|
|
439
|
-
// Check if we're closer to this char or the previous
|
|
440
|
-
const prevW = ctx.measureText(line.slice(0, i - 1)).width;
|
|
441
|
-
if (relX - prevW < w - relX) {
|
|
442
|
-
best = i - 1;
|
|
443
|
-
} else {
|
|
444
|
-
best = i;
|
|
445
|
-
}
|
|
446
|
-
break;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
ctx.restore();
|
|
451
|
-
return charsBefore + best;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// ---------------------------------------------------------------------------
|
|
455
|
-
// Geometry: compute highlight rectangles for a selection range
|
|
456
|
-
// ---------------------------------------------------------------------------
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Return a list of rectangles to paint as the selection highlight for
|
|
460
|
-
* characters [start, end) within a text node.
|
|
461
|
-
*/
|
|
462
|
-
function getSelectionRects(
|
|
463
|
-
ctx: CanvasRenderingContext2D,
|
|
464
|
-
node: VirtualNode,
|
|
465
|
-
start: number,
|
|
466
|
-
end: number,
|
|
467
|
-
): SelectionRect[] {
|
|
468
|
-
const props = node.props;
|
|
469
|
-
const layout = node.layout!;
|
|
470
|
-
const text = resolveText(node);
|
|
471
|
-
const fontSize = parseFloat(props.fontSize) || 16;
|
|
472
|
-
const fontWeight = props.fontWeight || "normal";
|
|
473
|
-
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
474
|
-
const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
|
|
475
|
-
const textAlign = props.textAlign || "left";
|
|
476
|
-
|
|
477
|
-
const fontStyle: FontStyle = { fontSize, fontWeight, fontFamily, lineHeight };
|
|
478
|
-
const font = createFontString(fontSize, fontWeight, fontFamily);
|
|
479
|
-
|
|
480
|
-
ctx.save();
|
|
481
|
-
ctx.font = font;
|
|
482
|
-
|
|
483
|
-
const metrics = measureText(ctx, text, fontStyle, layout.contentWidth);
|
|
484
|
-
const x = layout.x + layout.contentX;
|
|
485
|
-
const y = layout.y + layout.contentY;
|
|
486
|
-
|
|
487
|
-
const rects: SelectionRect[] = [];
|
|
488
|
-
|
|
489
|
-
// Map lines to character ranges
|
|
490
|
-
let charOffset = 0;
|
|
491
|
-
for (let i = 0; i < metrics.lines.length; i++) {
|
|
492
|
-
const line = metrics.lines[i];
|
|
493
|
-
const lineStart = charOffset;
|
|
494
|
-
const lineEnd = charOffset + line.length;
|
|
495
|
-
|
|
496
|
-
// Find actual position in original text for this line
|
|
497
|
-
if (i > 0) {
|
|
498
|
-
const idx = text.indexOf(line, charOffset);
|
|
499
|
-
if (idx > charOffset) charOffset = idx;
|
|
500
|
-
}
|
|
501
|
-
const actualLineStart = charOffset;
|
|
502
|
-
const actualLineEnd = charOffset + line.length;
|
|
503
|
-
|
|
504
|
-
// Does this line overlap with the selection?
|
|
505
|
-
if (actualLineEnd <= start || actualLineStart >= end) {
|
|
506
|
-
charOffset = actualLineEnd;
|
|
507
|
-
continue;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// Clamp selection to this line
|
|
511
|
-
const selStart = Math.max(0, start - actualLineStart);
|
|
512
|
-
const selEnd = Math.min(line.length, end - actualLineStart);
|
|
513
|
-
|
|
514
|
-
// Calculate line X offset (text-align)
|
|
515
|
-
let lineX = x;
|
|
516
|
-
const lineWidth = ctx.measureText(line).width;
|
|
517
|
-
if (textAlign === "center") {
|
|
518
|
-
lineX = x + (layout.contentWidth - lineWidth) / 2;
|
|
519
|
-
} else if (textAlign === "right") {
|
|
520
|
-
lineX = x + layout.contentWidth - lineWidth;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// Measure selection bounds within this line
|
|
524
|
-
const startX = ctx.measureText(line.slice(0, selStart)).width;
|
|
525
|
-
const endX = ctx.measureText(line.slice(0, selEnd)).width;
|
|
526
|
-
|
|
527
|
-
rects.push({
|
|
528
|
-
x: lineX + startX,
|
|
529
|
-
y: y + i * metrics.lineHeight,
|
|
530
|
-
width: endX - startX,
|
|
531
|
-
height: metrics.lineHeight,
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
charOffset = actualLineEnd;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
ctx.restore();
|
|
538
|
-
return rects;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
383
|
// ---------------------------------------------------------------------------
|
|
542
384
|
// Clipboard fallback
|
|
543
385
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Geometry
|
|
3
|
+
*
|
|
4
|
+
* Pure point↔offset↔rect math shared by static-text selection
|
|
5
|
+
* (SelectionManager), input caret placement, and selection/caret painting.
|
|
6
|
+
* Everything is parameterized by an explicit {@link TextGeometry} — no
|
|
7
|
+
* node-type assumptions — so the same math serves Text, Input, and Textarea.
|
|
8
|
+
*
|
|
9
|
+
* Font sizes go through `cssLengthToPx` (matching paint.ts) so geometry
|
|
10
|
+
* lines up with painted glyphs even for `rem`-valued props.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { FontStyle, Point, Rectangle, VirtualNode } from "./types.js";
|
|
14
|
+
import { measureText } from "./text.js";
|
|
15
|
+
import { createFontString, cssLengthToPx } from "./utils.js";
|
|
16
|
+
|
|
17
|
+
/** Selection highlight color shared by static text and input painting. */
|
|
18
|
+
export const SELECTION_HIGHLIGHT_COLOR = "rgba(59, 130, 246, 0.35)";
|
|
19
|
+
|
|
20
|
+
export interface TextGeometry {
|
|
21
|
+
/** Already-resolved text (node text or input value, post-transform). */
|
|
22
|
+
text: string;
|
|
23
|
+
font: FontStyle;
|
|
24
|
+
textAlign: "left" | "center" | "right";
|
|
25
|
+
verticalAlign: "top" | "middle" | "bottom";
|
|
26
|
+
/** Content-box origin in the coordinate space of queried points / returned rects. */
|
|
27
|
+
contentX: number;
|
|
28
|
+
contentY: number;
|
|
29
|
+
contentWidth: number;
|
|
30
|
+
contentHeight: number;
|
|
31
|
+
/** Width used for line wrapping; undefined = single line (no wrap). */
|
|
32
|
+
wrapWidth?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a node's displayed text, applying `textTransform` the same way
|
|
37
|
+
* paint does — geometry must measure what is actually drawn.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveNodeText(node: VirtualNode): string {
|
|
40
|
+
const raw = node.props[0] || node.props.text || "";
|
|
41
|
+
let text = String(raw);
|
|
42
|
+
const tt = node.props.textTransform || "none";
|
|
43
|
+
if (tt === "uppercase") text = text.toUpperCase();
|
|
44
|
+
else if (tt === "lowercase") text = text.toLowerCase();
|
|
45
|
+
else if (tt === "capitalize") text = text.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Font style from node props with paint.ts parity defaults. */
|
|
50
|
+
export function nodeFontStyle(props: Record<string, any>): FontStyle {
|
|
51
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
52
|
+
return {
|
|
53
|
+
fontSize,
|
|
54
|
+
fontWeight: props.fontWeight || "normal",
|
|
55
|
+
fontFamily: props.fontFamily || "system-ui, sans-serif",
|
|
56
|
+
lineHeight: cssLengthToPx(props.lineHeight) ?? fontSize * 1.2,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a TextGeometry for a node whose content box starts at
|
|
62
|
+
* (contentX, contentY) in the caller's coordinate space. Wrapping and
|
|
63
|
+
* vertical alignment are caller decisions: static text wraps at content
|
|
64
|
+
* width and is top-aligned (paintText parity); single-line inputs don't
|
|
65
|
+
* wrap and are middle-aligned (paintInput parity).
|
|
66
|
+
*/
|
|
67
|
+
export function nodeTextGeometry(
|
|
68
|
+
node: VirtualNode,
|
|
69
|
+
text: string,
|
|
70
|
+
contentX: number,
|
|
71
|
+
contentY: number,
|
|
72
|
+
opts?: { verticalAlign?: TextGeometry["verticalAlign"]; wrap?: boolean },
|
|
73
|
+
): TextGeometry {
|
|
74
|
+
const layout = node.layout!;
|
|
75
|
+
return {
|
|
76
|
+
text,
|
|
77
|
+
font: nodeFontStyle(node.props),
|
|
78
|
+
textAlign: (node.props.textAlign || "left") as TextGeometry["textAlign"],
|
|
79
|
+
verticalAlign: opts?.verticalAlign ?? "top",
|
|
80
|
+
contentX,
|
|
81
|
+
contentY,
|
|
82
|
+
contentWidth: layout.contentWidth,
|
|
83
|
+
contentHeight: layout.contentHeight,
|
|
84
|
+
wrapWidth: (opts?.wrap ?? true) ? layout.contentWidth : undefined,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Character range each wrapped line covers in the original text. Word wrap
|
|
90
|
+
* eats the separator between lines, so line N+1's start is re-anchored with
|
|
91
|
+
* indexOf — same bookkeeping the selection code has always used.
|
|
92
|
+
*/
|
|
93
|
+
export interface LineRange {
|
|
94
|
+
start: number;
|
|
95
|
+
end: number;
|
|
96
|
+
line: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function lineRanges(text: string, lines: string[]): LineRange[] {
|
|
100
|
+
const ranges: LineRange[] = [];
|
|
101
|
+
let charOffset = 0;
|
|
102
|
+
for (let i = 0; i < lines.length; i++) {
|
|
103
|
+
if (i > 0) {
|
|
104
|
+
if (lines[i] === "") {
|
|
105
|
+
// A blank line (from a hard "\n\n" or a trailing Enter) can't be
|
|
106
|
+
// re-anchored with indexOf — it sits just past its newline.
|
|
107
|
+
if (text[charOffset] === "\n") charOffset += 1;
|
|
108
|
+
} else {
|
|
109
|
+
const idx = text.indexOf(lines[i], charOffset);
|
|
110
|
+
if (idx > charOffset) charOffset = idx;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
ranges.push({ start: charOffset, end: charOffset + lines[i].length, line: lines[i] });
|
|
114
|
+
charOffset += lines[i].length;
|
|
115
|
+
}
|
|
116
|
+
return ranges;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Line layout for geometry purposes. Wrapping goes through `measureText`
|
|
121
|
+
* (pretext-aware) per hard-break paragraph. Single-line text (`wrapWidth`
|
|
122
|
+
* undefined) deliberately does NOT: line-layout engines strip trailing
|
|
123
|
+
* whitespace at line ends, which would pin the caret before a just-typed
|
|
124
|
+
* space until the next visible character arrives. A single-line input's
|
|
125
|
+
* "line" is the raw text, spaces and all — `ctx.measureText` advances
|
|
126
|
+
* include them.
|
|
127
|
+
*
|
|
128
|
+
* For wrapped text, each paragraph's stripped whitespace tail is
|
|
129
|
+
* re-attached to its last line for the same reason — a textarea caret must
|
|
130
|
+
* sit after a just-typed space, including one typed right before a newline.
|
|
131
|
+
* (Blank lines from "\n" come from measureText itself.)
|
|
132
|
+
*/
|
|
133
|
+
function layoutLines(
|
|
134
|
+
ctx: CanvasRenderingContext2D,
|
|
135
|
+
g: TextGeometry,
|
|
136
|
+
): { lines: string[]; lineHeight: number; height: number } {
|
|
137
|
+
const lineHeight = g.font.lineHeight || g.font.fontSize * 1.2;
|
|
138
|
+
|
|
139
|
+
if (g.wrapWidth === undefined) {
|
|
140
|
+
return { lines: [g.text], lineHeight, height: lineHeight };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const lines: string[] = [];
|
|
144
|
+
for (const paragraph of g.text.split("\n")) {
|
|
145
|
+
if (paragraph === "") {
|
|
146
|
+
lines.push("");
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const m = measureText(ctx, paragraph, g.font, g.wrapWidth);
|
|
150
|
+
const paragraphLines = [...m.lines];
|
|
151
|
+
// Re-attach the paragraph's trailing whitespace (stripped by wrap
|
|
152
|
+
// layout) so the caret can sit after it.
|
|
153
|
+
const ranges = lineRanges(paragraph, paragraphLines);
|
|
154
|
+
const lastEnd = ranges.length > 0 ? ranges[ranges.length - 1].end : 0;
|
|
155
|
+
const tail = paragraph.slice(lastEnd);
|
|
156
|
+
if (tail.length > 0 && tail.trim() === "") {
|
|
157
|
+
paragraphLines[paragraphLines.length - 1] += tail;
|
|
158
|
+
}
|
|
159
|
+
lines.push(...paragraphLines);
|
|
160
|
+
}
|
|
161
|
+
if (lines.length === 0) lines.push("");
|
|
162
|
+
|
|
163
|
+
return { lines, lineHeight, height: lines.length * lineHeight };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function alignedStartY(g: TextGeometry, totalHeight: number): number {
|
|
167
|
+
if (g.verticalAlign === "middle") return g.contentY + (g.contentHeight - totalHeight) / 2;
|
|
168
|
+
if (g.verticalAlign === "bottom") return g.contentY + g.contentHeight - totalHeight;
|
|
169
|
+
return g.contentY;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function alignedLineX(ctx: CanvasRenderingContext2D, g: TextGeometry, line: string): number {
|
|
173
|
+
if (g.textAlign === "center") {
|
|
174
|
+
return g.contentX + (g.contentWidth - ctx.measureText(line).width) / 2;
|
|
175
|
+
}
|
|
176
|
+
if (g.textAlign === "right") {
|
|
177
|
+
return g.contentX + g.contentWidth - ctx.measureText(line).width;
|
|
178
|
+
}
|
|
179
|
+
return g.contentX;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Map a point to the character offset it falls on.
|
|
184
|
+
*/
|
|
185
|
+
export function pointToOffset(
|
|
186
|
+
ctx: CanvasRenderingContext2D,
|
|
187
|
+
g: TextGeometry,
|
|
188
|
+
point: Point,
|
|
189
|
+
): number {
|
|
190
|
+
ctx.save();
|
|
191
|
+
ctx.font = createFontString(g.font.fontSize, g.font.fontWeight, g.font.fontFamily);
|
|
192
|
+
|
|
193
|
+
const metrics = layoutLines(ctx, g);
|
|
194
|
+
const startY = alignedStartY(g, metrics.height);
|
|
195
|
+
|
|
196
|
+
const relY = point.y - startY;
|
|
197
|
+
let lineIndex = Math.floor(relY / metrics.lineHeight);
|
|
198
|
+
lineIndex = Math.max(0, Math.min(lineIndex, metrics.lines.length - 1));
|
|
199
|
+
|
|
200
|
+
const ranges = lineRanges(g.text, metrics.lines);
|
|
201
|
+
const { start: lineStart, line } = ranges[lineIndex];
|
|
202
|
+
|
|
203
|
+
const relX = point.x - alignedLineX(ctx, g, line);
|
|
204
|
+
|
|
205
|
+
// Prefix widths are monotonic in prefix length, so bisect for the longest
|
|
206
|
+
// prefix that fits, then snap to whichever side of the boundary glyph is
|
|
207
|
+
// closer. A point left of the line start clamps to 0.
|
|
208
|
+
let best = 0;
|
|
209
|
+
if (relX > 0) {
|
|
210
|
+
let lo = 0;
|
|
211
|
+
let hi = line.length;
|
|
212
|
+
while (lo < hi) {
|
|
213
|
+
const mid = (lo + hi + 1) >> 1;
|
|
214
|
+
if (ctx.measureText(line.slice(0, mid)).width <= relX) {
|
|
215
|
+
lo = mid;
|
|
216
|
+
} else {
|
|
217
|
+
hi = mid - 1;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
best = lo;
|
|
221
|
+
if (lo < line.length) {
|
|
222
|
+
const w = ctx.measureText(line.slice(0, lo)).width;
|
|
223
|
+
const nextW = ctx.measureText(line.slice(0, lo + 1)).width;
|
|
224
|
+
best = relX - w < nextW - relX ? lo : lo + 1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
ctx.restore();
|
|
229
|
+
return lineStart + best;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Rectangles covering characters [start, end) — one per wrapped line the
|
|
234
|
+
* range touches. Used for selection highlights.
|
|
235
|
+
*/
|
|
236
|
+
export function rangeToRects(
|
|
237
|
+
ctx: CanvasRenderingContext2D,
|
|
238
|
+
g: TextGeometry,
|
|
239
|
+
start: number,
|
|
240
|
+
end: number,
|
|
241
|
+
): Rectangle[] {
|
|
242
|
+
if (start >= end) return [];
|
|
243
|
+
ctx.save();
|
|
244
|
+
ctx.font = createFontString(g.font.fontSize, g.font.fontWeight, g.font.fontFamily);
|
|
245
|
+
|
|
246
|
+
const metrics = layoutLines(ctx, g);
|
|
247
|
+
const startY = alignedStartY(g, metrics.height);
|
|
248
|
+
const ranges = lineRanges(g.text, metrics.lines);
|
|
249
|
+
|
|
250
|
+
const rects: Rectangle[] = [];
|
|
251
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
252
|
+
const { start: lineStart, end: lineEnd, line } = ranges[i];
|
|
253
|
+
if (lineEnd <= start || lineStart >= end) continue;
|
|
254
|
+
|
|
255
|
+
const selStart = Math.max(0, start - lineStart);
|
|
256
|
+
const selEnd = Math.min(line.length, end - lineStart);
|
|
257
|
+
|
|
258
|
+
const lineX = alignedLineX(ctx, g, line);
|
|
259
|
+
const fromX = ctx.measureText(line.slice(0, selStart)).width;
|
|
260
|
+
const toX = ctx.measureText(line.slice(0, selEnd)).width;
|
|
261
|
+
|
|
262
|
+
rects.push({
|
|
263
|
+
x: lineX + fromX,
|
|
264
|
+
y: startY + i * metrics.lineHeight,
|
|
265
|
+
width: toX - fromX,
|
|
266
|
+
height: metrics.lineHeight,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
ctx.restore();
|
|
271
|
+
return rects;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Caret rectangle for a character offset. At a wrap boundary the caret
|
|
276
|
+
* sits at the end of the earlier line (matching browser end-of-line
|
|
277
|
+
* placement for soft wraps).
|
|
278
|
+
*/
|
|
279
|
+
export function offsetToCaretRect(
|
|
280
|
+
ctx: CanvasRenderingContext2D,
|
|
281
|
+
g: TextGeometry,
|
|
282
|
+
offset: number,
|
|
283
|
+
): Rectangle {
|
|
284
|
+
ctx.save();
|
|
285
|
+
ctx.font = createFontString(g.font.fontSize, g.font.fontWeight, g.font.fontFamily);
|
|
286
|
+
|
|
287
|
+
const metrics = layoutLines(ctx, g);
|
|
288
|
+
const startY = alignedStartY(g, metrics.height);
|
|
289
|
+
const ranges = lineRanges(g.text, metrics.lines);
|
|
290
|
+
|
|
291
|
+
const clamped = Math.max(0, Math.min(offset, g.text.length));
|
|
292
|
+
let lineIndex = ranges.length - 1;
|
|
293
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
294
|
+
if (clamped <= ranges[i].end) {
|
|
295
|
+
lineIndex = i;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const { start: lineStart, line } = ranges[lineIndex];
|
|
301
|
+
const col = Math.max(0, Math.min(clamped - lineStart, line.length));
|
|
302
|
+
const x = alignedLineX(ctx, g, line) + ctx.measureText(line.slice(0, col)).width;
|
|
303
|
+
|
|
304
|
+
ctx.restore();
|
|
305
|
+
return {
|
|
306
|
+
x,
|
|
307
|
+
y: startY + lineIndex * metrics.lineHeight,
|
|
308
|
+
width: 1,
|
|
309
|
+
height: metrics.lineHeight,
|
|
310
|
+
};
|
|
311
|
+
}
|