@8btc/mditor 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,437 @@
1
+ /**
2
+ * 为编辑区域添加行号标记:
3
+ * - 为所有包含 `data-block` 的块级节点写入 `data-linenumber`
4
+ * - 额外为每个 `ul` 的直接 `li` 子元素写入 `data-linenumber`(不修改 `ul` 本身)
5
+ * 通过解析传入的 Markdown 源文本,建立内容到源文件行号的映射,
6
+ * 兼顾多行文本、嵌套列表、重复内容及空白/特殊字符,并提供基础错误保护。
7
+ */
8
+ export const attachLineNumbersToBlocks = (
9
+ root: HTMLElement,
10
+ sourceMarkdown: string
11
+ ): void => {
12
+ if (!root || !sourceMarkdown) {
13
+ return;
14
+ }
15
+
16
+ const ZWSP = "\u200b";
17
+
18
+ const normalize = (text: string): string => {
19
+ return text
20
+ .replace(/\r\n|\r/g, "\n")
21
+ .replace(new RegExp(ZWSP, "g"), "")
22
+ .replace(/\u00a0/g, " ")
23
+ .replace(/\u2006/g, "")
24
+ .replace(/[\t\f\v ]+/g, " ");
25
+ };
26
+
27
+ const srcNorm = normalize(sourceMarkdown);
28
+ const srcLines = srcNorm.split("\n");
29
+ const stripInlineMD = (text: string): string => {
30
+ return (
31
+ text
32
+ .replace(/\\([*_`~])/g, "$1")
33
+ .replace(/\*\*|__/g, "")
34
+ .replace(/\*|_/g, "")
35
+ .replace(/~~/g, "")
36
+ .replace(/`+/g, "")
37
+ // 数学行内分隔符:保留内容,仅移除分隔符
38
+ .replace(/\\\(/g, "(")
39
+ .replace(/\\\)/g, ")")
40
+ .replace(/\\\[/g, "[")
41
+ .replace(/\\\]/g, "]")
42
+ .replace(/\$/g, "")
43
+ .trim()
44
+ );
45
+ };
46
+ const stripInlineForList = (text: string): string => {
47
+ return (
48
+ stripInlineMD(text)
49
+ // 移除 $...$(行内公式整体)
50
+ .replace(/\$(?:\\.|[^$])*\$/g, "")
51
+ // 移除 \(...\)、\[...\](行内/行间公式整体)
52
+ .replace(/\\\([^)]*\\\)/g, "")
53
+ .replace(/\\\[[^\]]*\\\]/g, "")
54
+ // 去除通用 TeX 命令,如 \alpha、\mathbf 等
55
+ .replace(/\\[a-zA-Z]+/g, "")
56
+ // 去除多余大括号
57
+ .replace(/[{}]/g, "")
58
+ .trim()
59
+ );
60
+ };
61
+ const strippedLines = srcLines.map((l) => stripInlineMD(l));
62
+ const lineLookup = new Map<string, number[]>();
63
+ for (let i = 0; i < strippedLines.length; i++) {
64
+ const key = strippedLines[i];
65
+ if (!lineLookup.has(key)) {
66
+ lineLookup.set(key, [i + 1]);
67
+ } else {
68
+ lineLookup.get(key)!.push(i + 1);
69
+ }
70
+ }
71
+ const usedLines = new Set<number>();
72
+ const pickFirstUnused = (candidates?: number[]): number | -1 => {
73
+ if (!candidates || candidates.length === 0) return -1;
74
+ for (const ln of candidates) {
75
+ if (!usedLines.has(ln)) return ln;
76
+ }
77
+ return -1;
78
+ };
79
+ const findLineNumberByText = (text: string): number | -1 => {
80
+ const stripped = stripInlineMD(text);
81
+ const ln = pickFirstUnused(lineLookup.get(stripped));
82
+ if (ln !== -1) return ln;
83
+ for (let i = 0; i < strippedLines.length; i++) {
84
+ if (
85
+ !usedLines.has(i + 1) &&
86
+ stripped &&
87
+ strippedLines[i].indexOf(stripped) !== -1
88
+ ) {
89
+ return i + 1;
90
+ }
91
+ }
92
+ return -1;
93
+ };
94
+
95
+ /**
96
+ * 预处理:收集 Markdown 中所有无序列表(ul)行及其去除行内标记后的内容,构建快速查找结构。
97
+ * 仅匹配以 `*`、`-`、`+` 开头的条目,支持任务列表如 `- [ ]`、`* [x]`。
98
+ */
99
+ const unorderedListEntries: Array<{ ln: number; content: string }> = [];
100
+ const unorderedLookup = new Map<string, number[]>();
101
+ const usedUnorderedLines = new Set<number>();
102
+ for (let i = 0; i < srcLines.length; i++) {
103
+ const raw = srcLines[i];
104
+ const m = raw.match(/^\s*[*+-]\s+(?:\[(?: |x|X)\]\s+)?(.*)$/);
105
+ if (m && typeof m[1] === "string") {
106
+ const contentStripped = stripInlineForList(m[1]);
107
+ unorderedListEntries.push({ ln: i + 1, content: contentStripped });
108
+ if (!unorderedLookup.has(contentStripped)) {
109
+ unorderedLookup.set(contentStripped, [i + 1]);
110
+ } else {
111
+ unorderedLookup.get(contentStripped)!.push(i + 1);
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * 从无序列表候选行中为给定文本选择一个行号。
118
+ * 优先精确匹配(去标记后完全相同),否则回退到包含匹配。
119
+ */
120
+ const pickFirstUnusedUnordered = (candidates?: number[]): number | -1 => {
121
+ if (!candidates || candidates.length === 0) return -1;
122
+ for (const ln of candidates) {
123
+ if (!usedUnorderedLines.has(ln)) return ln;
124
+ }
125
+ return -1;
126
+ };
127
+ const findUnorderedListLineNumber = (text: string): number | -1 => {
128
+ const stripped = stripInlineForList(text);
129
+ // 先尝试精确匹配
130
+ const exact = pickFirstUnusedUnordered(unorderedLookup.get(stripped));
131
+ if (exact !== -1) return exact;
132
+ // 回退到包含匹配
133
+ for (let i = 0; i < unorderedListEntries.length; i++) {
134
+ const entry = unorderedListEntries[i];
135
+ if (usedUnorderedLines.has(entry.ln)) continue;
136
+ if (!stripped) continue;
137
+ if (
138
+ entry.content.indexOf(stripped) !== -1 ||
139
+ stripped.indexOf(entry.content) !== -1
140
+ ) {
141
+ return entry.ln;
142
+ }
143
+ }
144
+ return -1;
145
+ };
146
+
147
+ /**
148
+ * 预处理:收集 Markdown 中所有有序列表(ol)行及其内容,支持 `1.` 或 `1)` 以及任务列表前缀。
149
+ */
150
+ const orderedListEntries: Array<{ ln: number; content: string }> = [];
151
+ const orderedLookup = new Map<string, number[]>();
152
+ const usedOrderedLines = new Set<number>();
153
+ const ORDERED_RE =
154
+ /\s*\d+(?:[.)、.。]|[\uFF0E\uFF09\u3001])\s+(?:\[(?: |x|X)\]\s+)?(.*)/;
155
+ for (let i = 0; i < srcLines.length; i++) {
156
+ const raw = srcLines[i];
157
+ const m = raw.match(new RegExp(`^${ORDERED_RE.source}$`));
158
+ if (m && typeof m[1] === "string") {
159
+ const contentStripped = stripInlineForList(m[1]);
160
+ orderedListEntries.push({ ln: i + 1, content: contentStripped });
161
+ if (!orderedLookup.has(contentStripped)) {
162
+ orderedLookup.set(contentStripped, [i + 1]);
163
+ } else {
164
+ orderedLookup.get(contentStripped)!.push(i + 1);
165
+ }
166
+ }
167
+ }
168
+ console.debug("[LineNumber][ol:index]", {
169
+ total: orderedListEntries.length,
170
+ });
171
+
172
+ const orderedGroups: Array<number[]> = [];
173
+ for (let i = 0; i < srcLines.length; ) {
174
+ const raw = srcLines[i];
175
+ if (new RegExp(`^${ORDERED_RE.source}$`).test(raw)) {
176
+ const group: number[] = [];
177
+ while (
178
+ i < srcLines.length &&
179
+ new RegExp(`^${ORDERED_RE.source}$`).test(srcLines[i])
180
+ ) {
181
+ group.push(i + 1);
182
+ i++;
183
+ }
184
+ if (group.length > 0) {
185
+ orderedGroups.push(group);
186
+ }
187
+ } else {
188
+ i++;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * 为给定文本在有序列表候选中选择行号,精确匹配优先,包含匹配回退。
194
+ */
195
+ const pickFirstUnusedOrdered = (candidates?: number[]): number | -1 => {
196
+ if (!candidates || candidates.length === 0) return -1;
197
+ for (const ln of candidates) {
198
+ if (!usedOrderedLines.has(ln)) return ln;
199
+ }
200
+ return -1;
201
+ };
202
+ const findOrderedListLineNumber = (text: string): number | -1 => {
203
+ const stripped = stripInlineForList(text);
204
+ const exact = pickFirstUnusedOrdered(orderedLookup.get(stripped));
205
+ if (exact !== -1) return exact;
206
+ for (let i = 0; i < orderedListEntries.length; i++) {
207
+ const entry = orderedListEntries[i];
208
+ if (usedOrderedLines.has(entry.ln)) continue;
209
+ if (!stripped) continue;
210
+ if (
211
+ entry.content.indexOf(stripped) !== -1 ||
212
+ stripped.indexOf(entry.content) !== -1
213
+ ) {
214
+ return entry.ln;
215
+ }
216
+ }
217
+ return -1;
218
+ };
219
+
220
+ /**
221
+ * 获取 `li` 的直接内容文本:仅拼接其直系子节点中的文本,排除嵌套的 `UL/OL` 列表。
222
+ * 以便在嵌套列表场景下准确定位每个条目对应的源行。
223
+ */
224
+ const getImmediateLiText = (li: HTMLElement): string => {
225
+ let buf = "";
226
+ li.childNodes.forEach((node) => {
227
+ if (node.nodeType === 3) {
228
+ buf += (node as Text).data;
229
+ } else if (
230
+ (node as HTMLElement).tagName !== "UL" &&
231
+ (node as HTMLElement).tagName !== "OL" &&
232
+ !(node as HTMLElement).classList?.contains("katex")
233
+ ) {
234
+ buf += (node as HTMLElement).textContent || "";
235
+ }
236
+ });
237
+ return buf;
238
+ };
239
+
240
+ const blocks = root.querySelectorAll("[data-block]");
241
+ blocks.forEach((el) => {
242
+ try {
243
+ const container = el as HTMLElement;
244
+ const dataType = container.getAttribute("data-type") || "";
245
+ if (dataType === "math-block" || dataType === "code-block") {
246
+ // container.setAttribute("data-linenumber", "");
247
+ return;
248
+ }
249
+ const text = container.textContent || "";
250
+ const normText = normalize(text);
251
+ // 跳过纯空白块
252
+ if (!normText.trim()) {
253
+ // container.setAttribute("data-linenumber", "");
254
+ return;
255
+ }
256
+
257
+ let lineNumber = -1;
258
+
259
+ const tag = container.tagName;
260
+ if (tag === "BLOCKQUOTE") {
261
+ const firstLine =
262
+ normText.split("\n").find((l) => l.trim().length > 0) ||
263
+ normText;
264
+ const stripped = stripInlineMD(firstLine);
265
+ for (let i = 0; i < srcLines.length; i++) {
266
+ if (usedLines.has(i + 1)) continue;
267
+ if (/^\s*>+\s/.test(srcLines[i])) {
268
+ const content = stripInlineMD(
269
+ srcLines[i].replace(/^\s*>+\s+/, "")
270
+ );
271
+ if (content.indexOf(stripped) !== -1) {
272
+ lineNumber = i + 1;
273
+ break;
274
+ }
275
+ }
276
+ }
277
+ } else {
278
+ const firstLine =
279
+ normText.split("\n").find((l) => l.trim().length > 0) ||
280
+ normText;
281
+ lineNumber = findLineNumberByText(firstLine);
282
+ }
283
+
284
+ if (lineNumber !== -1) {
285
+ usedLines.add(lineNumber);
286
+ container.setAttribute("data-linenumber", String(lineNumber));
287
+ }
288
+ } catch {
289
+ void 0;
290
+ }
291
+ });
292
+
293
+ /**
294
+ * 为所有 `ul` 下的每个直接 `li` 子元素追加 `data-linenumber`,不修改 `ul` 本身。
295
+ * 支持多层嵌套与非文本起始节点(如加粗/行内代码)。
296
+ */
297
+ const ulElements = root.querySelectorAll("ul");
298
+ ulElements.forEach((ul) => {
299
+ try {
300
+ const children = Array.from(ul.children);
301
+ for (const child of children) {
302
+ if (child.tagName !== "LI") continue;
303
+ const li = child as HTMLElement;
304
+ const rawText = getImmediateLiText(li);
305
+ const norm = normalize(rawText);
306
+ const firstLine =
307
+ norm.split("\n").find((l) => l.trim().length > 0) || norm;
308
+
309
+ const ln = findUnorderedListLineNumber(firstLine);
310
+ if (ln !== -1) {
311
+ usedUnorderedLines.add(ln);
312
+ li.setAttribute("data-linenumber", String(ln));
313
+ } else {
314
+ li.setAttribute("data-linenumber", "");
315
+ }
316
+ }
317
+ } catch {
318
+ void 0;
319
+ }
320
+ });
321
+
322
+ /**
323
+ * 为所有 `ol` 下的每个直接 `li` 子元素追加 `data-linenumber`,不修改 `ol` 本身。
324
+ */
325
+ const olElements = root.querySelectorAll("ol");
326
+ olElements.forEach((ol) => {
327
+ try {
328
+ let currentGroupIdx = -1;
329
+ let lastAssigned = -1;
330
+ const children = Array.from(ol.children);
331
+ for (const child of children) {
332
+ if (child.tagName !== "LI") continue;
333
+ const li = child as HTMLElement;
334
+ const rawText = getImmediateLiText(li);
335
+ const norm = normalize(rawText);
336
+ const firstLine =
337
+ norm.split("\n").find((l) => l.trim().length > 0) || norm;
338
+
339
+ const stripped0 = stripInlineForList(firstLine);
340
+ const stripped = stripped0 || stripInlineMD(firstLine);
341
+ const candidates = orderedLookup.get(stripped) || [];
342
+ const available = candidates.filter(
343
+ (n) => !usedOrderedLines.has(n)
344
+ );
345
+ const ln = findOrderedListLineNumber(firstLine);
346
+ if (ln !== -1) {
347
+ usedOrderedLines.add(ln);
348
+ li.setAttribute("data-linenumber", String(ln));
349
+ // 成功映射日志
350
+ console.debug("[LineNumber][ol>li]", {
351
+ text: firstLine,
352
+ stripped,
353
+ line: ln,
354
+ });
355
+ // 记录当前组
356
+ for (let gi = 0; gi < orderedGroups.length; gi++) {
357
+ if (orderedGroups[gi].includes(ln)) {
358
+ currentGroupIdx = gi;
359
+ break;
360
+ }
361
+ }
362
+ lastAssigned = ln;
363
+ } else {
364
+ // 顺序回退:基于当前组或猜测组,按序分配下一未使用行
365
+ let assigned = -1;
366
+ const pickNextFromGroup = (gi: number): number | -1 => {
367
+ if (gi < 0 || gi >= orderedGroups.length) return -1;
368
+ const lines = orderedGroups[gi];
369
+ // 优先从上次分配后续查找
370
+ let startIdx = 0;
371
+ if (lastAssigned !== -1) {
372
+ const idx = lines.indexOf(lastAssigned);
373
+ startIdx = idx >= 0 ? idx + 1 : 0;
374
+ }
375
+ for (let k = startIdx; k < lines.length; k++) {
376
+ const cand = lines[k];
377
+ if (!usedOrderedLines.has(cand)) return cand;
378
+ }
379
+ for (let k = 0; k < startIdx; k++) {
380
+ const cand = lines[k];
381
+ if (!usedOrderedLines.has(cand)) return cand;
382
+ }
383
+ return -1;
384
+ };
385
+
386
+ if (currentGroupIdx !== -1) {
387
+ assigned = pickNextFromGroup(currentGroupIdx);
388
+ }
389
+ if (assigned === -1) {
390
+ // 猜测组:选择拥有可用行的首个组
391
+ for (
392
+ let gi = 0;
393
+ gi < orderedGroups.length && assigned === -1;
394
+ gi++
395
+ ) {
396
+ const cand = pickNextFromGroup(gi);
397
+ if (cand !== -1) {
398
+ currentGroupIdx = gi;
399
+ assigned = cand;
400
+ }
401
+ }
402
+ }
403
+
404
+ if (assigned !== -1) {
405
+ usedOrderedLines.add(assigned);
406
+ li.setAttribute("data-linenumber", String(assigned));
407
+ console.debug("[LineNumber][ol>li][fallback-seq]", {
408
+ text: firstLine,
409
+ stripped,
410
+ line: assigned,
411
+ group: currentGroupIdx,
412
+ });
413
+ lastAssigned = assigned;
414
+ } else {
415
+ li.setAttribute("data-linenumber", "");
416
+ // 失败诊断日志
417
+ console.warn("[LineNumber][ol>li][missing]", {
418
+ text: firstLine,
419
+ stripped,
420
+ candidates,
421
+ available,
422
+ reason: !stripped0
423
+ ? "empty-after-strip"
424
+ : candidates.length === 0
425
+ ? "no-index-candidates"
426
+ : available.length === 0
427
+ ? "all-candidates-consumed"
428
+ : "fallback-no-match",
429
+ });
430
+ }
431
+ }
432
+ }
433
+ } catch {
434
+ void 0;
435
+ }
436
+ });
437
+ };
@@ -1,11 +1,15 @@
1
- import {getMarkdown} from "../markdown/getMarkdown";
2
- import {accessLocalStorage} from "../util/compatibility";
1
+ import { getMarkdown } from "../markdown/getMarkdown";
2
+ import { accessLocalStorage } from "../util/compatibility";
3
+ import { attachLineNumbersToBlocks } from "../util/attachLineNumbers";
3
4
 
4
- export const afterRenderEvent = (vditor: IVditor, options = {
5
- enableAddUndoStack: true,
6
- enableHint: false,
7
- enableInput: true,
8
- }) => {
5
+ export const afterRenderEvent = (
6
+ vditor: IVditor,
7
+ options = {
8
+ enableAddUndoStack: true,
9
+ enableHint: false,
10
+ enableInput: true,
11
+ }
12
+ ) => {
9
13
  if (options.enableHint) {
10
14
  vditor.hint.render(vditor);
11
15
  }
@@ -37,5 +41,11 @@ export const afterRenderEvent = (vditor: IVditor, options = {
37
41
  if (options.enableAddUndoStack) {
38
42
  vditor.undo.addToUndoStack(vditor);
39
43
  }
44
+
45
+ try {
46
+ attachLineNumbersToBlocks(vditor.wysiwyg.element, text);
47
+ } catch {
48
+ void 0;
49
+ }
40
50
  }, vditor.options.undoDelay);
41
51
  };