mardora 1.2.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/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/chunk-3OCUX4OO.js +7690 -0
- package/dist/chunk-3OCUX4OO.js.map +1 -0
- package/dist/chunk-3ZOCCFDL.cjs +74 -0
- package/dist/chunk-3ZOCCFDL.cjs.map +1 -0
- package/dist/chunk-7JOEPNEV.cjs +7740 -0
- package/dist/chunk-7JOEPNEV.cjs.map +1 -0
- package/dist/chunk-BIKZQZ6W.js +33 -0
- package/dist/chunk-BIKZQZ6W.js.map +1 -0
- package/dist/chunk-EQJESPP2.js +234 -0
- package/dist/chunk-EQJESPP2.js.map +1 -0
- package/dist/chunk-G4SE26YY.js +70 -0
- package/dist/chunk-G4SE26YY.js.map +1 -0
- package/dist/chunk-KNDWF2DP.cjs +35 -0
- package/dist/chunk-KNDWF2DP.cjs.map +1 -0
- package/dist/chunk-MLBEBFHB.cjs +2971 -0
- package/dist/chunk-MLBEBFHB.cjs.map +1 -0
- package/dist/chunk-P7JFCYU3.js +905 -0
- package/dist/chunk-P7JFCYU3.js.map +1 -0
- package/dist/chunk-SWFUKJDO.cjs +243 -0
- package/dist/chunk-SWFUKJDO.cjs.map +1 -0
- package/dist/chunk-WFVCG4LD.cjs +926 -0
- package/dist/chunk-WFVCG4LD.cjs.map +1 -0
- package/dist/chunk-XL6WFGJT.js +2901 -0
- package/dist/chunk-XL6WFGJT.js.map +1 -0
- package/dist/editor/index.cjs +277 -0
- package/dist/editor/index.cjs.map +1 -0
- package/dist/editor/index.d.cts +186 -0
- package/dist/editor/index.d.ts +186 -0
- package/dist/editor/index.js +4 -0
- package/dist/editor/index.js.map +1 -0
- package/dist/index.cjs +405 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- 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/mardora-DCwjomil.d.cts +640 -0
- package/dist/mardora-DCwjomil.d.ts +640 -0
- package/dist/plugins/index.cjs +104 -0
- package/dist/plugins/index.cjs.map +1 -0
- package/dist/plugins/index.d.cts +740 -0
- package/dist/plugins/index.d.ts +740 -0
- package/dist/plugins/index.js +7 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/preview/index.cjs +38 -0
- package/dist/preview/index.cjs.map +1 -0
- package/dist/preview/index.d.cts +101 -0
- package/dist/preview/index.d.ts +101 -0
- package/dist/preview/index.js +5 -0
- package/dist/preview/index.js.map +1 -0
- package/dist/types-NBsaxl4d.d.cts +71 -0
- package/dist/types-Pw2SWWAR.d.ts +71 -0
- package/package.json +92 -0
- package/src/editor/attachments/extension.ts +181 -0
- package/src/editor/attachments/format.ts +63 -0
- package/src/editor/attachments/index.ts +3 -0
- package/src/editor/attachments/types.ts +37 -0
- package/src/editor/heading-fold/config.ts +25 -0
- package/src/editor/heading-fold/extension.ts +268 -0
- package/src/editor/heading-fold/extract.ts +88 -0
- package/src/editor/heading-fold/index.ts +5 -0
- package/src/editor/heading-fold/theme.ts +85 -0
- package/src/editor/heading-fold/types.ts +24 -0
- package/src/editor/i18n.ts +13 -0
- package/src/editor/icons/index.ts +367 -0
- package/src/editor/index.ts +16 -0
- package/src/editor/mardora.ts +257 -0
- package/src/editor/media-lightbox-theme.ts +146 -0
- package/src/editor/media-lightbox.ts +125 -0
- package/src/editor/plugin.ts +294 -0
- package/src/editor/selection-toolbar/activation.ts +123 -0
- package/src/editor/selection-toolbar/commands.ts +279 -0
- package/src/editor/selection-toolbar/extension.ts +564 -0
- package/src/editor/selection-toolbar/i18n.ts +164 -0
- package/src/editor/selection-toolbar/index.ts +7 -0
- package/src/editor/selection-toolbar/menu.ts +252 -0
- package/src/editor/selection-toolbar/position.ts +43 -0
- package/src/editor/selection-toolbar/theme.ts +195 -0
- package/src/editor/selection-toolbar/types.ts +155 -0
- package/src/editor/slash/default-commands.ts +190 -0
- package/src/editor/slash/extension.ts +319 -0
- package/src/editor/slash/index.ts +7 -0
- package/src/editor/slash/insertions.ts +26 -0
- package/src/editor/slash/menu.ts +123 -0
- package/src/editor/slash/position.ts +61 -0
- package/src/editor/slash/query.ts +33 -0
- package/src/editor/slash/theme.ts +113 -0
- package/src/editor/slash/types.ts +40 -0
- package/src/editor/table-of-contents/extension.ts +202 -0
- package/src/editor/table-of-contents/extract.ts +53 -0
- package/src/editor/table-of-contents/index.ts +7 -0
- package/src/editor/table-of-contents/panel.ts +83 -0
- package/src/editor/table-of-contents/slug.ts +50 -0
- package/src/editor/table-of-contents/storage.ts +35 -0
- package/src/editor/table-of-contents/theme.ts +153 -0
- package/src/editor/table-of-contents/types.ts +44 -0
- package/src/editor/theme.ts +72 -0
- package/src/editor/utils.ts +176 -0
- package/src/editor/view-plugin.ts +189 -0
- package/src/index.ts +5 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/input-handler.ts +47 -0
- package/src/plugins/code-plugin.theme.ts +545 -0
- package/src/plugins/code-plugin.ts +1892 -0
- package/src/plugins/emoji-plugin.ts +140 -0
- package/src/plugins/heading-plugin.ts +194 -0
- package/src/plugins/hr-plugin.ts +102 -0
- package/src/plugins/html-plugin.ts +353 -0
- package/src/plugins/image-plugin.ts +806 -0
- package/src/plugins/index.ts +71 -0
- package/src/plugins/inline-plugin.ts +311 -0
- package/src/plugins/link-plugin.ts +509 -0
- package/src/plugins/list-plugin.ts +492 -0
- package/src/plugins/math-plugin.ts +526 -0
- package/src/plugins/mermaid-plugin.ts +513 -0
- package/src/plugins/paragraph-plugin.ts +38 -0
- package/src/plugins/quote-plugin.ts +733 -0
- package/src/plugins/table-controls-theme.ts +126 -0
- package/src/plugins/table-controls.ts +423 -0
- package/src/plugins/table-model.ts +661 -0
- package/src/plugins/table-plugin.ts +2111 -0
- package/src/preview/context.ts +45 -0
- package/src/preview/css-generator.ts +64 -0
- package/src/preview/default-renderers.ts +29 -0
- package/src/preview/index.ts +29 -0
- package/src/preview/preview.ts +41 -0
- package/src/preview/renderer.ts +184 -0
- package/src/preview/syntax-theme.ts +112 -0
- package/src/preview/toc.ts +23 -0
- package/src/preview/types.ts +89 -0
|
@@ -0,0 +1,1892 @@
|
|
|
1
|
+
import { Decoration, EditorView, KeyBinding, WidgetType } from "@codemirror/view";
|
|
2
|
+
import { EditorState, Extension, Transaction, TransactionSpec } from "@codemirror/state";
|
|
3
|
+
import { LanguageDescription, syntaxTree } from "@codemirror/language";
|
|
4
|
+
import { DecorationContext, DecorationPlugin } from "../editor/plugin";
|
|
5
|
+
import { toggleMarkdownStyle } from "../editor";
|
|
6
|
+
import { Parser, SyntaxNode } from "@lezer/common";
|
|
7
|
+
import { Highlighter, highlightCode } from "@lezer/highlight";
|
|
8
|
+
import { languages } from "@codemirror/language-data";
|
|
9
|
+
import { createWrapSelectionInputHandler } from "../lib";
|
|
10
|
+
import { codePluginTheme as theme } from "./code-plugin.theme";
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Constants
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
/** Copy icon SVG (clipboard) */
|
|
17
|
+
const COPY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
|
|
18
|
+
|
|
19
|
+
/** Checkmark icon SVG (success state) */
|
|
20
|
+
export const CODE_COPY_SUCCESS_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-icon lucide-check"><path d="M20 6 9 17l-5-5"></path></svg>`;
|
|
21
|
+
|
|
22
|
+
/** Chevron icon SVG */
|
|
23
|
+
const CHEVRON_DOWN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"></path></svg>`;
|
|
24
|
+
|
|
25
|
+
/** Search icon SVG */
|
|
26
|
+
const SEARCH_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg>`;
|
|
27
|
+
|
|
28
|
+
/** Language selected icon SVG */
|
|
29
|
+
const LANGUAGE_SELECTED_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>`;
|
|
30
|
+
|
|
31
|
+
/** Delay before resetting copy button state (ms) */
|
|
32
|
+
const COPY_RESET_DELAY = 2000;
|
|
33
|
+
|
|
34
|
+
/** Code fence marker in markdown blocks */
|
|
35
|
+
const CODE_FENCE = "```";
|
|
36
|
+
|
|
37
|
+
/** Regex for quoted code info values like title="file.ts" */
|
|
38
|
+
const QUOTED_INFO_PATTERN = /(\w+)="([^"]*)"/g;
|
|
39
|
+
|
|
40
|
+
/** Regex for /pattern/ with optional instance selectors (/pattern/1-3,5) */
|
|
41
|
+
const TEXT_HIGHLIGHT_PATTERN = /\/([^/]+)\/(?:(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*))?/g;
|
|
42
|
+
|
|
43
|
+
const codeLanguageOptions = [
|
|
44
|
+
["Plain text", ""],
|
|
45
|
+
["Bash", "bash"],
|
|
46
|
+
["C", "c"],
|
|
47
|
+
["C++", "cpp"],
|
|
48
|
+
["C#", "csharp"],
|
|
49
|
+
["CSS", "css"],
|
|
50
|
+
["Go", "go"],
|
|
51
|
+
["HTML", "html"],
|
|
52
|
+
["Java", "java"],
|
|
53
|
+
["JavaScript", "javascript"],
|
|
54
|
+
["JSON", "json"],
|
|
55
|
+
["Markdown", "markdown"],
|
|
56
|
+
["Python", "python"],
|
|
57
|
+
["Ruby", "ruby"],
|
|
58
|
+
["Rust", "rust"],
|
|
59
|
+
["Shell", "shell"],
|
|
60
|
+
["SQL", "sql"],
|
|
61
|
+
["Swift", "swift"],
|
|
62
|
+
["TypeScript", "typescript"],
|
|
63
|
+
["TSX", "tsx"],
|
|
64
|
+
["Vue", "vue"],
|
|
65
|
+
["YAML", "yaml"],
|
|
66
|
+
] as const;
|
|
67
|
+
|
|
68
|
+
const codeLanguageAliases: Record<string, string> = {
|
|
69
|
+
bash: "Bash",
|
|
70
|
+
c: "C",
|
|
71
|
+
cpp: "C++",
|
|
72
|
+
"c++": "C++",
|
|
73
|
+
csharp: "C#",
|
|
74
|
+
"c#": "C#",
|
|
75
|
+
css: "CSS",
|
|
76
|
+
go: "Go",
|
|
77
|
+
html: "HTML",
|
|
78
|
+
java: "Java",
|
|
79
|
+
javascript: "JavaScript",
|
|
80
|
+
js: "JavaScript",
|
|
81
|
+
json: "JSON",
|
|
82
|
+
markdown: "Markdown",
|
|
83
|
+
md: "Markdown",
|
|
84
|
+
python: "Python",
|
|
85
|
+
py: "Python",
|
|
86
|
+
ruby: "Ruby",
|
|
87
|
+
rust: "Rust",
|
|
88
|
+
sql: "SQL",
|
|
89
|
+
swift: "Swift",
|
|
90
|
+
typescript: "TypeScript",
|
|
91
|
+
ts: "TypeScript",
|
|
92
|
+
tsx: "TSX",
|
|
93
|
+
vue: "Vue",
|
|
94
|
+
yaml: "YAML",
|
|
95
|
+
yml: "YAML",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function isCodeInfoDirective(token: string): boolean {
|
|
99
|
+
const normalizedToken = token.toLowerCase();
|
|
100
|
+
return (
|
|
101
|
+
/^(?:line-numbers|linenumbers|showlinenumbers)(?:\{\d+\})?$/.test(normalizedToken) ||
|
|
102
|
+
normalizedToken === "copy" ||
|
|
103
|
+
normalizedToken === "diff" ||
|
|
104
|
+
normalizedToken.startsWith("{") ||
|
|
105
|
+
normalizedToken.startsWith("/") ||
|
|
106
|
+
normalizedToken.startsWith("title=") ||
|
|
107
|
+
normalizedToken.startsWith("caption=")
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function replaceCodeInfoLanguage(codeInfo: string, nextLanguage: string): string {
|
|
112
|
+
const trimmedInfo = codeInfo.trim();
|
|
113
|
+
const normalizedLanguage = nextLanguage.trim();
|
|
114
|
+
const tokens = trimmedInfo ? trimmedInfo.split(/\s+/) : [];
|
|
115
|
+
|
|
116
|
+
if (tokens.length > 0 && tokens[0] && !isCodeInfoDirective(tokens[0])) {
|
|
117
|
+
tokens.shift();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return [normalizedLanguage, ...tokens].filter(Boolean).join(" ");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getCodeInfoLanguageTokenLength(codeInfo: string): number {
|
|
124
|
+
const match = codeInfo.match(/^(\S+)(\s*)/);
|
|
125
|
+
const token = match?.[1];
|
|
126
|
+
if (!token || isCodeInfoDirective(token)) return 0;
|
|
127
|
+
return token.length + (match[2]?.length ?? 0);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function encodeCodeCopyPayload(code: string): string {
|
|
131
|
+
if (typeof btoa !== "undefined") {
|
|
132
|
+
return btoa(encodeURIComponent(code));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return Buffer.from(encodeURIComponent(code), "utf8").toString("base64");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function decodeCodeCopyPayload(payload: string): string {
|
|
139
|
+
const decoded = typeof atob !== "undefined" ? atob(payload) : Buffer.from(payload, "base64").toString("utf8");
|
|
140
|
+
return decodeURIComponent(decoded);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getClipboardApi(documentRef: Document): Clipboard | undefined {
|
|
144
|
+
return documentRef.defaultView?.navigator.clipboard ?? (typeof navigator !== "undefined" ? navigator.clipboard : undefined);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function copyTextWithTextAreaFallback(text: string, documentRef: Document): boolean {
|
|
148
|
+
const textArea = documentRef.createElement("textarea");
|
|
149
|
+
textArea.value = text;
|
|
150
|
+
textArea.setAttribute("readonly", "true");
|
|
151
|
+
textArea.style.position = "fixed";
|
|
152
|
+
textArea.style.left = "-9999px";
|
|
153
|
+
textArea.style.top = "0";
|
|
154
|
+
documentRef.body.appendChild(textArea);
|
|
155
|
+
textArea.select();
|
|
156
|
+
textArea.setSelectionRange(0, text.length);
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
return documentRef.execCommand("copy");
|
|
160
|
+
} finally {
|
|
161
|
+
textArea.remove();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function copyCodeTextToClipboard(
|
|
166
|
+
text: string,
|
|
167
|
+
documentRef: Document | undefined = typeof document !== "undefined" ? document : undefined
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
if (!documentRef) {
|
|
170
|
+
throw new Error("A browser document is required to copy code text");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const clipboard = getClipboardApi(documentRef);
|
|
174
|
+
if (clipboard?.writeText) {
|
|
175
|
+
try {
|
|
176
|
+
await clipboard.writeText(text);
|
|
177
|
+
return;
|
|
178
|
+
} catch {
|
|
179
|
+
// Fall back to a selected textarea to force a text/plain clipboard payload.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (copyTextWithTextAreaFallback(text, documentRef)) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
throw new Error("Unable to copy code text");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function markCodeCopyButtonCopied(copyBtn: HTMLButtonElement): void {
|
|
191
|
+
copyBtn.classList.add("copied");
|
|
192
|
+
copyBtn.innerHTML = CODE_COPY_SUCCESS_ICON;
|
|
193
|
+
setTimeout(() => {
|
|
194
|
+
copyBtn.classList.remove("copied");
|
|
195
|
+
copyBtn.innerHTML = COPY_ICON;
|
|
196
|
+
}, COPY_RESET_DELAY);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function bindCodeCopyButtons(root: HTMLElement | Document): () => void {
|
|
200
|
+
const onClick = (event: Event) => {
|
|
201
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
202
|
+
const copyBtn = target?.closest<HTMLButtonElement>(".cm-mardora-code-copy-btn[data-code]");
|
|
203
|
+
if (!copyBtn || !root.contains(copyBtn)) return;
|
|
204
|
+
|
|
205
|
+
const codePayload = copyBtn.dataset.code ?? "";
|
|
206
|
+
const code = copyBtn.dataset.encoded === "true" ? decodeCodeCopyPayload(codePayload) : codePayload;
|
|
207
|
+
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
event.stopPropagation();
|
|
210
|
+
void copyCodeTextToClipboard(code, copyBtn.ownerDocument).then(() => {
|
|
211
|
+
markCodeCopyButtonCopied(copyBtn);
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
root.addEventListener("click", onClick);
|
|
216
|
+
return () => root.removeEventListener("click", onClick);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface CodeFenceAutoCloseInput {
|
|
220
|
+
text: string;
|
|
221
|
+
from: number;
|
|
222
|
+
to: number;
|
|
223
|
+
lineFrom: number;
|
|
224
|
+
lineTo: number;
|
|
225
|
+
lineText: string;
|
|
226
|
+
selectionEmpty: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface CodeFenceAutoCloseResult {
|
|
230
|
+
changes: { from: number; to: number; insert: string };
|
|
231
|
+
selection: { anchor: number };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function resolveCodeFenceAutoClose(input: CodeFenceAutoCloseInput): CodeFenceAutoCloseResult | null {
|
|
235
|
+
if (input.from !== input.to || !input.selectionEmpty) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const cursorOffset = input.from - input.lineFrom;
|
|
240
|
+
const beforeCursor = input.lineText.slice(0, cursorOffset);
|
|
241
|
+
const afterCursor = input.lineText.slice(cursorOffset);
|
|
242
|
+
const openingMatch =
|
|
243
|
+
input.text === "`" ? beforeCursor.match(/^(\s*)``$/) : input.text === CODE_FENCE ? beforeCursor.match(/^(\s*)$/) : null;
|
|
244
|
+
|
|
245
|
+
if (!openingMatch || afterCursor.trim() !== "") {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const indent = openingMatch[1] ?? "";
|
|
250
|
+
const insert = `${indent}${CODE_FENCE}\n${indent}\n${indent}${CODE_FENCE}`;
|
|
251
|
+
const anchor = input.lineFrom + indent.length + CODE_FENCE.length + 1 + indent.length;
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
changes: { from: input.lineFrom, to: input.lineTo, insert },
|
|
255
|
+
selection: { anchor },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function isInsideFencedCode(state: EditorState, pos: number, lineFrom: number): boolean {
|
|
260
|
+
const inspectPos = pos > lineFrom ? pos - 1 : pos;
|
|
261
|
+
let node: SyntaxNode | null = syntaxTree(state).resolveInner(inspectPos, -1);
|
|
262
|
+
|
|
263
|
+
while (node) {
|
|
264
|
+
if (node.name === "FencedCode") return true;
|
|
265
|
+
node = node.parent;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function resolveCodeFenceAutoCloseTransaction(transaction: Transaction): TransactionSpec | null {
|
|
272
|
+
if (!transaction.docChanged || !transaction.startState.selection.main.empty || transaction.startState.selection.ranges.length !== 1) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let autoClose: TransactionSpec | null = null;
|
|
277
|
+
|
|
278
|
+
transaction.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
|
|
279
|
+
if (autoClose) return;
|
|
280
|
+
|
|
281
|
+
const line = transaction.startState.doc.lineAt(fromA);
|
|
282
|
+
if (isInsideFencedCode(transaction.startState, fromA, line.from)) return;
|
|
283
|
+
|
|
284
|
+
const result = resolveCodeFenceAutoClose({
|
|
285
|
+
text: inserted.toString(),
|
|
286
|
+
from: fromA,
|
|
287
|
+
to: toA,
|
|
288
|
+
lineFrom: line.from,
|
|
289
|
+
lineTo: line.to,
|
|
290
|
+
lineText: line.text,
|
|
291
|
+
selectionEmpty: true,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
if (!result) return;
|
|
295
|
+
|
|
296
|
+
const userEvent = transaction.annotation(Transaction.userEvent);
|
|
297
|
+
autoClose = {
|
|
298
|
+
changes: result.changes,
|
|
299
|
+
selection: result.selection,
|
|
300
|
+
scrollIntoView: true,
|
|
301
|
+
filter: false,
|
|
302
|
+
...(userEvent ? { userEvent } : {}),
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return autoClose;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function createCodeFenceAutoCloseTransactionFilter(): Extension {
|
|
310
|
+
return EditorState.transactionFilter.of((transaction) => resolveCodeFenceAutoCloseTransaction(transaction) ?? transaction);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function createCodeFenceAutoCloseInputHandler(): Extension {
|
|
314
|
+
return EditorView.inputHandler.of((view, from, to, text) => {
|
|
315
|
+
const selectionEmpty = view.state.selection.ranges.length === 1 && view.state.selection.main.empty;
|
|
316
|
+
const line = view.state.doc.lineAt(from);
|
|
317
|
+
const result = resolveCodeFenceAutoClose({
|
|
318
|
+
text,
|
|
319
|
+
from,
|
|
320
|
+
to,
|
|
321
|
+
lineFrom: line.from,
|
|
322
|
+
lineTo: line.to,
|
|
323
|
+
lineText: line.text,
|
|
324
|
+
selectionEmpty,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
if (!result) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
view.dispatch({
|
|
332
|
+
changes: result.changes,
|
|
333
|
+
selection: result.selection,
|
|
334
|
+
scrollIntoView: true,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return true;
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function createCodeFenceAutoCloseBeforeInputHandler(): Extension {
|
|
342
|
+
return EditorView.domEventHandlers({
|
|
343
|
+
beforeinput(event, view) {
|
|
344
|
+
const inputEvent = event as InputEvent;
|
|
345
|
+
if (inputEvent.inputType !== "insertText") {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const text = inputEvent.data ?? "";
|
|
350
|
+
const { from, to } = view.state.selection.main;
|
|
351
|
+
const selectionEmpty = view.state.selection.ranges.length === 1 && view.state.selection.main.empty;
|
|
352
|
+
const line = view.state.doc.lineAt(from);
|
|
353
|
+
const result = resolveCodeFenceAutoClose({
|
|
354
|
+
text,
|
|
355
|
+
from,
|
|
356
|
+
to,
|
|
357
|
+
lineFrom: line.from,
|
|
358
|
+
lineTo: line.to,
|
|
359
|
+
lineText: line.text,
|
|
360
|
+
selectionEmpty,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
if (!result) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
view.dispatch({
|
|
369
|
+
changes: result.changes,
|
|
370
|
+
selection: result.selection,
|
|
371
|
+
scrollIntoView: true,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
return true;
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function formatLanguageLabel(language: string): string {
|
|
380
|
+
const normalized = language.trim().toLowerCase();
|
|
381
|
+
if (!normalized) return "Text";
|
|
382
|
+
return codeLanguageAliases[normalized] ?? language;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
interface ToolbarElement extends HTMLElement {
|
|
386
|
+
__mardoraDestroyToolbar?: () => void;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
interface PreviewRenderContext {
|
|
390
|
+
sliceDoc(from: number, to: number): string;
|
|
391
|
+
sanitize(html: string): string;
|
|
392
|
+
syntaxHighlighters?: readonly Highlighter[];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ============================================================================
|
|
396
|
+
// Decorations
|
|
397
|
+
// ============================================================================
|
|
398
|
+
|
|
399
|
+
/** Mark and line decorations for code elements */
|
|
400
|
+
const codeMarkDecorations = {
|
|
401
|
+
// Inline code
|
|
402
|
+
"inline-code": Decoration.mark({ class: "cm-mardora-code-inline" }),
|
|
403
|
+
"inline-mark": Decoration.replace({}),
|
|
404
|
+
|
|
405
|
+
// Fenced code block
|
|
406
|
+
"code-block-line": Decoration.line({ class: "cm-mardora-code-block-line" }),
|
|
407
|
+
"code-block-line-start": Decoration.line({ class: "cm-mardora-code-block-line-start" }),
|
|
408
|
+
"code-block-line-end": Decoration.line({ class: "cm-mardora-code-block-line-end" }),
|
|
409
|
+
"code-block-single-line": Decoration.line({ class: "cm-mardora-code-block-single-line" }),
|
|
410
|
+
"code-block-rendered": Decoration.line({ class: "cm-mardora-code-block-rendered" }),
|
|
411
|
+
"code-fence-line": Decoration.line({ class: "cm-mardora-code-fence-line" }),
|
|
412
|
+
"code-fence": Decoration.mark({ class: "cm-mardora-code-fence" }),
|
|
413
|
+
"code-hidden": Decoration.replace({}),
|
|
414
|
+
|
|
415
|
+
// Highlights
|
|
416
|
+
"code-line-highlight": Decoration.line({ class: "cm-mardora-code-line-highlight" }),
|
|
417
|
+
"code-text-highlight": Decoration.mark({ class: "cm-mardora-code-text-highlight" }),
|
|
418
|
+
|
|
419
|
+
// Diff preview
|
|
420
|
+
"diff-line-add": Decoration.line({ class: "cm-mardora-code-line-diff-add" }),
|
|
421
|
+
"diff-line-del": Decoration.line({ class: "cm-mardora-code-line-diff-del" }),
|
|
422
|
+
"diff-sign-add": Decoration.mark({ class: "cm-mardora-code-diff-sign-add" }),
|
|
423
|
+
"diff-sign-del": Decoration.mark({ class: "cm-mardora-code-diff-sign-del" }),
|
|
424
|
+
"diff-mod-add": Decoration.mark({ class: "cm-mardora-code-diff-mod-add" }),
|
|
425
|
+
"diff-mod-del": Decoration.mark({ class: "cm-mardora-code-diff-mod-del" }),
|
|
426
|
+
"diff-escape-hidden": Decoration.replace({}),
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Text highlight definition
|
|
431
|
+
* Matches text or regex patterns with optional instance selection
|
|
432
|
+
*/
|
|
433
|
+
export interface TextHighlight {
|
|
434
|
+
/** The pattern to match (regex without slashes) */
|
|
435
|
+
pattern: string;
|
|
436
|
+
/** Specific instances to highlight (e.g., [3,5] or range [3,4,5]) */
|
|
437
|
+
instances?: number[];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Properties extracted from CodeInfo string
|
|
442
|
+
*
|
|
443
|
+
* Example: ```tsx line-numbers{5} title="hello.tsx" caption="Example" copy {2-4,5} /Hello/3-5
|
|
444
|
+
*/
|
|
445
|
+
export interface CodeBlockProperties {
|
|
446
|
+
/** Language identifier (first token) */
|
|
447
|
+
language: string;
|
|
448
|
+
/** Show line numbers, optionally starting from a specific number */
|
|
449
|
+
showLineNumbers?: number | boolean;
|
|
450
|
+
/** Title to display */
|
|
451
|
+
title?: string;
|
|
452
|
+
/** Caption to display */
|
|
453
|
+
caption?: string;
|
|
454
|
+
/** Show copy button */
|
|
455
|
+
copy?: boolean;
|
|
456
|
+
/** Enable diff preview mode */
|
|
457
|
+
diff?: boolean;
|
|
458
|
+
/** Lines to highlight (e.g., [2,3,4,5,9]) */
|
|
459
|
+
highlightLines?: number[];
|
|
460
|
+
/** Text patterns to highlight with optional instance selection */
|
|
461
|
+
highlightText?: TextHighlight[];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
type DiffLineKind = "normal" | "addition" | "deletion";
|
|
465
|
+
|
|
466
|
+
interface DiffLineState {
|
|
467
|
+
kind: DiffLineKind;
|
|
468
|
+
content: string;
|
|
469
|
+
contentOffset: number;
|
|
470
|
+
escapedMarker: boolean;
|
|
471
|
+
modificationRanges?: Array<[number, number]>;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
interface DiffDisplayLineNumbers {
|
|
475
|
+
oldLine: number | null;
|
|
476
|
+
newLine: number | null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ============================================================================
|
|
480
|
+
// Widgets
|
|
481
|
+
// ============================================================================
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Widget for the compact code block hover toolbar.
|
|
485
|
+
*/
|
|
486
|
+
class CodeBlockToolbarWidget extends WidgetType {
|
|
487
|
+
constructor(
|
|
488
|
+
private props: CodeBlockProperties,
|
|
489
|
+
private codeContent: string,
|
|
490
|
+
private codeInfo: string,
|
|
491
|
+
private openingLineFrom: number,
|
|
492
|
+
private openingLineTo: number,
|
|
493
|
+
private openingFence: string,
|
|
494
|
+
private forceVisible: boolean
|
|
495
|
+
) {
|
|
496
|
+
super();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Creates the toolbar DOM element with language switcher and copy button. */
|
|
500
|
+
toDOM(view: EditorView): HTMLElement {
|
|
501
|
+
const toolbar = document.createElement("div") as ToolbarElement;
|
|
502
|
+
toolbar.className = "cm-mardora-code-toolbar";
|
|
503
|
+
if (this.forceVisible) {
|
|
504
|
+
toolbar.classList.add("is-visible");
|
|
505
|
+
}
|
|
506
|
+
toolbar.addEventListener("mousedown", (event) => {
|
|
507
|
+
event.preventDefault();
|
|
508
|
+
event.stopPropagation();
|
|
509
|
+
});
|
|
510
|
+
toolbar.addEventListener("click", (event) => {
|
|
511
|
+
event.stopPropagation();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const languageControl = this.createLanguageControl(view, toolbar);
|
|
515
|
+
toolbar.appendChild(languageControl);
|
|
516
|
+
|
|
517
|
+
if (this.props.copy !== false) {
|
|
518
|
+
const copyBtn = document.createElement("button");
|
|
519
|
+
copyBtn.className = "cm-mardora-code-copy-btn";
|
|
520
|
+
copyBtn.type = "button";
|
|
521
|
+
copyBtn.title = "Copy code";
|
|
522
|
+
copyBtn.setAttribute("aria-label", "Copy code");
|
|
523
|
+
copyBtn.innerHTML = COPY_ICON;
|
|
524
|
+
|
|
525
|
+
copyBtn.addEventListener("click", (e) => {
|
|
526
|
+
e.preventDefault();
|
|
527
|
+
e.stopPropagation();
|
|
528
|
+
void copyCodeTextToClipboard(this.codeContent, copyBtn.ownerDocument).then(() => {
|
|
529
|
+
markCodeCopyButtonCopied(copyBtn);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
toolbar.appendChild(copyBtn);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
requestAnimationFrame(() => this.bindHoverLines(toolbar));
|
|
537
|
+
return toolbar;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Checks equality for widget reuse optimization. */
|
|
541
|
+
override eq(other: CodeBlockToolbarWidget): boolean {
|
|
542
|
+
return (
|
|
543
|
+
this.props.title === other.props.title &&
|
|
544
|
+
this.props.language === other.props.language &&
|
|
545
|
+
this.props.copy === other.props.copy &&
|
|
546
|
+
this.codeContent === other.codeContent &&
|
|
547
|
+
this.codeInfo === other.codeInfo &&
|
|
548
|
+
this.openingLineFrom === other.openingLineFrom &&
|
|
549
|
+
this.openingLineTo === other.openingLineTo &&
|
|
550
|
+
this.openingFence === other.openingFence &&
|
|
551
|
+
this.forceVisible === other.forceVisible
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
override destroy(dom: HTMLElement): void {
|
|
556
|
+
const toolbar = dom as ToolbarElement;
|
|
557
|
+
toolbar.__mardoraDestroyToolbar?.();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Allow click events to propagate for toolbar interaction. */
|
|
561
|
+
override ignoreEvent(): boolean {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private createLanguageControl(view: EditorView, toolbar: ToolbarElement): HTMLElement {
|
|
566
|
+
const control = document.createElement("div");
|
|
567
|
+
control.className = "cm-mardora-code-language-control";
|
|
568
|
+
|
|
569
|
+
const button = document.createElement("button");
|
|
570
|
+
button.className = "cm-mardora-code-language-button";
|
|
571
|
+
button.type = "button";
|
|
572
|
+
button.setAttribute("aria-haspopup", "listbox");
|
|
573
|
+
button.setAttribute("aria-expanded", "false");
|
|
574
|
+
button.innerHTML = `<span>${this.escapeHtml(formatLanguageLabel(this.props.language))}</span>${CHEVRON_DOWN_ICON}`;
|
|
575
|
+
|
|
576
|
+
const menu = document.createElement("div");
|
|
577
|
+
menu.className = "cm-mardora-code-language-menu";
|
|
578
|
+
menu.setAttribute("role", "listbox");
|
|
579
|
+
menu.hidden = true;
|
|
580
|
+
|
|
581
|
+
const searchWrap = document.createElement("label");
|
|
582
|
+
searchWrap.className = "cm-mardora-code-language-search";
|
|
583
|
+
const searchInput = document.createElement("input");
|
|
584
|
+
searchInput.type = "search";
|
|
585
|
+
searchInput.placeholder = "Search...";
|
|
586
|
+
searchInput.autocomplete = "off";
|
|
587
|
+
searchInput.spellcheck = false;
|
|
588
|
+
const searchIcon = document.createElement("span");
|
|
589
|
+
searchIcon.className = "cm-mardora-code-language-search-icon";
|
|
590
|
+
searchIcon.innerHTML = SEARCH_ICON;
|
|
591
|
+
searchWrap.append(searchInput, searchIcon);
|
|
592
|
+
|
|
593
|
+
const list = document.createElement("div");
|
|
594
|
+
list.className = "cm-mardora-code-language-list";
|
|
595
|
+
menu.append(searchWrap, list);
|
|
596
|
+
control.append(button, menu);
|
|
597
|
+
|
|
598
|
+
const closeMenu = () => {
|
|
599
|
+
menu.hidden = true;
|
|
600
|
+
toolbar.classList.remove("is-menu-open");
|
|
601
|
+
button.setAttribute("aria-expanded", "false");
|
|
602
|
+
toolbar.ownerDocument.removeEventListener("pointerdown", handleOutsidePointerDown, true);
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const renderList = () => {
|
|
606
|
+
const query = searchInput.value.trim().toLowerCase();
|
|
607
|
+
list.textContent = "";
|
|
608
|
+
|
|
609
|
+
for (const [label, value] of codeLanguageOptions) {
|
|
610
|
+
const searchable = `${label} ${value}`.toLowerCase();
|
|
611
|
+
if (query && !searchable.includes(query)) continue;
|
|
612
|
+
|
|
613
|
+
const item = document.createElement("button");
|
|
614
|
+
item.className = "cm-mardora-code-language-item";
|
|
615
|
+
item.type = "button";
|
|
616
|
+
item.setAttribute("role", "option");
|
|
617
|
+
item.setAttribute("data-language", value);
|
|
618
|
+
const selected = this.props.language.trim().toLowerCase() === value.toLowerCase();
|
|
619
|
+
item.setAttribute("aria-selected", String(selected));
|
|
620
|
+
item.innerHTML = `<span>${this.escapeHtml(label)}</span>${selected ? LANGUAGE_SELECTED_ICON : ""}`;
|
|
621
|
+
item.addEventListener("click", (event) => {
|
|
622
|
+
event.preventDefault();
|
|
623
|
+
event.stopPropagation();
|
|
624
|
+
const nextInfo = replaceCodeInfoLanguage(this.codeInfo, value);
|
|
625
|
+
view.dispatch({
|
|
626
|
+
changes: {
|
|
627
|
+
from: this.openingLineFrom,
|
|
628
|
+
to: this.openingLineTo,
|
|
629
|
+
insert: `${this.openingFence}${nextInfo ? nextInfo : ""}`,
|
|
630
|
+
},
|
|
631
|
+
selection: view.state.selection,
|
|
632
|
+
scrollIntoView: false,
|
|
633
|
+
});
|
|
634
|
+
closeMenu();
|
|
635
|
+
view.focus();
|
|
636
|
+
});
|
|
637
|
+
list.appendChild(item);
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const openMenu = () => {
|
|
642
|
+
renderList();
|
|
643
|
+
menu.hidden = false;
|
|
644
|
+
toolbar.classList.add("is-menu-open");
|
|
645
|
+
button.setAttribute("aria-expanded", "true");
|
|
646
|
+
toolbar.ownerDocument.addEventListener("pointerdown", handleOutsidePointerDown, true);
|
|
647
|
+
searchInput.focus();
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const handleOutsidePointerDown = (event: PointerEvent) => {
|
|
651
|
+
if (toolbar.contains(event.target as Node | null)) return;
|
|
652
|
+
closeMenu();
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
button.addEventListener("click", (event) => {
|
|
656
|
+
event.preventDefault();
|
|
657
|
+
event.stopPropagation();
|
|
658
|
+
if (menu.hidden) {
|
|
659
|
+
openMenu();
|
|
660
|
+
} else {
|
|
661
|
+
closeMenu();
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
searchInput.addEventListener("input", renderList);
|
|
666
|
+
searchInput.addEventListener("keydown", (event) => {
|
|
667
|
+
if (event.key === "Escape") {
|
|
668
|
+
event.preventDefault();
|
|
669
|
+
closeMenu();
|
|
670
|
+
view.focus();
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
toolbar.__mardoraDestroyToolbar = () => closeMenu();
|
|
675
|
+
return control;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private bindHoverLines(toolbar: HTMLElement): void {
|
|
679
|
+
const firstLine = toolbar.closest(".cm-line");
|
|
680
|
+
if (!(firstLine instanceof HTMLElement)) return;
|
|
681
|
+
const editorRoot = firstLine.closest(".cm-editor");
|
|
682
|
+
if (!(editorRoot instanceof HTMLElement)) return;
|
|
683
|
+
|
|
684
|
+
const codeLines: HTMLElement[] = [];
|
|
685
|
+
let current: Element | null = firstLine;
|
|
686
|
+
|
|
687
|
+
while (current instanceof HTMLElement && current.classList.contains("cm-mardora-code-block-line")) {
|
|
688
|
+
codeLines.push(current);
|
|
689
|
+
if (current.classList.contains("cm-mardora-code-block-line-end")) break;
|
|
690
|
+
current = current.nextElementSibling;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const rectContains = (rect: DOMRect, event: MouseEvent) =>
|
|
694
|
+
event.clientX >= rect.left &&
|
|
695
|
+
event.clientX <= rect.right &&
|
|
696
|
+
event.clientY >= rect.top &&
|
|
697
|
+
event.clientY <= rect.bottom;
|
|
698
|
+
|
|
699
|
+
const updateVisibility = (event: MouseEvent) => {
|
|
700
|
+
const overCodeBlock = codeLines.some((line) => rectContains(line.getBoundingClientRect(), event));
|
|
701
|
+
const overToolbar = rectContains(toolbar.getBoundingClientRect(), event);
|
|
702
|
+
if (this.forceVisible || overCodeBlock || overToolbar || toolbar.classList.contains("is-menu-open")) {
|
|
703
|
+
toolbar.classList.add("is-visible");
|
|
704
|
+
} else {
|
|
705
|
+
toolbar.classList.remove("is-visible");
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
const hide = () => {
|
|
709
|
+
if (this.forceVisible) {
|
|
710
|
+
toolbar.classList.add("is-visible");
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (!toolbar.classList.contains("is-menu-open")) toolbar.classList.remove("is-visible");
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
editorRoot.addEventListener("mousemove", updateVisibility);
|
|
717
|
+
editorRoot.addEventListener("mouseleave", hide);
|
|
718
|
+
|
|
719
|
+
const existingDestroy = (toolbar as ToolbarElement).__mardoraDestroyToolbar;
|
|
720
|
+
(toolbar as ToolbarElement).__mardoraDestroyToolbar = () => {
|
|
721
|
+
existingDestroy?.();
|
|
722
|
+
editorRoot.removeEventListener("mousemove", updateVisibility);
|
|
723
|
+
editorRoot.removeEventListener("mouseleave", hide);
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private escapeHtml(value: string): string {
|
|
728
|
+
return value
|
|
729
|
+
.replace(/&/g, "&")
|
|
730
|
+
.replace(/</g, "<")
|
|
731
|
+
.replace(/>/g, ">")
|
|
732
|
+
.replace(/"/g, """)
|
|
733
|
+
.replace(/'/g, "'");
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Widget for code block caption.
|
|
739
|
+
* Displays descriptive text below the code block.
|
|
740
|
+
*/
|
|
741
|
+
class CodeBlockCaptionWidget extends WidgetType {
|
|
742
|
+
constructor(private caption: string) {
|
|
743
|
+
super();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Creates the caption DOM element. */
|
|
747
|
+
toDOM(): HTMLElement {
|
|
748
|
+
const captionEl = document.createElement("div");
|
|
749
|
+
captionEl.className = "cm-mardora-code-caption";
|
|
750
|
+
captionEl.textContent = this.caption;
|
|
751
|
+
return captionEl;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Checks equality for widget reuse optimization. */
|
|
755
|
+
override eq(other: CodeBlockCaptionWidget): boolean {
|
|
756
|
+
return this.caption === other.caption;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Allow click events to propagate for caption interaction. */
|
|
760
|
+
override ignoreEvent(): boolean {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ============================================================================
|
|
766
|
+
// Plugin
|
|
767
|
+
// ============================================================================
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* CodePlugin - Handles inline code and fenced code blocks.
|
|
771
|
+
*
|
|
772
|
+
* **Inline code:** `code`
|
|
773
|
+
* Hides backticks when cursor is not in range.
|
|
774
|
+
*
|
|
775
|
+
* **Fenced code blocks:**
|
|
776
|
+
* Supports syntax highlighting, line numbers, line/text highlighting,
|
|
777
|
+
* title, caption, and copy button via CodeInfo properties.
|
|
778
|
+
*
|
|
779
|
+
* @example
|
|
780
|
+
* ```tsx line-numbers{5} title="example.tsx" {2-4} /pattern/
|
|
781
|
+
* const x = 1;
|
|
782
|
+
* ```
|
|
783
|
+
*/
|
|
784
|
+
export class CodePlugin extends DecorationPlugin {
|
|
785
|
+
readonly name = "code";
|
|
786
|
+
readonly version = "1.0.0";
|
|
787
|
+
override decorationPriority = 25;
|
|
788
|
+
override readonly requiredNodes = ["InlineCode", "FencedCode", "CodeMark", "CodeInfo", "CodeText"] as const;
|
|
789
|
+
private readonly parserCache = new Map<string, Promise<Parser | null>>();
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Plugin theme
|
|
793
|
+
*/
|
|
794
|
+
override get theme() {
|
|
795
|
+
return theme;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Keyboard shortcuts for code formatting
|
|
800
|
+
*/
|
|
801
|
+
override getKeymap(): KeyBinding[] {
|
|
802
|
+
return [
|
|
803
|
+
{
|
|
804
|
+
key: "Mod-e",
|
|
805
|
+
run: toggleMarkdownStyle("`"),
|
|
806
|
+
preventDefault: true,
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
key: "Mod-Shift-e",
|
|
810
|
+
run: (view) => this.toggleCodeBlock(view),
|
|
811
|
+
preventDefault: true,
|
|
812
|
+
},
|
|
813
|
+
];
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Intercepts backtick typing to wrap selected text as inline code.
|
|
818
|
+
*
|
|
819
|
+
* If user types '`' while text is selected, wraps each selected range
|
|
820
|
+
* with backticks (selected -> `selected`).
|
|
821
|
+
*/
|
|
822
|
+
override getExtensions(): Extension[] {
|
|
823
|
+
return [
|
|
824
|
+
createCodeFenceAutoCloseTransactionFilter(),
|
|
825
|
+
createCodeFenceAutoCloseBeforeInputHandler(),
|
|
826
|
+
createCodeFenceAutoCloseInputHandler(),
|
|
827
|
+
createWrapSelectionInputHandler({ "`": "`" }),
|
|
828
|
+
];
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Toggle code block on current line or selected lines
|
|
833
|
+
*/
|
|
834
|
+
private toggleCodeBlock(view: EditorView): boolean {
|
|
835
|
+
const { state } = view;
|
|
836
|
+
const { from, to } = state.selection.main;
|
|
837
|
+
|
|
838
|
+
// Get all lines in selection
|
|
839
|
+
const startLine = state.doc.lineAt(from);
|
|
840
|
+
const endLine = state.doc.lineAt(to);
|
|
841
|
+
|
|
842
|
+
// Check if lines are already in a code block
|
|
843
|
+
const prevLineNum = startLine.number > 1 ? startLine.number - 1 : startLine.number;
|
|
844
|
+
const nextLineNum = endLine.number < state.doc.lines ? endLine.number + 1 : endLine.number;
|
|
845
|
+
|
|
846
|
+
const prevLine = state.doc.line(prevLineNum);
|
|
847
|
+
const nextLine = state.doc.line(nextLineNum);
|
|
848
|
+
|
|
849
|
+
const isWrapped =
|
|
850
|
+
prevLine.text.trim().startsWith(CODE_FENCE) &&
|
|
851
|
+
nextLine.text.trim() === CODE_FENCE &&
|
|
852
|
+
prevLineNum !== startLine.number &&
|
|
853
|
+
nextLineNum !== endLine.number;
|
|
854
|
+
|
|
855
|
+
if (isWrapped) {
|
|
856
|
+
// Remove the fence lines
|
|
857
|
+
view.dispatch({
|
|
858
|
+
changes: [
|
|
859
|
+
{ from: prevLine.from, to: prevLine.to + 1, insert: "" }, // Remove opening fence + newline
|
|
860
|
+
{ from: nextLine.from - 1, to: nextLine.to, insert: "" }, // Remove newline + closing fence
|
|
861
|
+
],
|
|
862
|
+
});
|
|
863
|
+
} else {
|
|
864
|
+
// Wrap with code fence
|
|
865
|
+
const openFence = `${CODE_FENCE}\n`;
|
|
866
|
+
const closeFence = `\n${CODE_FENCE}`;
|
|
867
|
+
|
|
868
|
+
view.dispatch({
|
|
869
|
+
changes: [
|
|
870
|
+
{ from: startLine.from, insert: openFence },
|
|
871
|
+
{ from: endLine.to, insert: closeFence },
|
|
872
|
+
],
|
|
873
|
+
selection: { anchor: startLine.from + openFence.length, head: endLine.to + openFence.length },
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Parse CodeInfo string into structured properties
|
|
882
|
+
*
|
|
883
|
+
* @param codeInfo - The raw CodeInfo string (e.g., "tsx line-numbers{5} title=\"hello.tsx\" copy {2-4,5} /Hello/3-5")
|
|
884
|
+
* @returns Parsed CodeBlockProperties object
|
|
885
|
+
*
|
|
886
|
+
* @example
|
|
887
|
+
* ```typescript
|
|
888
|
+
* parseCodeInfo("tsx line-numbers{5} title=\"hello.tsx\" copy {2-4,5} /Hello/3-5")
|
|
889
|
+
* ```
|
|
890
|
+
*
|
|
891
|
+
* Returns:
|
|
892
|
+
* ```json
|
|
893
|
+
* {
|
|
894
|
+
* language: "tsx",
|
|
895
|
+
* lineNumbers: 5,
|
|
896
|
+
* title: "hello.tsx",
|
|
897
|
+
* copy: true,
|
|
898
|
+
* diff: false,
|
|
899
|
+
* highlightLines: [2,3,4,5],
|
|
900
|
+
* highlightText: [{ pattern: "Hello", instances: [3,4,5] }]
|
|
901
|
+
* }
|
|
902
|
+
* ```
|
|
903
|
+
*/
|
|
904
|
+
parseCodeInfo(codeInfo: string): CodeBlockProperties {
|
|
905
|
+
const props: CodeBlockProperties = { language: "" };
|
|
906
|
+
|
|
907
|
+
if (!codeInfo || !codeInfo.trim()) {
|
|
908
|
+
return props;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
let remaining = codeInfo.trim();
|
|
912
|
+
|
|
913
|
+
// Extract language (first token), but only when it isn't a known directive.
|
|
914
|
+
const firstTokenMatch = remaining.match(/^([^\s]+)/);
|
|
915
|
+
if (firstTokenMatch && firstTokenMatch[1]) {
|
|
916
|
+
const firstToken = firstTokenMatch[1];
|
|
917
|
+
if (!isCodeInfoDirective(firstToken)) {
|
|
918
|
+
props.language = firstToken;
|
|
919
|
+
remaining = remaining.slice(firstToken.length).trim();
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Extract quoted values (title="..." caption="...")
|
|
924
|
+
let quotedMatch;
|
|
925
|
+
while ((quotedMatch = QUOTED_INFO_PATTERN.exec(remaining)) !== null) {
|
|
926
|
+
const key = quotedMatch[1]?.toLowerCase();
|
|
927
|
+
const value = quotedMatch[2];
|
|
928
|
+
|
|
929
|
+
if (key === "title" && value !== undefined) {
|
|
930
|
+
props.title = value;
|
|
931
|
+
} else if (key === "caption" && value !== undefined) {
|
|
932
|
+
props.caption = value;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
// Remove matched quoted values
|
|
936
|
+
remaining = remaining.replace(QUOTED_INFO_PATTERN, "").trim();
|
|
937
|
+
|
|
938
|
+
// Check for line numbers with optional start value.
|
|
939
|
+
// Supports both `line-numbers` and legacy `showLineNumbers` tokens.
|
|
940
|
+
const lineNumbersMatch = remaining.match(/\b(?:line-numbers|lineNumbers|showLineNumbers)(?:\{(\d+)\})?/i);
|
|
941
|
+
if (lineNumbersMatch) {
|
|
942
|
+
if (lineNumbersMatch[1]) {
|
|
943
|
+
props.showLineNumbers = parseInt(lineNumbersMatch[1], 10);
|
|
944
|
+
} else {
|
|
945
|
+
props.showLineNumbers = true;
|
|
946
|
+
}
|
|
947
|
+
remaining = remaining.replace(lineNumbersMatch[0], "").trim();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Check for copy flag
|
|
951
|
+
if (/\bcopy\b/.test(remaining)) {
|
|
952
|
+
props.copy = true;
|
|
953
|
+
remaining = remaining.replace(/\bcopy\b/, "").trim();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Check for diff flag
|
|
957
|
+
if (/\bdiff\b/.test(remaining)) {
|
|
958
|
+
props.diff = true;
|
|
959
|
+
remaining = remaining.replace(/\bdiff\b/, "").trim();
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Extract line highlights {2-4,5,9}
|
|
963
|
+
const lineHighlightMatch = remaining.match(/\{([^}]+)\}/);
|
|
964
|
+
if (lineHighlightMatch && lineHighlightMatch[1]) {
|
|
965
|
+
const highlightLines = this.parseNumberList(lineHighlightMatch[1]);
|
|
966
|
+
|
|
967
|
+
if (highlightLines.length > 0) {
|
|
968
|
+
props.highlightLines = highlightLines;
|
|
969
|
+
}
|
|
970
|
+
remaining = remaining.replace(lineHighlightMatch[0], "").trim();
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Extract text/regex highlights /pattern/ or /pattern/3-5 or /pattern/3,5
|
|
974
|
+
let textMatch;
|
|
975
|
+
const highlightText: TextHighlight[] = [];
|
|
976
|
+
|
|
977
|
+
while ((textMatch = TEXT_HIGHLIGHT_PATTERN.exec(remaining)) !== null) {
|
|
978
|
+
if (!textMatch[1]) continue;
|
|
979
|
+
const highlight: TextHighlight = {
|
|
980
|
+
pattern: textMatch[1],
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
// Parse instance selection if present
|
|
984
|
+
if (textMatch[2]) {
|
|
985
|
+
const instances = this.parseNumberList(textMatch[2]);
|
|
986
|
+
|
|
987
|
+
if (instances.length > 0) {
|
|
988
|
+
highlight.instances = instances;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
highlightText.push(highlight);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (highlightText.length > 0) {
|
|
996
|
+
props.highlightText = highlightText;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
return props;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Build decorations for inline code and fenced code blocks.
|
|
1004
|
+
* Handles line numbers, highlights, header/caption widgets, and fence visibility.
|
|
1005
|
+
*/
|
|
1006
|
+
buildDecorations(ctx: DecorationContext): void {
|
|
1007
|
+
const tree = syntaxTree(ctx.view.state);
|
|
1008
|
+
|
|
1009
|
+
tree.iterate({
|
|
1010
|
+
enter: (node) => {
|
|
1011
|
+
if (node.name === "InlineCode") {
|
|
1012
|
+
this.decorateInlineCode(node, ctx);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (node.name === "FencedCode") {
|
|
1017
|
+
this.decorateFencedCode(node, ctx);
|
|
1018
|
+
}
|
|
1019
|
+
},
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
private decorateInlineCode(node: { from: number; to: number; node: SyntaxNode }, ctx: DecorationContext): void {
|
|
1024
|
+
const { from, to } = node;
|
|
1025
|
+
ctx.decorations.push(codeMarkDecorations["inline-code"].range(from, to));
|
|
1026
|
+
|
|
1027
|
+
if (ctx.selectionOverlapsRange(from, to)) {
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
for (let child = node.node.firstChild; child; child = child.nextSibling) {
|
|
1032
|
+
if (child.name === "CodeMark") {
|
|
1033
|
+
ctx.decorations.push(codeMarkDecorations["inline-mark"].range(child.from, child.to));
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private decorateFencedCode(node: { from: number; to: number; node: SyntaxNode }, ctx: DecorationContext): void {
|
|
1039
|
+
const { view, decorations } = ctx;
|
|
1040
|
+
const nodeLineStart = view.state.doc.lineAt(node.from);
|
|
1041
|
+
const nodeLineEnd = view.state.doc.lineAt(node.to);
|
|
1042
|
+
const cursorInRange = ctx.selectionOverlapsRange(nodeLineStart.from, nodeLineEnd.to);
|
|
1043
|
+
|
|
1044
|
+
let infoProps: CodeBlockProperties = { language: "" };
|
|
1045
|
+
let codeContent = "";
|
|
1046
|
+
let codeInfo = "";
|
|
1047
|
+
|
|
1048
|
+
for (let child = node.node.firstChild; child; child = child.nextSibling) {
|
|
1049
|
+
if (child.name === "CodeInfo") {
|
|
1050
|
+
codeInfo = view.state.sliceDoc(child.from, child.to).trim();
|
|
1051
|
+
infoProps = this.parseCodeInfo(codeInfo);
|
|
1052
|
+
}
|
|
1053
|
+
if (child.name === "CodeText") {
|
|
1054
|
+
codeContent = view.state.sliceDoc(child.from, child.to);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const openingLineText = view.state.sliceDoc(nodeLineStart.from, nodeLineStart.to);
|
|
1059
|
+
const openingFenceMatch = openingLineText.match(/^(\s*)(```+|~~~+)/);
|
|
1060
|
+
const openingFence = openingFenceMatch ? `${openingFenceMatch[1] ?? ""}${openingFenceMatch[2] ?? CODE_FENCE}` : CODE_FENCE;
|
|
1061
|
+
|
|
1062
|
+
const codeLines: string[] = [];
|
|
1063
|
+
for (let i = nodeLineStart.number + 1; i <= nodeLineEnd.number - 1; i++) {
|
|
1064
|
+
const codeLine = view.state.doc.line(i);
|
|
1065
|
+
codeLines.push(view.state.sliceDoc(codeLine.from, codeLine.to));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const totalCodeLines = nodeLineEnd.number - nodeLineStart.number - 1;
|
|
1069
|
+
const startLineNum = typeof infoProps.showLineNumbers === "number" ? infoProps.showLineNumbers : 1;
|
|
1070
|
+
const maxLineNum = startLineNum + totalCodeLines - 1;
|
|
1071
|
+
const lineNumWidth = Math.max(String(maxLineNum).length, String(startLineNum).length);
|
|
1072
|
+
const highlightInstanceCounters = new Array(infoProps.highlightText?.length ?? 0).fill(0);
|
|
1073
|
+
|
|
1074
|
+
const diffStates = infoProps.diff ? this.analyzeDiffLines(codeLines) : [];
|
|
1075
|
+
const diffDisplayLineNumbers = infoProps.diff ? this.computeDiffDisplayLineNumbers(diffStates, startLineNum) : [];
|
|
1076
|
+
const displayLineNumbers = infoProps.diff
|
|
1077
|
+
? diffDisplayLineNumbers.map((numbers, index) => numbers.newLine ?? numbers.oldLine ?? startLineNum + index)
|
|
1078
|
+
: codeLines.map((_, index) => startLineNum + index);
|
|
1079
|
+
const diffHighlightLineNumbers = infoProps.diff
|
|
1080
|
+
? this.computeDiffDisplayLineNumbers(diffStates, startLineNum).map(
|
|
1081
|
+
(numbers, index) => numbers.newLine ?? numbers.oldLine ?? startLineNum + index
|
|
1082
|
+
)
|
|
1083
|
+
: [];
|
|
1084
|
+
const maxOldDiffLineNum = diffDisplayLineNumbers.reduce((max, numbers) => {
|
|
1085
|
+
const oldLine = numbers.oldLine ?? 0;
|
|
1086
|
+
return oldLine > max ? oldLine : max;
|
|
1087
|
+
}, startLineNum);
|
|
1088
|
+
const maxNewDiffLineNum = diffDisplayLineNumbers.reduce((max, numbers) => {
|
|
1089
|
+
const newLine = numbers.newLine ?? 0;
|
|
1090
|
+
return newLine > max ? newLine : max;
|
|
1091
|
+
}, startLineNum);
|
|
1092
|
+
const diffOldLineNumWidth = Math.max(String(startLineNum).length, String(maxOldDiffLineNum).length);
|
|
1093
|
+
const diffNewLineNumWidth = Math.max(String(startLineNum).length, String(maxNewDiffLineNum).length);
|
|
1094
|
+
|
|
1095
|
+
const shouldShowCaption = !cursorInRange && !!infoProps.caption;
|
|
1096
|
+
|
|
1097
|
+
const firstContentLineNumber = nodeLineStart.number + 1;
|
|
1098
|
+
const lastContentLineNumber = nodeLineEnd.number - 1;
|
|
1099
|
+
const toolbarLineNumber =
|
|
1100
|
+
firstContentLineNumber <= lastContentLineNumber ? firstContentLineNumber : nodeLineStart.number;
|
|
1101
|
+
const toolbarLine = view.state.doc.line(toolbarLineNumber);
|
|
1102
|
+
|
|
1103
|
+
decorations.push(
|
|
1104
|
+
Decoration.widget({
|
|
1105
|
+
widget: new CodeBlockToolbarWidget(
|
|
1106
|
+
infoProps,
|
|
1107
|
+
codeContent,
|
|
1108
|
+
codeInfo,
|
|
1109
|
+
nodeLineStart.from,
|
|
1110
|
+
nodeLineStart.to,
|
|
1111
|
+
openingFence,
|
|
1112
|
+
cursorInRange
|
|
1113
|
+
),
|
|
1114
|
+
block: false,
|
|
1115
|
+
side: -1,
|
|
1116
|
+
}).range(toolbarLine.from)
|
|
1117
|
+
);
|
|
1118
|
+
|
|
1119
|
+
let codeLineIndex = 0;
|
|
1120
|
+
for (let lineNumber = nodeLineStart.number; lineNumber <= nodeLineEnd.number; lineNumber++) {
|
|
1121
|
+
const line = view.state.doc.line(lineNumber);
|
|
1122
|
+
const isFenceLine = lineNumber === nodeLineStart.number || lineNumber === nodeLineEnd.number;
|
|
1123
|
+
const relativeLineNum = displayLineNumbers[codeLineIndex] ?? startLineNum + codeLineIndex;
|
|
1124
|
+
|
|
1125
|
+
if (isFenceLine) {
|
|
1126
|
+
decorations.push(codeMarkDecorations["code-fence-line"].range(line.from));
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
decorations.push(codeMarkDecorations["code-block-line"].range(line.from));
|
|
1131
|
+
if (!cursorInRange) {
|
|
1132
|
+
decorations.push(codeMarkDecorations["code-block-rendered"].range(line.from));
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (lineNumber === firstContentLineNumber) {
|
|
1136
|
+
decorations.push(codeMarkDecorations["code-block-line-start"].range(line.from));
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (lineNumber === lastContentLineNumber) {
|
|
1140
|
+
decorations.push(codeMarkDecorations["code-block-line-end"].range(line.from));
|
|
1141
|
+
if (shouldShowCaption) {
|
|
1142
|
+
decorations.push(Decoration.line({ class: "cm-mardora-code-block-has-caption" }).range(line.from));
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (firstContentLineNumber === lastContentLineNumber) {
|
|
1147
|
+
decorations.push(codeMarkDecorations["code-block-single-line"].range(line.from));
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (!isFenceLine && infoProps.showLineNumbers && !infoProps.diff) {
|
|
1151
|
+
decorations.push(
|
|
1152
|
+
Decoration.line({
|
|
1153
|
+
class: "cm-mardora-code-line-numbered",
|
|
1154
|
+
attributes: {
|
|
1155
|
+
"data-line-num": String(relativeLineNum),
|
|
1156
|
+
style: `--line-num-width: ${lineNumWidth}ch`,
|
|
1157
|
+
},
|
|
1158
|
+
}).range(line.from)
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (!isFenceLine && infoProps.showLineNumbers && infoProps.diff) {
|
|
1163
|
+
const diffLineNumbers = diffDisplayLineNumbers[codeLineIndex];
|
|
1164
|
+
const diffState = diffStates[codeLineIndex];
|
|
1165
|
+
const diffMarker = diffState?.kind === "addition" ? "+" : diffState?.kind === "deletion" ? "-" : " ";
|
|
1166
|
+
decorations.push(
|
|
1167
|
+
Decoration.line({
|
|
1168
|
+
class: "cm-mardora-code-line-numbered-diff",
|
|
1169
|
+
attributes: {
|
|
1170
|
+
"data-line-num-old": diffLineNumbers?.oldLine != null ? String(diffLineNumbers.oldLine) : "",
|
|
1171
|
+
"data-line-num-new": diffLineNumbers?.newLine != null ? String(diffLineNumbers.newLine) : "",
|
|
1172
|
+
"data-diff-marker": diffMarker,
|
|
1173
|
+
style: `--line-num-old-width: ${diffOldLineNumWidth}ch; --line-num-new-width: ${diffNewLineNumWidth}ch`,
|
|
1174
|
+
},
|
|
1175
|
+
}).range(line.from)
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (!isFenceLine && infoProps.diff) {
|
|
1180
|
+
this.decorateDiffLine(line, codeLineIndex, diffStates, cursorInRange, !infoProps.showLineNumbers, decorations);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (!isFenceLine && infoProps.highlightLines) {
|
|
1184
|
+
const highlightLineNumber = infoProps.diff
|
|
1185
|
+
? (diffHighlightLineNumbers[codeLineIndex] ?? codeLineIndex + 1)
|
|
1186
|
+
: startLineNum + codeLineIndex;
|
|
1187
|
+
if (infoProps.highlightLines.includes(highlightLineNumber)) {
|
|
1188
|
+
decorations.push(codeMarkDecorations["code-line-highlight"].range(line.from));
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
if (!isFenceLine && infoProps.highlightText?.length) {
|
|
1193
|
+
this.decorateTextHighlights(
|
|
1194
|
+
line.from,
|
|
1195
|
+
view.state.sliceDoc(line.from, line.to),
|
|
1196
|
+
infoProps.highlightText,
|
|
1197
|
+
highlightInstanceCounters,
|
|
1198
|
+
decorations
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
if (!isFenceLine) {
|
|
1203
|
+
codeLineIndex++;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
this.decorateFenceMarkers(node.node, decorations);
|
|
1208
|
+
|
|
1209
|
+
if (!cursorInRange && infoProps.caption) {
|
|
1210
|
+
decorations.push(
|
|
1211
|
+
Decoration.widget({
|
|
1212
|
+
widget: new CodeBlockCaptionWidget(infoProps.caption),
|
|
1213
|
+
block: false,
|
|
1214
|
+
side: 1,
|
|
1215
|
+
}).range(nodeLineEnd.to)
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
private decorateFenceMarkers(
|
|
1221
|
+
node: SyntaxNode,
|
|
1222
|
+
decorations: DecorationContext["decorations"]
|
|
1223
|
+
): void {
|
|
1224
|
+
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
1225
|
+
if (child.name === "CodeMark") {
|
|
1226
|
+
decorations.push(codeMarkDecorations["code-hidden"].range(child.from, child.to));
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (child.name === "CodeInfo") {
|
|
1231
|
+
decorations.push(codeMarkDecorations["code-hidden"].range(child.from, child.to));
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
private decorateDiffLine(
|
|
1237
|
+
line: { from: number; to: number },
|
|
1238
|
+
codeLineIndex: number,
|
|
1239
|
+
diffStates: DiffLineState[],
|
|
1240
|
+
cursorInRange: boolean,
|
|
1241
|
+
showDiffMarkerGutter: boolean,
|
|
1242
|
+
decorations: DecorationContext["decorations"]
|
|
1243
|
+
): void {
|
|
1244
|
+
const diffState = diffStates[codeLineIndex];
|
|
1245
|
+
const diffMarker = diffState?.kind === "addition" ? "+" : diffState?.kind === "deletion" ? "-" : " ";
|
|
1246
|
+
|
|
1247
|
+
if (showDiffMarkerGutter) {
|
|
1248
|
+
decorations.push(
|
|
1249
|
+
Decoration.line({
|
|
1250
|
+
class: "cm-mardora-code-line-diff-gutter",
|
|
1251
|
+
attributes: {
|
|
1252
|
+
"data-diff-marker": diffMarker,
|
|
1253
|
+
},
|
|
1254
|
+
}).range(line.from)
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (diffState?.kind === "addition") {
|
|
1259
|
+
decorations.push(codeMarkDecorations["diff-line-add"].range(line.from));
|
|
1260
|
+
if (cursorInRange && line.to > line.from) {
|
|
1261
|
+
decorations.push(codeMarkDecorations["diff-sign-add"].range(line.from, line.from + 1));
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (diffState?.kind === "deletion") {
|
|
1266
|
+
decorations.push(codeMarkDecorations["diff-line-del"].range(line.from));
|
|
1267
|
+
if (cursorInRange && line.to > line.from) {
|
|
1268
|
+
decorations.push(codeMarkDecorations["diff-sign-del"].range(line.from, line.from + 1));
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (
|
|
1273
|
+
!cursorInRange &&
|
|
1274
|
+
line.to > line.from &&
|
|
1275
|
+
(diffState?.escapedMarker || diffState?.kind === "addition" || diffState?.kind === "deletion")
|
|
1276
|
+
) {
|
|
1277
|
+
decorations.push(codeMarkDecorations["diff-escape-hidden"].range(line.from, line.from + 1));
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
if (diffState?.modificationRanges?.length) {
|
|
1281
|
+
for (const [start, end] of diffState.modificationRanges) {
|
|
1282
|
+
const rangeFrom = line.from + diffState.contentOffset + start;
|
|
1283
|
+
const rangeTo = line.from + diffState.contentOffset + end;
|
|
1284
|
+
if (rangeTo > rangeFrom) {
|
|
1285
|
+
decorations.push(
|
|
1286
|
+
(diffState.kind === "addition"
|
|
1287
|
+
? codeMarkDecorations["diff-mod-add"]
|
|
1288
|
+
: codeMarkDecorations["diff-mod-del"]
|
|
1289
|
+
).range(rangeFrom, rangeTo)
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
private decorateTextHighlights(
|
|
1297
|
+
lineFrom: number,
|
|
1298
|
+
lineText: string,
|
|
1299
|
+
highlights: TextHighlight[],
|
|
1300
|
+
instanceCounters: number[],
|
|
1301
|
+
decorations: DecorationContext["decorations"]
|
|
1302
|
+
): void {
|
|
1303
|
+
for (const [highlightIndex, textHighlight] of highlights.entries()) {
|
|
1304
|
+
try {
|
|
1305
|
+
const regex = new RegExp(textHighlight.pattern, "g");
|
|
1306
|
+
let match: RegExpExecArray | null;
|
|
1307
|
+
|
|
1308
|
+
while ((match = regex.exec(lineText)) !== null) {
|
|
1309
|
+
instanceCounters[highlightIndex] = (instanceCounters[highlightIndex] ?? 0) + 1;
|
|
1310
|
+
const globalMatchIndex = instanceCounters[highlightIndex];
|
|
1311
|
+
const shouldHighlight = !textHighlight.instances || textHighlight.instances.includes(globalMatchIndex);
|
|
1312
|
+
|
|
1313
|
+
if (shouldHighlight) {
|
|
1314
|
+
const matchFrom = lineFrom + match.index;
|
|
1315
|
+
const matchTo = matchFrom + match[0].length;
|
|
1316
|
+
decorations.push(codeMarkDecorations["code-text-highlight"].range(matchFrom, matchTo));
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
} catch {
|
|
1320
|
+
// Invalid regex; ignore this highlight pattern.
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Render code elements to HTML for static preview.
|
|
1327
|
+
* Applies syntax highlighting using @lezer/highlight.
|
|
1328
|
+
*/
|
|
1329
|
+
override async renderToHTML(node: SyntaxNode, _children: string, ctx: PreviewRenderContext): Promise<string | null> {
|
|
1330
|
+
// Hide CodeMark (backticks)
|
|
1331
|
+
if (node.name === "CodeMark") {
|
|
1332
|
+
return "";
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Inline code
|
|
1336
|
+
if (node.name === "InlineCode") {
|
|
1337
|
+
// Extract content without backticks
|
|
1338
|
+
let content = ctx.sliceDoc(node.from, node.to);
|
|
1339
|
+
// Remove leading and trailing backticks
|
|
1340
|
+
const match = content.match(/^`+(.+?)`+$/s);
|
|
1341
|
+
if (match && match[1]) {
|
|
1342
|
+
content = match[1];
|
|
1343
|
+
}
|
|
1344
|
+
return `<code class="cm-mardora-code-inline" style="padding: 0.1rem 0.25rem">${this.escapeHtml(content)}</code>`;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Fenced code block
|
|
1348
|
+
if (node.name === "FencedCode") {
|
|
1349
|
+
const content = ctx.sliceDoc(node.from, node.to);
|
|
1350
|
+
const lines = content.split("\n");
|
|
1351
|
+
|
|
1352
|
+
// Extract info string from first line (everything after ```)
|
|
1353
|
+
const firstLine = lines[0] || "";
|
|
1354
|
+
const infoMatch = firstLine.match(/^```(.*)$/);
|
|
1355
|
+
const infoString = infoMatch?.[1]?.trim() || "";
|
|
1356
|
+
|
|
1357
|
+
// Parse properties from info string
|
|
1358
|
+
const props = this.parseCodeInfo(infoString);
|
|
1359
|
+
|
|
1360
|
+
// Get code content (without fence lines)
|
|
1361
|
+
const codeLines = lines.slice(1, -1);
|
|
1362
|
+
const code = codeLines.join("\n");
|
|
1363
|
+
|
|
1364
|
+
// Build HTML parts
|
|
1365
|
+
let html = "";
|
|
1366
|
+
|
|
1367
|
+
// Wrapper container
|
|
1368
|
+
html += `<div class="cm-mardora-code-container">`;
|
|
1369
|
+
|
|
1370
|
+
html += `<div class="cm-mardora-code-toolbar">`;
|
|
1371
|
+
html += `<button class="cm-mardora-code-language-button" type="button"${props.language ? ` data-lang="${this.escapeAttribute(props.language)}"` : ""}>`;
|
|
1372
|
+
html += `<span>${this.escapeHtml(formatLanguageLabel(props.language))}</span>${CHEVRON_DOWN_ICON}`;
|
|
1373
|
+
html += `</button>`;
|
|
1374
|
+
if (props.copy !== false) {
|
|
1375
|
+
// Encode code as base64 to safely store in data attribute (preserves newlines and special chars)
|
|
1376
|
+
const encodedCode = encodeCodeCopyPayload(code);
|
|
1377
|
+
html += `<button class="cm-mardora-code-copy-btn" type="button" title="Copy code" data-code="${encodedCode}" data-encoded="true">`;
|
|
1378
|
+
html += COPY_ICON;
|
|
1379
|
+
html += `</button>`;
|
|
1380
|
+
}
|
|
1381
|
+
html += `</div>`;
|
|
1382
|
+
|
|
1383
|
+
// Calculate line number info
|
|
1384
|
+
const startLineNum = typeof props.showLineNumbers === "number" ? props.showLineNumbers : 1;
|
|
1385
|
+
const previewHighlightCounters = new Array(props.highlightText?.length ?? 0).fill(0);
|
|
1386
|
+
const diffStates = props.diff ? this.analyzeDiffLines(codeLines) : [];
|
|
1387
|
+
const previewDiffLineNumbers = props.diff ? this.computeDiffDisplayLineNumbers(diffStates, startLineNum) : [];
|
|
1388
|
+
const previewLineNumbers = props.diff
|
|
1389
|
+
? previewDiffLineNumbers.map((numbers, index) => numbers.newLine ?? numbers.oldLine ?? startLineNum + index)
|
|
1390
|
+
: codeLines.map((_, index) => startLineNum + index);
|
|
1391
|
+
const previewHighlightLineNumbers = props.diff
|
|
1392
|
+
? this.computeDiffDisplayLineNumbers(diffStates, startLineNum).map(
|
|
1393
|
+
(numbers, index) => numbers.newLine ?? numbers.oldLine ?? startLineNum + index
|
|
1394
|
+
)
|
|
1395
|
+
: [];
|
|
1396
|
+
const lineNumWidth = String(Math.max(...previewLineNumbers, startLineNum)).length;
|
|
1397
|
+
const previewOldLineNumWidth = String(
|
|
1398
|
+
Math.max(...previewDiffLineNumbers.map((numbers) => numbers.oldLine ?? 0), startLineNum)
|
|
1399
|
+
).length;
|
|
1400
|
+
const previewNewLineNumWidth = String(
|
|
1401
|
+
Math.max(...previewDiffLineNumbers.map((numbers) => numbers.newLine ?? 0), startLineNum)
|
|
1402
|
+
).length;
|
|
1403
|
+
const previewContentLines = props.diff ? diffStates.map((state) => state.content) : codeLines;
|
|
1404
|
+
const highlightedLines = await this.highlightCodeLines(
|
|
1405
|
+
previewContentLines.join("\n"),
|
|
1406
|
+
props.language || "",
|
|
1407
|
+
ctx.syntaxHighlighters
|
|
1408
|
+
);
|
|
1409
|
+
|
|
1410
|
+
// Code block with line processing
|
|
1411
|
+
const hasCaption = props.caption ? " cm-mardora-code-block-has-caption" : "";
|
|
1412
|
+
html += `<pre class="cm-mardora-code-block${hasCaption}"${props.language ? ` data-lang="${this.escapeAttribute(props.language)}"` : ""}>`;
|
|
1413
|
+
html += `<code>`;
|
|
1414
|
+
|
|
1415
|
+
// Process each line
|
|
1416
|
+
codeLines.forEach((line, index) => {
|
|
1417
|
+
const lineNum = previewLineNumbers[index] ?? startLineNum + index;
|
|
1418
|
+
const highlightLineNumber = props.diff
|
|
1419
|
+
? (previewHighlightLineNumbers[index] ?? startLineNum + index)
|
|
1420
|
+
: startLineNum + index;
|
|
1421
|
+
const isHighlighted = props.highlightLines?.includes(highlightLineNumber);
|
|
1422
|
+
const diffState = props.diff ? diffStates[index] : undefined;
|
|
1423
|
+
const diffLineNumbers = props.diff ? previewDiffLineNumbers[index] : undefined;
|
|
1424
|
+
|
|
1425
|
+
// Line classes
|
|
1426
|
+
const lineClasses: string[] = ["cm-mardora-code-line"];
|
|
1427
|
+
if (isHighlighted) lineClasses.push("cm-mardora-code-line-highlight");
|
|
1428
|
+
if (props.showLineNumbers) {
|
|
1429
|
+
lineClasses.push(props.diff ? "cm-mardora-code-line-numbered-diff" : "cm-mardora-code-line-numbered");
|
|
1430
|
+
}
|
|
1431
|
+
if (diffState?.kind === "addition") lineClasses.push("cm-mardora-code-line-diff-add");
|
|
1432
|
+
if (diffState?.kind === "deletion") lineClasses.push("cm-mardora-code-line-diff-del");
|
|
1433
|
+
|
|
1434
|
+
// Line attributes
|
|
1435
|
+
const lineAttrs: string[] = [`class="${lineClasses.join(" ")}"`];
|
|
1436
|
+
if (props.showLineNumbers && !props.diff) {
|
|
1437
|
+
lineAttrs.push(`data-line-num="${lineNum}"`);
|
|
1438
|
+
lineAttrs.push(`style="--line-num-width: ${lineNumWidth}ch"`);
|
|
1439
|
+
}
|
|
1440
|
+
if (props.diff) {
|
|
1441
|
+
const diffMarker = diffState?.kind === "addition" ? "+" : diffState?.kind === "deletion" ? "-" : " ";
|
|
1442
|
+
if (props.showLineNumbers) {
|
|
1443
|
+
lineAttrs.push(`data-line-num-old="${diffLineNumbers?.oldLine != null ? diffLineNumbers.oldLine : ""}"`);
|
|
1444
|
+
lineAttrs.push(`data-line-num-new="${diffLineNumbers?.newLine != null ? diffLineNumbers.newLine : ""}"`);
|
|
1445
|
+
lineAttrs.push(`data-diff-marker="${diffMarker}"`);
|
|
1446
|
+
lineAttrs.push(
|
|
1447
|
+
`style="--line-num-old-width: ${previewOldLineNumWidth}ch; --line-num-new-width: ${previewNewLineNumWidth}ch"`
|
|
1448
|
+
);
|
|
1449
|
+
} else {
|
|
1450
|
+
lineAttrs.push(`data-diff-marker="${diffMarker}"`);
|
|
1451
|
+
lineClasses.push("cm-mardora-code-line-diff-gutter");
|
|
1452
|
+
lineAttrs[0] = `class="${lineClasses.join(" ")}"`;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Highlight text content
|
|
1457
|
+
const highlightedLine = highlightedLines[index] ?? this.escapeHtml(previewContentLines[index] ?? line);
|
|
1458
|
+
let lineContent = highlightedLine;
|
|
1459
|
+
|
|
1460
|
+
if (diffState) {
|
|
1461
|
+
lineContent = this.renderDiffPreviewLine(diffState, highlightedLine);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Apply text highlights
|
|
1465
|
+
if (props.highlightText && props.highlightText.length > 0) {
|
|
1466
|
+
lineContent = this.applyTextHighlights(lineContent, props.highlightText, previewHighlightCounters);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
html += `<span ${lineAttrs.join(" ")}>${lineContent || " "}</span>`;
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
html += `</code></pre>`;
|
|
1473
|
+
|
|
1474
|
+
// Caption
|
|
1475
|
+
if (props.caption) {
|
|
1476
|
+
html += `<div class="cm-mardora-code-caption">${this.escapeHtml(props.caption)}</div>`;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// Close wrapper container
|
|
1480
|
+
html += `</div>`;
|
|
1481
|
+
|
|
1482
|
+
return html;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// Hide CodeInfo and CodeText - they're handled by FencedCode
|
|
1486
|
+
if (node.name === "CodeInfo" || node.name === "CodeText") {
|
|
1487
|
+
return "";
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
return null;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/** Parse comma-separated numbers and ranges (e.g. "1,3-5") into [1,3,4,5]. */
|
|
1494
|
+
private parseNumberList(value: string): number[] {
|
|
1495
|
+
const result: number[] = [];
|
|
1496
|
+
|
|
1497
|
+
for (const part of value.split(",")) {
|
|
1498
|
+
const trimmed = part.trim();
|
|
1499
|
+
const rangeMatch = trimmed.match(/^(\d+)-(\d+)$/);
|
|
1500
|
+
|
|
1501
|
+
if (rangeMatch && rangeMatch[1] && rangeMatch[2]) {
|
|
1502
|
+
const start = parseInt(rangeMatch[1], 10);
|
|
1503
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
1504
|
+
for (let i = start; i <= end; i++) {
|
|
1505
|
+
result.push(i);
|
|
1506
|
+
}
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (/^\d+$/.test(trimmed)) {
|
|
1511
|
+
result.push(parseInt(trimmed, 10));
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
return result;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Highlight a single line of code using the language's Lezer parser.
|
|
1520
|
+
* Falls back to sanitized plain text if the language is not supported.
|
|
1521
|
+
*/
|
|
1522
|
+
private async highlightCodeLines(
|
|
1523
|
+
code: string,
|
|
1524
|
+
lang: string,
|
|
1525
|
+
syntaxHighlighters?: readonly Highlighter[]
|
|
1526
|
+
): Promise<string[]> {
|
|
1527
|
+
const rawLines = code.split("\n");
|
|
1528
|
+
if (!lang || !code) {
|
|
1529
|
+
return rawLines.map((line) => this.escapeHtml(line));
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const parser = await this.resolveLanguageParser(lang);
|
|
1533
|
+
if (!parser) {
|
|
1534
|
+
return rawLines.map((line) => this.escapeHtml(line));
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
try {
|
|
1538
|
+
const tree = parser.parse(code);
|
|
1539
|
+
const highlightedLines: string[] = [""];
|
|
1540
|
+
|
|
1541
|
+
highlightCode(
|
|
1542
|
+
code,
|
|
1543
|
+
tree,
|
|
1544
|
+
syntaxHighlighters && syntaxHighlighters.length > 0 ? syntaxHighlighters : [],
|
|
1545
|
+
(text, classes) => {
|
|
1546
|
+
const chunk = classes
|
|
1547
|
+
? `<span class="${this.escapeAttribute(classes)}">${this.escapeHtml(text)}</span>`
|
|
1548
|
+
: this.escapeHtml(text);
|
|
1549
|
+
highlightedLines[highlightedLines.length - 1] += chunk;
|
|
1550
|
+
},
|
|
1551
|
+
() => {
|
|
1552
|
+
highlightedLines.push("");
|
|
1553
|
+
}
|
|
1554
|
+
);
|
|
1555
|
+
|
|
1556
|
+
return rawLines.map((line, index) => highlightedLines[index] || this.escapeHtml(line));
|
|
1557
|
+
} catch {
|
|
1558
|
+
return rawLines.map((line) => this.escapeHtml(line));
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
private async resolveLanguageParser(lang: string): Promise<Parser | null> {
|
|
1563
|
+
const normalizedLang = this.normalizeLanguage(lang);
|
|
1564
|
+
if (!normalizedLang) return null;
|
|
1565
|
+
|
|
1566
|
+
const cached = this.parserCache.get(normalizedLang);
|
|
1567
|
+
if (cached) return cached;
|
|
1568
|
+
|
|
1569
|
+
const parserPromise = (async () => {
|
|
1570
|
+
const langDesc = LanguageDescription.matchLanguageName(languages, normalizedLang, true);
|
|
1571
|
+
|
|
1572
|
+
if (!langDesc) return null;
|
|
1573
|
+
|
|
1574
|
+
if (langDesc.support) {
|
|
1575
|
+
return langDesc.support.language.parser;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
if (typeof langDesc.load === "function") {
|
|
1579
|
+
try {
|
|
1580
|
+
const support = await langDesc.load();
|
|
1581
|
+
return support.language.parser;
|
|
1582
|
+
} catch {
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
return null;
|
|
1588
|
+
})();
|
|
1589
|
+
|
|
1590
|
+
this.parserCache.set(normalizedLang, parserPromise);
|
|
1591
|
+
return parserPromise;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
private normalizeLanguage(lang: string): string {
|
|
1595
|
+
const normalized = lang.trim().toLowerCase();
|
|
1596
|
+
if (!normalized) return "";
|
|
1597
|
+
|
|
1598
|
+
const normalizedMap: Record<string, string> = {
|
|
1599
|
+
"c++": "cpp",
|
|
1600
|
+
"c#": "csharp",
|
|
1601
|
+
"f#": "fsharp",
|
|
1602
|
+
py: "python",
|
|
1603
|
+
js: "javascript",
|
|
1604
|
+
ts: "typescript",
|
|
1605
|
+
sh: "shell",
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
return normalizedMap[normalized] ?? normalized;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
private escapeHtml(value: string): string {
|
|
1612
|
+
return value
|
|
1613
|
+
.replace(/&/g, "&")
|
|
1614
|
+
.replace(/</g, "<")
|
|
1615
|
+
.replace(/>/g, ">")
|
|
1616
|
+
.replace(/"/g, """)
|
|
1617
|
+
.replace(/'/g, "'");
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
private escapeAttribute(value: string): string {
|
|
1621
|
+
return this.escapeHtml(value).replace(/`/g, "`");
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
private analyzeDiffLines(lines: string[]): DiffLineState[] {
|
|
1625
|
+
const states = lines.map((line) => this.parseDiffLineState(line));
|
|
1626
|
+
|
|
1627
|
+
let index = 0;
|
|
1628
|
+
while (index < states.length) {
|
|
1629
|
+
if (states[index]?.kind !== "deletion") {
|
|
1630
|
+
index++;
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const deletionStart = index;
|
|
1635
|
+
while (index < states.length && states[index]?.kind === "deletion") {
|
|
1636
|
+
index++;
|
|
1637
|
+
}
|
|
1638
|
+
const deletionEnd = index;
|
|
1639
|
+
|
|
1640
|
+
const additionStart = index;
|
|
1641
|
+
while (index < states.length && states[index]?.kind === "addition") {
|
|
1642
|
+
index++;
|
|
1643
|
+
}
|
|
1644
|
+
const additionEnd = index;
|
|
1645
|
+
|
|
1646
|
+
if (additionStart === additionEnd) {
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
const pairCount = Math.min(deletionEnd - deletionStart, additionEnd - additionStart);
|
|
1651
|
+
for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
|
|
1652
|
+
const deletionState = states[deletionStart + pairIndex];
|
|
1653
|
+
const additionState = states[additionStart + pairIndex];
|
|
1654
|
+
|
|
1655
|
+
if (!deletionState || !additionState) {
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const ranges = this.computeChangedRanges(deletionState.content, additionState.content);
|
|
1660
|
+
if (ranges.oldRanges.length > 0) {
|
|
1661
|
+
deletionState.modificationRanges = ranges.oldRanges;
|
|
1662
|
+
}
|
|
1663
|
+
if (ranges.newRanges.length > 0) {
|
|
1664
|
+
additionState.modificationRanges = ranges.newRanges;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
return states;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
private computeDiffDisplayLineNumbers(states: DiffLineState[], startLineNum: number): DiffDisplayLineNumbers[] {
|
|
1673
|
+
const numbers: DiffDisplayLineNumbers[] = [];
|
|
1674
|
+
let oldLineNumber = startLineNum;
|
|
1675
|
+
let newLineNumber = startLineNum;
|
|
1676
|
+
|
|
1677
|
+
for (const state of states) {
|
|
1678
|
+
if (state.kind === "deletion") {
|
|
1679
|
+
numbers.push({ oldLine: oldLineNumber, newLine: null });
|
|
1680
|
+
oldLineNumber++;
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
if (state.kind === "addition") {
|
|
1685
|
+
numbers.push({ oldLine: null, newLine: newLineNumber });
|
|
1686
|
+
newLineNumber++;
|
|
1687
|
+
continue;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
numbers.push({ oldLine: oldLineNumber, newLine: newLineNumber });
|
|
1691
|
+
oldLineNumber++;
|
|
1692
|
+
newLineNumber++;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
return numbers;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
private parseDiffLineState(line: string): DiffLineState {
|
|
1699
|
+
const escapedMarker = line.startsWith("\\+") || line.startsWith("\\-");
|
|
1700
|
+
|
|
1701
|
+
if (escapedMarker) {
|
|
1702
|
+
return {
|
|
1703
|
+
kind: "normal",
|
|
1704
|
+
content: line.slice(1),
|
|
1705
|
+
contentOffset: 1,
|
|
1706
|
+
escapedMarker: true,
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
if (line.startsWith("+")) {
|
|
1711
|
+
return {
|
|
1712
|
+
kind: "addition",
|
|
1713
|
+
content: line.slice(1),
|
|
1714
|
+
contentOffset: 1,
|
|
1715
|
+
escapedMarker: false,
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
if (line.startsWith("-")) {
|
|
1720
|
+
return {
|
|
1721
|
+
kind: "deletion",
|
|
1722
|
+
content: line.slice(1),
|
|
1723
|
+
contentOffset: 1,
|
|
1724
|
+
escapedMarker: false,
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
return {
|
|
1729
|
+
kind: "normal",
|
|
1730
|
+
content: line,
|
|
1731
|
+
contentOffset: 0,
|
|
1732
|
+
escapedMarker: false,
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
private computeChangedRanges(
|
|
1737
|
+
oldText: string,
|
|
1738
|
+
newText: string
|
|
1739
|
+
): { oldRanges: Array<[number, number]>; newRanges: Array<[number, number]> } {
|
|
1740
|
+
let prefix = 0;
|
|
1741
|
+
while (prefix < oldText.length && prefix < newText.length && oldText[prefix] === newText[prefix]) {
|
|
1742
|
+
prefix++;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
let oldSuffix = oldText.length;
|
|
1746
|
+
let newSuffix = newText.length;
|
|
1747
|
+
while (oldSuffix > prefix && newSuffix > prefix && oldText[oldSuffix - 1] === newText[newSuffix - 1]) {
|
|
1748
|
+
oldSuffix--;
|
|
1749
|
+
newSuffix--;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const oldRanges: Array<[number, number]> = [];
|
|
1753
|
+
const newRanges: Array<[number, number]> = [];
|
|
1754
|
+
|
|
1755
|
+
if (oldSuffix > prefix) {
|
|
1756
|
+
oldRanges.push([prefix, oldSuffix]);
|
|
1757
|
+
}
|
|
1758
|
+
if (newSuffix > prefix) {
|
|
1759
|
+
newRanges.push([prefix, newSuffix]);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
return { oldRanges, newRanges };
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
private renderDiffPreviewLine(diffState: DiffLineState, highlightedContent: string): string {
|
|
1766
|
+
const modClass =
|
|
1767
|
+
diffState.kind === "addition"
|
|
1768
|
+
? "cm-mardora-code-diff-mod-add"
|
|
1769
|
+
: diffState.kind === "deletion"
|
|
1770
|
+
? "cm-mardora-code-diff-mod-del"
|
|
1771
|
+
: "";
|
|
1772
|
+
|
|
1773
|
+
const baseHighlightedContent = highlightedContent || this.escapeHtml(diffState.content);
|
|
1774
|
+
|
|
1775
|
+
const contentHtml =
|
|
1776
|
+
diffState.modificationRanges && modClass
|
|
1777
|
+
? this.applyRangesToHighlightedHTML(baseHighlightedContent, diffState.modificationRanges, modClass)
|
|
1778
|
+
: baseHighlightedContent;
|
|
1779
|
+
|
|
1780
|
+
return contentHtml || " ";
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
private applyRangesToHighlightedHTML(
|
|
1784
|
+
htmlContent: string,
|
|
1785
|
+
ranges: Array<[number, number]>,
|
|
1786
|
+
className: string
|
|
1787
|
+
): string {
|
|
1788
|
+
const normalizedRanges = ranges
|
|
1789
|
+
.map(([start, end]) => [Math.max(0, start), Math.max(0, end)] as [number, number])
|
|
1790
|
+
.filter(([start, end]) => end > start)
|
|
1791
|
+
.sort((a, b) => a[0] - b[0]);
|
|
1792
|
+
|
|
1793
|
+
if (normalizedRanges.length === 0 || !htmlContent) {
|
|
1794
|
+
return htmlContent;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
const isInsideRange = (position: number) => {
|
|
1798
|
+
for (const [start, end] of normalizedRanges) {
|
|
1799
|
+
if (position >= start && position < end) return true;
|
|
1800
|
+
if (position < start) return false;
|
|
1801
|
+
}
|
|
1802
|
+
return false;
|
|
1803
|
+
};
|
|
1804
|
+
|
|
1805
|
+
let result = "";
|
|
1806
|
+
let htmlIndex = 0;
|
|
1807
|
+
let textPosition = 0;
|
|
1808
|
+
let markOpen = false;
|
|
1809
|
+
|
|
1810
|
+
while (htmlIndex < htmlContent.length) {
|
|
1811
|
+
const char = htmlContent[htmlIndex];
|
|
1812
|
+
|
|
1813
|
+
if (char === "<") {
|
|
1814
|
+
const tagEnd = htmlContent.indexOf(">", htmlIndex);
|
|
1815
|
+
if (tagEnd === -1) {
|
|
1816
|
+
result += htmlContent.slice(htmlIndex);
|
|
1817
|
+
break;
|
|
1818
|
+
}
|
|
1819
|
+
result += htmlContent.slice(htmlIndex, tagEnd + 1);
|
|
1820
|
+
htmlIndex = tagEnd + 1;
|
|
1821
|
+
continue;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
let token = char;
|
|
1825
|
+
if (char === "&") {
|
|
1826
|
+
const entityEnd = htmlContent.indexOf(";", htmlIndex);
|
|
1827
|
+
if (entityEnd !== -1) {
|
|
1828
|
+
token = htmlContent.slice(htmlIndex, entityEnd + 1);
|
|
1829
|
+
htmlIndex = entityEnd + 1;
|
|
1830
|
+
} else {
|
|
1831
|
+
htmlIndex += 1;
|
|
1832
|
+
}
|
|
1833
|
+
} else {
|
|
1834
|
+
htmlIndex += 1;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
const shouldMark = isInsideRange(textPosition);
|
|
1838
|
+
|
|
1839
|
+
if (shouldMark && !markOpen) {
|
|
1840
|
+
result += `<mark class="${className}">`;
|
|
1841
|
+
markOpen = true;
|
|
1842
|
+
}
|
|
1843
|
+
if (!shouldMark && markOpen) {
|
|
1844
|
+
result += "</mark>";
|
|
1845
|
+
markOpen = false;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
result += token;
|
|
1849
|
+
textPosition += 1;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
if (markOpen) {
|
|
1853
|
+
result += "</mark>";
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
return result;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* Apply text highlights (regex patterns) to already syntax-highlighted HTML.
|
|
1861
|
+
* Wraps matched patterns in `<mark>` elements.
|
|
1862
|
+
*/
|
|
1863
|
+
private applyTextHighlights(htmlContent: string, highlights: TextHighlight[], instanceCounters?: number[]): string {
|
|
1864
|
+
let result = htmlContent;
|
|
1865
|
+
|
|
1866
|
+
for (const [highlightIndex, highlight] of highlights.entries()) {
|
|
1867
|
+
try {
|
|
1868
|
+
// Create regex from pattern
|
|
1869
|
+
const regex = new RegExp(`(${highlight.pattern})`, "g");
|
|
1870
|
+
let matchCount = instanceCounters?.[highlightIndex] ?? 0;
|
|
1871
|
+
|
|
1872
|
+
result = result.replace(regex, (match) => {
|
|
1873
|
+
matchCount++;
|
|
1874
|
+
// Check if this instance should be highlighted
|
|
1875
|
+
const shouldHighlight = !highlight.instances || highlight.instances.includes(matchCount);
|
|
1876
|
+
if (shouldHighlight) {
|
|
1877
|
+
return `<mark class="cm-mardora-code-text-highlight">${match}</mark>`;
|
|
1878
|
+
}
|
|
1879
|
+
return match;
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
if (instanceCounters) {
|
|
1883
|
+
instanceCounters[highlightIndex] = matchCount;
|
|
1884
|
+
}
|
|
1885
|
+
} catch {
|
|
1886
|
+
// Invalid regex, skip
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
return result;
|
|
1891
|
+
}
|
|
1892
|
+
}
|