@comitor/ui 3.4.0 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -5
- package/dist/components/ui/item.d.ts +1 -1
- package/dist/editor/bubble-toolbar.d.ts +53 -0
- package/dist/editor/bubble-toolbar.js +231 -0
- package/dist/editor/bubble-toolbar.js.map +1 -0
- package/dist/editor/editor-core.d.ts +28 -0
- package/dist/editor/editor-core.js +354 -0
- package/dist/editor/editor-core.js.map +1 -0
- package/dist/editor/editor-types.d.ts +3 -0
- package/dist/editor/editor-types.js +1 -0
- package/dist/editor/editor-types.js.map +1 -0
- package/dist/editor/fixed-toolbar.d.ts +19 -0
- package/dist/editor/fixed-toolbar.js +106 -0
- package/dist/editor/fixed-toolbar.js.map +1 -0
- package/dist/editor/image-upload.d.ts +3 -0
- package/dist/editor/image-upload.js +137 -0
- package/dist/editor/image-upload.js.map +1 -0
- package/dist/editor/index.d.ts +6 -0
- package/dist/editor/index.js +11 -0
- package/dist/editor/index.js.map +1 -0
- package/dist/editor/markdown-editor-classes.d.ts +16 -0
- package/dist/editor/markdown-editor-classes.js +119 -0
- package/dist/editor/markdown-editor-classes.js.map +1 -0
- package/dist/editor/markdown-editor-constants.d.ts +98 -0
- package/dist/editor/markdown-editor-constants.js +6 -0
- package/dist/editor/markdown-editor-constants.js.map +1 -0
- package/dist/editor/markdown-editor.d.ts +48 -0
- package/dist/editor/markdown-editor.js +33 -0
- package/dist/editor/markdown-editor.js.map +1 -0
- package/dist/editor/markdown-extensions.d.ts +96 -0
- package/dist/editor/markdown-extensions.js +291 -0
- package/dist/editor/markdown-extensions.js.map +1 -0
- package/dist/editor/markdown-syntax.d.ts +87 -0
- package/dist/editor/markdown-syntax.js +58 -0
- package/dist/editor/markdown-syntax.js.map +1 -0
- package/dist/editor/rich-text-editor.d.ts +48 -0
- package/dist/editor/rich-text-editor.js +33 -0
- package/dist/editor/rich-text-editor.js.map +1 -0
- package/dist/editor/slash-commands.d.ts +65 -0
- package/dist/editor/slash-commands.js +191 -0
- package/dist/editor/slash-commands.js.map +1 -0
- package/dist/editor/suggestion-menu.d.ts +38 -0
- package/dist/editor/suggestion-menu.js +144 -0
- package/dist/editor/suggestion-menu.js.map +1 -0
- package/dist/editor/suggestion-store.d.ts +47 -0
- package/dist/editor/suggestion-store.js +49 -0
- package/dist/editor/suggestion-store.js.map +1 -0
- package/dist/editor/toolbar-actions.d.ts +44 -0
- package/dist/editor/toolbar-actions.js +173 -0
- package/dist/editor/toolbar-actions.js.map +1 -0
- package/dist/editor/toolbar-button.d.ts +15 -0
- package/dist/editor/toolbar-button.js +47 -0
- package/dist/editor/toolbar-button.js.map +1 -0
- package/dist/editor-types-BMST7-VR.d.ts +171 -0
- package/dist/i18n/locale-provider.d.ts +1 -0
- package/dist/i18n/messages.d.ts +2 -0
- package/dist/i18n/messages.js +3 -0
- package/dist/i18n/messages.js.map +1 -1
- package/dist/i18n/namespaces/markdown-editor.d.ts +6 -0
- package/dist/i18n/namespaces/markdown-editor.js +133 -0
- package/dist/i18n/namespaces/markdown-editor.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/package.json +79 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Extension, AnyExtension } from '@tiptap/core';
|
|
2
|
+
import { MentionOptions } from '@tiptap/extension-mention';
|
|
3
|
+
import { Slice } from '@tiptap/pm/model';
|
|
4
|
+
import { f as EditorFormat } from '../editor-types-BMST7-VR.js';
|
|
5
|
+
import './markdown-editor-constants.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* LƯỢC ĐỒ của hai trình soạn — danh sách extension Tiptap, không React, không DOM.
|
|
9
|
+
*
|
|
10
|
+
* Mọi thứ quyết định "tài liệu này lưu xuống thành chuỗi nào, và đọc lên lại thành tài liệu nào"
|
|
11
|
+
* nằm ở đây, và CHỈ ở đây: trình soạn dùng nó, cổng `test:editor-markdown` dựng `MarkdownManager`
|
|
12
|
+
* từ chính hàm này trên `dist/` rồi đo vòng tài liệu → markdown → tài liệu. Hai nơi mà dùng hai danh
|
|
13
|
+
* sách là hai lược đồ, và cổng xanh trên một lược đồ không ai dùng.
|
|
14
|
+
*
|
|
15
|
+
* Hai định dạng LƯU, hai lược đồ khác nhau ở đúng những chỗ định dạng ấy biểu diễn được hay không
|
|
16
|
+
* (`createSchemaExtensions`): markdown (`MarkdownEditor`) hẹp hơn — không gạch chân, ô bảng một đoạn —
|
|
17
|
+
* vì thứ gì không ghi được thành markdown thì sẽ mất ở lần lưu; HTML (`RichTextEditor`) giữ được cả hai.
|
|
18
|
+
*
|
|
19
|
+
* Phần GIAO DIỆN (menu `/`, menu `@`, tải ảnh, phím tắt) gắn vào qua tham số — xem
|
|
20
|
+
* `MarkdownSchemaOptions`. Gọi không tham số là ra đúng lược đồ, đủ để đọc/ghi.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Giá trị đổi được SAU KHI trình soạn đã dựng. Extension đọc chúng lúc CẦN (lúc vẽ chữ mờ, lúc vẽ một
|
|
25
|
+
* lần nhắc tên…), không chép lúc dựng — vì đổi danh sách extension là dựng lại cả trình soạn: mất
|
|
26
|
+
* lịch sử hoàn tác, mất con trỏ. Component giữ object này trong một ref và ghi đè từng lượt render.
|
|
27
|
+
*/
|
|
28
|
+
interface MarkdownEditorRuntime {
|
|
29
|
+
placeholder: string;
|
|
30
|
+
taskCheckbox: (text: string) => string;
|
|
31
|
+
/** Đổi `src` ĐÃ LƯU thành địa chỉ HIỂN THỊ được (vd URL đã ký). Markdown vẫn giữ `src` gốc. */
|
|
32
|
+
resolveImageSrc?: (src: string) => string;
|
|
33
|
+
/** Tên SỐNG của một người theo id. `undefined` thì rơi về tên chụp lúc nhắc. */
|
|
34
|
+
resolveMentionLabel?: (id: string) => string | undefined;
|
|
35
|
+
}
|
|
36
|
+
interface MarkdownSchemaOptions {
|
|
37
|
+
/** Định dạng LƯU — quyết định lược đồ. Mặc định `markdown`. */
|
|
38
|
+
format?: EditorFormat;
|
|
39
|
+
/** Đọc lười — xem `MarkdownEditorRuntime`. Bỏ trống ở nơi chỉ cần đọc/ghi (cổng, máy chủ). */
|
|
40
|
+
runtime?: {
|
|
41
|
+
readonly current: MarkdownEditorRuntime;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Cấu hình gợi ý `@` của `Mention` (danh sách, cách vẽ, `allow`). Bỏ trống = node `mention` vẫn có
|
|
45
|
+
* trong lược đồ — để tài liệu CÓ nhắc tên vẫn đọc ra đúng ở chế độ chỉ đọc — nhưng gõ `@` không
|
|
46
|
+
* mở gì.
|
|
47
|
+
*/
|
|
48
|
+
mentionSuggestion?: Partial<MentionOptions["suggestion"]>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Gộp một đoạn dán thành MỘT dòng nội dòng: các khối chữ nối nhau bằng một dấu cách, xuống dòng (cả
|
|
52
|
+
* `hardBreak` lẫn `\n` trong khối mã) thành dấu cách, khối không phải chữ (ảnh, đường kẻ) bỏ đi.
|
|
53
|
+
* Nội dung nội dòng sẵn thì để nguyên; `keepTables` (đoạn dán GỐC có bảng) thì đoạn dán mang ô bảng
|
|
54
|
+
* cũng để nguyên — dán theo ô là việc của `prosemirror-tables`.
|
|
55
|
+
* Xuất ra cho cổng `test:editor-markdown`; không nằm trong entry.
|
|
56
|
+
*/
|
|
57
|
+
declare function flattenSliceToLine(slice: Slice, keepTables?: boolean): Slice;
|
|
58
|
+
/**
|
|
59
|
+
* Danh sách extension tạo nên lược đồ của trình soạn, theo định dạng LƯU.
|
|
60
|
+
*
|
|
61
|
+
* Markdown (`format: "markdown"`, mặc định) hẹp hơn HTML ở hai chỗ, cả hai vì cùng một lý do — thứ gì
|
|
62
|
+
* không ghi được thành markdown thì MẤT ở lần lưu, mà người dùng không được báo:
|
|
63
|
+
* · `underline` tắt: markdown không có cú pháp gạch chân. `@tiptap/markdown` ghi nó thành
|
|
64
|
+
* `++chữ++` — cú pháp riêng, nên email thông báo, tác tử đọc qua MCP và mọi trình xem markdown
|
|
65
|
+
* khác in nguyên hai dấu cộng;
|
|
66
|
+
* · ô bảng chỉ chứa MỘT đoạn văn (`ComitorTableCell`).
|
|
67
|
+
* Hai bản vá markdown (`ComitorParagraph`, `Markdown` với `Marked` riêng) cũng chỉ nạp ở định dạng đó.
|
|
68
|
+
*
|
|
69
|
+
* HTML (`format: "html"`) giữ cả gạch chân lẫn ô bảng nhiều khối — `<u>`, `<td><ul>…</ul></td>` là HTML
|
|
70
|
+
* hợp lệ và đọc lên lại đúng.
|
|
71
|
+
*/
|
|
72
|
+
declare function createSchemaExtensions(options?: MarkdownSchemaOptions): AnyExtension[];
|
|
73
|
+
/** Lược đồ của `MarkdownEditor` — tên có từ `3.5.0`, cổng `test:editor-markdown` gọi thẳng nó. */
|
|
74
|
+
declare function createMarkdownSchemaExtensions(options?: Omit<MarkdownSchemaOptions, "format">): AnyExtension[];
|
|
75
|
+
/** Phím tắt riêng của trình soạn — xem `createEditorKeymap`. */
|
|
76
|
+
interface EditorKeymapHandlers {
|
|
77
|
+
/** ⌘/Ctrl+K — mở ô liên kết. Trả `true` khi đã xử lý. */
|
|
78
|
+
onLinkShortcut: () => boolean;
|
|
79
|
+
/** ⌘/Ctrl+Enter ngoài danh sách việc. Trả `false` để phím đi tiếp (xuống dòng cứng của Tiptap). */
|
|
80
|
+
onSubmitShortcut: () => boolean;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Phím tắt của RIÊNG gói, ưu tiên cao hơn mặc định của Tiptap.
|
|
84
|
+
*
|
|
85
|
+
* ⌘/Ctrl+Enter: trong danh sách việc thì tick/bỏ tick mục đang đứng (như Linear); ngoài đó thì gửi
|
|
86
|
+
* (`onSubmit`) — đúng phím mà ô bình luận nào cũng dùng. Không có `onSubmit` thì trả phím về cho
|
|
87
|
+
* Tiptap (bản gốc gán nó cho xuống dòng cứng).
|
|
88
|
+
*
|
|
89
|
+
* ⌘/Ctrl+K: ô liên kết. Không đụng bảng lệnh của shell — listener của shell tự bỏ qua phím khi
|
|
90
|
+
* đích là vùng `contenteditable` (`shell-context.tsx`, nhánh `isTyping`).
|
|
91
|
+
*/
|
|
92
|
+
declare function createEditorKeymap(handlers: {
|
|
93
|
+
readonly current: EditorKeymapHandlers;
|
|
94
|
+
}, format?: EditorFormat): Extension;
|
|
95
|
+
|
|
96
|
+
export { type EditorKeymapHandlers, type MarkdownEditorRuntime, type MarkdownSchemaOptions, createEditorKeymap, createMarkdownSchemaExtensions, createSchemaExtensions, flattenSliceToLine };
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { Extension, InputRule, mergeAttributes } from "@tiptap/core";
|
|
2
|
+
import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight";
|
|
3
|
+
import { Image } from "@tiptap/extension-image";
|
|
4
|
+
import { TaskItem, TaskList } from "@tiptap/extension-list";
|
|
5
|
+
import { Mention } from "@tiptap/extension-mention";
|
|
6
|
+
import { Paragraph } from "@tiptap/extension-paragraph";
|
|
7
|
+
import { renderTableToMarkdown, Table, TableCell, TableHeader, TableKit } from "@tiptap/extension-table";
|
|
8
|
+
import { Placeholder } from "@tiptap/extensions";
|
|
9
|
+
import { Markdown } from "@tiptap/markdown";
|
|
10
|
+
import { Fragment, Slice } from "@tiptap/pm/model";
|
|
11
|
+
import { Plugin } from "@tiptap/pm/state";
|
|
12
|
+
import { StarterKit } from "@tiptap/starter-kit";
|
|
13
|
+
import { common, createLowlight } from "lowlight";
|
|
14
|
+
import { Marked } from "marked";
|
|
15
|
+
import {
|
|
16
|
+
codeFence,
|
|
17
|
+
escapeParagraphMarkdown,
|
|
18
|
+
imageMarkdown,
|
|
19
|
+
parseMentionMarkdown,
|
|
20
|
+
renderMentionMarkdown
|
|
21
|
+
} from "./markdown-syntax.js";
|
|
22
|
+
const DEFAULT_RUNTIME = {
|
|
23
|
+
placeholder: "",
|
|
24
|
+
taskCheckbox: (text) => text
|
|
25
|
+
};
|
|
26
|
+
const lowlight = createLowlight(common);
|
|
27
|
+
const ComitorParagraph = Paragraph.extend({
|
|
28
|
+
renderMarkdown(node, helpers, context) {
|
|
29
|
+
return escapeParagraphMarkdown(this.parent?.(node, helpers, context) ?? "");
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
const ComitorCodeBlock = CodeBlockLowlight.extend({
|
|
33
|
+
renderMarkdown(node) {
|
|
34
|
+
const language = typeof node.attrs?.language === "string" ? node.attrs.language : "";
|
|
35
|
+
const text = (node.content ?? []).map((child) => child.text ?? "").join("");
|
|
36
|
+
const fence = codeFence(text);
|
|
37
|
+
return `${fence}${language}
|
|
38
|
+
${text}
|
|
39
|
+
${fence}`;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
const ComitorTable = Table.extend({
|
|
43
|
+
renderMarkdown(node, helpers) {
|
|
44
|
+
return renderTableToMarkdown(node, helpers).replace(/^\n+|\n+$/g, "");
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const ComitorTableCell = TableCell.extend({ content: "paragraph" });
|
|
48
|
+
const ComitorTableHeader = TableHeader.extend({ content: "paragraph" });
|
|
49
|
+
const HORIZONTAL_RULE_INPUT = /^(?:---|—-|___\s|\*\*\*\s)$/;
|
|
50
|
+
const MarkdownTableCellGuard = Extension.create({
|
|
51
|
+
name: "comitorMarkdownTableCellGuard",
|
|
52
|
+
// Cao hơn StarterKit (100): mỗi extension một plugin luật nhập, xếp theo `priority` — luật dưới đây
|
|
53
|
+
// phải được hỏi TRƯỚC luật đường kẻ, không thì đường kẻ đã chèn xong.
|
|
54
|
+
priority: 1e3,
|
|
55
|
+
addInputRules() {
|
|
56
|
+
return [
|
|
57
|
+
new InputRule({
|
|
58
|
+
find: HORIZONTAL_RULE_INPUT,
|
|
59
|
+
undoable: false,
|
|
60
|
+
handler: ({ state, range, match }) => {
|
|
61
|
+
if (!isInTableCell(state.doc.resolve(range.from))) return null;
|
|
62
|
+
state.tr.insertText(match[0], range.from, range.to);
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
];
|
|
66
|
+
},
|
|
67
|
+
addProseMirrorPlugins() {
|
|
68
|
+
let pastedTable = false;
|
|
69
|
+
return [
|
|
70
|
+
new Plugin({
|
|
71
|
+
props: {
|
|
72
|
+
transformPastedHTML: (html) => {
|
|
73
|
+
pastedTable = /<(?:table|tr|td|th)[\s>/]/i.test(html);
|
|
74
|
+
return html;
|
|
75
|
+
},
|
|
76
|
+
transformPastedText: (text) => {
|
|
77
|
+
pastedTable = false;
|
|
78
|
+
return text;
|
|
79
|
+
},
|
|
80
|
+
transformPasted: (slice, view) => {
|
|
81
|
+
const keepTables = pastedTable;
|
|
82
|
+
pastedTable = false;
|
|
83
|
+
return isInTableCell(view.state.selection.$from) ? flattenSliceToLine(slice, keepTables) : slice;
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
filterTransaction: (tr, state) => {
|
|
87
|
+
if (!tr.docChanged || tr.getMeta("history$") || !isInTableCell(state.selection.$from)) return true;
|
|
88
|
+
if (replacesWholeDocument(tr.steps, state.doc.content.size)) return true;
|
|
89
|
+
return countTables(tr.doc) <= countTables(state.doc);
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
function flattenSliceToLine(slice, keepTables = false) {
|
|
96
|
+
const first = slice.content.firstChild;
|
|
97
|
+
if (!first || first.isInline) return slice;
|
|
98
|
+
if (keepTables) {
|
|
99
|
+
let tabular = false;
|
|
100
|
+
slice.content.descendants((node) => {
|
|
101
|
+
if (node.type.spec.tableRole) tabular = true;
|
|
102
|
+
return !tabular;
|
|
103
|
+
});
|
|
104
|
+
if (tabular) return slice;
|
|
105
|
+
}
|
|
106
|
+
const schema = first.type.schema;
|
|
107
|
+
const line = [];
|
|
108
|
+
slice.content.descendants((node) => {
|
|
109
|
+
if (!node.isTextblock) return true;
|
|
110
|
+
if (node.content.size === 0) return false;
|
|
111
|
+
if (line.length > 0) line.push(schema.text(" "));
|
|
112
|
+
node.content.forEach((child) => {
|
|
113
|
+
if (child.type.name === "hardBreak") line.push(schema.text(" "));
|
|
114
|
+
else if (child.isText && child.text?.includes("\n"))
|
|
115
|
+
line.push(schema.text(child.text.replace(/\s*\n\s*/g, " "), child.marks));
|
|
116
|
+
else line.push(child);
|
|
117
|
+
});
|
|
118
|
+
return false;
|
|
119
|
+
});
|
|
120
|
+
return new Slice(Fragment.from(line), 0, 0);
|
|
121
|
+
}
|
|
122
|
+
function countTables(doc) {
|
|
123
|
+
let count = 0;
|
|
124
|
+
doc.descendants((node) => {
|
|
125
|
+
if (node.type.name === "table") count++;
|
|
126
|
+
return node.type.name !== "table";
|
|
127
|
+
});
|
|
128
|
+
return count;
|
|
129
|
+
}
|
|
130
|
+
function replacesWholeDocument(steps, size) {
|
|
131
|
+
return steps.some((step) => {
|
|
132
|
+
const { from, to } = step;
|
|
133
|
+
return from === 0 && to === size;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function createMarkedInstance() {
|
|
137
|
+
return new Marked();
|
|
138
|
+
}
|
|
139
|
+
function createSchemaExtensions(options = {}) {
|
|
140
|
+
const runtime = options.runtime ?? { current: DEFAULT_RUNTIME };
|
|
141
|
+
const markdown = (options.format ?? "markdown") === "markdown";
|
|
142
|
+
return [
|
|
143
|
+
StarterKit.configure({
|
|
144
|
+
paragraph: false,
|
|
145
|
+
codeBlock: false,
|
|
146
|
+
...markdown ? { underline: false } : {},
|
|
147
|
+
link: {
|
|
148
|
+
// Đang sửa thì bấm vào liên kết là đặt con trỏ, không phải mở tab mới (thanh định dạng có nút
|
|
149
|
+
// Mở). Chỉ đọc thì Tiptap không chặn cú bấm — thẻ `<a target="_blank">` tự mở như thường.
|
|
150
|
+
openOnClick: false,
|
|
151
|
+
autolink: true,
|
|
152
|
+
linkOnPaste: true,
|
|
153
|
+
// Gõ/dán `[chữ](https://…)` là thành liên kết — người quen markdown gõ vậy theo phản xạ.
|
|
154
|
+
markdownLinks: true,
|
|
155
|
+
defaultProtocol: "https",
|
|
156
|
+
HTMLAttributes: { target: "_blank", rel: "noopener noreferrer nofollow" }
|
|
157
|
+
}
|
|
158
|
+
}),
|
|
159
|
+
markdown ? ComitorParagraph : Paragraph,
|
|
160
|
+
ComitorCodeBlock.configure({
|
|
161
|
+
lowlight,
|
|
162
|
+
defaultLanguage: null,
|
|
163
|
+
// Tab trong khối mã là thụt lề — đúng điều người viết mã chờ đợi. Thoát khối: ↓ ở dòng cuối,
|
|
164
|
+
// hoặc Enter ba lần (mặc định của Tiptap).
|
|
165
|
+
enableTabIndentation: true,
|
|
166
|
+
tabSize: 2
|
|
167
|
+
}),
|
|
168
|
+
TaskList,
|
|
169
|
+
TaskItem.configure({
|
|
170
|
+
nested: true,
|
|
171
|
+
// Chữ của ĐOẠN ĐẦU, không phải `node.textContent`: mục cha có danh sách con thì `textContent` dính
|
|
172
|
+
// liền chữ của mọi mục con ("Gửi mã qua emailGiới hạn 5 lần/giờ") — đã gặp.
|
|
173
|
+
a11y: { checkboxLabel: (node) => runtime.current.taskCheckbox(node.firstChild?.textContent ?? "") }
|
|
174
|
+
}),
|
|
175
|
+
...markdown ? [
|
|
176
|
+
TableKit.configure({ table: false, tableCell: false, tableHeader: false }),
|
|
177
|
+
ComitorTable.configure({ resizable: false }),
|
|
178
|
+
ComitorTableCell,
|
|
179
|
+
ComitorTableHeader,
|
|
180
|
+
MarkdownTableCellGuard
|
|
181
|
+
] : [TableKit.configure({ table: { resizable: false } })],
|
|
182
|
+
createImageExtension(runtime),
|
|
183
|
+
createMentionExtension(runtime, options.mentionSuggestion),
|
|
184
|
+
Placeholder.configure({ placeholder: () => runtime.current.placeholder }),
|
|
185
|
+
...markdown ? [Markdown.configure({ marked: createMarkedInstance(), indentation: { style: "space", size: 2 } })] : []
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
function createMarkdownSchemaExtensions(options = {}) {
|
|
189
|
+
return createSchemaExtensions({ ...options, format: "markdown" });
|
|
190
|
+
}
|
|
191
|
+
function createImageExtension(runtime) {
|
|
192
|
+
return Image.extend({
|
|
193
|
+
addNodeView() {
|
|
194
|
+
return ({ node, HTMLAttributes }) => {
|
|
195
|
+
const image = document.createElement("img");
|
|
196
|
+
setAttributes(image, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ["src"]);
|
|
197
|
+
const src = typeof node.attrs.src === "string" ? node.attrs.src : "";
|
|
198
|
+
if (src) image.setAttribute("src", runtime.current.resolveImageSrc?.(src) ?? src);
|
|
199
|
+
return { dom: image, update: (updated) => sameNode(updated, node) };
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
renderMarkdown(node) {
|
|
203
|
+
return imageMarkdown(String(node.attrs?.src ?? ""), node.attrs?.alt, node.attrs?.title);
|
|
204
|
+
}
|
|
205
|
+
}).configure({ allowBase64: false });
|
|
206
|
+
}
|
|
207
|
+
function createMentionExtension(runtime, suggestion) {
|
|
208
|
+
const storedLabelOf = (attrs) => {
|
|
209
|
+
const stored = typeof attrs.label === "string" ? attrs.label : "";
|
|
210
|
+
return stored || (typeof attrs.id === "string" ? attrs.id : "");
|
|
211
|
+
};
|
|
212
|
+
const labelOf = (attrs) => runtime.current.resolveMentionLabel?.(typeof attrs.id === "string" ? attrs.id : "") ?? storedLabelOf(attrs);
|
|
213
|
+
return Mention.extend({
|
|
214
|
+
markdownTokenName: "mention",
|
|
215
|
+
markdownTokenizer: {
|
|
216
|
+
name: "mention",
|
|
217
|
+
level: "inline",
|
|
218
|
+
start: (source) => source.indexOf("[@"),
|
|
219
|
+
tokenize: (source) => {
|
|
220
|
+
const parsed = parseMentionMarkdown(source);
|
|
221
|
+
return parsed ? { type: "mention", raw: parsed.raw, id: parsed.id, label: parsed.label } : void 0;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
parseMarkdown: (token, helpers) => helpers.createNode("mention", { id: token.id, label: token.label }),
|
|
225
|
+
renderMarkdown: (node) => renderMentionMarkdown(String(node.attrs?.id ?? ""), node.attrs?.label),
|
|
226
|
+
addNodeView() {
|
|
227
|
+
return ({ node, HTMLAttributes }) => {
|
|
228
|
+
const dom = document.createElement("span");
|
|
229
|
+
setAttributes(dom, mergeAttributes({ "data-type": this.name }, this.options.HTMLAttributes, HTMLAttributes));
|
|
230
|
+
dom.textContent = `@${labelOf(node.attrs)}`;
|
|
231
|
+
return { dom, update: (updated) => sameNode(updated, node) };
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}).configure({
|
|
235
|
+
renderText: ({ node }) => `@${labelOf(node.attrs)}`,
|
|
236
|
+
// `options.HTMLAttributes` ở đây là bản Tiptap đã trộn sẵn (`data-type`, `data-id`, `data-label`).
|
|
237
|
+
renderHTML: ({ options, node }) => ["span", options.HTMLAttributes, `@${storedLabelOf(node.attrs)}`],
|
|
238
|
+
suggestion: { char: "@", allow: () => false, ...suggestion }
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function createEditorKeymap(handlers, format = "markdown") {
|
|
242
|
+
const swallowInCell = (editor) => format === "markdown" && isInTableCell(editor.state.selection.$from);
|
|
243
|
+
return Extension.create({
|
|
244
|
+
name: "comitorEditorKeymap",
|
|
245
|
+
priority: 1e3,
|
|
246
|
+
addKeyboardShortcuts() {
|
|
247
|
+
return {
|
|
248
|
+
"Mod-k": () => handlers.current.onLinkShortcut(),
|
|
249
|
+
"Mod-Enter": ({ editor }) => {
|
|
250
|
+
const toggled = editor.commands.command(({ tr, state }) => {
|
|
251
|
+
const { $from } = state.selection;
|
|
252
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
253
|
+
const node = $from.node(depth);
|
|
254
|
+
if (node.type.name !== "taskItem") continue;
|
|
255
|
+
tr.setNodeMarkup($from.before(depth), void 0, { ...node.attrs, checked: !node.attrs.checked });
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
});
|
|
260
|
+
if (toggled || handlers.current.onSubmitShortcut()) return true;
|
|
261
|
+
return swallowInCell(editor);
|
|
262
|
+
},
|
|
263
|
+
"Shift-Enter": ({ editor }) => swallowInCell(editor)
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function isInTableCell($pos) {
|
|
269
|
+
for (let depth = $pos.depth; depth > 0; depth--) {
|
|
270
|
+
const name = $pos.node(depth).type.name;
|
|
271
|
+
if (name === "tableCell" || name === "tableHeader") return true;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
function setAttributes(element, attributes, skip = []) {
|
|
276
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
277
|
+
if (skip.includes(key) || value === null || value === void 0 || value === false) continue;
|
|
278
|
+
element.setAttribute(key, String(value));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function sameNode(updated, current) {
|
|
282
|
+
if (updated.type !== current.type) return false;
|
|
283
|
+
return Object.keys(current.attrs).every((key) => updated.attrs[key] === current.attrs[key]);
|
|
284
|
+
}
|
|
285
|
+
export {
|
|
286
|
+
createEditorKeymap,
|
|
287
|
+
createMarkdownSchemaExtensions,
|
|
288
|
+
createSchemaExtensions,
|
|
289
|
+
flattenSliceToLine
|
|
290
|
+
};
|
|
291
|
+
//# sourceMappingURL=markdown-extensions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/editor/markdown-extensions.ts"],"sourcesContent":["/**\n * LƯỢC ĐỒ của hai trình soạn — danh sách extension Tiptap, không React, không DOM.\n *\n * Mọi thứ quyết định \"tài liệu này lưu xuống thành chuỗi nào, và đọc lên lại thành tài liệu nào\"\n * nằm ở đây, và CHỈ ở đây: trình soạn dùng nó, cổng `test:editor-markdown` dựng `MarkdownManager`\n * từ chính hàm này trên `dist/` rồi đo vòng tài liệu → markdown → tài liệu. Hai nơi mà dùng hai danh\n * sách là hai lược đồ, và cổng xanh trên một lược đồ không ai dùng.\n *\n * Hai định dạng LƯU, hai lược đồ khác nhau ở đúng những chỗ định dạng ấy biểu diễn được hay không\n * (`createSchemaExtensions`): markdown (`MarkdownEditor`) hẹp hơn — không gạch chân, ô bảng một đoạn —\n * vì thứ gì không ghi được thành markdown thì sẽ mất ở lần lưu; HTML (`RichTextEditor`) giữ được cả hai.\n *\n * Phần GIAO DIỆN (menu `/`, menu `@`, tải ảnh, phím tắt) gắn vào qua tham số — xem\n * `MarkdownSchemaOptions`. Gọi không tham số là ra đúng lược đồ, đủ để đọc/ghi.\n */\n\nimport { type AnyExtension, Extension, InputRule, type JSONContent, mergeAttributes } from \"@tiptap/core\";\nimport { CodeBlockLowlight } from \"@tiptap/extension-code-block-lowlight\";\nimport { Image } from \"@tiptap/extension-image\";\nimport { TaskItem, TaskList } from \"@tiptap/extension-list\";\nimport { Mention, type MentionOptions } from \"@tiptap/extension-mention\";\nimport { Paragraph } from \"@tiptap/extension-paragraph\";\nimport { renderTableToMarkdown, Table, TableCell, TableHeader, TableKit } from \"@tiptap/extension-table\";\nimport { Placeholder } from \"@tiptap/extensions\";\nimport { Markdown } from \"@tiptap/markdown\";\nimport { Fragment, type Node as ProseMirrorNode, type ResolvedPos, Slice } from \"@tiptap/pm/model\";\nimport { Plugin } from \"@tiptap/pm/state\";\nimport { StarterKit } from \"@tiptap/starter-kit\";\nimport { common, createLowlight } from \"lowlight\";\nimport { Marked, type marked } from \"marked\";\nimport type { EditorFormat } from \"./editor-types.js\";\nimport {\n codeFence,\n escapeParagraphMarkdown,\n imageMarkdown,\n parseMentionMarkdown,\n renderMentionMarkdown\n} from \"./markdown-syntax.js\";\n\n/**\n * Giá trị đổi được SAU KHI trình soạn đã dựng. Extension đọc chúng lúc CẦN (lúc vẽ chữ mờ, lúc vẽ một\n * lần nhắc tên…), không chép lúc dựng — vì đổi danh sách extension là dựng lại cả trình soạn: mất\n * lịch sử hoàn tác, mất con trỏ. Component giữ object này trong một ref và ghi đè từng lượt render.\n */\nexport interface MarkdownEditorRuntime {\n placeholder: string;\n taskCheckbox: (text: string) => string;\n /** Đổi `src` ĐÃ LƯU thành địa chỉ HIỂN THỊ được (vd URL đã ký). Markdown vẫn giữ `src` gốc. */\n resolveImageSrc?: (src: string) => string;\n /** Tên SỐNG của một người theo id. `undefined` thì rơi về tên chụp lúc nhắc. */\n resolveMentionLabel?: (id: string) => string | undefined;\n}\n\nconst DEFAULT_RUNTIME: MarkdownEditorRuntime = {\n placeholder: \"\",\n taskCheckbox: (text) => text\n};\n\nexport interface MarkdownSchemaOptions {\n /** Định dạng LƯU — quyết định lược đồ. Mặc định `markdown`. */\n format?: EditorFormat;\n /** Đọc lười — xem `MarkdownEditorRuntime`. Bỏ trống ở nơi chỉ cần đọc/ghi (cổng, máy chủ). */\n runtime?: { readonly current: MarkdownEditorRuntime };\n /**\n * Cấu hình gợi ý `@` của `Mention` (danh sách, cách vẽ, `allow`). Bỏ trống = node `mention` vẫn có\n * trong lược đồ — để tài liệu CÓ nhắc tên vẫn đọc ra đúng ở chế độ chỉ đọc — nhưng gõ `@` không\n * mở gì.\n */\n mentionSuggestion?: Partial<MentionOptions[\"suggestion\"]>;\n}\n\n/*\n * Ngôn ngữ tô màu: bộ `common` của highlight.js (37 ngôn ngữ, ~53KB gzip — đo bằng esbuild trên\n * lowlight 3.3.0). Dựng MỘT lần cho cả module: bảng ngôn ngữ chỉ đọc, chia được giữa các trình soạn.\n */\nconst lowlight = createLowlight(common);\n\n/**\n * Đoạn văn — thêm phép thoát cú pháp khối (ca 1 ở đầu `markdown-syntax.ts`). Mọi hành vi khác giữ\n * nguyên bản gốc, kể cả ` ` cho đoạn rỗng: đó là cách CỐ Ý của Tiptap để giữ dòng trống người\n * dùng gõ — bỏ nó đi là nội dung \"co\" lại sau mỗi lần lưu.\n */\nconst ComitorParagraph = Paragraph.extend({\n renderMarkdown(node, helpers, context) {\n return escapeParagraphMarkdown(this.parent?.(node, helpers, context) ?? \"\");\n }\n});\n\n/** Khối mã — hàng rào dài hơn mọi dãy backtick bên trong (ca 2 ở đầu `markdown-syntax.ts`). */\nconst ComitorCodeBlock = CodeBlockLowlight.extend({\n renderMarkdown(node: JSONContent) {\n const language = typeof node.attrs?.language === \"string\" ? node.attrs.language : \"\";\n const text = (node.content ?? []).map((child) => child.text ?? \"\").join(\"\");\n const fence = codeFence(text);\n return `${fence}${language}\\n${text}\\n${fence}`;\n }\n});\n\n/** Bảng — bỏ dòng trống bản gốc kẹp hai đầu (xem `trimMarkdown`). */\nconst ComitorTable = Table.extend({\n renderMarkdown(node, helpers) {\n return renderTableToMarkdown(node, helpers).replace(/^\\n+|\\n+$/g, \"\");\n }\n});\n\n/*\n * Ô bảng chứa ĐÚNG MỘT đoạn văn — bản gốc cho `block+` (danh sách, khối mã, nhiều đoạn trong một ô).\n *\n * Ô bảng GFM là một dòng chữ, không chứa khối được. Đã đo trên 3.31.4: ô có hai đoạn ghi thành\n * `dòng 1<br>dòng 2`, ô có danh sách thành `- a<br>- b`, khối mã thành ```` ```<br>code<br>``` ````;\n * mở lại thì `<br>` là CHỮ THƯỜNG, và lần lưu kế tiếp nó thành `<br>` — mỗi lần lưu hỏng thêm\n * một lớp. Thu hẹp lược đồ là cách duy nhất chặn từ gốc: luật nhập (`- `, `# `, ```` ``` ````) và\n * lệnh khối tự thất bại trong ô vì lược đồ không cho, không cần chặn từng chỗ. Xuống dòng cứng trong ô\n * thì `createEditorKeymap` nuốt — `hardBreak` là node NỘI DÒNG nên lược đồ không chặn được nó.\n */\nconst ComitorTableCell = TableCell.extend({ content: \"paragraph\" });\nconst ComitorTableHeader = TableHeader.extend({ content: \"paragraph\" });\n\n/** Mẫu luật nhập của `HorizontalRule` trong StarterKit (3.31.4) — xem `MarkdownTableCellGuard`. */\nconst HORIZONTAL_RULE_INPUT = /^(?:---|—-|___\\s|\\*\\*\\*\\s)$/;\n\n/**\n * Ô bảng MARKDOWN — chặn nốt những đường CHÈN KHỐI mà lược đồ không chặn được.\n *\n * `ComitorTableCell` làm luật nhập BỌC (`- `, `# `, `> `) và lệnh đổi khối tự thất bại trong ô. Nhưng\n * phép THAY của ProseMirror không thất bại: khối không vừa chỗ thì nó TÁCH các node cha cho tới khi\n * vừa — tức tách CẢ BẢNG. Đã đo trên 3.31.4 (harness của `3.6.0`; cả ba có từ `3.5.0`):\n * · gõ `---` trong ô → bảng gãy làm hai, một đường kẻ ở giữa (luật nhập của `HorizontalRule`);\n * · dán một danh sách vào ô → bảng gãy làm hai, danh sách ở giữa;\n * · dán hai dòng chữ vào ô → hai dòng GHI ĐÈ lên ô đang đứng và ô kế bên — chữ cũ của cả hai mất.\n * Ba lớp, từ cụ thể tới chung:\n * 1. dán/thả từ ngoài: mọi khối của đoạn dán gộp thành MỘT dòng (`flattenSliceToLine`) — ô GFM là\n * một dòng chữ;\n * 2. `---` gõ trong ô ở lại là CHỮ — \"---\" là cách người ta hay ghi \"không có\" trong một ô bảng;\n * 3. lưới cuối: giao dịch nào làm SỐ BẢNG tăng khi con trỏ đang ở trong ô thì bị bỏ. Nó bắt mọi\n * đường còn lại (luật nhập ảnh ``, kéo thả một khối trong tài liệu, `insertContent` của app),\n * với cái giá là phím hay cú thả vừa rồi không có tác dụng — mất một phím còn hơn gãy một bảng.\n * Hoàn tác/làm lại và thay CẢ tài liệu (`setContent`, `value` đổi từ ngoài) đi thẳng.\n *\n * Ô bảng của `RichTextEditor` chứa được khối nên không cần lớp nào trong ba.\n */\nconst MarkdownTableCellGuard = Extension.create({\n name: \"comitorMarkdownTableCellGuard\",\n // Cao hơn StarterKit (100): mỗi extension một plugin luật nhập, xếp theo `priority` — luật dưới đây\n // phải được hỏi TRƯỚC luật đường kẻ, không thì đường kẻ đã chèn xong.\n priority: 1000,\n addInputRules() {\n return [\n new InputRule({\n find: HORIZONTAL_RULE_INPUT,\n undoable: false,\n handler: ({ state, range, match }) => {\n if (!isInTableCell(state.doc.resolve(range.from))) return null;\n state.tr.insertText(match[0], range.from, range.to);\n }\n })\n ];\n },\n addProseMirrorPlugins() {\n /*\n * Đoạn dán có thật là BẢNG không — phải hỏi ở chuỗi GỐC. `normalizeSiblings` của ProseMirror bọc\n * sẵn các đoạn văn dán vào ô thành Ô BẢNG (để chúng \"vừa\" chỗ) trước khi `transformPasted` thấy\n * chúng, nên nhìn slice thì \"dán hai dòng chữ\" và \"dán hai ô\" là một — và `prosemirror-tables` dán\n * cả hai theo kiểu ô, đè lên ô kế bên. Đã đo: đó chính là ca \"hai dòng ghi đè hai ô\" ở trên.\n */\n let pastedTable = false;\n return [\n new Plugin({\n props: {\n transformPastedHTML: (html) => {\n pastedTable = /<(?:table|tr|td|th)[\\s>/]/i.test(html);\n return html;\n },\n transformPastedText: (text) => {\n pastedTable = false;\n return text;\n },\n transformPasted: (slice, view) => {\n const keepTables = pastedTable;\n pastedTable = false;\n return isInTableCell(view.state.selection.$from) ? flattenSliceToLine(slice, keepTables) : slice;\n }\n },\n filterTransaction: (tr, state) => {\n if (!tr.docChanged || tr.getMeta(\"history$\") || !isInTableCell(state.selection.$from)) return true;\n if (replacesWholeDocument(tr.steps, state.doc.content.size)) return true;\n return countTables(tr.doc) <= countTables(state.doc);\n }\n })\n ];\n }\n});\n\n/**\n * Gộp một đoạn dán thành MỘT dòng nội dòng: các khối chữ nối nhau bằng một dấu cách, xuống dòng (cả\n * `hardBreak` lẫn `\\n` trong khối mã) thành dấu cách, khối không phải chữ (ảnh, đường kẻ) bỏ đi.\n * Nội dung nội dòng sẵn thì để nguyên; `keepTables` (đoạn dán GỐC có bảng) thì đoạn dán mang ô bảng\n * cũng để nguyên — dán theo ô là việc của `prosemirror-tables`.\n * Xuất ra cho cổng `test:editor-markdown`; không nằm trong entry.\n */\nexport function flattenSliceToLine(slice: Slice, keepTables = false): Slice {\n const first = slice.content.firstChild;\n if (!first || first.isInline) return slice;\n if (keepTables) {\n let tabular = false;\n slice.content.descendants((node) => {\n if (node.type.spec.tableRole) tabular = true;\n return !tabular;\n });\n if (tabular) return slice;\n }\n\n const schema = first.type.schema;\n const line: ProseMirrorNode[] = [];\n slice.content.descendants((node) => {\n if (!node.isTextblock) return true;\n if (node.content.size === 0) return false;\n if (line.length > 0) line.push(schema.text(\" \"));\n node.content.forEach((child) => {\n if (child.type.name === \"hardBreak\") line.push(schema.text(\" \"));\n else if (child.isText && child.text?.includes(\"\\n\"))\n line.push(schema.text(child.text.replace(/\\s*\\n\\s*/g, \" \"), child.marks));\n else line.push(child);\n });\n return false;\n });\n return new Slice(Fragment.from(line), 0, 0);\n}\n\nfunction countTables(doc: ProseMirrorNode): number {\n let count = 0;\n doc.descendants((node) => {\n if (node.type.name === \"table\") count++;\n return node.type.name !== \"table\";\n });\n return count;\n}\n\n/** Có bước nào thay TOÀN BỘ tài liệu không — `setContent` làm đúng một bước như vậy. */\nfunction replacesWholeDocument(steps: readonly unknown[], size: number): boolean {\n return steps.some((step) => {\n const { from, to } = step as { from?: unknown; to?: unknown };\n return from === 0 && to === size;\n });\n}\n\n/**\n * Bảng chữ markdown RIÊNG cho từng trình soạn.\n *\n * ⚠ Không truyền thì `@tiptap/markdown` dùng `marked` TOÀN CỤC, và mỗi lần trình soạn mount nó gọi\n * `marked.use()` đăng ký lại bộ tách từ (bảng, nhắc tên…) — không bao giờ gỡ. Đã đo trên 3.31.4:\n * mỗi `MarkdownManager` thêm 4 bộ tách; sau 200 lần dựng, parse cùng một tài liệu 200 đoạn chậm từ\n * 18,6ms lên 282ms, và còn tăng tiếp. Trong một SPA, 200 lần là một buổi làm việc mở việc này sang\n * việc khác. Một bản `Marked` mỗi trình soạn: 4 bộ tách, 5,7ms, mất cùng trình soạn.\n *\n * Ép kiểu vì option khai `typeof marked` (hàm `marked` toàn cục, gọi được) mà thứ đúng là một bản\n * `Marked`: `MarkdownManager` chỉ dùng `.use`, `.Lexer`, `.defaults`, `.lexer`, `.setOptions` — bản\n * `Marked` có đủ cả năm, chỉ thiếu chữ ký gọi-như-hàm mà không chỗ nào gọi tới.\n */\nfunction createMarkedInstance(): typeof marked {\n return new Marked() as unknown as typeof marked;\n}\n\n/**\n * Danh sách extension tạo nên lược đồ của trình soạn, theo định dạng LƯU.\n *\n * Markdown (`format: \"markdown\"`, mặc định) hẹp hơn HTML ở hai chỗ, cả hai vì cùng một lý do — thứ gì\n * không ghi được thành markdown thì MẤT ở lần lưu, mà người dùng không được báo:\n * · `underline` tắt: markdown không có cú pháp gạch chân. `@tiptap/markdown` ghi nó thành\n * `++chữ++` — cú pháp riêng, nên email thông báo, tác tử đọc qua MCP và mọi trình xem markdown\n * khác in nguyên hai dấu cộng;\n * · ô bảng chỉ chứa MỘT đoạn văn (`ComitorTableCell`).\n * Hai bản vá markdown (`ComitorParagraph`, `Markdown` với `Marked` riêng) cũng chỉ nạp ở định dạng đó.\n *\n * HTML (`format: \"html\"`) giữ cả gạch chân lẫn ô bảng nhiều khối — `<u>`, `<td><ul>…</ul></td>` là HTML\n * hợp lệ và đọc lên lại đúng.\n */\nexport function createSchemaExtensions(options: MarkdownSchemaOptions = {}): AnyExtension[] {\n const runtime = options.runtime ?? { current: DEFAULT_RUNTIME };\n const markdown = (options.format ?? \"markdown\") === \"markdown\";\n\n return [\n StarterKit.configure({\n paragraph: false,\n codeBlock: false,\n ...(markdown ? { underline: false as const } : {}),\n link: {\n // Đang sửa thì bấm vào liên kết là đặt con trỏ, không phải mở tab mới (thanh định dạng có nút\n // Mở). Chỉ đọc thì Tiptap không chặn cú bấm — thẻ `<a target=\"_blank\">` tự mở như thường.\n openOnClick: false,\n autolink: true,\n linkOnPaste: true,\n // Gõ/dán `[chữ](https://…)` là thành liên kết — người quen markdown gõ vậy theo phản xạ.\n markdownLinks: true,\n defaultProtocol: \"https\",\n HTMLAttributes: { target: \"_blank\", rel: \"noopener noreferrer nofollow\" }\n }\n }),\n markdown ? ComitorParagraph : Paragraph,\n ComitorCodeBlock.configure({\n lowlight,\n defaultLanguage: null,\n // Tab trong khối mã là thụt lề — đúng điều người viết mã chờ đợi. Thoát khối: ↓ ở dòng cuối,\n // hoặc Enter ba lần (mặc định của Tiptap).\n enableTabIndentation: true,\n tabSize: 2\n }),\n TaskList,\n TaskItem.configure({\n nested: true,\n // Chữ của ĐOẠN ĐẦU, không phải `node.textContent`: mục cha có danh sách con thì `textContent` dính\n // liền chữ của mọi mục con (\"Gửi mã qua emailGiới hạn 5 lần/giờ\") — đã gặp.\n a11y: { checkboxLabel: (node) => runtime.current.taskCheckbox(node.firstChild?.textContent ?? \"\") }\n }),\n ...(markdown\n ? [\n TableKit.configure({ table: false, tableCell: false, tableHeader: false }),\n ComitorTable.configure({ resizable: false }),\n ComitorTableCell,\n ComitorTableHeader,\n MarkdownTableCellGuard\n ]\n : [TableKit.configure({ table: { resizable: false } })]),\n createImageExtension(runtime),\n createMentionExtension(runtime, options.mentionSuggestion),\n Placeholder.configure({ placeholder: () => runtime.current.placeholder }),\n ...(markdown\n ? [Markdown.configure({ marked: createMarkedInstance(), indentation: { style: \"space\", size: 2 } })]\n : [])\n ];\n}\n\n/** Lược đồ của `MarkdownEditor` — tên có từ `3.5.0`, cổng `test:editor-markdown` gọi thẳng nó. */\nexport function createMarkdownSchemaExtensions(options: Omit<MarkdownSchemaOptions, \"format\"> = {}): AnyExtension[] {\n return createSchemaExtensions({ ...options, format: \"markdown\" });\n}\n\n/**\n * Ảnh — tách địa chỉ LƯU khỏi địa chỉ HIỂN THỊ, và thoát cú pháp khi ghi markdown.\n *\n * Kho ảnh của module Comitor là bucket RIÊNG TƯ: thứ hiện được là URL đã ký, có hạn. Lưu URL đó vào\n * nội dung là lưu một giá trị sẽ chết ngay trong database — nên nội dung giữ `src` app trả về (khoá,\n * hoặc một route ổn định của app), còn `resolveImageSrc` đổi nó thành địa chỉ vẽ được lúc vẽ.\n *\n * ⚠ Đổi địa chỉ ở NODE VIEW, không ở `renderHTML`. `renderHTML` vừa vẽ ảnh trong trình soạn vừa là\n * thứ `editor.getHTML()` dùng để GHI — đổi `src` ở đó (cách của `3.5.0`, khi giá trị còn là markdown)\n * là `RichTextEditor` ghi URL đã ký vào HTML lưu xuống. Node view chỉ là DOM của màn hình; HTML lưu\n * vẫn đi `renderHTML` gốc với `src` gốc.\n *\n * Ảnh ĐANG tải lên không phải node — xem đầu `image-upload.ts`.\n */\nfunction createImageExtension(runtime: { readonly current: MarkdownEditorRuntime }) {\n return Image.extend({\n addNodeView() {\n return ({ node, HTMLAttributes }) => {\n const image = document.createElement(\"img\");\n setAttributes(image, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), [\"src\"]);\n const src = typeof node.attrs.src === \"string\" ? node.attrs.src : \"\";\n // `src=\"\"` thì trình duyệt tải lại CHÍNH trang làm ảnh — thiếu địa chỉ thì bỏ hẳn thuộc tính.\n if (src) image.setAttribute(\"src\", runtime.current.resolveImageSrc?.(src) ?? src);\n return { dom: image, update: (updated) => sameNode(updated, node) };\n };\n },\n renderMarkdown(node: JSONContent) {\n return imageMarkdown(String(node.attrs?.src ?? \"\"), node.attrs?.alt, node.attrs?.title);\n }\n }).configure({ allowBase64: false });\n}\n\n/**\n * Nhắc tên — định dạng lưu `[@Tên](mention:<id>)` (lý do ở `markdown-syntax.ts`) và tên SỐNG lúc vẽ.\n *\n * Tên sống chỉ đi vào thứ NGƯỜI XEM thấy: node view và `renderText` (chữ khi chép ra ngoài).\n * `renderHTML` — thứ `getHTML()` ghi xuống — giữ TÊN CHỤP lúc nhắc, cùng luật với markdown: nội dung\n * lưu không tự đổi theo danh bạ chỉ vì có người mở và lưu lại.\n */\nfunction createMentionExtension(\n runtime: { readonly current: MarkdownEditorRuntime },\n suggestion: MarkdownSchemaOptions[\"mentionSuggestion\"]\n) {\n const storedLabelOf = (attrs: Record<string, unknown>) => {\n const stored = typeof attrs.label === \"string\" ? attrs.label : \"\";\n return stored || (typeof attrs.id === \"string\" ? attrs.id : \"\");\n };\n const labelOf = (attrs: Record<string, unknown>) =>\n runtime.current.resolveMentionLabel?.(typeof attrs.id === \"string\" ? attrs.id : \"\") ?? storedLabelOf(attrs);\n\n return Mention.extend({\n markdownTokenName: \"mention\",\n markdownTokenizer: {\n name: \"mention\",\n level: \"inline\",\n start: (source: string) => source.indexOf(\"[@\"),\n tokenize: (source: string) => {\n const parsed = parseMentionMarkdown(source);\n return parsed ? { type: \"mention\", raw: parsed.raw, id: parsed.id, label: parsed.label } : undefined;\n }\n },\n parseMarkdown: (token, helpers) => helpers.createNode(\"mention\", { id: token.id, label: token.label }),\n renderMarkdown: (node: JSONContent) =>\n renderMentionMarkdown(String(node.attrs?.id ?? \"\"), node.attrs?.label as string | null | undefined),\n addNodeView() {\n return ({ node, HTMLAttributes }) => {\n const dom = document.createElement(\"span\");\n setAttributes(dom, mergeAttributes({ \"data-type\": this.name }, this.options.HTMLAttributes, HTMLAttributes));\n dom.textContent = `@${labelOf(node.attrs)}`;\n return { dom, update: (updated) => sameNode(updated, node) };\n };\n }\n }).configure({\n renderText: ({ node }) => `@${labelOf(node.attrs)}`,\n // `options.HTMLAttributes` ở đây là bản Tiptap đã trộn sẵn (`data-type`, `data-id`, `data-label`).\n renderHTML: ({ options, node }) => [\"span\", options.HTMLAttributes, `@${storedLabelOf(node.attrs)}`],\n suggestion: { char: \"@\", allow: () => false, ...suggestion }\n });\n}\n\n/** Phím tắt riêng của trình soạn — xem `createEditorKeymap`. */\nexport interface EditorKeymapHandlers {\n /** ⌘/Ctrl+K — mở ô liên kết. Trả `true` khi đã xử lý. */\n onLinkShortcut: () => boolean;\n /** ⌘/Ctrl+Enter ngoài danh sách việc. Trả `false` để phím đi tiếp (xuống dòng cứng của Tiptap). */\n onSubmitShortcut: () => boolean;\n}\n\n/**\n * Phím tắt của RIÊNG gói, ưu tiên cao hơn mặc định của Tiptap.\n *\n * ⌘/Ctrl+Enter: trong danh sách việc thì tick/bỏ tick mục đang đứng (như Linear); ngoài đó thì gửi\n * (`onSubmit`) — đúng phím mà ô bình luận nào cũng dùng. Không có `onSubmit` thì trả phím về cho\n * Tiptap (bản gốc gán nó cho xuống dòng cứng).\n *\n * ⌘/Ctrl+K: ô liên kết. Không đụng bảng lệnh của shell — listener của shell tự bỏ qua phím khi\n * đích là vùng `contenteditable` (`shell-context.tsx`, nhánh `isTyping`).\n */\nexport function createEditorKeymap(\n handlers: { readonly current: EditorKeymapHandlers },\n format: EditorFormat = \"markdown\"\n): Extension {\n // Chỉ ô bảng MARKDOWN cấm xuống dòng cứng (`ComitorTableCell`); ô bảng HTML chứa được cả khối.\n const swallowInCell = (editor: { state: { selection: { $from: ResolvedPos } } }) =>\n format === \"markdown\" && isInTableCell(editor.state.selection.$from);\n return Extension.create({\n name: \"comitorEditorKeymap\",\n priority: 1000,\n addKeyboardShortcuts() {\n return {\n \"Mod-k\": () => handlers.current.onLinkShortcut(),\n \"Mod-Enter\": ({ editor }) => {\n const toggled = editor.commands.command(({ tr, state }) => {\n const { $from } = state.selection;\n for (let depth = $from.depth; depth > 0; depth--) {\n const node = $from.node(depth);\n if (node.type.name !== \"taskItem\") continue;\n tr.setNodeMarkup($from.before(depth), undefined, { ...node.attrs, checked: !node.attrs.checked });\n return true;\n }\n return false;\n });\n if (toggled || handlers.current.onSubmitShortcut()) return true;\n // Phím đi tiếp tới Tiptap là xuống dòng cứng — trong ô bảng markdown thì nuốt (xem `ComitorTableCell`).\n return swallowInCell(editor);\n },\n \"Shift-Enter\": ({ editor }) => swallowInCell(editor)\n };\n }\n });\n}\n\n/** Con trỏ có đang đứng trong một ô bảng (thường hay tiêu đề) không. */\nfunction isInTableCell($pos: ResolvedPos): boolean {\n for (let depth = $pos.depth; depth > 0; depth--) {\n const name = $pos.node(depth).type.name;\n if (name === \"tableCell\" || name === \"tableHeader\") return true;\n }\n return false;\n}\n\n/** Ghi thuộc tính lên phần tử của node view — bỏ giá trị rỗng, như `DOMSerializer` làm với `renderHTML`. */\nfunction setAttributes(element: HTMLElement, attributes: Record<string, unknown>, skip: readonly string[] = []): void {\n for (const [key, value] of Object.entries(attributes)) {\n if (skip.includes(key) || value === null || value === undefined || value === false) continue;\n element.setAttribute(key, String(value));\n }\n}\n\n/**\n * Node view của nút LÁ (ảnh, nhắc tên): thuộc tính không đổi thì giữ DOM; đổi thì trả `false` để\n * ProseMirror dựng lại — rẻ, và không có trạng thái nào trong DOM đáng giữ.\n */\nfunction sameNode(updated: ProseMirrorNode, current: ProseMirrorNode): boolean {\n if (updated.type !== current.type) return false;\n return Object.keys(current.attrs).every((key) => updated.attrs[key] === current.attrs[key]);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phép biến đổi CHUỖI markdown của `MarkdownEditor` — hàm thuần, không nhập Tiptap, không DOM.
|
|
3
|
+
*
|
|
4
|
+
* Tách khỏi `markdown-extensions.ts` để cổng `test:editor-markdown` gọi thẳng được từng hàm trên
|
|
5
|
+
* `dist/`, và để mỗi chỗ vá `@tiptap/markdown` nằm cạnh lý do của nó.
|
|
6
|
+
*
|
|
7
|
+
* ── VÌ SAO PHẢI VÁ ───────────────────────────────────────────────────────────────────────────
|
|
8
|
+
* Markdown là định dạng LƯU của trình soạn (cột mô tả, bình luận, tài liệu; công cụ MCP đọc/ghi
|
|
9
|
+
* thẳng nó). Nên phép đo đúng không phải "hiện ra có đẹp không" mà là: tài liệu → markdown → tài
|
|
10
|
+
* liệu có ra ĐÚNG tài liệu ban đầu không. `@tiptap/markdown` 3.31.4 trượt hai ca, cả hai IM LẶNG —
|
|
11
|
+
* không lỗi, chỉ là lần mở sau nội dung đã khác (đo bằng `MarkdownManager` trên chính bản đó):
|
|
12
|
+
*
|
|
13
|
+
* 1. Đoạn văn mở đầu bằng cú pháp KHỐI không được thoát. Đoạn "# không phải tiêu đề" ra
|
|
14
|
+
* `# không phải tiêu đề` → mở lại thành TIÊU ĐỀ. Cùng họ: "- …" thành danh sách, "2024. …"
|
|
15
|
+
* thành danh sách số bắt đầu từ 2024, dòng toàn `===` biến dòng trên thành tiêu đề, và 4 dấu
|
|
16
|
+
* cách đầu đoạn thành khối mã. Bộ thoát nội dòng của nó chỉ lo `\ ` * _ [ ] ~` và thực thể
|
|
17
|
+
* HTML. Ca thật dễ gặp nhất: gõ "1. " (luật nhập đổi thành danh sách) rồi ⌘Z để giữ chữ.
|
|
18
|
+
* 2. Khối mã CHỨA ``` vẫn được bọc bằng ``` — hàng rào bên trong đóng khối sớm, phần còn lại
|
|
19
|
+
* thành văn bản thường. Ca thật: tài liệu hướng dẫn viết markdown, đúng thứ đội phần mềm viết.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Thoát cú pháp KHỐI ở đầu MỖI dòng của một đoạn văn đã dựng — vá ca 1.
|
|
23
|
+
*
|
|
24
|
+
* Mỗi dòng chứ không chỉ dòng đầu: xuống dòng cứng (Shift+Enter) ra `" \n"`, và dòng sau đó vẫn
|
|
25
|
+
* mở được một danh sách (danh sách được phép ngắt đoạn văn trong CommonMark).
|
|
26
|
+
*
|
|
27
|
+
* Khoảng trắng đầu dòng bị BỎ: markdown vốn bỏ tới 3 dấu cách lúc đọc, còn 4 thì đổi đoạn thành
|
|
28
|
+
* khối mã — giữ chúng không mua được gì ngoài ca hỏng đó.
|
|
29
|
+
*
|
|
30
|
+
* `*` không cần ở đây: bộ thoát nội dòng của `@tiptap/markdown` đã đổi nó thành `\*`. `>` cũng
|
|
31
|
+
* không: nó ra `>`. Ô bảng đi qua hàm này cũng không sao — `\-` vẫn hiện là `-`.
|
|
32
|
+
*
|
|
33
|
+
* @example escapeParagraphMarkdown("# C#") // "\\# C#"
|
|
34
|
+
* @example escapeParagraphMarkdown("2024. Mục") // "2024\\. Mục"
|
|
35
|
+
*/
|
|
36
|
+
declare function escapeParagraphMarkdown(markdown: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Hàng rào cho một khối mã — vá ca 2. Luật CommonMark: hàng rào đóng phải dài ít nhất bằng hàng
|
|
39
|
+
* rào mở, và một dãy backtick NGẮN hơn bên trong không đóng được khối. Nên chọn dài hơn dãy dài
|
|
40
|
+
* nhất có trong nội dung, tối thiểu 3.
|
|
41
|
+
*
|
|
42
|
+
* @example codeFence("a ``` b") // "````"
|
|
43
|
+
*/
|
|
44
|
+
declare function codeFence(content: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Cắt xuống dòng THỪA ở hai đầu chuỗi đã dựng. Bảng ra `"\n| … |\n"` (đã đo), nên một tài liệu mở
|
|
47
|
+
* đầu bằng bảng có dòng trống ở đầu; còn `TrailingNode` luôn giữ một đoạn rỗng sau khối mã/bảng
|
|
48
|
+
* cuối, thành `"\n\n"` ở đuôi. Cả hai không đổi nghĩa — chỉ làm hai bản lưu của CÙNG một nội dung
|
|
49
|
+
* khác nhau, tức một phép so sánh phiên bản báo "có sửa" cho một lần không ai sửa gì.
|
|
50
|
+
*
|
|
51
|
+
* Đầu chuỗi chỉ cắt `\n` (dấu cách đầu đoạn đã bị `escapeParagraphMarkdown` bỏ); đuôi cắt mọi
|
|
52
|
+
* khoảng trắng — kể cả `" "` của một xuống dòng cứng đứng cuối, thứ không còn nghĩa gì ở đó.
|
|
53
|
+
*/
|
|
54
|
+
declare function trimMarkdown(markdown: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Một ảnh markdown — thay bản gốc của `@tiptap/extension-image`, bản ghép thẳng ``
|
|
57
|
+
* không thoát gì. Hai chỗ hỏng thật với ảnh người dùng tải lên:
|
|
58
|
+
* · `alt` mặc định là TÊN TỆP, và tên tệp có `]` lẻ ("bao-gia]v2.png") đóng phần chữ sớm;
|
|
59
|
+
* · `src` có dấu cách hay ngoặc tròn (một route app tự đặt) cắt địa chỉ làm đôi.
|
|
60
|
+
* CommonMark cho bọc địa chỉ trong `<…>` để chứa được cả hai; `<`/`>` bên trong thì mã hoá phần trăm.
|
|
61
|
+
*
|
|
62
|
+
* @example imageMarkdown("/files/a b.png", "ảnh]1") // "![ảnh\\]1](</files/a b.png>)"
|
|
63
|
+
*/
|
|
64
|
+
declare function imageMarkdown(src: string, alt?: unknown, title?: unknown): string;
|
|
65
|
+
/** Lược đồ URL của liên kết nhắc tên. Đổi nó là đổi định dạng LƯU — dữ liệu cũ thôi đọc được. */
|
|
66
|
+
declare const MENTION_URL_SCHEME = "mention:";
|
|
67
|
+
/**
|
|
68
|
+
* Dạng lưu của một lần nhắc tên. Tên rỗng thì dùng id — liên kết không được có chữ rỗng.
|
|
69
|
+
*
|
|
70
|
+
* ⚠ `encodeURIComponent` CHỪA `(` và `)` — mà ngoặc tròn chính là thứ đóng phần địa chỉ của liên
|
|
71
|
+
* kết markdown. Id `user (1)` thì ra `mention:user%20(1)` và cổng `test:editor-markdown` đọc lại thành
|
|
72
|
+
* một liên kết thường. Mã hoá nốt hai ký tự đó.
|
|
73
|
+
*/
|
|
74
|
+
declare function renderMentionMarkdown(id: string, label?: string | null): string;
|
|
75
|
+
interface ParsedMention {
|
|
76
|
+
/** Toàn bộ đoạn đã khớp — để bộ tách từ (tokenizer) biết đã ăn bao nhiêu ký tự */
|
|
77
|
+
raw: string;
|
|
78
|
+
id: string;
|
|
79
|
+
label: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Đọc MỘT lần nhắc tên ở ĐẦU chuỗi. Trả `undefined` khi không khớp — kể cả khi phần trăm mã hoá
|
|
83
|
+
* hỏng (`%E0%A4%A`): khi đó chuỗi được giữ nguyên là chữ thường, thay vì ném lỗi giữa lúc mở tài liệu.
|
|
84
|
+
*/
|
|
85
|
+
declare function parseMentionMarkdown(source: string): ParsedMention | undefined;
|
|
86
|
+
|
|
87
|
+
export { MENTION_URL_SCHEME, type ParsedMention, codeFence, escapeParagraphMarkdown, imageMarkdown, parseMentionMarkdown, renderMentionMarkdown, trimMarkdown };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const ATX_HEADING = /^#{1,6}(?:[ \t]|$)/;
|
|
2
|
+
const BULLET_MARKER = /^[-+](?:[ \t]|$)/;
|
|
3
|
+
const ORDERED_MARKER = /^(\d{1,9})([.)])(?=[ \t]|$)/;
|
|
4
|
+
const SETEXT_UNDERLINE = /^(?:=+|-+)[ \t]*$/;
|
|
5
|
+
function escapeParagraphMarkdown(markdown) {
|
|
6
|
+
return markdown.split("\n").map((line) => {
|
|
7
|
+
const text = line.replace(/^[ \t]+/, "");
|
|
8
|
+
if (ATX_HEADING.test(text) || BULLET_MARKER.test(text) || SETEXT_UNDERLINE.test(text)) return `\\${text}`;
|
|
9
|
+
const ordered = ORDERED_MARKER.exec(text);
|
|
10
|
+
if (ordered?.[1]) return `${ordered[1]}\\${text.slice(ordered[1].length)}`;
|
|
11
|
+
return text;
|
|
12
|
+
}).join("\n");
|
|
13
|
+
}
|
|
14
|
+
function codeFence(content) {
|
|
15
|
+
let longest = 0;
|
|
16
|
+
for (const run of content.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
|
|
17
|
+
return "`".repeat(Math.max(3, longest + 1));
|
|
18
|
+
}
|
|
19
|
+
function trimMarkdown(markdown) {
|
|
20
|
+
return markdown.replace(/^\n+/, "").replace(/\s+$/, "");
|
|
21
|
+
}
|
|
22
|
+
function imageMarkdown(src, alt, title) {
|
|
23
|
+
const text = typeof alt === "string" ? alt.replace(/[\\[\]]/g, "\\$&") : "";
|
|
24
|
+
const destination = /[\s()<>]/.test(src) ? `<${src.replace(/</g, "%3C").replace(/>/g, "%3E")}>` : src;
|
|
25
|
+
const escapedTitle = typeof title === "string" ? title.replace(/[\x22\\]/g, "\\$&") : "";
|
|
26
|
+
const caption = escapedTitle ? ` "${escapedTitle}"` : "";
|
|
27
|
+
return ``;
|
|
28
|
+
}
|
|
29
|
+
const MENTION_URL_SCHEME = "mention:";
|
|
30
|
+
const MENTION_LINK = /^\[@((?:\\.|[^\\\]\n])*)\]\(mention:([^()\s]+)\)/;
|
|
31
|
+
function escapeMentionLabel(label) {
|
|
32
|
+
return label.replace(/[\\[\]]/g, "\\$&");
|
|
33
|
+
}
|
|
34
|
+
function renderMentionMarkdown(id, label) {
|
|
35
|
+
const encodedId = encodeURIComponent(id).replace(/\(/g, "%28").replace(/\)/g, "%29");
|
|
36
|
+
return `[@${escapeMentionLabel(label || id)}](${MENTION_URL_SCHEME}${encodedId})`;
|
|
37
|
+
}
|
|
38
|
+
function parseMentionMarkdown(source) {
|
|
39
|
+
const match = MENTION_LINK.exec(source);
|
|
40
|
+
if (!match?.[2]) return void 0;
|
|
41
|
+
try {
|
|
42
|
+
const id = decodeURIComponent(match[2]);
|
|
43
|
+
const label = (match[1] ?? "").replace(/\\(.)/g, "$1");
|
|
44
|
+
return { raw: match[0], id, label: label || id };
|
|
45
|
+
} catch {
|
|
46
|
+
return void 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
MENTION_URL_SCHEME,
|
|
51
|
+
codeFence,
|
|
52
|
+
escapeParagraphMarkdown,
|
|
53
|
+
imageMarkdown,
|
|
54
|
+
parseMentionMarkdown,
|
|
55
|
+
renderMentionMarkdown,
|
|
56
|
+
trimMarkdown
|
|
57
|
+
};
|
|
58
|
+
//# sourceMappingURL=markdown-syntax.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/editor/markdown-syntax.ts"],"sourcesContent":["/**\n * Phép biến đổi CHUỖI markdown của `MarkdownEditor` — hàm thuần, không nhập Tiptap, không DOM.\n *\n * Tách khỏi `markdown-extensions.ts` để cổng `test:editor-markdown` gọi thẳng được từng hàm trên\n * `dist/`, và để mỗi chỗ vá `@tiptap/markdown` nằm cạnh lý do của nó.\n *\n * ── VÌ SAO PHẢI VÁ ───────────────────────────────────────────────────────────────────────────\n * Markdown là định dạng LƯU của trình soạn (cột mô tả, bình luận, tài liệu; công cụ MCP đọc/ghi\n * thẳng nó). Nên phép đo đúng không phải \"hiện ra có đẹp không\" mà là: tài liệu → markdown → tài\n * liệu có ra ĐÚNG tài liệu ban đầu không. `@tiptap/markdown` 3.31.4 trượt hai ca, cả hai IM LẶNG —\n * không lỗi, chỉ là lần mở sau nội dung đã khác (đo bằng `MarkdownManager` trên chính bản đó):\n *\n * 1. Đoạn văn mở đầu bằng cú pháp KHỐI không được thoát. Đoạn \"# không phải tiêu đề\" ra\n * `# không phải tiêu đề` → mở lại thành TIÊU ĐỀ. Cùng họ: \"- …\" thành danh sách, \"2024. …\"\n * thành danh sách số bắt đầu từ 2024, dòng toàn `===` biến dòng trên thành tiêu đề, và 4 dấu\n * cách đầu đoạn thành khối mã. Bộ thoát nội dòng của nó chỉ lo `\\ ` * _ [ ] ~` và thực thể\n * HTML. Ca thật dễ gặp nhất: gõ \"1. \" (luật nhập đổi thành danh sách) rồi ⌘Z để giữ chữ.\n * 2. Khối mã CHỨA ``` vẫn được bọc bằng ``` — hàng rào bên trong đóng khối sớm, phần còn lại\n * thành văn bản thường. Ca thật: tài liệu hướng dẫn viết markdown, đúng thứ đội phần mềm viết.\n */\n\n/** Cú pháp khối mở đầu một dòng — xem ca 1 ở đầu file. */\nconst ATX_HEADING = /^#{1,6}(?:[ \\t]|$)/;\nconst BULLET_MARKER = /^[-+](?:[ \\t]|$)/;\nconst ORDERED_MARKER = /^(\\d{1,9})([.)])(?=[ \\t]|$)/;\nconst SETEXT_UNDERLINE = /^(?:=+|-+)[ \\t]*$/;\n\n/**\n * Thoát cú pháp KHỐI ở đầu MỖI dòng của một đoạn văn đã dựng — vá ca 1.\n *\n * Mỗi dòng chứ không chỉ dòng đầu: xuống dòng cứng (Shift+Enter) ra `\" \\n\"`, và dòng sau đó vẫn\n * mở được một danh sách (danh sách được phép ngắt đoạn văn trong CommonMark).\n *\n * Khoảng trắng đầu dòng bị BỎ: markdown vốn bỏ tới 3 dấu cách lúc đọc, còn 4 thì đổi đoạn thành\n * khối mã — giữ chúng không mua được gì ngoài ca hỏng đó.\n *\n * `*` không cần ở đây: bộ thoát nội dòng của `@tiptap/markdown` đã đổi nó thành `\\*`. `>` cũng\n * không: nó ra `>`. Ô bảng đi qua hàm này cũng không sao — `\\-` vẫn hiện là `-`.\n *\n * @example escapeParagraphMarkdown(\"# C#\") // \"\\\\# C#\"\n * @example escapeParagraphMarkdown(\"2024. Mục\") // \"2024\\\\. Mục\"\n */\nexport function escapeParagraphMarkdown(markdown: string): string {\n return markdown\n .split(\"\\n\")\n .map((line) => {\n const text = line.replace(/^[ \\t]+/, \"\");\n if (ATX_HEADING.test(text) || BULLET_MARKER.test(text) || SETEXT_UNDERLINE.test(text)) return `\\\\${text}`;\n const ordered = ORDERED_MARKER.exec(text);\n if (ordered?.[1]) return `${ordered[1]}\\\\${text.slice(ordered[1].length)}`;\n return text;\n })\n .join(\"\\n\");\n}\n\n/**\n * Hàng rào cho một khối mã — vá ca 2. Luật CommonMark: hàng rào đóng phải dài ít nhất bằng hàng\n * rào mở, và một dãy backtick NGẮN hơn bên trong không đóng được khối. Nên chọn dài hơn dãy dài\n * nhất có trong nội dung, tối thiểu 3.\n *\n * @example codeFence(\"a ``` b\") // \"````\"\n */\nexport function codeFence(content: string): string {\n let longest = 0;\n for (const run of content.match(/`+/g) ?? []) longest = Math.max(longest, run.length);\n return \"`\".repeat(Math.max(3, longest + 1));\n}\n\n/**\n * Cắt xuống dòng THỪA ở hai đầu chuỗi đã dựng. Bảng ra `\"\\n| … |\\n\"` (đã đo), nên một tài liệu mở\n * đầu bằng bảng có dòng trống ở đầu; còn `TrailingNode` luôn giữ một đoạn rỗng sau khối mã/bảng\n * cuối, thành `\"\\n\\n\"` ở đuôi. Cả hai không đổi nghĩa — chỉ làm hai bản lưu của CÙNG một nội dung\n * khác nhau, tức một phép so sánh phiên bản báo \"có sửa\" cho một lần không ai sửa gì.\n *\n * Đầu chuỗi chỉ cắt `\\n` (dấu cách đầu đoạn đã bị `escapeParagraphMarkdown` bỏ); đuôi cắt mọi\n * khoảng trắng — kể cả `\" \"` của một xuống dòng cứng đứng cuối, thứ không còn nghĩa gì ở đó.\n */\nexport function trimMarkdown(markdown: string): string {\n return markdown.replace(/^\\n+/, \"\").replace(/\\s+$/, \"\");\n}\n\n/**\n * Một ảnh markdown — thay bản gốc của `@tiptap/extension-image`, bản ghép thẳng ``\n * không thoát gì. Hai chỗ hỏng thật với ảnh người dùng tải lên:\n * · `alt` mặc định là TÊN TỆP, và tên tệp có `]` lẻ (\"bao-gia]v2.png\") đóng phần chữ sớm;\n * · `src` có dấu cách hay ngoặc tròn (một route app tự đặt) cắt địa chỉ làm đôi.\n * CommonMark cho bọc địa chỉ trong `<…>` để chứa được cả hai; `<`/`>` bên trong thì mã hoá phần trăm.\n *\n * @example imageMarkdown(\"/files/a b.png\", \"ảnh]1\") // \"![ảnh\\\\]1](</files/a b.png>)\"\n */\nexport function imageMarkdown(src: string, alt?: unknown, title?: unknown): string {\n const text = typeof alt === \"string\" ? alt.replace(/[\\\\[\\]]/g, \"\\\\$&\") : \"\";\n const destination = /[\\s()<>]/.test(src) ? `<${src.replace(/</g, \"%3C\").replace(/>/g, \"%3E\")}>` : src;\n // `\\x22` là dấu nháy kép. Viết thẳng ký tự đó trong regex literal thì bộ quét của `test:i18n` (không\n // hiểu regex) tưởng là mở một chuỗi, và báo nửa sau của file là chữ hiển thị tiếng Việt — đã gặp.\n const escapedTitle = typeof title === \"string\" ? title.replace(/[\\x22\\\\]/g, \"\\\\$&\") : \"\";\n const caption = escapedTitle ? ` \"${escapedTitle}\"` : \"\";\n return ``;\n}\n\n/*\n * ── NHẮC TÊN (`@`) ───────────────────────────────────────────────────────────────────────────\n * Dạng lưu là một LIÊN KẾT markdown thường: `[@Tên hiển thị](mention:<id>)`.\n *\n * Không dùng dạng mặc định của Tiptap (`[@ id=\"…\" label=\"…\"]`): đó là cú pháp riêng, nên mọi chỗ\n * đọc markdown mà không có Tiptap — email thông báo, tác tử đọc qua MCP, trình xem markdown bất kỳ —\n * in nguyên chuỗi ngoặc vuông ra cho người đọc. Dạng liên kết thì rơi về đúng chữ \"@Tên\": trình xem\n * nào bỏ lược đồ lạ thì còn chữ, trình nào giữ thì còn cả liên kết.\n *\n * Tên trong đó chỉ là ẢNH CHỤP lúc nhắc. Thứ có thẩm quyền là `id` — tên sống thì app tra lại khi\n * hiển thị (`mentions.resolveLabel`), cùng lý do một module Comitor không giữ cột tên người.\n */\n\n/** Lược đồ URL của liên kết nhắc tên. Đổi nó là đổi định dạng LƯU — dữ liệu cũ thôi đọc được. */\nexport const MENTION_URL_SCHEME = \"mention:\";\n\n/*\n * Tên: mọi ký tự trừ `]` và `\\` trần (chúng đi kèm dấu thoát), không xuống dòng. Id: không dấu\n * cách, không ngoặc tròn — `renderMentionMarkdown` đã mã hoá phần trăm mọi ký tự đặc biệt.\n */\nconst MENTION_LINK = /^\\[@((?:\\\\.|[^\\\\\\]\\n])*)\\]\\(mention:([^()\\s]+)\\)/;\n\n/** Thoát `\\`, `[`, `]` trong tên — ba ký tự duy nhất phá được cấu trúc `[@…](…)`. */\nfunction escapeMentionLabel(label: string): string {\n return label.replace(/[\\\\[\\]]/g, \"\\\\$&\");\n}\n\n/**\n * Dạng lưu của một lần nhắc tên. Tên rỗng thì dùng id — liên kết không được có chữ rỗng.\n *\n * ⚠ `encodeURIComponent` CHỪA `(` và `)` — mà ngoặc tròn chính là thứ đóng phần địa chỉ của liên\n * kết markdown. Id `user (1)` thì ra `mention:user%20(1)` và cổng `test:editor-markdown` đọc lại thành\n * một liên kết thường. Mã hoá nốt hai ký tự đó.\n */\nexport function renderMentionMarkdown(id: string, label?: string | null): string {\n const encodedId = encodeURIComponent(id).replace(/\\(/g, \"%28\").replace(/\\)/g, \"%29\");\n return `[@${escapeMentionLabel(label || id)}](${MENTION_URL_SCHEME}${encodedId})`;\n}\n\nexport interface ParsedMention {\n /** Toàn bộ đoạn đã khớp — để bộ tách từ (tokenizer) biết đã ăn bao nhiêu ký tự */\n raw: string;\n id: string;\n label: string;\n}\n\n/**\n * Đọc MỘT lần nhắc tên ở ĐẦU chuỗi. Trả `undefined` khi không khớp — kể cả khi phần trăm mã hoá\n * hỏng (`%E0%A4%A`): khi đó chuỗi được giữ nguyên là chữ thường, thay vì ném lỗi giữa lúc mở tài liệu.\n */\nexport function parseMentionMarkdown(source: string): ParsedMention | undefined {\n const match = MENTION_LINK.exec(source);\n if (!match?.[2]) return undefined;\n try {\n const id = decodeURIComponent(match[2]);\n const label = (match[1] ?? \"\").replace(/\\\\(.)/g, \"$1\");\n return { raw: match[0], id, label: label || id };\n } catch {\n return undefined;\n }\n}\n"],"mappings":"AAsBA,MAAM,cAAc;AACpB,MAAM,gBAAgB;AACtB,MAAM,iBAAiB;AACvB,MAAM,mBAAmB;AAiBlB,SAAS,wBAAwB,UAA0B;AAChE,SAAO,SACJ,MAAM,IAAI,EACV,IAAI,CAAC,SAAS;AACb,UAAM,OAAO,KAAK,QAAQ,WAAW,EAAE;AACvC,QAAI,YAAY,KAAK,IAAI,KAAK,cAAc,KAAK,IAAI,KAAK,iBAAiB,KAAK,IAAI,EAAG,QAAO,KAAK,IAAI;AACvG,UAAM,UAAU,eAAe,KAAK,IAAI;AACxC,QAAI,UAAU,CAAC,EAAG,QAAO,GAAG,QAAQ,CAAC,CAAC,KAAK,KAAK,MAAM,QAAQ,CAAC,EAAE,MAAM,CAAC;AACxE,WAAO;AAAA,EACT,CAAC,EACA,KAAK,IAAI;AACd;AASO,SAAS,UAAU,SAAyB;AACjD,MAAI,UAAU;AACd,aAAW,OAAO,QAAQ,MAAM,KAAK,KAAK,CAAC,EAAG,WAAU,KAAK,IAAI,SAAS,IAAI,MAAM;AACpF,SAAO,IAAI,OAAO,KAAK,IAAI,GAAG,UAAU,CAAC,CAAC;AAC5C;AAWO,SAAS,aAAa,UAA0B;AACrD,SAAO,SAAS,QAAQ,QAAQ,EAAE,EAAE,QAAQ,QAAQ,EAAE;AACxD;AAWO,SAAS,cAAc,KAAa,KAAe,OAAyB;AACjF,QAAM,OAAO,OAAO,QAAQ,WAAW,IAAI,QAAQ,YAAY,MAAM,IAAI;AACzE,QAAM,cAAc,WAAW,KAAK,GAAG,IAAI,IAAI,IAAI,QAAQ,MAAM,KAAK,EAAE,QAAQ,MAAM,KAAK,CAAC,MAAM;AAGlG,QAAM,eAAe,OAAO,UAAU,WAAW,MAAM,QAAQ,aAAa,MAAM,IAAI;AACtF,QAAM,UAAU,eAAe,KAAK,YAAY,MAAM;AACtD,SAAO,KAAK,IAAI,KAAK,WAAW,GAAG,OAAO;AAC5C;AAgBO,MAAM,qBAAqB;AAMlC,MAAM,eAAe;AAGrB,SAAS,mBAAmB,OAAuB;AACjD,SAAO,MAAM,QAAQ,YAAY,MAAM;AACzC;AASO,SAAS,sBAAsB,IAAY,OAA+B;AAC/E,QAAM,YAAY,mBAAmB,EAAE,EAAE,QAAQ,OAAO,KAAK,EAAE,QAAQ,OAAO,KAAK;AACnF,SAAO,KAAK,mBAAmB,SAAS,EAAE,CAAC,KAAK,kBAAkB,GAAG,SAAS;AAChF;AAaO,SAAS,qBAAqB,QAA2C;AAC9E,QAAM,QAAQ,aAAa,KAAK,MAAM;AACtC,MAAI,CAAC,QAAQ,CAAC,EAAG,QAAO;AACxB,MAAI;AACF,UAAM,KAAK,mBAAmB,MAAM,CAAC,CAAC;AACtC,UAAM,SAAS,MAAM,CAAC,KAAK,IAAI,QAAQ,UAAU,IAAI;AACrD,WAAO,EAAE,KAAK,MAAM,CAAC,GAAG,IAAI,OAAO,SAAS,GAAG;AAAA,EACjD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","names":[]}
|