draftly 1.0.7 → 2.1.0
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 +12 -0
- package/dist/chunk-3T55CBNZ.cjs +33 -0
- package/dist/chunk-3T55CBNZ.cjs.map +1 -0
- package/dist/chunk-5MC4T7JH.cjs +58 -0
- package/dist/chunk-5MC4T7JH.cjs.map +1 -0
- package/dist/{chunk-KBQDZ5IW.cjs → chunk-CLW73JRX.cjs} +100 -75
- package/dist/chunk-CLW73JRX.cjs.map +1 -0
- package/dist/{chunk-72ZYRGRT.cjs → chunk-EQUQHE2E.cjs} +30 -26
- package/dist/chunk-EQUQHE2E.cjs.map +1 -0
- package/dist/{chunk-HPSMS2WB.js → chunk-I563H35S.js} +101 -75
- package/dist/chunk-I563H35S.js.map +1 -0
- package/dist/chunk-IAXF4SJL.js +55 -0
- package/dist/chunk-IAXF4SJL.js.map +1 -0
- package/dist/chunk-JF3WXXMJ.js +31 -0
- package/dist/chunk-JF3WXXMJ.js.map +1 -0
- package/dist/{chunk-N3WL3XPB.js → chunk-NRPI5O6Y.js} +2603 -604
- package/dist/chunk-NRPI5O6Y.js.map +1 -0
- package/dist/{chunk-2B3A3VSQ.cjs → chunk-OMFUE4AQ.cjs} +2642 -622
- package/dist/chunk-OMFUE4AQ.cjs.map +1 -0
- package/dist/{chunk-DFQYXFOP.js → chunk-TD3L5C45.js} +28 -3
- package/dist/chunk-TD3L5C45.js.map +1 -0
- package/dist/{chunk-CG4M4TC7.js → chunk-UCHBDJ4R.js} +26 -22
- package/dist/chunk-UCHBDJ4R.js.map +1 -0
- package/dist/{chunk-KDEDLC3D.cjs → chunk-W75QUUQC.cjs} +29 -2
- package/dist/chunk-W75QUUQC.cjs.map +1 -0
- package/dist/{draftly-BLnx3uGX.d.cts → draftly-BBL-AdOl.d.cts} +5 -1
- package/dist/{draftly-BLnx3uGX.d.ts → draftly-BBL-AdOl.d.ts} +5 -1
- package/dist/editor/index.cjs +22 -14
- package/dist/editor/index.d.cts +2 -1
- package/dist/editor/index.d.ts +2 -1
- package/dist/editor/index.js +2 -2
- package/dist/index.cjs +65 -39
- package/dist/index.d.cts +6 -3
- package/dist/index.d.ts +6 -3
- package/dist/index.js +6 -4
- package/dist/lib/index.cjs +12 -0
- package/dist/lib/index.cjs.map +1 -0
- package/dist/lib/index.d.cts +16 -0
- package/dist/lib/index.d.ts +16 -0
- package/dist/lib/index.js +3 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/plugins/index.cjs +27 -17
- package/dist/plugins/index.d.cts +180 -10
- package/dist/plugins/index.d.ts +180 -10
- package/dist/plugins/index.js +5 -3
- package/dist/preview/index.cjs +16 -11
- package/dist/preview/index.d.cts +19 -4
- package/dist/preview/index.d.ts +19 -4
- package/dist/preview/index.js +3 -2
- package/package.json +8 -1
- package/src/editor/draftly.ts +30 -27
- package/src/editor/plugin.ts +5 -1
- package/src/editor/theme.ts +1 -0
- package/src/editor/utils.ts +33 -0
- package/src/index.ts +5 -4
- package/src/lib/index.ts +2 -0
- package/src/lib/input-handler.ts +45 -0
- package/src/plugins/code-plugin.theme.ts +426 -0
- package/src/plugins/code-plugin.ts +810 -561
- package/src/plugins/emoji-plugin.ts +140 -0
- package/src/plugins/index.ts +63 -57
- package/src/plugins/inline-plugin.ts +305 -291
- package/src/plugins/math-plugin.ts +12 -0
- package/src/plugins/table-plugin.ts +1759 -0
- package/src/preview/context.ts +4 -1
- package/src/preview/css-generator.ts +14 -1
- package/src/preview/index.ts +9 -1
- package/src/preview/preview.ts +2 -1
- package/src/preview/renderer.ts +21 -20
- package/src/preview/syntax-theme.ts +110 -0
- package/src/preview/types.ts +14 -0
- package/dist/chunk-2B3A3VSQ.cjs.map +0 -1
- package/dist/chunk-72ZYRGRT.cjs.map +0 -1
- package/dist/chunk-CG4M4TC7.js.map +0 -1
- package/dist/chunk-DFQYXFOP.js.map +0 -1
- package/dist/chunk-HPSMS2WB.js.map +0 -1
- package/dist/chunk-KBQDZ5IW.cjs.map +0 -1
- package/dist/chunk-KDEDLC3D.cjs.map +0 -1
- package/dist/chunk-N3WL3XPB.js.map +0 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Decoration, EditorView, KeyBinding, WidgetType } from "@codemirror/view";
|
|
2
|
-
import {
|
|
2
|
+
import { Extension } from "@codemirror/state";
|
|
3
|
+
import { LanguageDescription, syntaxTree } from "@codemirror/language";
|
|
3
4
|
import { DecorationContext, DecorationPlugin } from "../editor/plugin";
|
|
4
|
-
import {
|
|
5
|
-
import { SyntaxNode } from "@lezer/common";
|
|
6
|
-
import { highlightCode } from "@lezer/highlight";
|
|
5
|
+
import { toggleMarkdownStyle } from "../editor";
|
|
6
|
+
import { Parser, SyntaxNode } from "@lezer/common";
|
|
7
|
+
import { Highlighter, highlightCode } from "@lezer/highlight";
|
|
7
8
|
import { languages } from "@codemirror/language-data";
|
|
8
|
-
import {
|
|
9
|
+
import { createWrapSelectionInputHandler } from "../lib";
|
|
10
|
+
import { codePluginTheme as theme } from "./code-plugin.theme";
|
|
9
11
|
|
|
10
12
|
// ============================================================================
|
|
11
13
|
// Constants
|
|
@@ -20,6 +22,21 @@ const CHECK_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="1
|
|
|
20
22
|
/** Delay before resetting copy button state (ms) */
|
|
21
23
|
const COPY_RESET_DELAY = 2000;
|
|
22
24
|
|
|
25
|
+
/** Code fence marker in markdown blocks */
|
|
26
|
+
const CODE_FENCE = "```";
|
|
27
|
+
|
|
28
|
+
/** Regex for quoted code info values like title="file.ts" */
|
|
29
|
+
const QUOTED_INFO_PATTERN = /(\w+)="([^"]*)"/g;
|
|
30
|
+
|
|
31
|
+
/** Regex for /pattern/ with optional instance selectors (/pattern/1-3,5) */
|
|
32
|
+
const TEXT_HIGHLIGHT_PATTERN = /\/([^/]+)\/(?:(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*))?/g;
|
|
33
|
+
|
|
34
|
+
interface PreviewRenderContext {
|
|
35
|
+
sliceDoc(from: number, to: number): string;
|
|
36
|
+
sanitize(html: string): string;
|
|
37
|
+
syntaxHighlighters?: readonly Highlighter[];
|
|
38
|
+
}
|
|
39
|
+
|
|
23
40
|
// ============================================================================
|
|
24
41
|
// Decorations
|
|
25
42
|
// ============================================================================
|
|
@@ -40,6 +57,15 @@ const codeMarkDecorations = {
|
|
|
40
57
|
// Highlights
|
|
41
58
|
"code-line-highlight": Decoration.line({ class: "cm-draftly-code-line-highlight" }),
|
|
42
59
|
"code-text-highlight": Decoration.mark({ class: "cm-draftly-code-text-highlight" }),
|
|
60
|
+
|
|
61
|
+
// Diff preview
|
|
62
|
+
"diff-line-add": Decoration.line({ class: "cm-draftly-code-line-diff-add" }),
|
|
63
|
+
"diff-line-del": Decoration.line({ class: "cm-draftly-code-line-diff-del" }),
|
|
64
|
+
"diff-sign-add": Decoration.mark({ class: "cm-draftly-code-diff-sign-add" }),
|
|
65
|
+
"diff-sign-del": Decoration.mark({ class: "cm-draftly-code-diff-sign-del" }),
|
|
66
|
+
"diff-mod-add": Decoration.mark({ class: "cm-draftly-code-diff-mod-add" }),
|
|
67
|
+
"diff-mod-del": Decoration.mark({ class: "cm-draftly-code-diff-mod-del" }),
|
|
68
|
+
"diff-escape-hidden": Decoration.replace({}),
|
|
43
69
|
};
|
|
44
70
|
|
|
45
71
|
/**
|
|
@@ -62,19 +88,36 @@ export interface CodeBlockProperties {
|
|
|
62
88
|
/** Language identifier (first token) */
|
|
63
89
|
language: string;
|
|
64
90
|
/** Show line numbers, optionally starting from a specific number */
|
|
65
|
-
|
|
91
|
+
showLineNumbers?: number | boolean;
|
|
66
92
|
/** Title to display */
|
|
67
93
|
title?: string;
|
|
68
94
|
/** Caption to display */
|
|
69
95
|
caption?: string;
|
|
70
96
|
/** Show copy button */
|
|
71
97
|
copy?: boolean;
|
|
98
|
+
/** Enable diff preview mode */
|
|
99
|
+
diff?: boolean;
|
|
72
100
|
/** Lines to highlight (e.g., [2,3,4,5,9]) */
|
|
73
101
|
highlightLines?: number[];
|
|
74
102
|
/** Text patterns to highlight with optional instance selection */
|
|
75
103
|
highlightText?: TextHighlight[];
|
|
76
104
|
}
|
|
77
105
|
|
|
106
|
+
type DiffLineKind = "normal" | "addition" | "deletion";
|
|
107
|
+
|
|
108
|
+
interface DiffLineState {
|
|
109
|
+
kind: DiffLineKind;
|
|
110
|
+
content: string;
|
|
111
|
+
contentOffset: number;
|
|
112
|
+
escapedMarker: boolean;
|
|
113
|
+
modificationRanges?: Array<[number, number]>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface DiffDisplayLineNumbers {
|
|
117
|
+
oldLine: number | null;
|
|
118
|
+
newLine: number | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
78
121
|
// ============================================================================
|
|
79
122
|
// Widgets
|
|
80
123
|
// ============================================================================
|
|
@@ -213,6 +256,7 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
213
256
|
readonly version = "1.0.0";
|
|
214
257
|
override decorationPriority = 25;
|
|
215
258
|
override readonly requiredNodes = ["InlineCode", "FencedCode", "CodeMark", "CodeInfo", "CodeText"] as const;
|
|
259
|
+
private readonly parserCache = new Map<string, Promise<Parser | null>>();
|
|
216
260
|
|
|
217
261
|
/**
|
|
218
262
|
* Plugin theme
|
|
@@ -239,6 +283,16 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
239
283
|
];
|
|
240
284
|
}
|
|
241
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Intercepts backtick typing to wrap selected text as inline code.
|
|
288
|
+
*
|
|
289
|
+
* If user types '`' while text is selected, wraps each selected range
|
|
290
|
+
* with backticks (selected -> `selected`).
|
|
291
|
+
*/
|
|
292
|
+
override getExtensions(): Extension[] {
|
|
293
|
+
return [createWrapSelectionInputHandler({ "`": "`" })];
|
|
294
|
+
}
|
|
295
|
+
|
|
242
296
|
/**
|
|
243
297
|
* Toggle code block on current line or selected lines
|
|
244
298
|
*/
|
|
@@ -258,8 +312,8 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
258
312
|
const nextLine = state.doc.line(nextLineNum);
|
|
259
313
|
|
|
260
314
|
const isWrapped =
|
|
261
|
-
prevLine.text.trim().startsWith(
|
|
262
|
-
nextLine.text.trim() ===
|
|
315
|
+
prevLine.text.trim().startsWith(CODE_FENCE) &&
|
|
316
|
+
nextLine.text.trim() === CODE_FENCE &&
|
|
263
317
|
prevLineNum !== startLine.number &&
|
|
264
318
|
nextLineNum !== endLine.number;
|
|
265
319
|
|
|
@@ -273,8 +327,8 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
273
327
|
});
|
|
274
328
|
} else {
|
|
275
329
|
// Wrap with code fence
|
|
276
|
-
const openFence =
|
|
277
|
-
const closeFence =
|
|
330
|
+
const openFence = `${CODE_FENCE}\n`;
|
|
331
|
+
const closeFence = `\n${CODE_FENCE}`;
|
|
278
332
|
|
|
279
333
|
view.dispatch({
|
|
280
334
|
changes: [
|
|
@@ -306,6 +360,7 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
306
360
|
* lineNumbers: 5,
|
|
307
361
|
* title: "hello.tsx",
|
|
308
362
|
* copy: true,
|
|
363
|
+
* diff: false,
|
|
309
364
|
* highlightLines: [2,3,4,5],
|
|
310
365
|
* highlightText: [{ pattern: "Hello", instances: [3,4,5] }]
|
|
311
366
|
* }
|
|
@@ -320,17 +375,30 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
320
375
|
|
|
321
376
|
let remaining = codeInfo.trim();
|
|
322
377
|
|
|
323
|
-
// Extract language (first
|
|
324
|
-
const
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
378
|
+
// Extract language (first token), but only when it isn't a known directive.
|
|
379
|
+
const firstTokenMatch = remaining.match(/^([^\s]+)/);
|
|
380
|
+
if (firstTokenMatch && firstTokenMatch[1]) {
|
|
381
|
+
const firstToken = firstTokenMatch[1];
|
|
382
|
+
const normalizedToken = firstToken.toLowerCase();
|
|
383
|
+
const isLineNumberDirective = /^(?:line-numbers|linenumbers|showlinenumbers)(?:\{\d+\})?$/.test(
|
|
384
|
+
normalizedToken
|
|
385
|
+
);
|
|
386
|
+
const isKnownDirective =
|
|
387
|
+
isLineNumberDirective ||
|
|
388
|
+
normalizedToken === "copy" ||
|
|
389
|
+
normalizedToken === "diff" ||
|
|
390
|
+
normalizedToken.startsWith("{") ||
|
|
391
|
+
normalizedToken.startsWith("/");
|
|
392
|
+
|
|
393
|
+
if (!isKnownDirective) {
|
|
394
|
+
props.language = firstToken;
|
|
395
|
+
remaining = remaining.slice(firstToken.length).trim();
|
|
396
|
+
}
|
|
328
397
|
}
|
|
329
398
|
|
|
330
399
|
// Extract quoted values (title="..." caption="...")
|
|
331
|
-
const quotedPattern = /(\w+)="([^"]*)"/g;
|
|
332
400
|
let quotedMatch;
|
|
333
|
-
while ((quotedMatch =
|
|
401
|
+
while ((quotedMatch = QUOTED_INFO_PATTERN.exec(remaining)) !== null) {
|
|
334
402
|
const key = quotedMatch[1]?.toLowerCase();
|
|
335
403
|
const value = quotedMatch[2];
|
|
336
404
|
|
|
@@ -341,15 +409,16 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
341
409
|
}
|
|
342
410
|
}
|
|
343
411
|
// Remove matched quoted values
|
|
344
|
-
remaining = remaining.replace(
|
|
412
|
+
remaining = remaining.replace(QUOTED_INFO_PATTERN, "").trim();
|
|
345
413
|
|
|
346
|
-
// Check for line
|
|
347
|
-
|
|
414
|
+
// Check for line numbers with optional start value.
|
|
415
|
+
// Supports both `line-numbers` and legacy `showLineNumbers` tokens.
|
|
416
|
+
const lineNumbersMatch = remaining.match(/\b(?:line-numbers|lineNumbers|showLineNumbers)(?:\{(\d+)\})?/i);
|
|
348
417
|
if (lineNumbersMatch) {
|
|
349
418
|
if (lineNumbersMatch[1]) {
|
|
350
|
-
props.
|
|
419
|
+
props.showLineNumbers = parseInt(lineNumbersMatch[1], 10);
|
|
351
420
|
} else {
|
|
352
|
-
props.
|
|
421
|
+
props.showLineNumbers = true;
|
|
353
422
|
}
|
|
354
423
|
remaining = remaining.replace(lineNumbersMatch[0], "").trim();
|
|
355
424
|
}
|
|
@@ -360,28 +429,16 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
360
429
|
remaining = remaining.replace(/\bcopy\b/, "").trim();
|
|
361
430
|
}
|
|
362
431
|
|
|
432
|
+
// Check for diff flag
|
|
433
|
+
if (/\bdiff\b/.test(remaining)) {
|
|
434
|
+
props.diff = true;
|
|
435
|
+
remaining = remaining.replace(/\bdiff\b/, "").trim();
|
|
436
|
+
}
|
|
437
|
+
|
|
363
438
|
// Extract line highlights {2-4,5,9}
|
|
364
439
|
const lineHighlightMatch = remaining.match(/\{([^}]+)\}/);
|
|
365
440
|
if (lineHighlightMatch && lineHighlightMatch[1]) {
|
|
366
|
-
const highlightLines
|
|
367
|
-
const parts = lineHighlightMatch[1].split(",");
|
|
368
|
-
|
|
369
|
-
for (const part of parts) {
|
|
370
|
-
const trimmed = part.trim();
|
|
371
|
-
const rangeMatch = trimmed.match(/^(\d+)-(\d+)$/);
|
|
372
|
-
|
|
373
|
-
if (rangeMatch && rangeMatch[1] && rangeMatch[2]) {
|
|
374
|
-
// Range: 2-4 -> [2,3,4]
|
|
375
|
-
const start = parseInt(rangeMatch[1], 10);
|
|
376
|
-
const end = parseInt(rangeMatch[2], 10);
|
|
377
|
-
for (let i = start; i <= end; i++) {
|
|
378
|
-
highlightLines.push(i);
|
|
379
|
-
}
|
|
380
|
-
} else if (/^\d+$/.test(trimmed)) {
|
|
381
|
-
// Individual number
|
|
382
|
-
highlightLines.push(parseInt(trimmed, 10));
|
|
383
|
-
}
|
|
384
|
-
}
|
|
441
|
+
const highlightLines = this.parseNumberList(lineHighlightMatch[1]);
|
|
385
442
|
|
|
386
443
|
if (highlightLines.length > 0) {
|
|
387
444
|
props.highlightLines = highlightLines;
|
|
@@ -390,11 +447,10 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
390
447
|
}
|
|
391
448
|
|
|
392
449
|
// Extract text/regex highlights /pattern/ or /pattern/3-5 or /pattern/3,5
|
|
393
|
-
const textHighlightPattern = /\/([^/]+)\/(?:(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*))?/g;
|
|
394
450
|
let textMatch;
|
|
395
451
|
const highlightText: TextHighlight[] = [];
|
|
396
452
|
|
|
397
|
-
while ((textMatch =
|
|
453
|
+
while ((textMatch = TEXT_HIGHLIGHT_PATTERN.exec(remaining)) !== null) {
|
|
398
454
|
if (!textMatch[1]) continue;
|
|
399
455
|
const highlight: TextHighlight = {
|
|
400
456
|
pattern: textMatch[1],
|
|
@@ -402,23 +458,7 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
402
458
|
|
|
403
459
|
// Parse instance selection if present
|
|
404
460
|
if (textMatch[2]) {
|
|
405
|
-
const
|
|
406
|
-
const instances: number[] = [];
|
|
407
|
-
const instanceParts = instanceStr.split(",");
|
|
408
|
-
|
|
409
|
-
for (const part of instanceParts) {
|
|
410
|
-
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
|
411
|
-
if (rangeMatch && rangeMatch[1] && rangeMatch[2]) {
|
|
412
|
-
// Range: 3-5 -> [3,4,5]
|
|
413
|
-
const start = parseInt(rangeMatch[1], 10);
|
|
414
|
-
const end = parseInt(rangeMatch[2], 10);
|
|
415
|
-
for (let i = start; i <= end; i++) {
|
|
416
|
-
instances.push(i);
|
|
417
|
-
}
|
|
418
|
-
} else if (/^\d+$/.test(part)) {
|
|
419
|
-
instances.push(parseInt(part, 10));
|
|
420
|
-
}
|
|
421
|
-
}
|
|
461
|
+
const instances = this.parseNumberList(textMatch[2]);
|
|
422
462
|
|
|
423
463
|
if (instances.length > 0) {
|
|
424
464
|
highlight.instances = instances;
|
|
@@ -440,200 +480,311 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
440
480
|
* Handles line numbers, highlights, header/caption widgets, and fence visibility.
|
|
441
481
|
*/
|
|
442
482
|
buildDecorations(ctx: DecorationContext): void {
|
|
443
|
-
const
|
|
444
|
-
const tree = syntaxTree(view.state);
|
|
483
|
+
const tree = syntaxTree(ctx.view.state);
|
|
445
484
|
|
|
446
485
|
tree.iterate({
|
|
447
486
|
enter: (node) => {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (name === "InlineCode") {
|
|
452
|
-
// Add inline code styling
|
|
453
|
-
decorations.push(codeMarkDecorations["inline-code"].range(from, to));
|
|
454
|
-
|
|
455
|
-
// Hide backticks when cursor is not in range
|
|
456
|
-
const cursorInRange = ctx.selectionOverlapsRange(from, to);
|
|
457
|
-
if (!cursorInRange) {
|
|
458
|
-
// Find the CodeMark children (backticks)
|
|
459
|
-
for (let child = node.node.firstChild; child; child = child.nextSibling) {
|
|
460
|
-
if (child.name === "CodeMark") {
|
|
461
|
-
decorations.push(codeMarkDecorations["inline-mark"].range(child.from, child.to));
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
487
|
+
if (node.name === "InlineCode") {
|
|
488
|
+
this.decorateInlineCode(node, ctx);
|
|
489
|
+
return;
|
|
465
490
|
}
|
|
466
491
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
// Extract properties from CodeInfo
|
|
474
|
-
let infoProps: CodeBlockProperties = { language: "" };
|
|
475
|
-
for (let child = node.node.firstChild; child; child = child.nextSibling) {
|
|
476
|
-
if (child.name === "CodeInfo") {
|
|
477
|
-
infoProps = this.parseCodeInfo(view.state.sliceDoc(child.from, child.to).trim());
|
|
478
|
-
break;
|
|
479
|
-
}
|
|
480
|
-
}
|
|
492
|
+
if (node.name === "FencedCode") {
|
|
493
|
+
this.decorateFencedCode(node, ctx);
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
}
|
|
481
498
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const maxLineNum = startLineNum + totalCodeLines - 1;
|
|
486
|
-
const lineNumWidth = Math.max(String(maxLineNum).length, String(startLineNum).length);
|
|
487
|
-
|
|
488
|
-
// Track code line index (excluding fence lines)
|
|
489
|
-
let codeLineIndex = 0;
|
|
490
|
-
|
|
491
|
-
// Extract code content for copy button
|
|
492
|
-
let codeContent = "";
|
|
493
|
-
for (let child = node.node.firstChild; child; child = child.nextSibling) {
|
|
494
|
-
if (child.name === "CodeText") {
|
|
495
|
-
codeContent = view.state.sliceDoc(child.from, child.to);
|
|
496
|
-
break;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
+
private decorateInlineCode(node: { from: number; to: number; node: SyntaxNode }, ctx: DecorationContext): void {
|
|
500
|
+
const { from, to } = node;
|
|
501
|
+
ctx.decorations.push(codeMarkDecorations["inline-code"].range(from, to));
|
|
499
502
|
|
|
500
|
-
|
|
501
|
-
|
|
503
|
+
if (ctx.selectionOverlapsRange(from, to)) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
502
506
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}).range(nodeLineStart.from)
|
|
510
|
-
);
|
|
511
|
-
}
|
|
507
|
+
for (let child = node.node.firstChild; child; child = child.nextSibling) {
|
|
508
|
+
if (child.name === "CodeMark") {
|
|
509
|
+
ctx.decorations.push(codeMarkDecorations["inline-mark"].range(child.from, child.to));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
512
513
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
// Base line decoration
|
|
520
|
-
decorations.push(codeMarkDecorations["code-block-line"].range(line.from));
|
|
521
|
-
|
|
522
|
-
// Add start/end line decorations
|
|
523
|
-
if (i === nodeLineStart.number) {
|
|
524
|
-
decorations.push(codeMarkDecorations["code-block-line-start"].range(line.from));
|
|
525
|
-
// Add class for header presence
|
|
526
|
-
if (shouldShowHeader) {
|
|
527
|
-
decorations.push(
|
|
528
|
-
Decoration.line({
|
|
529
|
-
class: "cm-draftly-code-block-has-header",
|
|
530
|
-
}).range(line.from)
|
|
531
|
-
);
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
if (i === nodeLineEnd.number) {
|
|
535
|
-
decorations.push(codeMarkDecorations["code-block-line-end"].range(line.from));
|
|
536
|
-
// Add class for caption presence
|
|
537
|
-
if (shouldShowCaption) {
|
|
538
|
-
decorations.push(
|
|
539
|
-
Decoration.line({
|
|
540
|
-
class: "cm-draftly-code-block-has-caption",
|
|
541
|
-
}).range(line.from)
|
|
542
|
-
);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Line numbers (only for code lines, not fence lines)
|
|
547
|
-
if (!isFenceLine && infoProps.lineNumbers) {
|
|
548
|
-
decorations.push(
|
|
549
|
-
Decoration.line({
|
|
550
|
-
class: "cm-draftly-code-line-numbered",
|
|
551
|
-
attributes: {
|
|
552
|
-
"data-line-num": String(relativeLineNum),
|
|
553
|
-
style: `--line-num-width: ${lineNumWidth}ch`,
|
|
554
|
-
},
|
|
555
|
-
}).range(line.from)
|
|
556
|
-
);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Line highlight (check if this line should be highlighted)
|
|
560
|
-
if (!isFenceLine && infoProps.highlightLines) {
|
|
561
|
-
if (infoProps.highlightLines.includes(codeLineIndex + 1)) {
|
|
562
|
-
decorations.push(codeMarkDecorations["code-line-highlight"].range(line.from));
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// Text highlights
|
|
567
|
-
if (!isFenceLine && infoProps.highlightText && infoProps.highlightText.length > 0) {
|
|
568
|
-
const lineText = view.state.sliceDoc(line.from, line.to);
|
|
569
|
-
|
|
570
|
-
for (const textHighlight of infoProps.highlightText) {
|
|
571
|
-
try {
|
|
572
|
-
const regex = new RegExp(textHighlight.pattern, "g");
|
|
573
|
-
let match;
|
|
574
|
-
let matchIndex = 0;
|
|
575
|
-
|
|
576
|
-
while ((match = regex.exec(lineText)) !== null) {
|
|
577
|
-
matchIndex++;
|
|
578
|
-
|
|
579
|
-
// Check if this instance should be highlighted
|
|
580
|
-
const shouldHighlight = !textHighlight.instances || textHighlight.instances.includes(matchIndex);
|
|
581
|
-
|
|
582
|
-
if (shouldHighlight) {
|
|
583
|
-
const matchFrom = line.from + match.index;
|
|
584
|
-
const matchTo = matchFrom + match[0].length;
|
|
585
|
-
decorations.push(codeMarkDecorations["code-text-highlight"].range(matchFrom, matchTo));
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
} catch {
|
|
589
|
-
// Invalid regex, skip
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// Increment code line index (only for non-fence lines)
|
|
595
|
-
if (!isFenceLine) {
|
|
596
|
-
codeLineIndex++;
|
|
597
|
-
}
|
|
598
|
-
}
|
|
514
|
+
private decorateFencedCode(node: { from: number; to: number; node: SyntaxNode }, ctx: DecorationContext): void {
|
|
515
|
+
const { view, decorations } = ctx;
|
|
516
|
+
const nodeLineStart = view.state.doc.lineAt(node.from);
|
|
517
|
+
const nodeLineEnd = view.state.doc.lineAt(node.to);
|
|
518
|
+
const cursorInRange = ctx.selectionOverlapsRange(nodeLineStart.from, nodeLineEnd.to);
|
|
599
519
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
if (child.name === "CodeMark" || child.name === "CodeInfo") {
|
|
603
|
-
if (cursorInRange) {
|
|
604
|
-
// Show fence markers with styling when cursor is in range
|
|
605
|
-
decorations.push(codeMarkDecorations["code-fence"].range(child.from, child.to));
|
|
606
|
-
} else {
|
|
607
|
-
// Hide fence markers when cursor is not in range
|
|
608
|
-
decorations.push(codeMarkDecorations["code-hidden"].range(child.from, child.to));
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
}
|
|
520
|
+
let infoProps: CodeBlockProperties = { language: "" };
|
|
521
|
+
let codeContent = "";
|
|
612
522
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
523
|
+
for (let child = node.node.firstChild; child; child = child.nextSibling) {
|
|
524
|
+
if (child.name === "CodeInfo") {
|
|
525
|
+
infoProps = this.parseCodeInfo(view.state.sliceDoc(child.from, child.to).trim());
|
|
526
|
+
}
|
|
527
|
+
if (child.name === "CodeText") {
|
|
528
|
+
codeContent = view.state.sliceDoc(child.from, child.to);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const codeLines: string[] = [];
|
|
533
|
+
for (let i = nodeLineStart.number + 1; i <= nodeLineEnd.number - 1; i++) {
|
|
534
|
+
const codeLine = view.state.doc.line(i);
|
|
535
|
+
codeLines.push(view.state.sliceDoc(codeLine.from, codeLine.to));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const totalCodeLines = nodeLineEnd.number - nodeLineStart.number - 1;
|
|
539
|
+
const startLineNum = typeof infoProps.showLineNumbers === "number" ? infoProps.showLineNumbers : 1;
|
|
540
|
+
const maxLineNum = startLineNum + totalCodeLines - 1;
|
|
541
|
+
const lineNumWidth = Math.max(String(maxLineNum).length, String(startLineNum).length);
|
|
542
|
+
const highlightInstanceCounters = new Array(infoProps.highlightText?.length ?? 0).fill(0);
|
|
543
|
+
|
|
544
|
+
const diffStates = infoProps.diff ? this.analyzeDiffLines(codeLines) : [];
|
|
545
|
+
const diffDisplayLineNumbers = infoProps.diff ? this.computeDiffDisplayLineNumbers(diffStates, startLineNum) : [];
|
|
546
|
+
const displayLineNumbers = infoProps.diff
|
|
547
|
+
? diffDisplayLineNumbers.map((numbers, index) => numbers.newLine ?? numbers.oldLine ?? startLineNum + index)
|
|
548
|
+
: codeLines.map((_, index) => startLineNum + index);
|
|
549
|
+
const diffHighlightLineNumbers = infoProps.diff
|
|
550
|
+
? this.computeDiffDisplayLineNumbers(diffStates, startLineNum).map(
|
|
551
|
+
(numbers, index) => numbers.newLine ?? numbers.oldLine ?? startLineNum + index
|
|
552
|
+
)
|
|
553
|
+
: [];
|
|
554
|
+
const maxOldDiffLineNum = diffDisplayLineNumbers.reduce((max, numbers) => {
|
|
555
|
+
const oldLine = numbers.oldLine ?? 0;
|
|
556
|
+
return oldLine > max ? oldLine : max;
|
|
557
|
+
}, startLineNum);
|
|
558
|
+
const maxNewDiffLineNum = diffDisplayLineNumbers.reduce((max, numbers) => {
|
|
559
|
+
const newLine = numbers.newLine ?? 0;
|
|
560
|
+
return newLine > max ? newLine : max;
|
|
561
|
+
}, startLineNum);
|
|
562
|
+
const diffOldLineNumWidth = Math.max(String(startLineNum).length, String(maxOldDiffLineNum).length);
|
|
563
|
+
const diffNewLineNumWidth = Math.max(String(startLineNum).length, String(maxNewDiffLineNum).length);
|
|
564
|
+
|
|
565
|
+
const shouldShowHeader = !cursorInRange && (infoProps.title || infoProps.copy || infoProps.language);
|
|
566
|
+
const shouldShowCaption = !cursorInRange && !!infoProps.caption;
|
|
567
|
+
|
|
568
|
+
if (shouldShowHeader) {
|
|
569
|
+
decorations.push(
|
|
570
|
+
Decoration.widget({
|
|
571
|
+
widget: new CodeBlockHeaderWidget(infoProps, codeContent),
|
|
572
|
+
block: false,
|
|
573
|
+
side: -1,
|
|
574
|
+
}).range(nodeLineStart.from)
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
let codeLineIndex = 0;
|
|
579
|
+
for (let lineNumber = nodeLineStart.number; lineNumber <= nodeLineEnd.number; lineNumber++) {
|
|
580
|
+
const line = view.state.doc.line(lineNumber);
|
|
581
|
+
const isFenceLine = lineNumber === nodeLineStart.number || lineNumber === nodeLineEnd.number;
|
|
582
|
+
const relativeLineNum = displayLineNumbers[codeLineIndex] ?? startLineNum + codeLineIndex;
|
|
583
|
+
|
|
584
|
+
decorations.push(codeMarkDecorations["code-block-line"].range(line.from));
|
|
585
|
+
|
|
586
|
+
if (lineNumber === nodeLineStart.number) {
|
|
587
|
+
decorations.push(codeMarkDecorations["code-block-line-start"].range(line.from));
|
|
588
|
+
if (shouldShowHeader) {
|
|
589
|
+
decorations.push(Decoration.line({ class: "cm-draftly-code-block-has-header" }).range(line.from));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (lineNumber === nodeLineEnd.number) {
|
|
594
|
+
decorations.push(codeMarkDecorations["code-block-line-end"].range(line.from));
|
|
595
|
+
if (shouldShowCaption) {
|
|
596
|
+
decorations.push(Decoration.line({ class: "cm-draftly-code-block-has-caption" }).range(line.from));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (!isFenceLine && infoProps.showLineNumbers && !infoProps.diff) {
|
|
601
|
+
decorations.push(
|
|
602
|
+
Decoration.line({
|
|
603
|
+
class: "cm-draftly-code-line-numbered",
|
|
604
|
+
attributes: {
|
|
605
|
+
"data-line-num": String(relativeLineNum),
|
|
606
|
+
style: `--line-num-width: ${lineNumWidth}ch`,
|
|
607
|
+
},
|
|
608
|
+
}).range(line.from)
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (!isFenceLine && infoProps.showLineNumbers && infoProps.diff) {
|
|
613
|
+
const diffLineNumbers = diffDisplayLineNumbers[codeLineIndex];
|
|
614
|
+
const diffState = diffStates[codeLineIndex];
|
|
615
|
+
const diffMarker = diffState?.kind === "addition" ? "+" : diffState?.kind === "deletion" ? "-" : " ";
|
|
616
|
+
decorations.push(
|
|
617
|
+
Decoration.line({
|
|
618
|
+
class: "cm-draftly-code-line-numbered-diff",
|
|
619
|
+
attributes: {
|
|
620
|
+
"data-line-num-old": diffLineNumbers?.oldLine != null ? String(diffLineNumbers.oldLine) : "",
|
|
621
|
+
"data-line-num-new": diffLineNumbers?.newLine != null ? String(diffLineNumbers.newLine) : "",
|
|
622
|
+
"data-diff-marker": diffMarker,
|
|
623
|
+
style: `--line-num-old-width: ${diffOldLineNumWidth}ch; --line-num-new-width: ${diffNewLineNumWidth}ch`,
|
|
624
|
+
},
|
|
625
|
+
}).range(line.from)
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (!isFenceLine && infoProps.diff) {
|
|
630
|
+
this.decorateDiffLine(
|
|
631
|
+
line,
|
|
632
|
+
codeLineIndex,
|
|
633
|
+
diffStates,
|
|
634
|
+
cursorInRange,
|
|
635
|
+
!infoProps.showLineNumbers,
|
|
636
|
+
decorations
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (!isFenceLine && infoProps.highlightLines) {
|
|
641
|
+
const highlightLineNumber = infoProps.diff
|
|
642
|
+
? (diffHighlightLineNumbers[codeLineIndex] ?? codeLineIndex + 1)
|
|
643
|
+
: startLineNum + codeLineIndex;
|
|
644
|
+
if (infoProps.highlightLines.includes(highlightLineNumber)) {
|
|
645
|
+
decorations.push(codeMarkDecorations["code-line-highlight"].range(line.from));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (!isFenceLine && infoProps.highlightText?.length) {
|
|
650
|
+
this.decorateTextHighlights(
|
|
651
|
+
line.from,
|
|
652
|
+
view.state.sliceDoc(line.from, line.to),
|
|
653
|
+
infoProps.highlightText,
|
|
654
|
+
highlightInstanceCounters,
|
|
655
|
+
decorations
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (!isFenceLine) {
|
|
660
|
+
codeLineIndex++;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
this.decorateFenceMarkers(node.node, cursorInRange, decorations);
|
|
665
|
+
|
|
666
|
+
if (!cursorInRange && infoProps.caption) {
|
|
667
|
+
decorations.push(
|
|
668
|
+
Decoration.widget({
|
|
669
|
+
widget: new CodeBlockCaptionWidget(infoProps.caption),
|
|
670
|
+
block: false,
|
|
671
|
+
side: 1,
|
|
672
|
+
}).range(nodeLineEnd.to)
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private decorateFenceMarkers(
|
|
678
|
+
node: SyntaxNode,
|
|
679
|
+
cursorInRange: boolean,
|
|
680
|
+
decorations: DecorationContext["decorations"]
|
|
681
|
+
): void {
|
|
682
|
+
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
683
|
+
if (child.name === "CodeMark" || child.name === "CodeInfo") {
|
|
684
|
+
decorations.push(
|
|
685
|
+
(cursorInRange ? codeMarkDecorations["code-fence"] : codeMarkDecorations["code-hidden"]).range(
|
|
686
|
+
child.from,
|
|
687
|
+
child.to
|
|
688
|
+
)
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private decorateDiffLine(
|
|
695
|
+
line: { from: number; to: number },
|
|
696
|
+
codeLineIndex: number,
|
|
697
|
+
diffStates: DiffLineState[],
|
|
698
|
+
cursorInRange: boolean,
|
|
699
|
+
showDiffMarkerGutter: boolean,
|
|
700
|
+
decorations: DecorationContext["decorations"]
|
|
701
|
+
): void {
|
|
702
|
+
const diffState = diffStates[codeLineIndex];
|
|
703
|
+
const diffMarker = diffState?.kind === "addition" ? "+" : diffState?.kind === "deletion" ? "-" : " ";
|
|
704
|
+
|
|
705
|
+
if (showDiffMarkerGutter) {
|
|
706
|
+
decorations.push(
|
|
707
|
+
Decoration.line({
|
|
708
|
+
class: "cm-draftly-code-line-diff-gutter",
|
|
709
|
+
attributes: {
|
|
710
|
+
"data-diff-marker": diffMarker,
|
|
711
|
+
},
|
|
712
|
+
}).range(line.from)
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (diffState?.kind === "addition") {
|
|
717
|
+
decorations.push(codeMarkDecorations["diff-line-add"].range(line.from));
|
|
718
|
+
if (cursorInRange && line.to > line.from) {
|
|
719
|
+
decorations.push(codeMarkDecorations["diff-sign-add"].range(line.from, line.from + 1));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (diffState?.kind === "deletion") {
|
|
724
|
+
decorations.push(codeMarkDecorations["diff-line-del"].range(line.from));
|
|
725
|
+
if (cursorInRange && line.to > line.from) {
|
|
726
|
+
decorations.push(codeMarkDecorations["diff-sign-del"].range(line.from, line.from + 1));
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (
|
|
731
|
+
!cursorInRange &&
|
|
732
|
+
line.to > line.from &&
|
|
733
|
+
(diffState?.escapedMarker || diffState?.kind === "addition" || diffState?.kind === "deletion")
|
|
734
|
+
) {
|
|
735
|
+
decorations.push(codeMarkDecorations["diff-escape-hidden"].range(line.from, line.from + 1));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (diffState?.modificationRanges?.length) {
|
|
739
|
+
for (const [start, end] of diffState.modificationRanges) {
|
|
740
|
+
const rangeFrom = line.from + diffState.contentOffset + start;
|
|
741
|
+
const rangeTo = line.from + diffState.contentOffset + end;
|
|
742
|
+
if (rangeTo > rangeFrom) {
|
|
743
|
+
decorations.push(
|
|
744
|
+
(diffState.kind === "addition"
|
|
745
|
+
? codeMarkDecorations["diff-mod-add"]
|
|
746
|
+
: codeMarkDecorations["diff-mod-del"]
|
|
747
|
+
).range(rangeFrom, rangeTo)
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
private decorateTextHighlights(
|
|
755
|
+
lineFrom: number,
|
|
756
|
+
lineText: string,
|
|
757
|
+
highlights: TextHighlight[],
|
|
758
|
+
instanceCounters: number[],
|
|
759
|
+
decorations: DecorationContext["decorations"]
|
|
760
|
+
): void {
|
|
761
|
+
for (const [highlightIndex, textHighlight] of highlights.entries()) {
|
|
762
|
+
try {
|
|
763
|
+
const regex = new RegExp(textHighlight.pattern, "g");
|
|
764
|
+
let match: RegExpExecArray | null;
|
|
765
|
+
|
|
766
|
+
while ((match = regex.exec(lineText)) !== null) {
|
|
767
|
+
instanceCounters[highlightIndex] = (instanceCounters[highlightIndex] ?? 0) + 1;
|
|
768
|
+
const globalMatchIndex = instanceCounters[highlightIndex];
|
|
769
|
+
const shouldHighlight = !textHighlight.instances || textHighlight.instances.includes(globalMatchIndex);
|
|
770
|
+
|
|
771
|
+
if (shouldHighlight) {
|
|
772
|
+
const matchFrom = lineFrom + match.index;
|
|
773
|
+
const matchTo = matchFrom + match[0].length;
|
|
774
|
+
decorations.push(codeMarkDecorations["code-text-highlight"].range(matchFrom, matchTo));
|
|
622
775
|
}
|
|
623
776
|
}
|
|
624
|
-
}
|
|
625
|
-
|
|
777
|
+
} catch {
|
|
778
|
+
// Invalid regex; ignore this highlight pattern.
|
|
779
|
+
}
|
|
780
|
+
}
|
|
626
781
|
}
|
|
627
782
|
|
|
628
783
|
/**
|
|
629
784
|
* Render code elements to HTML for static preview.
|
|
630
785
|
* Applies syntax highlighting using @lezer/highlight.
|
|
631
786
|
*/
|
|
632
|
-
override renderToHTML(
|
|
633
|
-
node: SyntaxNode,
|
|
634
|
-
children: string,
|
|
635
|
-
ctx: { sliceDoc(from: number, to: number): string; sanitize(html: string): string }
|
|
636
|
-
): string | null {
|
|
787
|
+
override async renderToHTML(node: SyntaxNode, _children: string, ctx: PreviewRenderContext): Promise<string | null> {
|
|
637
788
|
// Hide CodeMark (backticks)
|
|
638
789
|
if (node.name === "CodeMark") {
|
|
639
790
|
return "";
|
|
@@ -648,7 +799,7 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
648
799
|
if (match && match[1]) {
|
|
649
800
|
content = match[1];
|
|
650
801
|
}
|
|
651
|
-
return `<code class="cm-draftly-code-inline" style="padding: 0.1rem 0.25rem">${
|
|
802
|
+
return `<code class="cm-draftly-code-inline" style="padding: 0.1rem 0.25rem">${this.escapeHtml(content)}</code>`;
|
|
652
803
|
}
|
|
653
804
|
|
|
654
805
|
// Fenced code block
|
|
@@ -680,9 +831,9 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
680
831
|
html += `<div class="cm-draftly-code-header">`;
|
|
681
832
|
html += `<div class="cm-draftly-code-header-left">`;
|
|
682
833
|
if (props.title) {
|
|
683
|
-
html += `<span class="cm-draftly-code-header-title">${
|
|
834
|
+
html += `<span class="cm-draftly-code-header-title">${this.escapeHtml(props.title)}</span>`;
|
|
684
835
|
} else if (props.language) {
|
|
685
|
-
html += `<span class="cm-draftly-code-header-lang">${
|
|
836
|
+
html += `<span class="cm-draftly-code-header-lang">${this.escapeHtml(props.language)}</span>`;
|
|
686
837
|
}
|
|
687
838
|
html += `</div>`;
|
|
688
839
|
if (props.copy !== false) {
|
|
@@ -699,38 +850,96 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
699
850
|
}
|
|
700
851
|
|
|
701
852
|
// Calculate line number info
|
|
702
|
-
const startLineNum = typeof props.
|
|
703
|
-
const
|
|
853
|
+
const startLineNum = typeof props.showLineNumbers === "number" ? props.showLineNumbers : 1;
|
|
854
|
+
const previewHighlightCounters = new Array(props.highlightText?.length ?? 0).fill(0);
|
|
855
|
+
const diffStates = props.diff ? this.analyzeDiffLines(codeLines) : [];
|
|
856
|
+
const previewDiffLineNumbers = props.diff ? this.computeDiffDisplayLineNumbers(diffStates, startLineNum) : [];
|
|
857
|
+
const previewLineNumbers = props.diff
|
|
858
|
+
? previewDiffLineNumbers.map((numbers, index) => numbers.newLine ?? numbers.oldLine ?? startLineNum + index)
|
|
859
|
+
: codeLines.map((_, index) => startLineNum + index);
|
|
860
|
+
const previewHighlightLineNumbers = props.diff
|
|
861
|
+
? this.computeDiffDisplayLineNumbers(diffStates, startLineNum).map(
|
|
862
|
+
(numbers, index) => numbers.newLine ?? numbers.oldLine ?? startLineNum + index
|
|
863
|
+
)
|
|
864
|
+
: [];
|
|
865
|
+
const lineNumWidth = String(Math.max(...previewLineNumbers, startLineNum)).length;
|
|
866
|
+
const previewOldLineNumWidth = String(
|
|
867
|
+
Math.max(
|
|
868
|
+
...previewDiffLineNumbers.map((numbers) => numbers.oldLine ?? 0),
|
|
869
|
+
startLineNum
|
|
870
|
+
)
|
|
871
|
+
).length;
|
|
872
|
+
const previewNewLineNumWidth = String(
|
|
873
|
+
Math.max(
|
|
874
|
+
...previewDiffLineNumbers.map((numbers) => numbers.newLine ?? 0),
|
|
875
|
+
startLineNum
|
|
876
|
+
)
|
|
877
|
+
).length;
|
|
878
|
+
const previewContentLines = props.diff ? diffStates.map((state) => state.content) : codeLines;
|
|
879
|
+
const highlightedLines = await this.highlightCodeLines(
|
|
880
|
+
previewContentLines.join("\n"),
|
|
881
|
+
props.language || "",
|
|
882
|
+
ctx.syntaxHighlighters
|
|
883
|
+
);
|
|
704
884
|
|
|
705
885
|
// Code block with line processing
|
|
706
886
|
const hasHeader = showHeader ? " cm-draftly-code-block-has-header" : "";
|
|
707
887
|
const hasCaption = props.caption ? " cm-draftly-code-block-has-caption" : "";
|
|
708
|
-
html += `<pre class="cm-draftly-code-block${hasHeader}${hasCaption}"${props.language ? ` data-lang="${
|
|
888
|
+
html += `<pre class="cm-draftly-code-block${hasHeader}${hasCaption}"${props.language ? ` data-lang="${this.escapeAttribute(props.language)}"` : ""}>`;
|
|
709
889
|
html += `<code>`;
|
|
710
890
|
|
|
711
891
|
// Process each line
|
|
712
892
|
codeLines.forEach((line, index) => {
|
|
713
|
-
const lineNum = startLineNum + index;
|
|
714
|
-
const
|
|
893
|
+
const lineNum = previewLineNumbers[index] ?? startLineNum + index;
|
|
894
|
+
const highlightLineNumber = props.diff
|
|
895
|
+
? (previewHighlightLineNumbers[index] ?? startLineNum + index)
|
|
896
|
+
: startLineNum + index;
|
|
897
|
+
const isHighlighted = props.highlightLines?.includes(highlightLineNumber);
|
|
898
|
+
const diffState = props.diff ? diffStates[index] : undefined;
|
|
899
|
+
const diffLineNumbers = props.diff ? previewDiffLineNumbers[index] : undefined;
|
|
715
900
|
|
|
716
901
|
// Line classes
|
|
717
902
|
const lineClasses: string[] = ["cm-draftly-code-line"];
|
|
718
903
|
if (isHighlighted) lineClasses.push("cm-draftly-code-line-highlight");
|
|
719
|
-
if (props.
|
|
904
|
+
if (props.showLineNumbers) {
|
|
905
|
+
lineClasses.push(props.diff ? "cm-draftly-code-line-numbered-diff" : "cm-draftly-code-line-numbered");
|
|
906
|
+
}
|
|
907
|
+
if (diffState?.kind === "addition") lineClasses.push("cm-draftly-code-line-diff-add");
|
|
908
|
+
if (diffState?.kind === "deletion") lineClasses.push("cm-draftly-code-line-diff-del");
|
|
720
909
|
|
|
721
910
|
// Line attributes
|
|
722
911
|
const lineAttrs: string[] = [`class="${lineClasses.join(" ")}"`];
|
|
723
|
-
if (props.
|
|
912
|
+
if (props.showLineNumbers && !props.diff) {
|
|
724
913
|
lineAttrs.push(`data-line-num="${lineNum}"`);
|
|
725
914
|
lineAttrs.push(`style="--line-num-width: ${lineNumWidth}ch"`);
|
|
726
915
|
}
|
|
916
|
+
if (props.diff) {
|
|
917
|
+
const diffMarker = diffState?.kind === "addition" ? "+" : diffState?.kind === "deletion" ? "-" : " ";
|
|
918
|
+
if (props.showLineNumbers) {
|
|
919
|
+
lineAttrs.push(`data-line-num-old="${diffLineNumbers?.oldLine != null ? diffLineNumbers.oldLine : ""}"`);
|
|
920
|
+
lineAttrs.push(`data-line-num-new="${diffLineNumbers?.newLine != null ? diffLineNumbers.newLine : ""}"`);
|
|
921
|
+
lineAttrs.push(`data-diff-marker="${diffMarker}"`);
|
|
922
|
+
lineAttrs.push(
|
|
923
|
+
`style="--line-num-old-width: ${previewOldLineNumWidth}ch; --line-num-new-width: ${previewNewLineNumWidth}ch"`
|
|
924
|
+
);
|
|
925
|
+
} else {
|
|
926
|
+
lineAttrs.push(`data-diff-marker="${diffMarker}"`);
|
|
927
|
+
lineClasses.push("cm-draftly-code-line-diff-gutter");
|
|
928
|
+
lineAttrs[0] = `class="${lineClasses.join(" ")}"`;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
727
931
|
|
|
728
932
|
// Highlight text content
|
|
729
|
-
|
|
933
|
+
const highlightedLine = highlightedLines[index] ?? this.escapeHtml(previewContentLines[index] ?? line);
|
|
934
|
+
let lineContent = highlightedLine;
|
|
935
|
+
|
|
936
|
+
if (diffState) {
|
|
937
|
+
lineContent = this.renderDiffPreviewLine(diffState, highlightedLine);
|
|
938
|
+
}
|
|
730
939
|
|
|
731
940
|
// Apply text highlights
|
|
732
941
|
if (props.highlightText && props.highlightText.length > 0) {
|
|
733
|
-
lineContent = this.applyTextHighlights(lineContent, props.highlightText);
|
|
942
|
+
lineContent = this.applyTextHighlights(lineContent, props.highlightText, previewHighlightCounters);
|
|
734
943
|
}
|
|
735
944
|
|
|
736
945
|
html += `<span ${lineAttrs.join(" ")}>${lineContent || " "}</span>`;
|
|
@@ -740,7 +949,7 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
740
949
|
|
|
741
950
|
// Caption
|
|
742
951
|
if (props.caption) {
|
|
743
|
-
html += `<div class="cm-draftly-code-caption">${
|
|
952
|
+
html += `<div class="cm-draftly-code-caption">${this.escapeHtml(props.caption)}</div>`;
|
|
744
953
|
}
|
|
745
954
|
|
|
746
955
|
// Close wrapper container
|
|
@@ -757,62 +966,384 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
757
966
|
return null;
|
|
758
967
|
}
|
|
759
968
|
|
|
969
|
+
/** Parse comma-separated numbers and ranges (e.g. "1,3-5") into [1,3,4,5]. */
|
|
970
|
+
private parseNumberList(value: string): number[] {
|
|
971
|
+
const result: number[] = [];
|
|
972
|
+
|
|
973
|
+
for (const part of value.split(",")) {
|
|
974
|
+
const trimmed = part.trim();
|
|
975
|
+
const rangeMatch = trimmed.match(/^(\d+)-(\d+)$/);
|
|
976
|
+
|
|
977
|
+
if (rangeMatch && rangeMatch[1] && rangeMatch[2]) {
|
|
978
|
+
const start = parseInt(rangeMatch[1], 10);
|
|
979
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
980
|
+
for (let i = start; i <= end; i++) {
|
|
981
|
+
result.push(i);
|
|
982
|
+
}
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (/^\d+$/.test(trimmed)) {
|
|
987
|
+
result.push(parseInt(trimmed, 10));
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return result;
|
|
992
|
+
}
|
|
993
|
+
|
|
760
994
|
/**
|
|
761
995
|
* Highlight a single line of code using the language's Lezer parser.
|
|
762
996
|
* Falls back to sanitized plain text if the language is not supported.
|
|
763
997
|
*/
|
|
764
|
-
private
|
|
765
|
-
|
|
766
|
-
|
|
998
|
+
private async highlightCodeLines(
|
|
999
|
+
code: string,
|
|
1000
|
+
lang: string,
|
|
1001
|
+
syntaxHighlighters?: readonly Highlighter[]
|
|
1002
|
+
): Promise<string[]> {
|
|
1003
|
+
const rawLines = code.split("\n");
|
|
1004
|
+
if (!lang || !code) {
|
|
1005
|
+
return rawLines.map((line) => this.escapeHtml(line));
|
|
767
1006
|
}
|
|
768
1007
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
);
|
|
773
|
-
|
|
774
|
-
if (!langDesc || !langDesc.support) {
|
|
775
|
-
return ctx.sanitize(line);
|
|
1008
|
+
const parser = await this.resolveLanguageParser(lang);
|
|
1009
|
+
if (!parser) {
|
|
1010
|
+
return rawLines.map((line) => this.escapeHtml(line));
|
|
776
1011
|
}
|
|
777
1012
|
|
|
778
1013
|
try {
|
|
779
|
-
const
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
let result = "";
|
|
1014
|
+
const tree = parser.parse(code);
|
|
1015
|
+
const highlightedLines: string[] = [""];
|
|
783
1016
|
|
|
784
1017
|
highlightCode(
|
|
785
|
-
|
|
1018
|
+
code,
|
|
786
1019
|
tree,
|
|
787
|
-
|
|
1020
|
+
syntaxHighlighters && syntaxHighlighters.length > 0 ? syntaxHighlighters : [],
|
|
788
1021
|
(text, classes) => {
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
}
|
|
1022
|
+
const chunk = classes
|
|
1023
|
+
? `<span class="${this.escapeAttribute(classes)}">${this.escapeHtml(text)}</span>`
|
|
1024
|
+
: this.escapeHtml(text);
|
|
1025
|
+
highlightedLines[highlightedLines.length - 1] += chunk;
|
|
794
1026
|
},
|
|
795
|
-
() => {
|
|
1027
|
+
() => {
|
|
1028
|
+
highlightedLines.push("");
|
|
1029
|
+
}
|
|
796
1030
|
);
|
|
797
1031
|
|
|
798
|
-
return
|
|
1032
|
+
return rawLines.map((line, index) => highlightedLines[index] || this.escapeHtml(line));
|
|
799
1033
|
} catch {
|
|
800
|
-
return
|
|
1034
|
+
return rawLines.map((line) => this.escapeHtml(line));
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private async resolveLanguageParser(lang: string): Promise<Parser | null> {
|
|
1039
|
+
const normalizedLang = this.normalizeLanguage(lang);
|
|
1040
|
+
if (!normalizedLang) return null;
|
|
1041
|
+
|
|
1042
|
+
const cached = this.parserCache.get(normalizedLang);
|
|
1043
|
+
if (cached) return cached;
|
|
1044
|
+
|
|
1045
|
+
const parserPromise = (async () => {
|
|
1046
|
+
const langDesc = LanguageDescription.matchLanguageName(languages, normalizedLang, true);
|
|
1047
|
+
|
|
1048
|
+
if (!langDesc) return null;
|
|
1049
|
+
|
|
1050
|
+
if (langDesc.support) {
|
|
1051
|
+
return langDesc.support.language.parser;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (typeof langDesc.load === "function") {
|
|
1055
|
+
try {
|
|
1056
|
+
const support = await langDesc.load();
|
|
1057
|
+
return support.language.parser;
|
|
1058
|
+
} catch {
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
return null;
|
|
1064
|
+
})();
|
|
1065
|
+
|
|
1066
|
+
this.parserCache.set(normalizedLang, parserPromise);
|
|
1067
|
+
return parserPromise;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
private normalizeLanguage(lang: string): string {
|
|
1071
|
+
const normalized = lang.trim().toLowerCase();
|
|
1072
|
+
if (!normalized) return "";
|
|
1073
|
+
|
|
1074
|
+
const normalizedMap: Record<string, string> = {
|
|
1075
|
+
"c++": "cpp",
|
|
1076
|
+
"c#": "csharp",
|
|
1077
|
+
"f#": "fsharp",
|
|
1078
|
+
py: "python",
|
|
1079
|
+
js: "javascript",
|
|
1080
|
+
ts: "typescript",
|
|
1081
|
+
sh: "shell",
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
return normalizedMap[normalized] ?? normalized;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
private escapeHtml(value: string): string {
|
|
1088
|
+
return value
|
|
1089
|
+
.replace(/&/g, "&")
|
|
1090
|
+
.replace(/</g, "<")
|
|
1091
|
+
.replace(/>/g, ">")
|
|
1092
|
+
.replace(/"/g, """)
|
|
1093
|
+
.replace(/'/g, "'");
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
private escapeAttribute(value: string): string {
|
|
1097
|
+
return this.escapeHtml(value).replace(/`/g, "`");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
private analyzeDiffLines(lines: string[]): DiffLineState[] {
|
|
1101
|
+
const states = lines.map((line) => this.parseDiffLineState(line));
|
|
1102
|
+
|
|
1103
|
+
let index = 0;
|
|
1104
|
+
while (index < states.length) {
|
|
1105
|
+
if (states[index]?.kind !== "deletion") {
|
|
1106
|
+
index++;
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const deletionStart = index;
|
|
1111
|
+
while (index < states.length && states[index]?.kind === "deletion") {
|
|
1112
|
+
index++;
|
|
1113
|
+
}
|
|
1114
|
+
const deletionEnd = index;
|
|
1115
|
+
|
|
1116
|
+
const additionStart = index;
|
|
1117
|
+
while (index < states.length && states[index]?.kind === "addition") {
|
|
1118
|
+
index++;
|
|
1119
|
+
}
|
|
1120
|
+
const additionEnd = index;
|
|
1121
|
+
|
|
1122
|
+
if (additionStart === additionEnd) {
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const pairCount = Math.min(deletionEnd - deletionStart, additionEnd - additionStart);
|
|
1127
|
+
for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
|
|
1128
|
+
const deletionState = states[deletionStart + pairIndex];
|
|
1129
|
+
const additionState = states[additionStart + pairIndex];
|
|
1130
|
+
|
|
1131
|
+
if (!deletionState || !additionState) {
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const ranges = this.computeChangedRanges(deletionState.content, additionState.content);
|
|
1136
|
+
if (ranges.oldRanges.length > 0) {
|
|
1137
|
+
deletionState.modificationRanges = ranges.oldRanges;
|
|
1138
|
+
}
|
|
1139
|
+
if (ranges.newRanges.length > 0) {
|
|
1140
|
+
additionState.modificationRanges = ranges.newRanges;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
return states;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
private computeDiffDisplayLineNumbers(states: DiffLineState[], startLineNum: number): DiffDisplayLineNumbers[] {
|
|
1149
|
+
const numbers: DiffDisplayLineNumbers[] = [];
|
|
1150
|
+
let oldLineNumber = startLineNum;
|
|
1151
|
+
let newLineNumber = startLineNum;
|
|
1152
|
+
|
|
1153
|
+
for (const state of states) {
|
|
1154
|
+
if (state.kind === "deletion") {
|
|
1155
|
+
numbers.push({ oldLine: oldLineNumber, newLine: null });
|
|
1156
|
+
oldLineNumber++;
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (state.kind === "addition") {
|
|
1161
|
+
numbers.push({ oldLine: null, newLine: newLineNumber });
|
|
1162
|
+
newLineNumber++;
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
numbers.push({ oldLine: oldLineNumber, newLine: newLineNumber });
|
|
1167
|
+
oldLineNumber++;
|
|
1168
|
+
newLineNumber++;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
return numbers;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
private parseDiffLineState(line: string): DiffLineState {
|
|
1175
|
+
const escapedMarker = line.startsWith("\\+") || line.startsWith("\\-");
|
|
1176
|
+
|
|
1177
|
+
if (escapedMarker) {
|
|
1178
|
+
return {
|
|
1179
|
+
kind: "normal",
|
|
1180
|
+
content: line.slice(1),
|
|
1181
|
+
contentOffset: 1,
|
|
1182
|
+
escapedMarker: true,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (line.startsWith("+")) {
|
|
1187
|
+
return {
|
|
1188
|
+
kind: "addition",
|
|
1189
|
+
content: line.slice(1),
|
|
1190
|
+
contentOffset: 1,
|
|
1191
|
+
escapedMarker: false,
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (line.startsWith("-")) {
|
|
1196
|
+
return {
|
|
1197
|
+
kind: "deletion",
|
|
1198
|
+
content: line.slice(1),
|
|
1199
|
+
contentOffset: 1,
|
|
1200
|
+
escapedMarker: false,
|
|
1201
|
+
};
|
|
801
1202
|
}
|
|
1203
|
+
|
|
1204
|
+
return {
|
|
1205
|
+
kind: "normal",
|
|
1206
|
+
content: line,
|
|
1207
|
+
contentOffset: 0,
|
|
1208
|
+
escapedMarker: false,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
private computeChangedRanges(
|
|
1213
|
+
oldText: string,
|
|
1214
|
+
newText: string
|
|
1215
|
+
): { oldRanges: Array<[number, number]>; newRanges: Array<[number, number]> } {
|
|
1216
|
+
let prefix = 0;
|
|
1217
|
+
while (prefix < oldText.length && prefix < newText.length && oldText[prefix] === newText[prefix]) {
|
|
1218
|
+
prefix++;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
let oldSuffix = oldText.length;
|
|
1222
|
+
let newSuffix = newText.length;
|
|
1223
|
+
while (oldSuffix > prefix && newSuffix > prefix && oldText[oldSuffix - 1] === newText[newSuffix - 1]) {
|
|
1224
|
+
oldSuffix--;
|
|
1225
|
+
newSuffix--;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const oldRanges: Array<[number, number]> = [];
|
|
1229
|
+
const newRanges: Array<[number, number]> = [];
|
|
1230
|
+
|
|
1231
|
+
if (oldSuffix > prefix) {
|
|
1232
|
+
oldRanges.push([prefix, oldSuffix]);
|
|
1233
|
+
}
|
|
1234
|
+
if (newSuffix > prefix) {
|
|
1235
|
+
newRanges.push([prefix, newSuffix]);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
return { oldRanges, newRanges };
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
private renderDiffPreviewLine(diffState: DiffLineState, highlightedContent: string): string {
|
|
1242
|
+
const modClass =
|
|
1243
|
+
diffState.kind === "addition"
|
|
1244
|
+
? "cm-draftly-code-diff-mod-add"
|
|
1245
|
+
: diffState.kind === "deletion"
|
|
1246
|
+
? "cm-draftly-code-diff-mod-del"
|
|
1247
|
+
: "";
|
|
1248
|
+
|
|
1249
|
+
const baseHighlightedContent = highlightedContent || this.escapeHtml(diffState.content);
|
|
1250
|
+
|
|
1251
|
+
const contentHtml =
|
|
1252
|
+
diffState.modificationRanges && modClass
|
|
1253
|
+
? this.applyRangesToHighlightedHTML(baseHighlightedContent, diffState.modificationRanges, modClass)
|
|
1254
|
+
: baseHighlightedContent;
|
|
1255
|
+
|
|
1256
|
+
return contentHtml || " ";
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
private applyRangesToHighlightedHTML(
|
|
1260
|
+
htmlContent: string,
|
|
1261
|
+
ranges: Array<[number, number]>,
|
|
1262
|
+
className: string
|
|
1263
|
+
): string {
|
|
1264
|
+
const normalizedRanges = ranges
|
|
1265
|
+
.map(([start, end]) => [Math.max(0, start), Math.max(0, end)] as [number, number])
|
|
1266
|
+
.filter(([start, end]) => end > start)
|
|
1267
|
+
.sort((a, b) => a[0] - b[0]);
|
|
1268
|
+
|
|
1269
|
+
if (normalizedRanges.length === 0 || !htmlContent) {
|
|
1270
|
+
return htmlContent;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const isInsideRange = (position: number) => {
|
|
1274
|
+
for (const [start, end] of normalizedRanges) {
|
|
1275
|
+
if (position >= start && position < end) return true;
|
|
1276
|
+
if (position < start) return false;
|
|
1277
|
+
}
|
|
1278
|
+
return false;
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
let result = "";
|
|
1282
|
+
let htmlIndex = 0;
|
|
1283
|
+
let textPosition = 0;
|
|
1284
|
+
let markOpen = false;
|
|
1285
|
+
|
|
1286
|
+
while (htmlIndex < htmlContent.length) {
|
|
1287
|
+
const char = htmlContent[htmlIndex];
|
|
1288
|
+
|
|
1289
|
+
if (char === "<") {
|
|
1290
|
+
const tagEnd = htmlContent.indexOf(">", htmlIndex);
|
|
1291
|
+
if (tagEnd === -1) {
|
|
1292
|
+
result += htmlContent.slice(htmlIndex);
|
|
1293
|
+
break;
|
|
1294
|
+
}
|
|
1295
|
+
result += htmlContent.slice(htmlIndex, tagEnd + 1);
|
|
1296
|
+
htmlIndex = tagEnd + 1;
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
let token = char;
|
|
1301
|
+
if (char === "&") {
|
|
1302
|
+
const entityEnd = htmlContent.indexOf(";", htmlIndex);
|
|
1303
|
+
if (entityEnd !== -1) {
|
|
1304
|
+
token = htmlContent.slice(htmlIndex, entityEnd + 1);
|
|
1305
|
+
htmlIndex = entityEnd + 1;
|
|
1306
|
+
} else {
|
|
1307
|
+
htmlIndex += 1;
|
|
1308
|
+
}
|
|
1309
|
+
} else {
|
|
1310
|
+
htmlIndex += 1;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const shouldMark = isInsideRange(textPosition);
|
|
1314
|
+
|
|
1315
|
+
if (shouldMark && !markOpen) {
|
|
1316
|
+
result += `<mark class="${className}">`;
|
|
1317
|
+
markOpen = true;
|
|
1318
|
+
}
|
|
1319
|
+
if (!shouldMark && markOpen) {
|
|
1320
|
+
result += "</mark>";
|
|
1321
|
+
markOpen = false;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
result += token;
|
|
1325
|
+
textPosition += 1;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (markOpen) {
|
|
1329
|
+
result += "</mark>";
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
return result;
|
|
802
1333
|
}
|
|
803
1334
|
|
|
804
1335
|
/**
|
|
805
1336
|
* Apply text highlights (regex patterns) to already syntax-highlighted HTML.
|
|
806
1337
|
* Wraps matched patterns in `<mark>` elements.
|
|
807
1338
|
*/
|
|
808
|
-
private applyTextHighlights(htmlContent: string, highlights: TextHighlight[]): string {
|
|
1339
|
+
private applyTextHighlights(htmlContent: string, highlights: TextHighlight[], instanceCounters?: number[]): string {
|
|
809
1340
|
let result = htmlContent;
|
|
810
1341
|
|
|
811
|
-
for (const highlight of highlights) {
|
|
1342
|
+
for (const [highlightIndex, highlight] of highlights.entries()) {
|
|
812
1343
|
try {
|
|
813
1344
|
// Create regex from pattern
|
|
814
1345
|
const regex = new RegExp(`(${highlight.pattern})`, "g");
|
|
815
|
-
let matchCount = 0;
|
|
1346
|
+
let matchCount = instanceCounters?.[highlightIndex] ?? 0;
|
|
816
1347
|
|
|
817
1348
|
result = result.replace(regex, (match) => {
|
|
818
1349
|
matchCount++;
|
|
@@ -823,6 +1354,10 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
823
1354
|
}
|
|
824
1355
|
return match;
|
|
825
1356
|
});
|
|
1357
|
+
|
|
1358
|
+
if (instanceCounters) {
|
|
1359
|
+
instanceCounters[highlightIndex] = matchCount;
|
|
1360
|
+
}
|
|
826
1361
|
} catch {
|
|
827
1362
|
// Invalid regex, skip
|
|
828
1363
|
}
|
|
@@ -831,289 +1366,3 @@ export class CodePlugin extends DecorationPlugin {
|
|
|
831
1366
|
return result;
|
|
832
1367
|
}
|
|
833
1368
|
}
|
|
834
|
-
|
|
835
|
-
// ============================================================================
|
|
836
|
-
// Theme
|
|
837
|
-
// ============================================================================
|
|
838
|
-
|
|
839
|
-
/** Theme styles for code elements (light and dark modes) */
|
|
840
|
-
const theme = createTheme({
|
|
841
|
-
default: {
|
|
842
|
-
// Inline code
|
|
843
|
-
".cm-draftly-code-inline": {
|
|
844
|
-
fontFamily: "var(--font-jetbrains-mono, monospace)",
|
|
845
|
-
fontSize: "0.9rem",
|
|
846
|
-
backgroundColor: "rgba(0, 0, 0, 0.05)",
|
|
847
|
-
padding: "0.1rem 0.25rem",
|
|
848
|
-
border: "1px solid var(--color-border)",
|
|
849
|
-
borderRadius: "3px",
|
|
850
|
-
},
|
|
851
|
-
|
|
852
|
-
// Fenced code block lines
|
|
853
|
-
".cm-draftly-code-block-line": {
|
|
854
|
-
"--radius": "0.375rem",
|
|
855
|
-
|
|
856
|
-
fontFamily: "var(--font-jetbrains-mono, monospace)",
|
|
857
|
-
fontSize: "0.9rem",
|
|
858
|
-
backgroundColor: "rgba(0, 0, 0, 0.03)",
|
|
859
|
-
padding: "0 1rem !important",
|
|
860
|
-
lineHeight: "1.5",
|
|
861
|
-
borderLeft: "1px solid var(--color-border)",
|
|
862
|
-
borderRight: "1px solid var(--color-border)",
|
|
863
|
-
},
|
|
864
|
-
|
|
865
|
-
// First line of code block
|
|
866
|
-
".cm-draftly-code-block-line-start": {
|
|
867
|
-
borderTopLeftRadius: "var(--radius)",
|
|
868
|
-
borderTopRightRadius: "var(--radius)",
|
|
869
|
-
position: "relative",
|
|
870
|
-
overflow: "hidden",
|
|
871
|
-
borderTop: "1px solid var(--color-border)",
|
|
872
|
-
paddingBottom: "0.5rem !important",
|
|
873
|
-
},
|
|
874
|
-
|
|
875
|
-
// Remove top radius when header is present
|
|
876
|
-
".cm-draftly-code-block-has-header": {
|
|
877
|
-
padding: "0 !important",
|
|
878
|
-
paddingBottom: "0.5rem !important",
|
|
879
|
-
},
|
|
880
|
-
|
|
881
|
-
// Code block header widget
|
|
882
|
-
".cm-draftly-code-header": {
|
|
883
|
-
display: "flex",
|
|
884
|
-
justifyContent: "space-between",
|
|
885
|
-
alignItems: "center",
|
|
886
|
-
padding: "0.25rem 1rem",
|
|
887
|
-
backgroundColor: "rgba(0, 0, 0, 0.06)",
|
|
888
|
-
fontFamily: "var(--font-jetbrains-mono, monospace)",
|
|
889
|
-
fontSize: "0.85rem",
|
|
890
|
-
},
|
|
891
|
-
|
|
892
|
-
".cm-draftly-code-header-left": {
|
|
893
|
-
display: "flex",
|
|
894
|
-
alignItems: "center",
|
|
895
|
-
gap: "0.5rem",
|
|
896
|
-
},
|
|
897
|
-
|
|
898
|
-
".cm-draftly-code-header-title": {
|
|
899
|
-
color: "var(--color-text, inherit)",
|
|
900
|
-
fontWeight: "500",
|
|
901
|
-
},
|
|
902
|
-
|
|
903
|
-
".cm-draftly-code-header-lang": {
|
|
904
|
-
color: "#6a737d",
|
|
905
|
-
opacity: "0.8",
|
|
906
|
-
},
|
|
907
|
-
|
|
908
|
-
".cm-draftly-code-header-right": {
|
|
909
|
-
display: "flex",
|
|
910
|
-
alignItems: "center",
|
|
911
|
-
gap: "0.5rem",
|
|
912
|
-
},
|
|
913
|
-
|
|
914
|
-
".cm-draftly-code-copy-btn": {
|
|
915
|
-
display: "flex",
|
|
916
|
-
alignItems: "center",
|
|
917
|
-
justifyContent: "center",
|
|
918
|
-
padding: "0.25rem",
|
|
919
|
-
backgroundColor: "transparent",
|
|
920
|
-
border: "none",
|
|
921
|
-
borderRadius: "4px",
|
|
922
|
-
cursor: "pointer",
|
|
923
|
-
color: "#6a737d",
|
|
924
|
-
transition: "color 0.2s, background-color 0.2s",
|
|
925
|
-
},
|
|
926
|
-
|
|
927
|
-
".cm-draftly-code-copy-btn:hover": {
|
|
928
|
-
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
|
929
|
-
color: "var(--color-text, inherit)",
|
|
930
|
-
},
|
|
931
|
-
|
|
932
|
-
".cm-draftly-code-copy-btn.copied": {
|
|
933
|
-
color: "#22c55e",
|
|
934
|
-
},
|
|
935
|
-
|
|
936
|
-
// Caption (below code block)
|
|
937
|
-
".cm-draftly-code-block-has-caption": {
|
|
938
|
-
padding: "0 !important",
|
|
939
|
-
paddingTop: "0.5rem !important",
|
|
940
|
-
},
|
|
941
|
-
|
|
942
|
-
".cm-draftly-code-caption": {
|
|
943
|
-
textAlign: "center",
|
|
944
|
-
fontSize: "0.85rem",
|
|
945
|
-
color: "#6a737d",
|
|
946
|
-
fontStyle: "italic",
|
|
947
|
-
padding: "0.25rem 1rem",
|
|
948
|
-
backgroundColor: "rgba(0, 0, 0, 0.06)",
|
|
949
|
-
},
|
|
950
|
-
|
|
951
|
-
// Last line of code block
|
|
952
|
-
".cm-draftly-code-block-line-end": {
|
|
953
|
-
borderBottomLeftRadius: "var(--radius)",
|
|
954
|
-
borderBottomRightRadius: "var(--radius)",
|
|
955
|
-
borderBottom: "1px solid var(--color-border)",
|
|
956
|
-
paddingTop: "0.5rem !important",
|
|
957
|
-
},
|
|
958
|
-
|
|
959
|
-
".cm-draftly-code-block-line-end br": {
|
|
960
|
-
display: "none",
|
|
961
|
-
},
|
|
962
|
-
|
|
963
|
-
// Fence markers (```)
|
|
964
|
-
".cm-draftly-code-fence": {
|
|
965
|
-
color: "#6a737d",
|
|
966
|
-
fontFamily: "var(--font-jetbrains-mono, monospace)",
|
|
967
|
-
},
|
|
968
|
-
|
|
969
|
-
// Line numbers
|
|
970
|
-
".cm-draftly-code-line-numbered": {
|
|
971
|
-
paddingLeft: "calc(var(--line-num-width, 2ch) + 1rem) !important",
|
|
972
|
-
position: "relative",
|
|
973
|
-
},
|
|
974
|
-
|
|
975
|
-
".cm-draftly-code-line-numbered::before": {
|
|
976
|
-
content: "attr(data-line-num)",
|
|
977
|
-
position: "absolute",
|
|
978
|
-
left: "0.5rem",
|
|
979
|
-
top: "0.2rem",
|
|
980
|
-
width: "var(--line-num-width, 2ch)",
|
|
981
|
-
textAlign: "right",
|
|
982
|
-
color: "#6a737d",
|
|
983
|
-
opacity: "0.6",
|
|
984
|
-
fontFamily: "var(--font-jetbrains-mono, monospace)",
|
|
985
|
-
fontSize: "0.85rem",
|
|
986
|
-
userSelect: "none",
|
|
987
|
-
},
|
|
988
|
-
|
|
989
|
-
// Preview: code lines (need block display for full-width highlights)
|
|
990
|
-
".cm-draftly-code-line": {
|
|
991
|
-
display: "block",
|
|
992
|
-
position: "relative",
|
|
993
|
-
paddingLeft: "1rem",
|
|
994
|
-
paddingRight: "1rem",
|
|
995
|
-
lineHeight: "1.5",
|
|
996
|
-
borderLeft: "3px solid transparent",
|
|
997
|
-
},
|
|
998
|
-
|
|
999
|
-
// Line highlight
|
|
1000
|
-
".cm-draftly-code-line-highlight": {
|
|
1001
|
-
backgroundColor: "rgba(255, 220, 100, 0.2) !important",
|
|
1002
|
-
borderLeft: "3px solid #f0b429 !important",
|
|
1003
|
-
},
|
|
1004
|
-
|
|
1005
|
-
// Text highlight
|
|
1006
|
-
".cm-draftly-code-text-highlight": {
|
|
1007
|
-
backgroundColor: "rgba(255, 220, 100, 0.4)",
|
|
1008
|
-
borderRadius: "2px",
|
|
1009
|
-
padding: "0.1rem 0",
|
|
1010
|
-
},
|
|
1011
|
-
// Preview: container wrapper
|
|
1012
|
-
".cm-draftly-code-container": {
|
|
1013
|
-
margin: "1rem 0",
|
|
1014
|
-
borderRadius: "var(--radius)",
|
|
1015
|
-
overflow: "hidden",
|
|
1016
|
-
border: "1px solid var(--color-border)",
|
|
1017
|
-
},
|
|
1018
|
-
|
|
1019
|
-
// Preview: header inside container
|
|
1020
|
-
".cm-draftly-code-container .cm-draftly-code-header": {
|
|
1021
|
-
borderRadius: "0",
|
|
1022
|
-
border: "none",
|
|
1023
|
-
borderBottom: "1px solid var(--color-border)",
|
|
1024
|
-
},
|
|
1025
|
-
|
|
1026
|
-
// Preview: code block inside container
|
|
1027
|
-
".cm-draftly-code-container .cm-draftly-code-block": {
|
|
1028
|
-
margin: "0",
|
|
1029
|
-
borderRadius: "0",
|
|
1030
|
-
border: "none",
|
|
1031
|
-
whiteSpace: "pre-wrap",
|
|
1032
|
-
},
|
|
1033
|
-
|
|
1034
|
-
// Preview: caption inside container
|
|
1035
|
-
".cm-draftly-code-container .cm-draftly-code-caption": {
|
|
1036
|
-
borderTop: "1px solid var(--color-border)",
|
|
1037
|
-
},
|
|
1038
|
-
|
|
1039
|
-
// Preview: standalone code block (not in container)
|
|
1040
|
-
".cm-draftly-code-block": {
|
|
1041
|
-
fontFamily: "var(--font-jetbrains-mono, monospace)",
|
|
1042
|
-
fontSize: "0.9rem",
|
|
1043
|
-
backgroundColor: "rgba(0, 0, 0, 0.03)",
|
|
1044
|
-
padding: "1rem",
|
|
1045
|
-
overflow: "auto",
|
|
1046
|
-
position: "relative",
|
|
1047
|
-
borderRadius: "var(--radius)",
|
|
1048
|
-
border: "1px solid var(--color-border)",
|
|
1049
|
-
},
|
|
1050
|
-
|
|
1051
|
-
// Preview: code block with header (remove top radius)
|
|
1052
|
-
".cm-draftly-code-block.cm-draftly-code-block-has-header": {
|
|
1053
|
-
borderTopLeftRadius: "0",
|
|
1054
|
-
borderTopRightRadius: "0",
|
|
1055
|
-
borderTop: "none",
|
|
1056
|
-
margin: "0",
|
|
1057
|
-
paddingTop: "0.5rem !important",
|
|
1058
|
-
},
|
|
1059
|
-
|
|
1060
|
-
// Preview: code block with caption (remove bottom radius)
|
|
1061
|
-
".cm-draftly-code-block.cm-draftly-code-block-has-caption": {
|
|
1062
|
-
borderBottomLeftRadius: "0",
|
|
1063
|
-
borderBottomRightRadius: "0",
|
|
1064
|
-
borderBottom: "none",
|
|
1065
|
-
paddingBottom: "0.5rem !important",
|
|
1066
|
-
},
|
|
1067
|
-
},
|
|
1068
|
-
|
|
1069
|
-
dark: {
|
|
1070
|
-
".cm-draftly-code-inline": {
|
|
1071
|
-
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
|
1072
|
-
},
|
|
1073
|
-
|
|
1074
|
-
".cm-draftly-code-block-line": {
|
|
1075
|
-
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
|
1076
|
-
},
|
|
1077
|
-
|
|
1078
|
-
".cm-draftly-code-fence": {
|
|
1079
|
-
color: "#8b949e",
|
|
1080
|
-
},
|
|
1081
|
-
|
|
1082
|
-
".cm-draftly-code-block": {
|
|
1083
|
-
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
|
1084
|
-
},
|
|
1085
|
-
|
|
1086
|
-
".cm-draftly-code-header": {
|
|
1087
|
-
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
|
1088
|
-
},
|
|
1089
|
-
|
|
1090
|
-
".cm-draftly-code-header-lang": {
|
|
1091
|
-
color: "#8b949e",
|
|
1092
|
-
},
|
|
1093
|
-
|
|
1094
|
-
".cm-draftly-code-copy-btn": {
|
|
1095
|
-
color: "#8b949e",
|
|
1096
|
-
},
|
|
1097
|
-
|
|
1098
|
-
".cm-draftly-code-copy-btn:hover": {
|
|
1099
|
-
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
|
1100
|
-
},
|
|
1101
|
-
|
|
1102
|
-
".cm-draftly-code-caption": {
|
|
1103
|
-
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
|
1104
|
-
},
|
|
1105
|
-
|
|
1106
|
-
".cm-draftly-code-line-numbered::before": {
|
|
1107
|
-
color: "#8b949e",
|
|
1108
|
-
},
|
|
1109
|
-
|
|
1110
|
-
".cm-draftly-code-line-highlight": {
|
|
1111
|
-
backgroundColor: "rgba(255, 220, 100, 0.15) !important",
|
|
1112
|
-
borderLeft: "3px solid #d9a520 !important",
|
|
1113
|
-
},
|
|
1114
|
-
|
|
1115
|
-
".cm-draftly-code-text-highlight": {
|
|
1116
|
-
backgroundColor: "rgba(255, 220, 100, 0.3)",
|
|
1117
|
-
},
|
|
1118
|
-
},
|
|
1119
|
-
});
|