granite-mem 0.1.8 → 0.1.9
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/README.md +124 -227
- package/dist/index.js +4113 -652
- package/dist/public/app.js +17 -540
- package/dist/public/graph-engine.js +102 -0
- package/dist/public/index.html +113 -135
- package/dist/public/markdown-renderer.js +1 -187
- package/dist/public/style.css +1 -960
- package/dist/templates/founder-os.yml +204 -0
- package/package.json +8 -3
- package/templates/founder-os.yml +204 -0
- package/dist/public/canvas-renderer.js +0 -524
- package/dist/public/graph.js +0 -377
|
@@ -1,524 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Granite Canvas Renderer
|
|
3
|
-
*
|
|
4
|
-
* Premium text rendering engine inspired by Pretext's approach:
|
|
5
|
-
* precise measurement, line-by-line layout, per-character styling.
|
|
6
|
-
* Renders markdown onto <canvas> with editorial typography.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const CanvasRenderer = (() => {
|
|
10
|
-
|
|
11
|
-
// ── Typography system ──
|
|
12
|
-
const FONTS = {
|
|
13
|
-
serif: '"Instrument Serif", Georgia, serif',
|
|
14
|
-
sans: '"Instrument Sans", -apple-system, sans-serif',
|
|
15
|
-
mono: '"JetBrains Mono", "SF Mono", monospace',
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
const THEME = {
|
|
19
|
-
bg: '#0b0b0e',
|
|
20
|
-
text: '#e4e4e8',
|
|
21
|
-
textBright: '#f8f8fa',
|
|
22
|
-
textDim: '#7e7e8a',
|
|
23
|
-
heading: '#ffffff',
|
|
24
|
-
headingAccent: '#b4c0fc',
|
|
25
|
-
link: '#818cf8',
|
|
26
|
-
linkBroken: '#f87171',
|
|
27
|
-
linkUnderline: 'rgba(129, 140, 248, 0.35)',
|
|
28
|
-
codeBg: '#141418',
|
|
29
|
-
codeBorder: '#222228',
|
|
30
|
-
codeText: '#c4ccda',
|
|
31
|
-
quoteBorder: '#3b3b45',
|
|
32
|
-
quoteText: '#9a9aaa',
|
|
33
|
-
bulletDot: '#4e4e5a',
|
|
34
|
-
hrColor: '#222228',
|
|
35
|
-
selection: 'rgba(99, 102, 241, 0.2)',
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
// Style definitions — font, size, weight, color, spacing
|
|
39
|
-
const STYLES = {
|
|
40
|
-
h1: {
|
|
41
|
-
font: `400 28px/1.25 ${FONTS.serif}`,
|
|
42
|
-
size: 28, color: THEME.heading,
|
|
43
|
-
marginTop: 32, marginBottom: 14,
|
|
44
|
-
letterSpacing: -0.3,
|
|
45
|
-
},
|
|
46
|
-
h2: {
|
|
47
|
-
font: `400 22px/1.3 ${FONTS.serif}`,
|
|
48
|
-
size: 22, color: THEME.heading,
|
|
49
|
-
marginTop: 28, marginBottom: 10,
|
|
50
|
-
letterSpacing: -0.2,
|
|
51
|
-
},
|
|
52
|
-
h3: {
|
|
53
|
-
font: `600 15px/1.4 ${FONTS.sans}`,
|
|
54
|
-
size: 15, color: THEME.headingAccent,
|
|
55
|
-
marginTop: 24, marginBottom: 8,
|
|
56
|
-
letterSpacing: 0.3,
|
|
57
|
-
transform: 'uppercase',
|
|
58
|
-
},
|
|
59
|
-
body: {
|
|
60
|
-
font: `400 14.5px/1.7 ${FONTS.sans}`,
|
|
61
|
-
size: 14.5, color: THEME.text,
|
|
62
|
-
marginTop: 0, marginBottom: 4,
|
|
63
|
-
letterSpacing: 0.1,
|
|
64
|
-
},
|
|
65
|
-
bullet: {
|
|
66
|
-
font: `400 14.5px/1.7 ${FONTS.sans}`,
|
|
67
|
-
size: 14.5, color: THEME.text,
|
|
68
|
-
marginTop: 1, marginBottom: 1,
|
|
69
|
-
letterSpacing: 0.1,
|
|
70
|
-
},
|
|
71
|
-
code: {
|
|
72
|
-
font: `400 12.5px/1.6 ${FONTS.mono}`,
|
|
73
|
-
size: 12.5, color: THEME.codeText,
|
|
74
|
-
marginTop: 12, marginBottom: 12,
|
|
75
|
-
padding: 14, radius: 6,
|
|
76
|
-
bg: THEME.codeBg, border: THEME.codeBorder,
|
|
77
|
-
},
|
|
78
|
-
blockquote: {
|
|
79
|
-
font: `italic 400 14.5px/1.7 ${FONTS.serif}`,
|
|
80
|
-
size: 14.5, color: THEME.quoteText,
|
|
81
|
-
marginTop: 10, marginBottom: 10,
|
|
82
|
-
borderColor: THEME.quoteBorder,
|
|
83
|
-
},
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const LINE_HEIGHT_RATIO = 1.7;
|
|
87
|
-
const PADDING_X = 32;
|
|
88
|
-
const PADDING_Y = 28;
|
|
89
|
-
const MAX_WIDTH = 600;
|
|
90
|
-
|
|
91
|
-
// ── Font size extractor ──
|
|
92
|
-
function getFontSize(style) {
|
|
93
|
-
return style.size || 14.5;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ── Markdown parser ──
|
|
97
|
-
function parseMarkdown(body, outgoingLinks) {
|
|
98
|
-
const lines = body.split('\n');
|
|
99
|
-
const segments = [];
|
|
100
|
-
let inCodeBlock = false;
|
|
101
|
-
let codeBuffer = [];
|
|
102
|
-
let codeLang = '';
|
|
103
|
-
|
|
104
|
-
for (const line of lines) {
|
|
105
|
-
if (line.startsWith('```')) {
|
|
106
|
-
if (inCodeBlock) {
|
|
107
|
-
segments.push({ type: 'code', text: codeBuffer.join('\n'), lang: codeLang });
|
|
108
|
-
codeBuffer = [];
|
|
109
|
-
inCodeBlock = false;
|
|
110
|
-
} else {
|
|
111
|
-
inCodeBlock = true;
|
|
112
|
-
codeLang = line.slice(3).trim();
|
|
113
|
-
}
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
if (inCodeBlock) { codeBuffer.push(line); continue; }
|
|
117
|
-
if (line.trim() === '') { segments.push({ type: 'blank' }); continue; }
|
|
118
|
-
|
|
119
|
-
const hMatch = line.match(/^(#{1,3})\s+(.+)/);
|
|
120
|
-
if (hMatch) {
|
|
121
|
-
const level = hMatch[1].length;
|
|
122
|
-
segments.push({ type: `h${level}`, text: hMatch[2], spans: parseInline(hMatch[2], outgoingLinks) });
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
|
|
126
|
-
segments.push({ type: 'hr' });
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
if (line.startsWith('>')) {
|
|
130
|
-
const text = line.replace(/^>\s?/, '');
|
|
131
|
-
segments.push({ type: 'blockquote', text, spans: parseInline(text, outgoingLinks) });
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
const bulletMatch = line.match(/^(\s*)[*\-+]\s+(.+)/);
|
|
135
|
-
if (bulletMatch) {
|
|
136
|
-
segments.push({ type: 'bullet', text: bulletMatch[2], indent: Math.floor(bulletMatch[1].length / 2), spans: parseInline(bulletMatch[2], outgoingLinks) });
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
// Numbered list
|
|
140
|
-
const numMatch = line.match(/^(\s*)\d+\.\s+(.+)/);
|
|
141
|
-
if (numMatch) {
|
|
142
|
-
segments.push({ type: 'bullet', text: numMatch[2], indent: Math.floor(numMatch[1].length / 2), spans: parseInline(numMatch[2], outgoingLinks), numbered: true });
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
segments.push({ type: 'body', text: line, spans: parseInline(line, outgoingLinks) });
|
|
146
|
-
}
|
|
147
|
-
if (inCodeBlock && codeBuffer.length > 0) {
|
|
148
|
-
segments.push({ type: 'code', text: codeBuffer.join('\n'), lang: codeLang });
|
|
149
|
-
}
|
|
150
|
-
return segments;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ── Inline parser ──
|
|
154
|
-
function parseInline(text, outgoingLinks) {
|
|
155
|
-
const spans = [];
|
|
156
|
-
const re = /(\[\[([^\]]+)\]\])|(`([^`]+)`)|(\*\*(.+?)\*\*)|(\*(.+?)\*)/g;
|
|
157
|
-
let lastIndex = 0;
|
|
158
|
-
let match;
|
|
159
|
-
|
|
160
|
-
while ((match = re.exec(text)) !== null) {
|
|
161
|
-
if (match.index > lastIndex) {
|
|
162
|
-
spans.push({ type: 'text', text: text.slice(lastIndex, match.index) });
|
|
163
|
-
}
|
|
164
|
-
if (match[1]) {
|
|
165
|
-
const inner = match[2];
|
|
166
|
-
const parts = inner.split('|');
|
|
167
|
-
const target = parts[0].trim();
|
|
168
|
-
const display = parts.length > 1 ? parts[1].trim() : target;
|
|
169
|
-
const linkInfo = outgoingLinks?.find(l => l.target === target);
|
|
170
|
-
spans.push({ type: 'wikilink', text: display, target, resolved: linkInfo?.resolved ?? false, slug: linkInfo?.resolved_slug || null });
|
|
171
|
-
} else if (match[3]) {
|
|
172
|
-
spans.push({ type: 'inline_code', text: match[4] });
|
|
173
|
-
} else if (match[5]) {
|
|
174
|
-
spans.push({ type: 'bold', text: match[6] });
|
|
175
|
-
} else if (match[7]) {
|
|
176
|
-
spans.push({ type: 'italic', text: match[8] });
|
|
177
|
-
}
|
|
178
|
-
lastIndex = match.index + match[0].length;
|
|
179
|
-
}
|
|
180
|
-
if (lastIndex < text.length) {
|
|
181
|
-
spans.push({ type: 'text', text: text.slice(lastIndex) });
|
|
182
|
-
}
|
|
183
|
-
return spans;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// ── Text measurement & wrapping ──
|
|
187
|
-
function wrapText(ctx, text, maxWidth) {
|
|
188
|
-
const words = text.split(/(\s+)/);
|
|
189
|
-
const lines = [];
|
|
190
|
-
let currentLine = '';
|
|
191
|
-
for (const word of words) {
|
|
192
|
-
const testLine = currentLine + word;
|
|
193
|
-
if (ctx.measureText(testLine).width > maxWidth && currentLine.trim() !== '') {
|
|
194
|
-
lines.push(currentLine);
|
|
195
|
-
currentLine = word.trimStart();
|
|
196
|
-
} else {
|
|
197
|
-
currentLine = testLine;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
if (currentLine) lines.push(currentLine);
|
|
201
|
-
return lines.length > 0 ? lines : [''];
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// ── Span layout: wrap text across spans preserving span styling ──
|
|
205
|
-
function layoutSpans(ctx, spans, baseFont, maxWidth) {
|
|
206
|
-
const fullText = spans.map(s => s.text).join('');
|
|
207
|
-
ctx.font = baseFont;
|
|
208
|
-
const wrappedLines = wrapText(ctx, fullText, maxWidth);
|
|
209
|
-
|
|
210
|
-
const charMap = [];
|
|
211
|
-
for (const span of spans) {
|
|
212
|
-
for (let i = 0; i < span.text.length; i++) charMap.push(span);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const result = [];
|
|
216
|
-
let charIdx = 0;
|
|
217
|
-
for (const line of wrappedLines) {
|
|
218
|
-
const lineSpans = [];
|
|
219
|
-
let runStart = charIdx;
|
|
220
|
-
let currentSpan = charMap[charIdx];
|
|
221
|
-
for (let i = 0; i < line.length; i++) {
|
|
222
|
-
const span = charMap[charIdx + i];
|
|
223
|
-
if (span !== currentSpan) {
|
|
224
|
-
lineSpans.push({ span: currentSpan, text: line.slice(runStart - charIdx, i) });
|
|
225
|
-
currentSpan = span;
|
|
226
|
-
runStart = charIdx + i;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
lineSpans.push({ span: currentSpan, text: line.slice(runStart - charIdx) });
|
|
230
|
-
result.push(lineSpans);
|
|
231
|
-
charIdx += line.length;
|
|
232
|
-
}
|
|
233
|
-
return result;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// ── Hit boxes for interactive elements ──
|
|
237
|
-
let hitBoxes = [];
|
|
238
|
-
|
|
239
|
-
function clearHitBoxes() { hitBoxes = []; }
|
|
240
|
-
|
|
241
|
-
function registerHitBox(x, y, width, height, data) {
|
|
242
|
-
hitBoxes.push({ x, y, width, height, ...data });
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function hitTest(x, y) {
|
|
246
|
-
for (const box of hitBoxes) {
|
|
247
|
-
if (x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height) {
|
|
248
|
-
return box;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// ── Drawing helpers ──
|
|
255
|
-
function roundRect(ctx, x, y, w, h, r) {
|
|
256
|
-
ctx.beginPath();
|
|
257
|
-
ctx.moveTo(x + r, y);
|
|
258
|
-
ctx.lineTo(x + w - r, y);
|
|
259
|
-
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
260
|
-
ctx.lineTo(x + w, y + h - r);
|
|
261
|
-
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
262
|
-
ctx.lineTo(x + r, y + h);
|
|
263
|
-
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
264
|
-
ctx.lineTo(x, y + r);
|
|
265
|
-
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
266
|
-
ctx.closePath();
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Subtle underline with custom thickness and offset
|
|
270
|
-
function drawUnderline(ctx, x, y, width, color, opts = {}) {
|
|
271
|
-
const { thickness = 1, offset = 3, dashed = false } = opts;
|
|
272
|
-
ctx.strokeStyle = color;
|
|
273
|
-
ctx.lineWidth = thickness;
|
|
274
|
-
if (dashed) ctx.setLineDash([3, 3]);
|
|
275
|
-
ctx.beginPath();
|
|
276
|
-
ctx.moveTo(x, y + offset);
|
|
277
|
-
ctx.lineTo(x + width, y + offset);
|
|
278
|
-
ctx.stroke();
|
|
279
|
-
if (dashed) ctx.setLineDash([]);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// ── Main render ──
|
|
283
|
-
function render(canvas, note, opts = {}) {
|
|
284
|
-
const dpr = window.devicePixelRatio || 1;
|
|
285
|
-
const container = canvas.parentElement;
|
|
286
|
-
const displayWidth = container.clientWidth;
|
|
287
|
-
const displayHeight = container.clientHeight;
|
|
288
|
-
|
|
289
|
-
canvas.width = displayWidth * dpr;
|
|
290
|
-
canvas.height = displayHeight * dpr;
|
|
291
|
-
canvas.style.width = displayWidth + 'px';
|
|
292
|
-
canvas.style.height = displayHeight + 'px';
|
|
293
|
-
|
|
294
|
-
const ctx = canvas.getContext('2d');
|
|
295
|
-
ctx.scale(dpr, dpr);
|
|
296
|
-
|
|
297
|
-
// Fill background
|
|
298
|
-
ctx.fillStyle = THEME.bg;
|
|
299
|
-
ctx.fillRect(0, 0, displayWidth, displayHeight);
|
|
300
|
-
|
|
301
|
-
clearHitBoxes();
|
|
302
|
-
|
|
303
|
-
const contentWidth = Math.min(displayWidth - PADDING_X * 2, MAX_WIDTH);
|
|
304
|
-
const offsetX = (displayWidth - contentWidth) / 2;
|
|
305
|
-
const scrollOffset = canvas._scrollOffset || 0;
|
|
306
|
-
let y = PADDING_Y - scrollOffset;
|
|
307
|
-
|
|
308
|
-
const segments = parseMarkdown(note.body, note.outgoing_links);
|
|
309
|
-
let bulletIndex = 0;
|
|
310
|
-
|
|
311
|
-
for (const seg of segments) {
|
|
312
|
-
// ── Blank line ──
|
|
313
|
-
if (seg.type === 'blank') {
|
|
314
|
-
y += 10;
|
|
315
|
-
bulletIndex = 0;
|
|
316
|
-
continue;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// ── Horizontal rule ──
|
|
320
|
-
if (seg.type === 'hr') {
|
|
321
|
-
y += 20;
|
|
322
|
-
ctx.strokeStyle = THEME.hrColor;
|
|
323
|
-
ctx.lineWidth = 1;
|
|
324
|
-
ctx.beginPath();
|
|
325
|
-
const hrInset = contentWidth * 0.2;
|
|
326
|
-
ctx.moveTo(offsetX + hrInset, y);
|
|
327
|
-
ctx.lineTo(offsetX + contentWidth - hrInset, y);
|
|
328
|
-
ctx.stroke();
|
|
329
|
-
y += 20;
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// ── Code block ──
|
|
334
|
-
if (seg.type === 'code') {
|
|
335
|
-
const style = STYLES.code;
|
|
336
|
-
y += style.marginTop;
|
|
337
|
-
ctx.font = style.font;
|
|
338
|
-
|
|
339
|
-
const codeLines = seg.text.split('\n');
|
|
340
|
-
const lineH = style.size * 1.6;
|
|
341
|
-
const blockHeight = codeLines.length * lineH + style.padding * 2;
|
|
342
|
-
|
|
343
|
-
// Background with border
|
|
344
|
-
ctx.fillStyle = style.bg;
|
|
345
|
-
roundRect(ctx, offsetX, y, contentWidth, blockHeight, style.radius);
|
|
346
|
-
ctx.fill();
|
|
347
|
-
ctx.strokeStyle = style.border;
|
|
348
|
-
ctx.lineWidth = 1;
|
|
349
|
-
roundRect(ctx, offsetX, y, contentWidth, blockHeight, style.radius);
|
|
350
|
-
ctx.stroke();
|
|
351
|
-
|
|
352
|
-
// Code text
|
|
353
|
-
ctx.fillStyle = style.color;
|
|
354
|
-
for (let i = 0; i < codeLines.length; i++) {
|
|
355
|
-
ctx.fillText(codeLines[i], offsetX + style.padding, y + style.padding + (i + 0.75) * lineH);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
y += blockHeight + style.marginBottom;
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// ── Blockquote ──
|
|
363
|
-
if (seg.type === 'blockquote') {
|
|
364
|
-
const style = STYLES.blockquote;
|
|
365
|
-
y += style.marginTop;
|
|
366
|
-
ctx.font = style.font;
|
|
367
|
-
const lineH = style.size * LINE_HEIGHT_RATIO;
|
|
368
|
-
const lines = layoutSpans(ctx, seg.spans, style.font, contentWidth - 24);
|
|
369
|
-
|
|
370
|
-
// Accent border
|
|
371
|
-
const borderHeight = lines.length * lineH + 4;
|
|
372
|
-
ctx.fillStyle = style.borderColor;
|
|
373
|
-
roundRect(ctx, offsetX, y - 2, 2, borderHeight, 1);
|
|
374
|
-
ctx.fill();
|
|
375
|
-
|
|
376
|
-
for (const lineSpans of lines) {
|
|
377
|
-
y += lineH;
|
|
378
|
-
drawSpanLine(ctx, lineSpans, offsetX + 18, y - style.size * 0.35, style, scrollOffset);
|
|
379
|
-
}
|
|
380
|
-
y += style.marginBottom;
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// ── Headings, body, bullets ──
|
|
385
|
-
const styleKey = seg.type.startsWith('h') ? seg.type : (seg.type === 'bullet' ? 'bullet' : 'body');
|
|
386
|
-
const style = STYLES[styleKey];
|
|
387
|
-
if (!style) continue;
|
|
388
|
-
|
|
389
|
-
y += style.marginTop;
|
|
390
|
-
|
|
391
|
-
// Set font
|
|
392
|
-
const fontStr = style.font;
|
|
393
|
-
ctx.font = fontStr;
|
|
394
|
-
|
|
395
|
-
const fontSize = getFontSize(style);
|
|
396
|
-
const lineH = fontSize * LINE_HEIGHT_RATIO;
|
|
397
|
-
const indentPx = seg.type === 'bullet' ? (seg.indent || 0) * 18 + 20 : 0;
|
|
398
|
-
const availWidth = contentWidth - indentPx;
|
|
399
|
-
|
|
400
|
-
if (seg.spans) {
|
|
401
|
-
// Handle h3 uppercase transform
|
|
402
|
-
let processedSpans = seg.spans;
|
|
403
|
-
if (style.transform === 'uppercase') {
|
|
404
|
-
processedSpans = seg.spans.map(s => ({ ...s, text: s.text.toUpperCase() }));
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
const lines = layoutSpans(ctx, processedSpans, fontStr, availWidth);
|
|
408
|
-
|
|
409
|
-
for (let li = 0; li < lines.length; li++) {
|
|
410
|
-
y += lineH;
|
|
411
|
-
let xOff = offsetX + indentPx;
|
|
412
|
-
|
|
413
|
-
// Bullet marker
|
|
414
|
-
if (seg.type === 'bullet' && li === 0) {
|
|
415
|
-
if (seg.numbered) {
|
|
416
|
-
bulletIndex++;
|
|
417
|
-
ctx.fillStyle = THEME.textDim;
|
|
418
|
-
ctx.font = `500 12px ${FONTS.sans}`;
|
|
419
|
-
ctx.fillText(`${bulletIndex}.`, offsetX + (seg.indent || 0) * 18, y - fontSize * 0.35);
|
|
420
|
-
ctx.font = fontStr;
|
|
421
|
-
} else {
|
|
422
|
-
ctx.fillStyle = THEME.bulletDot;
|
|
423
|
-
ctx.beginPath();
|
|
424
|
-
ctx.arc(offsetX + (seg.indent || 0) * 18 + 8, y - fontSize * 0.4, 2, 0, Math.PI * 2);
|
|
425
|
-
ctx.fill();
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
drawSpanLine(ctx, lines[li], xOff, y - fontSize * 0.35, style, scrollOffset);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
y += style.marginBottom;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
canvas._contentHeight = y + scrollOffset + PADDING_Y;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// ── Draw a line of styled spans ──
|
|
440
|
-
function drawSpanLine(ctx, lineSpans, x, y, baseStyle, scrollOffset) {
|
|
441
|
-
for (const { span, text } of lineSpans) {
|
|
442
|
-
if (!span) { x += ctx.measureText(text).width; continue; }
|
|
443
|
-
|
|
444
|
-
const fontSize = getFontSize(baseStyle);
|
|
445
|
-
let color = baseStyle.color;
|
|
446
|
-
let needsRestore = false;
|
|
447
|
-
|
|
448
|
-
switch (span.type) {
|
|
449
|
-
case 'wikilink':
|
|
450
|
-
color = span.resolved ? THEME.link : THEME.linkBroken;
|
|
451
|
-
break;
|
|
452
|
-
case 'bold':
|
|
453
|
-
ctx.font = `600 ${fontSize}px ${FONTS.sans}`;
|
|
454
|
-
color = THEME.textBright;
|
|
455
|
-
needsRestore = true;
|
|
456
|
-
break;
|
|
457
|
-
case 'italic':
|
|
458
|
-
ctx.font = `italic ${fontSize}px ${FONTS.serif}`;
|
|
459
|
-
needsRestore = true;
|
|
460
|
-
break;
|
|
461
|
-
case 'inline_code':
|
|
462
|
-
ctx.font = `400 ${Math.max(fontSize - 2, 11)}px ${FONTS.mono}`;
|
|
463
|
-
needsRestore = true;
|
|
464
|
-
break;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Inline code background
|
|
468
|
-
if (span.type === 'inline_code') {
|
|
469
|
-
const w = ctx.measureText(text).width;
|
|
470
|
-
ctx.fillStyle = THEME.codeBg;
|
|
471
|
-
roundRect(ctx, x - 3, y - fontSize * 0.7, w + 6, fontSize * 1.1, 3);
|
|
472
|
-
ctx.fill();
|
|
473
|
-
ctx.strokeStyle = THEME.codeBorder;
|
|
474
|
-
ctx.lineWidth = 0.5;
|
|
475
|
-
roundRect(ctx, x - 3, y - fontSize * 0.7, w + 6, fontSize * 1.1, 3);
|
|
476
|
-
ctx.stroke();
|
|
477
|
-
color = THEME.codeText;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
ctx.fillStyle = color;
|
|
481
|
-
|
|
482
|
-
// Letter spacing for headings
|
|
483
|
-
if (baseStyle.letterSpacing && baseStyle.letterSpacing !== 0 && Math.abs(baseStyle.letterSpacing) > 0.15) {
|
|
484
|
-
x = drawWithLetterSpacing(ctx, text, x, y, baseStyle.letterSpacing);
|
|
485
|
-
} else {
|
|
486
|
-
ctx.fillText(text, x, y);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const textWidth = ctx.measureText(text).width;
|
|
490
|
-
|
|
491
|
-
// Wikilink underline
|
|
492
|
-
if (span.type === 'wikilink') {
|
|
493
|
-
const underColor = span.resolved ? THEME.linkUnderline : THEME.linkBroken;
|
|
494
|
-
drawUnderline(ctx, x, y, textWidth, underColor, {
|
|
495
|
-
thickness: span.resolved ? 1.5 : 1,
|
|
496
|
-
offset: 3,
|
|
497
|
-
dashed: !span.resolved,
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
// Hit box
|
|
501
|
-
if (span.resolved && span.slug) {
|
|
502
|
-
registerHitBox(x, y + scrollOffset - fontSize, textWidth, fontSize * 1.5, { slug: span.slug });
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
if (!baseStyle.letterSpacing || Math.abs(baseStyle.letterSpacing) <= 0.15) {
|
|
507
|
-
x += textWidth;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
if (needsRestore) ctx.font = baseStyle.font;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// ── Draw text with custom letter spacing ──
|
|
515
|
-
function drawWithLetterSpacing(ctx, text, x, y, spacing) {
|
|
516
|
-
for (const char of text) {
|
|
517
|
-
ctx.fillText(char, x, y);
|
|
518
|
-
x += ctx.measureText(char).width + spacing;
|
|
519
|
-
}
|
|
520
|
-
return x;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
return { render, hitTest, parseMarkdown };
|
|
524
|
-
})();
|