@vyaz/renderer 0.0.3 → 0.0.5

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.
@@ -1,559 +0,0 @@
1
- /**
2
- * CanvasRenderer.ts — Layered Canvas rendering for Line[].
3
- *
4
- * Takes ready Line[] with absolute coordinates and provides
5
- * rendering functions for each visual layer:
6
- *
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.
19
- */
20
-
21
- import type { Line, Span } from '@vyaz/core';
22
- import type { DebugFlags } from './types.js';
23
- import type { CharPos } from './interactive.js';
24
- import { computeBBox } from './utils.js';
25
-
26
- export interface CanvasRenderOptions {
27
- /**
28
- * How the canvas size is determined:
29
- * 'frame' — use current ctx.canvas.width/height (default)
30
- * 'content' — compute bounding box from lines, resize canvas to fit
31
- */
32
- sizing?: 'frame' | 'content';
33
- /**
34
- * When true: render space spans with a space character.
35
- * When false (default): skip space spans (position is already accounted for in x).
36
- */
37
- preserveSpaces?: boolean;
38
- /** Background color for clearing. If omitted, canvas is cleared transparent. */
39
- backgroundColor?: string;
40
- /** Debug overlay flags. */
41
- debug?: DebugFlags;
42
- }
43
-
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;
67
- }
68
-
69
- /**
70
- * Resolve font style to CSS value.
71
- */
72
- function fontStyleCSS(style: string): string {
73
- return style === 'italic' ? 'italic' : 'normal';
74
- }
75
-
76
- /**
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.
207
- *
208
- * @param ctx — Canvas 2D rendering context
209
- * @param lines — ready Line[] with absolute coordinates
210
- * @param options — rendering options
211
- */
212
- export function renderToCanvas(
213
- ctx: CanvasRenderingContext2D | any,
214
- lines: Line[],
215
- options: CanvasRenderOptions = {},
216
- ): void {
217
- const sizing = options.sizing ?? 'frame';
218
- const preserveSpaces = options.preserveSpaces ?? false;
219
-
220
- // ── Sizing: content mode resizes canvas ──────────────────────────
221
- let canvasWidth: number;
222
- let canvasHeight: number;
223
-
224
- if (sizing === 'content') {
225
- const bbox = computeBBox(lines);
226
- canvasWidth = bbox.width;
227
- canvasHeight = bbox.height;
228
- ctx.canvas.width = canvasWidth;
229
- ctx.canvas.height = canvasHeight;
230
- } else {
231
- canvasWidth = ctx.canvas.width;
232
- canvasHeight = ctx.canvas.height;
233
- }
234
-
235
- // ── Background ───────────────────────────────────────────────────
236
- if (options.backgroundColor) {
237
- ctx.fillStyle = options.backgroundColor;
238
- ctx.fillRect(0, 0, canvasWidth, canvasHeight);
239
- } else {
240
- ctx.clearRect(0, 0, canvasWidth, canvasHeight);
241
- }
242
-
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.
246
- for (const line of lines) {
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;
252
-
253
- // Group by baseline offset (same logic as SVGRenderer expanded mode)
254
- const groups = groupSpansByBaseline(line, drawableSpans);
255
-
256
- for (const group of groups) {
257
- for (const span of group.spans) {
258
- renderSpan(ctx, line, span, group.targetY, preserveSpaces);
259
- }
260
- }
261
- }
262
-
263
- // Debug overlay
264
- if (options.debug) {
265
- renderDebugToCanvas(ctx, lines, canvasWidth, canvasHeight, options.debug);
266
- }
267
- }
268
-
269
- // ── Debug overlay ────────────────────────────────────────────────────
270
-
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
- */
289
- export function renderDebugToCanvas(
290
- ctx: CanvasRenderingContext2D,
291
- lines: Line[],
292
- _width: number,
293
- _height: number,
294
- flags: DebugFlags,
295
- ): void {
296
- ctx.save();
297
-
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) {
304
- const first = lines[0];
305
- const last = lines[lines.length - 1];
306
- const maxW = Math.max(...lines.map(l => l.x + l.width));
307
- const frameX = first.x;
308
- const frameY = first.y;
309
- const frameW = maxW - frameX;
310
- const frameH = last.y + last.height - first.y;
311
-
312
- ctx.strokeStyle = 'rgba(0,140,255,0.8)';
313
- ctx.lineWidth = sw;
314
- ctx.setLineDash([4, 3]);
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
- }
341
- }
342
-
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
- }
357
-
358
- // ── Per-line debug overlays (SVG: line 783) ─────────────────────
359
- for (const line of lines) {
360
- const bx = line.x;
361
- const by = line.y;
362
- const bw = line.width;
363
- const bh = line.height;
364
- const baselineY = line.y + line.baseline;
365
-
366
- // Line gap — blue filled rect for lineHeight visualization (SVG: line 787)
367
- if (flags.lineGap) {
368
- ctx.fillStyle = 'rgba(0,150,255,0.10)';
369
- ctx.fillRect(bx, by, bw, bh);
370
- }
371
-
372
- // Bounding box — red rect (SVG: line 790)
373
- if (flags.box) {
374
- ctx.strokeStyle = 'rgba(255,100,100,0.5)';
375
- ctx.lineWidth = sw;
376
- ctx.strokeRect(bx, by, bw, bh);
377
- }
378
-
379
- // Baseline — blue line (SVG: line 793)
380
- if (flags.baseline) {
381
- ctx.strokeStyle = 'rgba(100,100,255,0.5)';
382
- ctx.lineWidth = sw;
383
- ctx.beginPath();
384
- ctx.moveTo(bx, baselineY);
385
- ctx.lineTo(bx + bw, baselineY);
386
- ctx.stroke();
387
- }
388
-
389
- // Ascent / Descent — green dashed lines (SVG: line 796)
390
- if (flags.ascentDescent) {
391
- const ascentY = baselineY - line.ascent;
392
- const descentY = baselineY + line.descent;
393
- ctx.strokeStyle = 'rgba(100,255,100,0.4)';
394
- ctx.lineWidth = sw;
395
- ctx.setLineDash([3, 2]);
396
- ctx.beginPath();
397
- ctx.moveTo(bx, ascentY);
398
- ctx.lineTo(bx + bw, ascentY);
399
- ctx.stroke();
400
- ctx.beginPath();
401
- ctx.moveTo(bx, descentY);
402
- ctx.lineTo(bx + bw, descentY);
403
- ctx.stroke();
404
- ctx.setLineDash([]);
405
- }
406
-
407
- // Labels — small monospace text (SVG: line 800)
408
- if (flags.labels) {
409
- const labelY = by - 2;
410
- const label = `y=${by.toFixed(1)} x=${bx.toFixed(1)} w=${bw.toFixed(1)} h=${bh.toFixed(1)} bl=${baselineY.toFixed(1)}`;
411
- ctx.font = '9px monospace';
412
- ctx.fillStyle = 'rgba(0,0,0,0.55)';
413
- ctx.textBaseline = 'bottom';
414
- ctx.fillText(label, bx, labelY);
415
- }
416
-
417
- // Run boxes — purple rects around Spans (SVG: line 803)
418
- if (flags.runs) {
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;
425
- ctx.strokeStyle = 'rgba(200,100,255,0.4)';
426
- ctx.lineWidth = sw;
427
- ctx.strokeRect(rx, ry, rw, rh);
428
- }
429
- }
430
- }
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
-
558
- ctx.restore();
559
- }