@vyaz/renderer 0.0.1 → 0.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vyaz/renderer",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -15,6 +15,9 @@
15
15
  "@vyaz/core": "*"
16
16
  },
17
17
  "devDependencies": {
18
+ "@types/node": "^26.1.1",
19
+ "is-svg": "^6.1.0",
20
+ "svg-parser": "^2.0.4",
18
21
  "typescript": "^5.4.0"
19
22
  }
20
23
  }
@@ -1,18 +1,26 @@
1
1
  /**
2
- * CanvasRenderer.ts — render LineBox[] → Canvas.
2
+ * CanvasRenderer.ts — Layered Canvas rendering for Line[].
3
3
  *
4
- * Takes ready LineBox[] with absolute coordinates.
5
- * Does not compute anything only draws (dumb drawer principle).
4
+ * Takes ready Line[] with absolute coordinates and provides
5
+ * rendering functions for each visual layer:
6
6
  *
7
- * Options:
8
- * - sizing: 'frame' | 'content' auto canvas size
9
- * - preserveSpaces: boolean draw spaces (instead of skip)
10
- * - backgroundColor: string background color
11
- * - debug: DebugFlagsdebug overlays
7
+ * 1. Background layer — clearRect / fillRect
8
+ * 2. Text layer — text spans (dumb drawer)
9
+ * 3. Selection layer highlighted selection range
10
+ * 4. Cursor layer blinking caret
11
+ * 5. Debug overlay layer bounding boxes, baselines, labels
12
+ *
13
+ * Each function is standalone so consumers (e.g. fabric.js adapter)
14
+ * can compose layers in any order or skip layers as needed.
15
+ *
16
+ * Does NOT compute anything — only draws (dumb drawer principle).
17
+ *
18
+ * @see SVGRenderer for reference SVG implementation.
12
19
  */
13
20
 
14
- import type { LineBox, FragmentBox } from '@vyaz/core';
21
+ import type { Line, Span } from '@vyaz/core';
15
22
  import type { DebugFlags } from './types.js';
23
+ import type { CharPos } from './interactive.js';
16
24
  import { computeBBox } from './utils.js';
17
25
 
