@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/dist/index.js +1143 -0
- package/package.json +4 -1
- package/src/CanvasRenderer.ts +401 -91
- package/src/SVGRenderer.ts +756 -182
- package/src/index.ts +7 -4
- package/src/interactive.ts +284 -0
- package/src/types.ts +59 -4
- package/src/utils.ts +20 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vyaz/renderer",
|
|
3
|
-
"version": "0.0.
|
|
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
|
}
|
package/src/CanvasRenderer.ts
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CanvasRenderer.ts —
|
|
2
|
+
* CanvasRenderer.ts — Layered Canvas rendering for Line[].
|
|
3
3
|
*
|
|
4
|
-
* Takes ready
|
|
5
|
-
*
|
|
4
|
+
* Takes ready Line[] with absolute coordinates and provides
|
|
5
|
+
* rendering functions for each visual layer:
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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
|
|
27
|
-
* When false (default): skip space
|
|
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
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
168
|
-
ctx.
|
|
169
|
-
ctx.
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
417
|
+
// Run boxes — purple rects around Spans (SVG: line 803)
|
|
234
418
|
if (flags.runs) {
|
|
235
|
-
for (const
|
|
236
|
-
if (
|
|
237
|
-
const rx = line.x +
|
|
238
|
-
const ry = baselineY -
|
|
239
|
-
const rw =
|
|
240
|
-
const rh =
|
|
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 =
|
|
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
|
}
|