18
26
  export interface CanvasRenderOptions {
@@ -23,8 +31,8 @@ export interface CanvasRenderOptions {
23
31
  */
24
32
  sizing?: 'frame' | 'content';
25
33
  /**
26
- * When true: render space fragments with a space character.
27
- * When false (default): skip space fragments (position is already accounted for in x).
34
+ * When true: render space spans with a space character.
35
+ * When false (default): skip space spans (position is already accounted for in x).
28
36
  */
29
37
  preserveSpaces?: boolean;
30
38
  /** Background color for clearing. If omitted, canvas is cleared transparent. */
@@ -33,29 +41,177 @@ export interface CanvasRenderOptions {
33
41
  debug?: DebugFlags;
34
42
  }
35
43
 
36
- /** Font weight to CSS value */
37
- function fontWeightCSS(weight: string | number): string {
38
- if (weight === 'bold') return 'bold';
39
- if (weight === 'normal') return 'normal';
40
- if (typeof weight === 'number') return String(weight);
41
- return 'normal';
44
+ /**
45
+ * Options for cursor rendering.
46
+ */
47
+ export interface CursorOptions {
48
+ /** Cursor color. Default: '#000'. */
49
+ color?: string;
50
+ /** Cursor width in px. Default: 1. */
51
+ width?: number;
52
+ /** Cursor height relative to baseline. If undefined, uses the line's ascent + descent. */
53
+ height?: number;
54
+ }
55
+
56
+ // ── Style helpers ────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Resolve font weight to a numeric CSS value.
60
+ * Mirrors SVGRenderer's fontWeightNumeric for consistency.
61
+ */
62
+ function fontWeightNumeric(weight: string | number): number {
63
+ if (weight === 'bold') return 700;
64
+ if (weight === 'normal') return 400;
65
+ if (typeof weight === 'number') return weight;
66
+ return 400;
42
67
  }
43
68
 
44
- /** Font style to CSS value */
69
+ /**
70
+ * Resolve font style to CSS value.
71
+ */
45
72
  function fontStyleCSS(style: string): string {
46
73
  return style === 'italic' ? 'italic' : 'normal';
47
74
  }
48
75
 
49
76
  /**
50
- * Render LineBox[] array to Canvas.
77
+ * Build the CSS font string for Canvas 2D context.
78
+ * Matches format: `[font-style] [font-weight] [font-size]px [font-family]`
79
+ */
80
+ function buildFontString(span: Span): string {
81
+ const style = fontStyleCSS(span.style.fontStyle);
82
+ const weight = fontWeightNumeric(span.style.fontWeight);
83
+ const size = span.fontMetrics.fontSize;
84
+ const family = span.style.fontFamily;
85
+ return `${style} ${weight} ${size}px ${family}`;
86
+ }
87
+
88
+ // ── Text rendering ──────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Group spans within a line by their baseline offset (for sub/superscript).
92
+ *
93
+ * SVG expanded mode (SVGRenderer.ts lines 896-960) groups spans by targetY
94
+ * so sub/superscript gets a separate <text> element at the correct y.
95
+ * We mirror that here: each group gets drawn with its own ctx.save/restore.
96
+ */
97
+ interface SpanRenderGroup {
98
+ targetY: number;
99
+ spans: Span[];
100
+ }
101
+
102
+ function groupSpansByBaseline(line: Line, spans: Span[]): SpanRenderGroup[] {
103
+ const groups: SpanRenderGroup[] = [];
104
+ for (const span of spans) {
105
+ if (!span.text) continue;
106
+ const offset = span.fontMetrics.baselineOffset || 0;
107
+ const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
108
+ const last = groups[groups.length - 1];
109
+ if (last && last.targetY === targetY) {
110
+ last.spans.push(span);
111
+ } else {
112
+ groups.push({ targetY, spans: [span] });
113
+ }
114
+ }
115
+ return groups;
116
+ }
117
+
118
+ /**
119
+ * Draw background rect for a span (used for code blocks, highlights).
120
+ */
121
+ function drawSpanBackground(
122
+ ctx: CanvasRenderingContext2D,
123
+ line: Line,
124
+ span: Span,
125
+ ): void {
126
+ const baselineY = line.y + line.baseline;
127
+ const x = line.x + span.x;
128
+ const y = baselineY - span.fontMetrics.ascent;
129
+ const w = span.width;
130
+ const h = span.fontMetrics.ascent + span.fontMetrics.descent;
131
+
132
+ ctx.fillStyle = span.style.backgroundColor || 'transparent';
133
+ if (span.style.backgroundColor) {
134
+ ctx.fillRect(x, y, w, h);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Render a single span at the given baseline Y.
140
+ */
141
+ function renderSpan(
142
+ ctx: CanvasRenderingContext2D,
143
+ line: Line,
144
+ span: Span,
145
+ baselineY: number,
146
+ preserveSpaces: boolean,
147
+ ): void {
148
+ const x = line.x + span.x;
149
+
150
+ // ── Background ────────────────────────────────────────────────────
151
+ drawSpanBackground(ctx, line, span);
152
+
153
+ // ── Font setting ──────────────────────────────────────────────────
154
+ ctx.font = buildFontString(span);
155
+ ctx.fillStyle = span.style.color || '#000000';
156
+ ctx.textBaseline = 'alphabetic';
157
+
158
+ // ── Letter spacing ────────────────────────────────────────────────
159
+ const ls = span.style.letterSpacing;
160
+ if (ls !== undefined && ls !== 0) {
161
+ ctx.letterSpacing = `${ls}px`;
162
+ } else {
163
+ ctx.letterSpacing = 'normal';
164
+ }
165
+
166
+ // ── Draw text ────────────────────────────────────────────────────
167
+ if (preserveSpaces || span.type !== 'space') {
168
+ ctx.fillText(span.text, x, baselineY);
169
+ }
170
+
171
+ // ── Reset letter spacing ──────────────────────────────────────────
172
+ ctx.letterSpacing = 'normal';
173
+
174
+ // ── Underline ────────────────────────────────────────────────────
175
+ if (span.style.underline) {
176
+ const ulY = baselineY + 2;
177
+ ctx.strokeStyle = span.style.color || '#000000';
178
+ ctx.lineWidth = 1;
179
+ ctx.beginPath();
180
+ ctx.moveTo(x, ulY);
181
+ ctx.lineTo(x + span.width, ulY);
182
+ ctx.stroke();
183
+ }
184
+
185
+ // ── Strikethrough ────────────────────────────────────────────────
186
+ if (span.style.strikethrough) {
187
+ const stY = baselineY - span.fontMetrics.ascent * 0.4;
188
+ ctx.strokeStyle = span.style.color || '#000000';
189
+ ctx.lineWidth = 1;
190
+ ctx.beginPath();
191
+ ctx.moveTo(x, stY);
192
+ ctx.lineTo(x + span.width, stY);
193
+ ctx.stroke();
194
+ }
195
+
196
+ // ── InlineWidget (simple rectangle) ──────────────────────────────
197
+ if (span.inlineWidget) {
198
+ const iw = span.inlineWidget;
199
+ const iwY = baselineY - (iw.height || span.fontMetrics.ascent) + (iw.baselineOffset || 0);
200
+ ctx.fillStyle = '#cccccc';
201
+ ctx.fillRect(x, iwY, iw.width, iw.height);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Render Line[] array to Canvas.
51
207
  *
52
208
  * @param ctx — Canvas 2D rendering context
53
- * @param lines — ready LineBox[] with absolute coordinates
209
+ * @param lines — ready Line[] with absolute coordinates
54
210
  * @param options — rendering options
55
211
  */
56
212
  export function renderToCanvas(
57
213
  ctx: CanvasRenderingContext2D | any,
58
- lines: LineBox[],
214
+ lines: Line[],
59
215
  options: CanvasRenderOptions = {},
60
216
  ): void {
61
217
  const sizing = options.sizing ?? 'frame';
@@ -84,55 +240,22 @@ export function renderToCanvas(
84
240
  ctx.clearRect(0, 0, canvasWidth, canvasHeight);
85
241
  }
86
242
 
87
- // ── Render lines ─────────────────────────────────────────────────
243
+ // ── Render lines with baselineOffset support ──────────────────────
244
+ // SVG expanded mode groups spans by targetY (accounts for sub/superscript
245
+ // baselineOffset). We do the same here: each group gets its own y coordinate.
88
246
  for (const line of lines) {
89
- const baselineY = line.y + line.baseline;
90
-
91
- for (const frag of line.fragments) {
92
- const x = line.x + frag.x;
93
-
94
- // Font setting
95
- const style = fontStyleCSS(frag.style.fontStyle);
96
- const weight = fontWeightCSS(frag.style.fontWeight);
97
- const size = frag.fontMetrics.fontSize;
98
- const family = frag.style.fontFamily;
99
- ctx.font = `${style} ${weight} ${size}px ${family}`;
100
- ctx.fillStyle = frag.style.color || '#000000';
101
- ctx.textBaseline = 'alphabetic';
102
-
103
- // Draw text
104
- if (preserveSpaces || frag.type !== 'space') {
105
- ctx.fillText(frag.text, x, baselineY);
106
- }
247
+ // Collect all non-space spans that should be drawn
248
+ const drawableSpans = line.spans.filter(
249
+ s => preserveSpaces || s.type !== 'space',
250
+ );
251
+ if (drawableSpans.length === 0) continue;
107
252
 
108
- // Underline
109
- if (frag.style.underline) {
110
- const ulY = baselineY + 2;
111
- ctx.strokeStyle = frag.style.color || '#000000';
112
- ctx.lineWidth = 1;
113
- ctx.beginPath();
114
- ctx.moveTo(x, ulY);
115
- ctx.lineTo(x + frag.width, ulY);
116
- ctx.stroke();
117
- }
118
-
119
- // Strikethrough
120
- if (frag.style.strikethrough) {
121
- const stY = baselineY - frag.fontMetrics.ascent * 0.4;
122
- ctx.strokeStyle = frag.style.color || '#000000';
123
- ctx.lineWidth = 1;
124
- ctx.beginPath();
125
- ctx.moveTo(x, stY);
126
- ctx.lineTo(x + frag.width, stY);
127
- ctx.stroke();
128
- }
253
+ // Group by baseline offset (same logic as SVGRenderer expanded mode)
254
+ const groups = groupSpansByBaseline(line, drawableSpans);
129
255
 
130
- // InlineWidget (simple rectangle)
131
- if (frag.inlineWidget) {
132
- const iw = frag.inlineWidget;
133
- const iwY = baselineY - (iw.height || frag.fontMetrics.ascent) + (iw.baselineOffset || 0);
134
- ctx.fillStyle = '#cccccc';
135
- ctx.fillRect(x, iwY, iw.width, iw.height);
256
+ for (const group of groups) {
257
+ for (const span of group.spans) {
258
+ renderSpan(ctx, line, span, group.targetY, preserveSpaces);
136
259
  }
137
260
  }
138
261
  }
@@ -145,18 +268,39 @@ export function renderToCanvas(
145
268
 
146
269
  // ── Debug overlay ────────────────────────────────────────────────────
147
270
 
148
- /** Draw debug overlays on canvas */
271
+ /**
272
+ * Draw debug overlays on Canvas.
273
+ *
274
+ * Mirrors SVGRenderer's renderDebugToSVG (lines 658-814) but draws
275
+ * directly on Canvas 2D instead of generating SVG markup.
276
+ *
277
+ * Supported flags:
278
+ * frameBox / frame — frame container bounding box (blue dashed)
279
+ * contentBox — content bounding box (pink dotted)
280
+ * paragraphBox — per-paragraph colored boxes (requires groupLinesByParagraph)
281
+ * columnBox — column separators for multi-column layout
282
+ * box — line box outlines (red)
283
+ * baseline — baseline line (blue)
284
+ * ascentDescent — ascent/descent lines (green dashed)
285
+ * lineGap — line height fill (blue transparent)
286
+ * labels — coordinate labels
287
+ * runs — span bounding boxes (purple)
288
+ */
149
289
  export function renderDebugToCanvas(
150
290
  ctx: CanvasRenderingContext2D,
151
- lines: LineBox[],
291
+ lines: Line[],
152
292
  _width: number,
153
293
  _height: number,
154
294
  flags: DebugFlags,
155
295
  ): void {
156
296
  ctx.save();
157
297
 
158
- // Text frame outer bounding box of all lines
159
- if (flags.frame && lines.length > 0) {
298
+ const sw = flags.widthBorder ?? 1;
299
+ const hasBBox = lines.length > 0;
300
+ const bbox = hasBBox ? computeBBox(lines) : null;
301
+
302
+ // ── Frame container bounding box (SVG: line 671) ────────────────
303
+ if ((flags.frameBox || flags.frame) && hasBBox) {
160
304
  const first = lines[0];
161
305
  const last = lines[lines.length - 1];
162
306
  const maxW = Math.max(...lines.map(l => l.x + l.width));
@@ -164,14 +308,54 @@ export function renderDebugToCanvas(
164
308
  const frameY = first.y;
165
309
  const frameW = maxW - frameX;
166
310
  const frameH = last.y + last.height - first.y;
167
- ctx.strokeStyle = 'rgba(255,200,0,0.6)';
168
- ctx.lineWidth = 1;
169
- ctx.setLineDash([6, 2]);
311
+
312
+ ctx.strokeStyle = 'rgba(0,140,255,0.8)';
313
+ ctx.lineWidth = sw;
314
+ ctx.setLineDash([4, 3]);
170
315
  ctx.strokeRect(frameX, frameY, frameW, frameH);
316
+ ctx.setLineDash([]);
317
+
318
+ if (flags.labels && bbox) {
319
+ ctx.font = '10px monospace';
320
+ ctx.fillStyle = 'rgba(0,140,255,0.9)';
321
+ ctx.textBaseline = 'top';
322
+ ctx.fillText(`frame ${frameW.toFixed(0)}×${frameH.toFixed(0)}`, 4, 4);
323
+ }
324
+ }
325
+
326
+ // ── Content bounding box (SVG: line 684) ────────────────────────
327
+ if (flags.contentBox && bbox) {
328
+ ctx.strokeStyle = 'rgba(255,60,140,0.8)';
329
+ ctx.lineWidth = sw;
330
+ ctx.setLineDash([1, 2]);
331
+ ctx.strokeRect(bbox.x, bbox.y, bbox.width, bbox.height);
332
+ ctx.setLineDash([]);
333
+
334
+ if (flags.labels) {
335
+ const labelY = bbox.y + bbox.height + 4;
336
+ ctx.font = '10px monospace';
337
+ ctx.fillStyle = 'rgba(255,60,140,0.9)';
338
+ ctx.textBaseline = 'top';
339
+ ctx.fillText(`content ${bbox.width.toFixed(0)}×${bbox.height.toFixed(0)}`, bbox.x, labelY);
340
+ }
171
341
  }
172
342
 
173
- ctx.setLineDash([]);
343
+ // ── Overflow warning (SVG: line 699) ────────────────────────────
344
+ if (flags.contentBox && bbox && _width > 0 && _height > 0) {
345
+ const overflowX = bbox.width > _width;
346
+ const overflowY = bbox.height > _height;
347
+ if (overflowX || overflowY) {
348
+ ctx.font = '10px monospace';
349
+ ctx.fillStyle = 'rgba(220,0,0,0.9)';
350
+ ctx.textBaseline = 'top';
351
+ const warnParts: string[] = [];
352
+ if (overflowX) warnParts.push(`Δx=${(bbox.width - _width).toFixed(0)}`);
353
+ if (overflowY) warnParts.push(`Δy=${(bbox.height - _height).toFixed(0)}`);
354
+ ctx.fillText(`⚠ content overflow: ${warnParts.join(' ')}`, 4, _height + 4);
355
+ }
356
+ }
174
357
 
358
+ // ── Per-line debug overlays (SVG: line 783) ─────────────────────
175
359
  for (const line of lines) {
176
360
  const bx = line.x;
177
361
  const by = line.y;
@@ -179,35 +363,35 @@ export function renderDebugToCanvas(
179
363
  const bh = line.height;
180
364
  const baselineY = line.y + line.baseline;
181
365
 
182
- // Line gap — blue filled rect for lineHeight visualization
366
+ // Line gap — blue filled rect for lineHeight visualization (SVG: line 787)
183
367
  if (flags.lineGap) {
184
368
  ctx.fillStyle = 'rgba(0,150,255,0.10)';
185
369
  ctx.fillRect(bx, by, bw, bh);
186
370
  }
187
371
 
188
- // Bounding box — red rect
372
+ // Bounding box — red rect (SVG: line 790)
189
373
  if (flags.box) {
190
374
  ctx.strokeStyle = 'rgba(255,100,100,0.5)';
191
- ctx.lineWidth = 1;
375
+ ctx.lineWidth = sw;
192
376
  ctx.strokeRect(bx, by, bw, bh);
193
377
  }
194
378
 
195
- // Baseline — blue line
379
+ // Baseline — blue line (SVG: line 793)
196
380
  if (flags.baseline) {
197
381
  ctx.strokeStyle = 'rgba(100,100,255,0.5)';
198
- ctx.lineWidth = 1;
382
+ ctx.lineWidth = sw;
199
383
  ctx.beginPath();
200
384
  ctx.moveTo(bx, baselineY);
201
385
  ctx.lineTo(bx + bw, baselineY);
202
386
  ctx.stroke();
203
387
  }
204
388
 
205
- // Ascent / Descent — green dashed lines
389
+ // Ascent / Descent — green dashed lines (SVG: line 796)
206
390
  if (flags.ascentDescent) {
207
391
  const ascentY = baselineY - line.ascent;
208
392
  const descentY = baselineY + line.descent;
209
393
  ctx.strokeStyle = 'rgba(100,255,100,0.4)';
210
- ctx.lineWidth = 0.5;
394
+ ctx.lineWidth = sw;
211
395
  ctx.setLineDash([3, 2]);
212
396
  ctx.beginPath();
213
397
  ctx.moveTo(bx, ascentY);
@@ -220,7 +404,7 @@ export function renderDebugToCanvas(
220
404
  ctx.setLineDash([]);
221
405
  }
222
406
 
223
- // Labels — small monospace text
407
+ // Labels — small monospace text (SVG: line 800)
224
408
  if (flags.labels) {
225
409
  const labelY = by - 2;
226
410
  const label = `y=${by.toFixed(1)} x=${bx.toFixed(1)} w=${bw.toFixed(1)} h=${bh.toFixed(1)} bl=${baselineY.toFixed(1)}`;
@@ -230,20 +414,146 @@ export function renderDebugToCanvas(
230
414
  ctx.fillText(label, bx, labelY);
231
415
  }
232
416
 
233
- // Run boxes — purple rects around FragmentBox
417
+ // Run boxes — purple rects around Spans (SVG: line 803)
234
418
  if (flags.runs) {
235
- for (const frag of line.fragments) {
236
- if (frag.width <= 0) continue;
237
- const rx = line.x + frag.x;
238
- const ry = baselineY - frag.fontMetrics.ascent;
239
- const rw = frag.width;
240
- const rh = frag.fontMetrics.ascent + frag.fontMetrics.descent;
419
+ for (const span of line.spans) {
420
+ if (span.width <= 0) continue;
421
+ const rx = line.x + span.x;
422
+ const ry = baselineY - span.fontMetrics.ascent;
423
+ const rw = span.width;
424
+ const rh = span.fontMetrics.ascent + span.fontMetrics.descent;
241
425
  ctx.strokeStyle = 'rgba(200,100,255,0.4)';
242
- ctx.lineWidth = 0.5;
426
+ ctx.lineWidth = sw;
243
427
  ctx.strokeRect(rx, ry, rw, rh);
244
428
  }
245
429
  }
246
430
  }
247
431
 
432
+ ctx.restore();
433
+ }
434
+
435
+ // ── Selection layer ──────────────────────────────────────────────────
436
+
437
+ /**
438
+ * Render a text selection highlight overlay.
439
+ *
440
+ * Draws a semi-transparent blue rectangle for each character
441
+ * in the range [start, end). Supports cross-line selections.
442
+ *
443
+ * If `start` and `end` are in different lines, the full width
444
+ * of intermediate lines is highlighted.
445
+ *
446
+ * @param ctx — Canvas 2D rendering context
447
+ * @param lines — layout lines
448
+ * @param start — selection start position (inclusive)
449
+ * @param end — selection end position (exclusive)
450
+ * @param color — highlight color. Default: 'rgba(100, 150, 255, 0.3)'
451
+ */
452
+ export function renderSelection(
453
+ ctx: CanvasRenderingContext2D,
454
+ lines: Line[],
455
+ start: CharPos,
456
+ end: CharPos,
457
+ color: string = 'rgba(100, 150, 255, 0.3)',
458
+ ): void {
459
+ ctx.save();
460
+ ctx.fillStyle = color;
461
+
462
+ const liMin = Math.min(start.lineIndex, end.lineIndex);
463
+ const liMax = Math.max(start.lineIndex, end.lineIndex);
464
+
465
+ for (let li = liMin; li <= liMax; li++) {
466
+ const line = lines[li];
467
+ if (!line) continue;
468
+
469
+ const baselineY = line.y + line.baseline;
470
+
471
+ // Determine X range for this line
472
+ let xStart: number;
473
+ let xEnd: number;
474
+
475
+ if (li === liMin && li === liMax) {
476
+ // Selection within single line
477
+ xStart = Math.min(start.x, end.x);
478
+ xEnd = Math.max(start.x, end.x);
479
+ // If end has zero width (end-of-line), use the last span's right edge
480
+ if (xEnd === xStart) {
481
+ // Extend to the full line width if cursor is at the very end
482
+ const spansEnd = line.x + line.spans[line.spans.length - 1].x +
483
+ line.spans[line.spans.length - 1].width;
484
+ xEnd = spansEnd;
485
+ }
486
+ } else if (li === liMin) {
487
+ // Start line: from start.x to end of line
488
+ xStart = start.x;
489
+ // Compute the end of this line (right edge of last span)
490
+ const lastSpan = line.spans[line.spans.length - 1];
491
+ xEnd = line.x + lastSpan.x + lastSpan.width;
492
+ } else if (li === liMax) {
493
+ // End line: from start of line to end.x
494
+ xStart = line.x;
495
+ xEnd = end.x;
496
+ } else {
497
+ // Full line highlight
498
+ xStart = line.x;
499
+ const lastSpan = line.spans[line.spans.length - 1];
500
+ xEnd = line.x + lastSpan.x + lastSpan.width;
501
+ }
502
+
503
+ const y = baselineY - line.ascent;
504
+ const h = line.ascent + line.descent;
505
+ const w = xEnd - xStart;
506
+
507
+ if (w > 0) {
508
+ ctx.fillRect(xStart, y, w, h);
509
+ }
510
+ }
511
+
512
+ ctx.restore();
513
+ }
514
+
515
+ // ── Cursor layer ─────────────────────────────────────────────────────
516
+
517
+ /**
518
+ * Render a text cursor (caret) at the given character position.
519
+ *
520
+ * Draws a vertical line at the character's left edge.
521
+ * The caller is responsible for cursor blink timing.
522
+ *
523
+ * @param ctx — Canvas 2D rendering context
524
+ * @param lines — layout lines
525
+ * @param pos — cursor position (character left edge)
526
+ * @param options — cursor visual options
527
+ */
528
+ export function renderCursor(
529
+ ctx: CanvasRenderingContext2D,
530
+ lines: Line[],
531
+ pos: CharPos,
532
+ options: CursorOptions = {},
533
+ ): void {
534
+ const color = options.color ?? '#000';
535
+ const width = options.width ?? 1;
536
+
537
+ ctx.save();
538
+
539
+ // Determine cursor height
540
+ let cursorHeight: number;
541
+ if (options.height !== undefined) {
542
+ cursorHeight = options.height;
543
+ } else {
544
+ // Use the line's ascent + descent for the cursor height
545
+ const line = lines[pos.lineIndex];
546
+ cursorHeight = line.ascent + line.descent;
547
+ }
548
+
549
+ const topY = pos.y - (options.height !== undefined ? options.height : lines[pos.lineIndex].ascent);
550
+
551
+ ctx.strokeStyle = color;
552
+ ctx.lineWidth = width;
553
+ ctx.beginPath();
554
+ ctx.moveTo(pos.x, topY);
555
+ ctx.lineTo(pos.x, topY + cursorHeight);
556
+ ctx.stroke();
557
+
248
558
  ctx.restore();
249
559
  }