@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.
Files changed (63) hide show
  1. package/README.md +19 -5
  2. package/dist/components/ui/item.d.ts +1 -1
  3. package/dist/editor/bubble-toolbar.d.ts +53 -0
  4. package/dist/editor/bubble-toolbar.js +231 -0
  5. package/dist/editor/bubble-toolbar.js.map +1 -0
  6. package/dist/editor/editor-core.d.ts +28 -0
  7. package/dist/editor/editor-core.js +354 -0
  8. package/dist/editor/editor-core.js.map +1 -0
  9. package/dist/editor/editor-types.d.ts +3 -0
  10. package/dist/editor/editor-types.js +1 -0
  11. package/dist/editor/editor-types.js.map +1 -0
  12. package/dist/editor/fixed-toolbar.d.ts +19 -0
  13. package/dist/editor/fixed-toolbar.js +106 -0
  14. package/dist/editor/fixed-toolbar.js.map +1 -0
  15. package/dist/editor/image-upload.d.ts +3 -0
  16. package/dist/editor/image-upload.js +137 -0
  17. package/dist/editor/image-upload.js.map +1 -0
  18. package/dist/editor/index.d.ts +6 -0
  19. package/dist/editor/index.js +11 -0
  20. package/dist/editor/index.js.map +1 -0
  21. package/dist/editor/markdown-editor-classes.d.ts +16 -0
  22. package/dist/editor/markdown-editor-classes.js +119 -0
  23. package/dist/editor/markdown-editor-classes.js.map +1 -0
  24. package/dist/editor/markdown-editor-constants.d.ts +98 -0
  25. package/dist/editor/markdown-editor-constants.js +6 -0
  26. package/dist/editor/markdown-editor-constants.js.map +1 -0
  27. package/dist/editor/markdown-editor.d.ts +48 -0
  28. package/dist/editor/markdown-editor.js +33 -0
  29. package/dist/editor/markdown-editor.js.map +1 -0
  30. package/dist/editor/markdown-extensions.d.ts +96 -0
  31. package/dist/editor/markdown-extensions.js +291 -0
  32. package/dist/editor/markdown-extensions.js.map +1 -0
  33. package/dist/editor/markdown-syntax.d.ts +87 -0
  34. package/dist/editor/markdown-syntax.js +58 -0
  35. package/dist/editor/markdown-syntax.js.map +1 -0
  36. package/dist/editor/rich-text-editor.d.ts +48 -0
  37. package/dist/editor/rich-text-editor.js +33 -0
  38. package/dist/editor/rich-text-editor.js.map +1 -0
  39. package/dist/editor/slash-commands.d.ts +65 -0
  40. package/dist/editor/slash-commands.js +191 -0
  41. package/dist/editor/slash-commands.js.map +1 -0
  42. package/dist/editor/suggestion-menu.d.ts +38 -0
  43. package/dist/editor/suggestion-menu.js +144 -0
  44. package/dist/editor/suggestion-menu.js.map +1 -0
  45. package/dist/editor/suggestion-store.d.ts +47 -0
  46. package/dist/editor/suggestion-store.js +49 -0
  47. package/dist/editor/suggestion-store.js.map +1 -0
  48. package/dist/editor/toolbar-actions.d.ts +44 -0
  49. package/dist/editor/toolbar-actions.js +173 -0
  50. package/dist/editor/toolbar-actions.js.map +1 -0
  51. package/dist/editor/toolbar-button.d.ts +15 -0
  52. package/dist/editor/toolbar-button.js +47 -0
  53. package/dist/editor/toolbar-button.js.map +1 -0
  54. package/dist/editor-types-BMST7-VR.d.ts +171 -0
  55. package/dist/i18n/locale-provider.d.ts +1 -0
  56. package/dist/i18n/messages.d.ts +2 -0
  57. package/dist/i18n/messages.js +3 -0
  58. package/dist/i18n/messages.js.map +1 -1
  59. package/dist/i18n/namespaces/markdown-editor.d.ts +6 -0
  60. package/dist/i18n/namespaces/markdown-editor.js +133 -0
  61. package/dist/i18n/namespaces/markdown-editor.js.map +1 -0
  62. package/dist/index.d.ts +1 -0
  63. package/package.json +79 -2
@@ -0,0 +1,48 @@
1
+ import * as React from 'react';
2
+ import { g as EditorBaseProps, a as EditorToolbarMode, E as EditorHandleBase } from '../editor-types-BMST7-VR.js';
3
+ import '@tiptap/core';
4
+ import './markdown-editor-constants.js';
5
+
6
+ /**
7
+ * RichTextEditor — trình soạn văn bản giàu định dạng LƯU bằng HTML, cho nội dung xuất bản (CMS):
8
+ * bài viết, trang tin, mô tả sản phẩm, mẫu email — nơi nội dung được hiển thị lại ngoài trình soạn và
9
+ * người soạn chờ đợi một thanh công cụ.
10
+ *
11
+ * Cùng lõi với `MarkdownEditor` (`editor-core.tsx`): menu `/`, nhắc tên `@`, ảnh, liên kết, phím tắt và
12
+ * cú pháp gõ tắt (`## `, `- `…) y hệt. Khác ở ba chỗ:
13
+ * · giá trị là CHUỖI HTML (`editor.getHTML()`), tài liệu rỗng là `""`;
14
+ * · lược đồ RỘNG hơn — gạch chân, ô bảng chứa nhiều khối (danh sách, ảnh, nhiều đoạn): HTML giữ được
15
+ * cả hai, markdown thì không (lý do ở `createSchemaExtensions`);
16
+ * · thanh công cụ mặc định là `fixed` — thanh dính đầu khung, kiểu CMS.
17
+ *
18
+ * ⚠ HTML ĐI VÀO đi qua lược đồ, và lược đồ là một danh sách cho phép: thẻ và thuộc tính nó không biết
19
+ * (`<script>`, `style`, `onerror`, `<iframe>`) rơi mất khi nạp. Nhưng HTML ĐÃ LƯU là dữ liệu người dùng
20
+ * — ai gọi thẳng API của app thì ghi được bất cứ chuỗi nào. Hiển thị lại bằng
21
+ * `<RichTextEditor editable={false} value={html} />` (đi qua cùng lược đồ), đừng `dangerouslySetInnerHTML`
22
+ * khi chưa có bộ lọc phía máy chủ. Chi tiết ở docs/editor.md.
23
+ */
24
+
25
+ /** Điều khiển trình soạn từ ngoài — lấy qua prop `ref`. */
26
+ interface RichTextEditorHandle extends EditorHandleBase {
27
+ /** HTML hiện tại — cùng chuỗi mà `onChange` nhận (`""` khi rỗng). */
28
+ getHTML: () => string;
29
+ /** Thay toàn bộ nội dung bằng HTML (đi qua lược đồ — xem đầu file). Có gọi `onChange`. */
30
+ setHTML: (html: string) => void;
31
+ }
32
+ interface RichTextEditorProps extends EditorBaseProps {
33
+ /** HTML — điều khiển từ ngoài. Đổi giá trị KHÁC thứ trình soạn vừa phát là nạp lại nội dung. */
34
+ value?: string;
35
+ /** HTML ban đầu khi KHÔNG điều khiển. */
36
+ defaultValue?: string;
37
+ /** Mỗi lần nội dung đổi, nhận HTML của tài liệu — `""` khi tài liệu rỗng. */
38
+ onChange?: (html: string) => void;
39
+ /**
40
+ * `fixed` (mặc định) — thanh dính đầu khung, mọi thao tác luôn thấy. `bubble` — kiểu Linear, định
41
+ * dạng hiện ở chỗ bôi chọn. Xem `EditorToolbarMode`.
42
+ */
43
+ toolbar?: EditorToolbarMode;
44
+ ref?: React.Ref<RichTextEditorHandle>;
45
+ }
46
+ declare function RichTextEditor({ toolbar, ref, ...props }: RichTextEditorProps): React.JSX.Element;
47
+
48
+ export { RichTextEditor, type RichTextEditorHandle, type RichTextEditorProps };
@@ -0,0 +1,33 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { EditorCore } from "./editor-core.js";
4
+ function createRichTextHandle(core) {
5
+ return {
6
+ focus: core.focus,
7
+ getHTML: core.getValue,
8
+ setHTML: core.setValue,
9
+ clear: core.clear,
10
+ isEmpty: core.isEmpty,
11
+ isUploading: core.isUploading,
12
+ get editor() {
13
+ return core.editor;
14
+ }
15
+ };
16
+ }
17
+ function RichTextEditor({ toolbar = "fixed", ref, ...props }) {
18
+ return /* @__PURE__ */ jsx(
19
+ EditorCore,
20
+ {
21
+ ...props,
22
+ format: "html",
23
+ toolbar,
24
+ slot: "rich-text-editor",
25
+ handleRef: ref,
26
+ createHandle: createRichTextHandle
27
+ }
28
+ );
29
+ }
30
+ export {
31
+ RichTextEditor
32
+ };
33
+ //# sourceMappingURL=rich-text-editor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/editor/rich-text-editor.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * RichTextEditor — trình soạn văn bản giàu định dạng LƯU bằng HTML, cho nội dung xuất bản (CMS):\n * bài viết, trang tin, mô tả sản phẩm, mẫu email — nơi nội dung được hiển thị lại ngoài trình soạn và\n * người soạn chờ đợi một thanh công cụ.\n *\n * Cùng lõi với `MarkdownEditor` (`editor-core.tsx`): menu `/`, nhắc tên `@`, ảnh, liên kết, phím tắt và\n * cú pháp gõ tắt (`## `, `- `…) y hệt. Khác ở ba chỗ:\n * · giá trị là CHUỖI HTML (`editor.getHTML()`), tài liệu rỗng là `\"\"`;\n * · lược đồ RỘNG hơn — gạch chân, ô bảng chứa nhiều khối (danh sách, ảnh, nhiều đoạn): HTML giữ được\n * cả hai, markdown thì không (lý do ở `createSchemaExtensions`);\n * · thanh công cụ mặc định là `fixed` — thanh dính đầu khung, kiểu CMS.\n *\n * ⚠ HTML ĐI VÀO đi qua lược đồ, và lược đồ là một danh sách cho phép: thẻ và thuộc tính nó không biết\n * (`<script>`, `style`, `onerror`, `<iframe>`) rơi mất khi nạp. Nhưng HTML ĐÃ LƯU là dữ liệu người dùng\n * — ai gọi thẳng API của app thì ghi được bất cứ chuỗi nào. Hiển thị lại bằng\n * `<RichTextEditor editable={false} value={html} />` (đi qua cùng lược đồ), đừng `dangerouslySetInnerHTML`\n * khi chưa có bộ lọc phía máy chủ. Chi tiết ở docs/editor.md.\n */\n\nimport type * as React from \"react\";\nimport { EditorCore, type EditorCoreHandle } from \"./editor-core.js\";\nimport type { EditorBaseProps, EditorHandleBase, EditorToolbarMode } from \"./editor-types.js\";\n\n/** Điều khiển trình soạn từ ngoài — lấy qua prop `ref`. */\nexport interface RichTextEditorHandle extends EditorHandleBase {\n /** HTML hiện tại — cùng chuỗi mà `onChange` nhận (`\"\"` khi rỗng). */\n getHTML: () => string;\n /** Thay toàn bộ nội dung bằng HTML (đi qua lược đồ — xem đầu file). Có gọi `onChange`. */\n setHTML: (html: string) => void;\n}\n\nexport interface RichTextEditorProps extends EditorBaseProps {\n /** HTML — điều khiển từ ngoài. Đổi giá trị KHÁC thứ trình soạn vừa phát là nạp lại nội dung. */\n value?: string;\n /** HTML ban đầu khi KHÔNG điều khiển. */\n defaultValue?: string;\n /** Mỗi lần nội dung đổi, nhận HTML của tài liệu — `\"\"` khi tài liệu rỗng. */\n onChange?: (html: string) => void;\n /**\n * `fixed` (mặc định) — thanh dính đầu khung, mọi thao tác luôn thấy. `bubble` — kiểu Linear, định\n * dạng hiện ở chỗ bôi chọn. Xem `EditorToolbarMode`.\n */\n toolbar?: EditorToolbarMode;\n ref?: React.Ref<RichTextEditorHandle>;\n}\n\nfunction createRichTextHandle(core: EditorCoreHandle): RichTextEditorHandle {\n return {\n focus: core.focus,\n getHTML: core.getValue,\n setHTML: core.setValue,\n clear: core.clear,\n isEmpty: core.isEmpty,\n isUploading: core.isUploading,\n get editor() {\n return core.editor;\n }\n };\n}\n\nexport function RichTextEditor({ toolbar = \"fixed\", ref, ...props }: RichTextEditorProps) {\n return (\n <EditorCore\n {...props}\n format=\"html\"\n toolbar={toolbar}\n slot=\"rich-text-editor\"\n handleRef={ref}\n createHandle={createRichTextHandle}\n />\n );\n}\n"],"mappings":";AAgEI;AA1CJ,SAAS,kBAAyC;AA0BlD,SAAS,qBAAqB,MAA8C;AAC1E,SAAO;AAAA,IACL,OAAO,KAAK;AAAA,IACZ,SAAS,KAAK;AAAA,IACd,SAAS,KAAK;AAAA,IACd,OAAO,KAAK;AAAA,IACZ,SAAS,KAAK;AAAA,IACd,aAAa,KAAK;AAAA,IAClB,IAAI,SAAS;AACX,aAAO,KAAK;AAAA,IACd;AAAA,EACF;AACF;AAEO,SAAS,eAAe,EAAE,UAAU,SAAS,KAAK,GAAG,MAAM,GAAwB;AACxF,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ,QAAO;AAAA,MACP;AAAA,MACA,MAAK;AAAA,MACL,WAAW;AAAA,MACX,cAAc;AAAA;AAAA,EAChB;AAEJ;","names":[]}
@@ -0,0 +1,65 @@
1
+ import { Editor, Range, Extension } from '@tiptap/core';
2
+ import { PluginKey } from '@tiptap/pm/state';
3
+ import { SuggestionOptions } from '@tiptap/suggestion';
4
+ import { LucideIcon } from 'lucide-react';
5
+ import { f as EditorFormat } from '../editor-types-BMST7-VR.js';
6
+ import { MarkdownEditorBlockId, MarkdownEditorLabels } from './markdown-editor-constants.js';
7
+
8
+ /**
9
+ * Menu `/` — danh sách khối chèn được, và extension Tiptap mở nó.
10
+ *
11
+ * Không `"use client"`: bảng dưới đây là dữ liệu (icon là component, `run` là hàm) — file thuần.
12
+ * Chữ hiển thị KHÔNG nằm ở đây: tên khối và từ đồng nghĩa đi qua namespace `markdownEditor`; ở đây
13
+ * chỉ có bí danh MÁY tiếng Anh (`h1`, `ul`, `todo`…), thứ người dùng gõ như nhau ở mọi ngôn ngữ.
14
+ */
15
+
16
+ /** Thứ menu cần mà chỉ component biết — vd mở hộp chọn tệp. */
17
+ interface SlashCommandContext {
18
+ /** Định dạng lưu — quyết định khối nào chèn được vào ô bảng. Mặc định `markdown`. */
19
+ format?: EditorFormat;
20
+ /** Có = app đã truyền `onImageUpload`, khối "Ảnh" hiện ra. */
21
+ pickImage?: () => void;
22
+ }
23
+ interface SlashCommand {
24
+ id: MarkdownEditorBlockId;
25
+ icon: LucideIcon;
26
+ /**
27
+ * Cú pháp gõ tắt, hiện mờ bên phải mục — kiểu Linear. Menu `/` vì vậy cũng là chỗ DẠY cú pháp:
28
+ * lần sau người dùng gõ thẳng `## ` mà không cần mở menu.
29
+ */
30
+ hint?: string;
31
+ /** Bí danh máy, cách nhau bằng dấu cách — luôn tìm được, ở mọi ngôn ngữ. */
32
+ aliases: string;
33
+ /** Mục có hiện ở vị trí con trỏ này không. Bỏ trống = hiện ở mọi chỗ chèn được KHỐI (`blockAllowed`). */
34
+ when?: (editor: Editor, context: SlashCommandContext) => boolean;
35
+ run: (editor: Editor, range: Range, context: SlashCommandContext) => void;
36
+ }
37
+ /** Mục hiện trong menu: lệnh + chữ đã dịch, ngữ cảnh đã buộc sẵn. */
38
+ interface SlashMenuItem {
39
+ id: MarkdownEditorBlockId;
40
+ icon: LucideIcon;
41
+ hint?: string;
42
+ label: string;
43
+ execute: (editor: Editor, range: Range) => void;
44
+ }
45
+ declare const SLASH_COMMANDS: readonly SlashCommand[];
46
+ /**
47
+ * Các mục hiện được ở vị trí con trỏ, khớp với chữ đã gõ.
48
+ *
49
+ * Khớp = MỌI từ của chữ gõ nằm trong (tên đã dịch + từ đồng nghĩa đã dịch + bí danh máy) — không
50
+ * dấu, không phân biệt hoa thường, đúng luật `matchesSearch` của `Combobox`: "/tieu" ra "Tiêu đề",
51
+ * "/h2" ra "Tiêu đề 2" ở mọi ngôn ngữ.
52
+ */
53
+ declare function filterSlashCommands(editor: Editor, query: string, labels: MarkdownEditorLabels, context: SlashCommandContext): SlashMenuItem[];
54
+ declare const SLASH_PLUGIN_KEY: PluginKey<any>;
55
+ /**
56
+ * Extension mở menu `/`. Phần GIAO DIỆN (danh sách, bàn phím) đến qua `render` — xem
57
+ * `SuggestionStore`.
58
+ *
59
+ * `/` chỉ kích hoạt ở đầu dòng hoặc sau dấu cách (`allowedPrefixes` mặc định của Tiptap), nên gõ
60
+ * đường dẫn `a/b` hay ngày `02/10` không bật menu. Không bật trong khối mã và mã nội dòng: ở đó `/`
61
+ * là chữ của mã.
62
+ */
63
+ declare function createSlashCommandExtension(options: Pick<SuggestionOptions<SlashMenuItem, SlashMenuItem>, "items" | "render">): Extension;
64
+
65
+ export { SLASH_COMMANDS, SLASH_PLUGIN_KEY, type SlashCommand, type SlashCommandContext, type SlashMenuItem, createSlashCommandExtension, filterSlashCommands };
@@ -0,0 +1,191 @@
1
+ import { Extension } from "@tiptap/core";
2
+ import { PluginKey } from "@tiptap/pm/state";
3
+ import { Suggestion } from "@tiptap/suggestion";
4
+ import {
5
+ BetweenHorizontalEnd,
6
+ BetweenVerticalEnd,
7
+ Columns3,
8
+ Heading1,
9
+ Heading2,
10
+ Heading3,
11
+ ImagePlus,
12
+ List,
13
+ ListChecks,
14
+ ListOrdered,
15
+ Minus,
16
+ Pilcrow,
17
+ Rows3,
18
+ SquareCode,
19
+ Table,
20
+ TextQuote,
21
+ Trash2
22
+ } from "lucide-react";
23
+ import { matchesSearch } from "../lib/text-search.js";
24
+ const inTable = (editor) => editor.isActive("table");
25
+ const blockAllowed = (editor, context) => context.format === "html" || !inTable(editor);
26
+ const SLASH_COMMANDS = [
27
+ {
28
+ id: "text",
29
+ icon: Pilcrow,
30
+ aliases: "p text paragraph",
31
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setParagraph().run()
32
+ },
33
+ {
34
+ id: "heading1",
35
+ icon: Heading1,
36
+ hint: "#",
37
+ aliases: "h1 heading",
38
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run()
39
+ },
40
+ {
41
+ id: "heading2",
42
+ icon: Heading2,
43
+ hint: "##",
44
+ aliases: "h2 heading",
45
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run()
46
+ },
47
+ {
48
+ id: "heading3",
49
+ icon: Heading3,
50
+ hint: "###",
51
+ aliases: "h3 heading",
52
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run()
53
+ },
54
+ {
55
+ id: "bulletList",
56
+ icon: List,
57
+ hint: "-",
58
+ aliases: "ul bullet list",
59
+ run: (editor, range) => editor.chain().focus().deleteRange(range).toggleBulletList().run()
60
+ },
61
+ {
62
+ id: "orderedList",
63
+ icon: ListOrdered,
64
+ hint: "1.",
65
+ aliases: "ol ordered number list",
66
+ run: (editor, range) => editor.chain().focus().deleteRange(range).toggleOrderedList().run()
67
+ },
68
+ {
69
+ id: "taskList",
70
+ icon: ListChecks,
71
+ hint: "[]",
72
+ aliases: "todo task checklist checkbox",
73
+ run: (editor, range) => editor.chain().focus().deleteRange(range).toggleTaskList().run()
74
+ },
75
+ {
76
+ id: "blockquote",
77
+ icon: TextQuote,
78
+ hint: ">",
79
+ aliases: "quote blockquote",
80
+ run: (editor, range) => editor.chain().focus().deleteRange(range).toggleBlockquote().run()
81
+ },
82
+ {
83
+ id: "codeBlock",
84
+ icon: SquareCode,
85
+ hint: "```",
86
+ aliases: "code pre codeblock",
87
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setCodeBlock().run()
88
+ },
89
+ {
90
+ id: "table",
91
+ icon: Table,
92
+ aliases: "table spreadsheet",
93
+ // Không lồng bảng trong bảng — GFM không có, và hiếm khi là điều người soạn muốn.
94
+ when: (editor) => !inTable(editor),
95
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
96
+ },
97
+ {
98
+ id: "divider",
99
+ icon: Minus,
100
+ hint: "---",
101
+ aliases: "hr divider rule separator",
102
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setHorizontalRule().run()
103
+ },
104
+ {
105
+ id: "image",
106
+ icon: ImagePlus,
107
+ aliases: "img image picture upload",
108
+ when: (editor, context) => blockAllowed(editor, context) && Boolean(context.pickImage),
109
+ run: (editor, range, context) => {
110
+ editor.chain().focus().deleteRange(range).run();
111
+ context.pickImage?.();
112
+ }
113
+ },
114
+ {
115
+ id: "tableAddRow",
116
+ icon: BetweenHorizontalEnd,
117
+ aliases: "row add insert",
118
+ when: inTable,
119
+ run: (editor, range) => editor.chain().focus().deleteRange(range).addRowAfter().run()
120
+ },
121
+ {
122
+ id: "tableAddColumn",
123
+ icon: BetweenVerticalEnd,
124
+ aliases: "column col add insert",
125
+ when: inTable,
126
+ run: (editor, range) => editor.chain().focus().deleteRange(range).addColumnAfter().run()
127
+ },
128
+ {
129
+ id: "tableDeleteRow",
130
+ icon: Rows3,
131
+ aliases: "row delete remove",
132
+ when: inTable,
133
+ run: (editor, range) => editor.chain().focus().deleteRange(range).deleteRow().run()
134
+ },
135
+ {
136
+ id: "tableDeleteColumn",
137
+ icon: Columns3,
138
+ aliases: "column col delete remove",
139
+ when: inTable,
140
+ run: (editor, range) => editor.chain().focus().deleteRange(range).deleteColumn().run()
141
+ },
142
+ {
143
+ id: "tableDelete",
144
+ icon: Trash2,
145
+ aliases: "table delete remove",
146
+ when: inTable,
147
+ run: (editor, range) => editor.chain().focus().deleteRange(range).deleteTable().run()
148
+ }
149
+ ];
150
+ function filterSlashCommands(editor, query, labels, context) {
151
+ const words = query.trim().split(/\s+/).filter(Boolean);
152
+ return SLASH_COMMANDS.filter(
153
+ (command) => command.when ? command.when(editor, context) : blockAllowed(editor, context)
154
+ ).filter((command) => {
155
+ const haystack = `${labels[command.id]} ${labels.slashKeywords[command.id]} ${command.aliases}`;
156
+ return words.every((word) => matchesSearch(haystack, word));
157
+ }).map((command) => ({
158
+ id: command.id,
159
+ icon: command.icon,
160
+ hint: command.hint,
161
+ label: labels[command.id],
162
+ execute: (target, range) => command.run(target, range, context)
163
+ }));
164
+ }
165
+ const SLASH_PLUGIN_KEY = new PluginKey("comitorSlashMenu");
166
+ function createSlashCommandExtension(options) {
167
+ return Extension.create({
168
+ name: "comitorSlashMenu",
169
+ addProseMirrorPlugins() {
170
+ return [
171
+ Suggestion({
172
+ editor: this.editor,
173
+ pluginKey: SLASH_PLUGIN_KEY,
174
+ char: "/",
175
+ allowSpaces: false,
176
+ items: options.items,
177
+ render: options.render,
178
+ command: ({ editor, range, props }) => props.execute(editor, range),
179
+ allow: ({ editor }) => editor.isEditable && !editor.isActive("codeBlock") && !editor.isActive("code")
180
+ })
181
+ ];
182
+ }
183
+ });
184
+ }
185
+ export {
186
+ SLASH_COMMANDS,
187
+ SLASH_PLUGIN_KEY,
188
+ createSlashCommandExtension,
189
+ filterSlashCommands
190
+ };
191
+ //# sourceMappingURL=slash-commands.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/editor/slash-commands.ts"],"sourcesContent":["/**\n * Menu `/` — danh sách khối chèn được, và extension Tiptap mở nó.\n *\n * Không `\"use client\"`: bảng dưới đây là dữ liệu (icon là component, `run` là hàm) — file thuần.\n * Chữ hiển thị KHÔNG nằm ở đây: tên khối và từ đồng nghĩa đi qua namespace `markdownEditor`; ở đây\n * chỉ có bí danh MÁY tiếng Anh (`h1`, `ul`, `todo`…), thứ người dùng gõ như nhau ở mọi ngôn ngữ.\n */\n\nimport { type Editor, Extension, type Range } from \"@tiptap/core\";\nimport { PluginKey } from \"@tiptap/pm/state\";\nimport { Suggestion, type SuggestionOptions } from \"@tiptap/suggestion\";\nimport {\n BetweenHorizontalEnd,\n BetweenVerticalEnd,\n Columns3,\n Heading1,\n Heading2,\n Heading3,\n ImagePlus,\n List,\n ListChecks,\n ListOrdered,\n type LucideIcon,\n Minus,\n Pilcrow,\n Rows3,\n SquareCode,\n Table,\n TextQuote,\n Trash2\n} from \"lucide-react\";\nimport { matchesSearch } from \"../lib/text-search.js\";\nimport type { EditorFormat } from \"./editor-types.js\";\nimport type { MarkdownEditorBlockId, MarkdownEditorLabels } from \"./markdown-editor-constants.js\";\n\n/** Thứ menu cần mà chỉ component biết — vd mở hộp chọn tệp. */\nexport interface SlashCommandContext {\n /** Định dạng lưu — quyết định khối nào chèn được vào ô bảng. Mặc định `markdown`. */\n format?: EditorFormat;\n /** Có = app đã truyền `onImageUpload`, khối \"Ảnh\" hiện ra. */\n pickImage?: () => void;\n}\n\nexport interface SlashCommand {\n id: MarkdownEditorBlockId;\n icon: LucideIcon;\n /**\n * Cú pháp gõ tắt, hiện mờ bên phải mục — kiểu Linear. Menu `/` vì vậy cũng là chỗ DẠY cú pháp:\n * lần sau người dùng gõ thẳng `## ` mà không cần mở menu.\n */\n hint?: string;\n /** Bí danh máy, cách nhau bằng dấu cách — luôn tìm được, ở mọi ngôn ngữ. */\n aliases: string;\n /** Mục có hiện ở vị trí con trỏ này không. Bỏ trống = hiện ở mọi chỗ chèn được KHỐI (`blockAllowed`). */\n when?: (editor: Editor, context: SlashCommandContext) => boolean;\n run: (editor: Editor, range: Range, context: SlashCommandContext) => void;\n}\n\n/** Mục hiện trong menu: lệnh + chữ đã dịch, ngữ cảnh đã buộc sẵn. */\nexport interface SlashMenuItem {\n id: MarkdownEditorBlockId;\n icon: LucideIcon;\n hint?: string;\n label: string;\n execute: (editor: Editor, range: Range) => void;\n}\n\nconst inTable = (editor: Editor) => editor.isActive(\"table\");\n\n/**\n * Chỗ con trỏ đứng có nhận một KHỐI không. Ô bảng của trình soạn MARKDOWN chỉ chứa một đoạn văn (xem\n * `ComitorTableCell`), nên ở đó mọi khối có hiện cũng không chèn được; ô bảng HTML thì nhận.\n */\nconst blockAllowed = (editor: Editor, context: SlashCommandContext) => context.format === \"html\" || !inTable(editor);\n\n/*\n * Thứ tự theo tần suất dùng của một mô tả công việc (văn bản → tiêu đề → danh sách → khối), không\n * theo bảng chữ cái: menu lọc theo chữ gõ, nên thứ tự chỉ có nghĩa ở lượt mở ĐẦU, khi chưa gõ gì.\n *\n * Trong bảng có thêm thao tác bảng (thêm/xoá hàng, cột); ở trình soạn markdown thì CHỈ còn chúng.\n */\nexport const SLASH_COMMANDS: readonly SlashCommand[] = [\n {\n id: \"text\",\n icon: Pilcrow,\n aliases: \"p text paragraph\",\n run: (editor, range) => editor.chain().focus().deleteRange(range).setParagraph().run()\n },\n {\n id: \"heading1\",\n icon: Heading1,\n hint: \"#\",\n aliases: \"h1 heading\",\n run: (editor, range) => editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run()\n },\n {\n id: \"heading2\",\n icon: Heading2,\n hint: \"##\",\n aliases: \"h2 heading\",\n run: (editor, range) => editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run()\n },\n {\n id: \"heading3\",\n icon: Heading3,\n hint: \"###\",\n aliases: \"h3 heading\",\n run: (editor, range) => editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run()\n },\n {\n id: \"bulletList\",\n icon: List,\n hint: \"-\",\n aliases: \"ul bullet list\",\n run: (editor, range) => editor.chain().focus().deleteRange(range).toggleBulletList().run()\n },\n {\n id: \"orderedList\",\n icon: ListOrdered,\n hint: \"1.\",\n aliases: \"ol ordered number list\",\n run: (editor, range) => editor.chain().focus().deleteRange(range).toggleOrderedList().run()\n },\n {\n id: \"taskList\",\n icon: ListChecks,\n hint: \"[]\",\n aliases: \"todo task checklist checkbox\",\n run: (editor, range) => editor.chain().focus().deleteRange(range).toggleTaskList().run()\n },\n {\n id: \"blockquote\",\n icon: TextQuote,\n hint: \">\",\n aliases: \"quote blockquote\",\n run: (editor, range) => editor.chain().focus().deleteRange(range).toggleBlockquote().run()\n },\n {\n id: \"codeBlock\",\n icon: SquareCode,\n hint: \"```\",\n aliases: \"code pre codeblock\",\n run: (editor, range) => editor.chain().focus().deleteRange(range).setCodeBlock().run()\n },\n {\n id: \"table\",\n icon: Table,\n aliases: \"table spreadsheet\",\n // Không lồng bảng trong bảng — GFM không có, và hiếm khi là điều người soạn muốn.\n when: (editor) => !inTable(editor),\n run: (editor, range) =>\n editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()\n },\n {\n id: \"divider\",\n icon: Minus,\n hint: \"---\",\n aliases: \"hr divider rule separator\",\n run: (editor, range) => editor.chain().focus().deleteRange(range).setHorizontalRule().run()\n },\n {\n id: \"image\",\n icon: ImagePlus,\n aliases: \"img image picture upload\",\n when: (editor, context) => blockAllowed(editor, context) && Boolean(context.pickImage),\n run: (editor, range, context) => {\n editor.chain().focus().deleteRange(range).run();\n context.pickImage?.();\n }\n },\n {\n id: \"tableAddRow\",\n icon: BetweenHorizontalEnd,\n aliases: \"row add insert\",\n when: inTable,\n run: (editor, range) => editor.chain().focus().deleteRange(range).addRowAfter().run()\n },\n {\n id: \"tableAddColumn\",\n icon: BetweenVerticalEnd,\n aliases: \"column col add insert\",\n when: inTable,\n run: (editor, range) => editor.chain().focus().deleteRange(range).addColumnAfter().run()\n },\n {\n id: \"tableDeleteRow\",\n icon: Rows3,\n aliases: \"row delete remove\",\n when: inTable,\n run: (editor, range) => editor.chain().focus().deleteRange(range).deleteRow().run()\n },\n {\n id: \"tableDeleteColumn\",\n icon: Columns3,\n aliases: \"column col delete remove\",\n when: inTable,\n run: (editor, range) => editor.chain().focus().deleteRange(range).deleteColumn().run()\n },\n {\n id: \"tableDelete\",\n icon: Trash2,\n aliases: \"table delete remove\",\n when: inTable,\n run: (editor, range) => editor.chain().focus().deleteRange(range).deleteTable().run()\n }\n];\n\n/**\n * Các mục hiện được ở vị trí con trỏ, khớp với chữ đã gõ.\n *\n * Khớp = MỌI từ của chữ gõ nằm trong (tên đã dịch + từ đồng nghĩa đã dịch + bí danh máy) — không\n * dấu, không phân biệt hoa thường, đúng luật `matchesSearch` của `Combobox`: \"/tieu\" ra \"Tiêu đề\",\n * \"/h2\" ra \"Tiêu đề 2\" ở mọi ngôn ngữ.\n */\nexport function filterSlashCommands(\n editor: Editor,\n query: string,\n labels: MarkdownEditorLabels,\n context: SlashCommandContext\n): SlashMenuItem[] {\n const words = query.trim().split(/\\s+/).filter(Boolean);\n return SLASH_COMMANDS.filter((command) =>\n command.when ? command.when(editor, context) : blockAllowed(editor, context)\n )\n .filter((command) => {\n const haystack = `${labels[command.id]} ${labels.slashKeywords[command.id]} ${command.aliases}`;\n return words.every((word) => matchesSearch(haystack, word));\n })\n .map((command) => ({\n id: command.id,\n icon: command.icon,\n hint: command.hint,\n label: labels[command.id],\n execute: (target, range) => command.run(target, range, context)\n }));\n}\n\nexport const SLASH_PLUGIN_KEY = new PluginKey(\"comitorSlashMenu\");\n\n/**\n * Extension mở menu `/`. Phần GIAO DIỆN (danh sách, bàn phím) đến qua `render` — xem\n * `SuggestionStore`.\n *\n * `/` chỉ kích hoạt ở đầu dòng hoặc sau dấu cách (`allowedPrefixes` mặc định của Tiptap), nên gõ\n * đường dẫn `a/b` hay ngày `02/10` không bật menu. Không bật trong khối mã và mã nội dòng: ở đó `/`\n * là chữ của mã.\n */\nexport function createSlashCommandExtension(\n options: Pick<SuggestionOptions<SlashMenuItem, SlashMenuItem>, \"items\" | \"render\">\n): Extension {\n return Extension.create({\n name: \"comitorSlashMenu\",\n addProseMirrorPlugins() {\n return [\n Suggestion<SlashMenuItem, SlashMenuItem>({\n editor: this.editor,\n pluginKey: SLASH_PLUGIN_KEY,\n char: \"/\",\n allowSpaces: false,\n items: options.items,\n render: options.render,\n command: ({ editor, range, props }) => props.execute(editor, range),\n allow: ({ editor }) => editor.isEditable && !editor.isActive(\"codeBlock\") && !editor.isActive(\"code\")\n })\n ];\n }\n });\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,38 @@
1
+ import { Editor } from '@tiptap/core';
2
+ import * as React from 'react';
3
+ import { SuggestionStore } from './suggestion-store.js';
4
+ import '@tiptap/pm/state';
5
+ import '@tiptap/suggestion';
6
+
7
+ /**
8
+ * Danh sách gợi ý nổi của trình soạn — DÙNG CHUNG cho menu `/` (khối) và `@` (người).
9
+ *
10
+ * Con trỏ KHÔNG rời vùng soạn: người dùng vẫn đang gõ chữ lọc, nên focus ở lại ProseMirror và bàn
11
+ * phím đi đường plugin → `SuggestionStore.keyHandler` → đây. Vì vậy danh sách là `role="listbox"`
12
+ * điều khiển bằng `aria-activedescendant` đặt lên CHÍNH vùng soạn (khuôn combobox của ARIA), không
13
+ * phải một danh sách nhận focus.
14
+ *
15
+ * Mở bằng `Popover` của gói neo vào một phần tử ẢO (khung của đoạn `/que…` đang gõ) — lý do không
16
+ * gắn thẳng vào `document.body` ở đầu `suggestion-store.ts`.
17
+ */
18
+
19
+ interface SuggestionMenuProps<Item, Selected> {
20
+ editor: Editor;
21
+ store: SuggestionStore<Item, Selected>;
22
+ /** Tên của listbox cho trình đọc màn hình */
23
+ label: string;
24
+ emptyLabel: string;
25
+ loadingLabel?: string;
26
+ getKey: (item: Item) => string;
27
+ renderItem: (item: Item) => React.ReactNode;
28
+ /**
29
+ * Ẩn hẳn menu (và thoát chế độ gợi ý) khi KHÔNG có kết quả. Dùng cho `@`: tên người có dấu cách
30
+ * nên chữ lọc được phép chứa dấu cách — khi không còn ai khớp, người dùng đang viết câu thường
31
+ * chứ không còn tìm người, và một ô "Không tìm thấy" bám theo từng chữ là phiền.
32
+ */
33
+ exitWhenEmpty?: (query: string) => boolean;
34
+ className?: string;
35
+ }
36
+ declare function SuggestionMenu<Item, Selected>({ editor, store, label, emptyLabel, loadingLabel, getKey, renderItem, exitWhenEmpty, className }: SuggestionMenuProps<Item, Selected>): React.JSX.Element;
37
+
38
+ export { SuggestionMenu, type SuggestionMenuProps };
@@ -0,0 +1,144 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { Popover, PopoverAnchor, PopoverContent } from "../components/ui/popover.js";
5
+ import { cn } from "../lib/cn.js";
6
+ import { useAsRef } from "../lib/use-as-ref.js";
7
+ function SuggestionMenu({
8
+ editor,
9
+ store,
10
+ label,
11
+ emptyLabel,
12
+ loadingLabel,
13
+ getKey,
14
+ renderItem,
15
+ exitWhenEmpty,
16
+ className
17
+ }) {
18
+ const snapshot = React.useSyncExternalStore(
19
+ store.subscribe,
20
+ store.getSnapshot,
21
+ store.getServerSnapshot
22
+ );
23
+ const [lastSnapshot, setLastSnapshot] = React.useState(snapshot);
24
+ if (snapshot && snapshot !== lastSnapshot) setLastSnapshot(snapshot);
25
+ const shown = snapshot ?? lastSnapshot;
26
+ const [activeIndex, setActiveIndex] = React.useState(0);
27
+ const activeIndexRef = useAsRef(activeIndex);
28
+ const listId = React.useId();
29
+ const optionId = (index) => `${listId}-option-${index}`;
30
+ const items = shown?.items ?? [];
31
+ const query = shown?.query ?? "";
32
+ const loading = Boolean(shown?.loading);
33
+ const hidden = Boolean(snapshot && !loading && items.length === 0 && exitWhenEmpty?.(snapshot.query));
34
+ React.useEffect(() => setActiveIndex(0), [query, items.length]);
35
+ React.useEffect(() => {
36
+ if (hidden) snapshot?.exit();
37
+ }, [hidden, snapshot]);
38
+ React.useEffect(() => {
39
+ store.keyHandler = (event) => {
40
+ const current = store.getSnapshot();
41
+ const count = current?.items.length ?? 0;
42
+ if (!current || count === 0) return false;
43
+ if (event.key === "ArrowDown") {
44
+ setActiveIndex((index) => (index + 1) % count);
45
+ return true;
46
+ }
47
+ if (event.key === "ArrowUp") {
48
+ setActiveIndex((index) => (index - 1 + count) % count);
49
+ return true;
50
+ }
51
+ if (event.key === "Enter" || event.key === "Tab") {
52
+ const item = current.items[Math.min(activeIndexRef.current, count - 1)];
53
+ if (item === void 0) return false;
54
+ current.select(item);
55
+ return true;
56
+ }
57
+ return false;
58
+ };
59
+ return () => {
60
+ store.keyHandler = null;
61
+ };
62
+ }, [store, activeIndexRef]);
63
+ const open = Boolean(snapshot) && !hidden;
64
+ const activeOption = open && items.length > 0 ? optionId(Math.min(activeIndex, items.length - 1)) : null;
65
+ React.useEffect(() => {
66
+ const dom = editor.view.dom;
67
+ if (!open) return;
68
+ dom.setAttribute("aria-controls", listId);
69
+ if (activeOption) dom.setAttribute("aria-activedescendant", activeOption);
70
+ else dom.removeAttribute("aria-activedescendant");
71
+ return () => {
72
+ dom.removeAttribute("aria-controls");
73
+ dom.removeAttribute("aria-activedescendant");
74
+ };
75
+ }, [editor, open, listId, activeOption]);
76
+ React.useEffect(() => {
77
+ if (activeOption) document.getElementById(activeOption)?.scrollIntoView({ block: "nearest" });
78
+ }, [activeOption]);
79
+ const latest = useAsRef(shown);
80
+ const anchorRect = React.useRef(null);
81
+ const anchor = React.useRef({
82
+ getBoundingClientRect: () => {
83
+ const rect = latest.current?.clientRect();
84
+ if (rect) anchorRect.current = rect;
85
+ return anchorRect.current ?? new DOMRect();
86
+ }
87
+ });
88
+ return /* @__PURE__ */ jsxs(
89
+ Popover,
90
+ {
91
+ open,
92
+ onOpenChange: (next) => {
93
+ if (!next) snapshot?.exit();
94
+ },
95
+ children: [
96
+ /* @__PURE__ */ jsx(PopoverAnchor, { virtualRef: anchor }),
97
+ /* @__PURE__ */ jsx(
98
+ PopoverContent,
99
+ {
100
+ align: "start",
101
+ side: "bottom",
102
+ sideOffset: 6,
103
+ updatePositionStrategy: "always",
104
+ onOpenAutoFocus: (event) => event.preventDefault(),
105
+ onCloseAutoFocus: (event) => event.preventDefault(),
106
+ className: cn("max-h-80 w-72 overflow-y-auto p-1", className),
107
+ children: /* @__PURE__ */ jsx("div", { role: "listbox", id: listId, "aria-label": label, children: items.length === 0 ? /* @__PURE__ */ jsx("div", { className: "px-2 py-6 text-center text-sm text-muted-foreground", children: loading && loadingLabel ? loadingLabel : emptyLabel }) : items.map((item, index) => {
108
+ const active = index === activeIndex;
109
+ return (
110
+ // biome-ignore lint/a11y/useKeyWithClickEvents: bàn phím đi qua vùng soạn (aria-activedescendant), xem đầu file
111
+ /* @__PURE__ */ jsx(
112
+ "div",
113
+ {
114
+ id: optionId(index),
115
+ role: "option",
116
+ tabIndex: -1,
117
+ "aria-selected": active,
118
+ "data-selected": active || void 0,
119
+ onMouseDown: (event) => event.preventDefault(),
120
+ onPointerMove: () => setActiveIndex(index),
121
+ onClick: () => snapshot?.select(item),
122
+ className: cn(
123
+ // Bộ lớp của `CommandItem`: mục sáng `bg-accent`, đệm theo trục mật độ, icon mặc định 16px.
124
+ "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-(--menu-item-py) text-sm outline-hidden select-none",
125
+ "mt-1 first:mt-0 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
126
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
127
+ "[&_svg:not([class*='text-'])]:text-muted-foreground"
128
+ ),
129
+ children: renderItem(item)
130
+ },
131
+ getKey(item)
132
+ )
133
+ );
134
+ }) })
135
+ }
136
+ )
137
+ ]
138
+ }
139
+ );
140
+ }
141
+ export {
142
+ SuggestionMenu
143
+ };
144
+ //# sourceMappingURL=suggestion-menu.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/editor/suggestion-menu.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * Danh sách gợi ý nổi của trình soạn — DÙNG CHUNG cho menu `/` (khối) và `@` (người).\n *\n * Con trỏ KHÔNG rời vùng soạn: người dùng vẫn đang gõ chữ lọc, nên focus ở lại ProseMirror và bàn\n * phím đi đường plugin → `SuggestionStore.keyHandler` → đây. Vì vậy danh sách là `role=\"listbox\"`\n * điều khiển bằng `aria-activedescendant` đặt lên CHÍNH vùng soạn (khuôn combobox của ARIA), không\n * phải một danh sách nhận focus.\n *\n * Mở bằng `Popover` của gói neo vào một phần tử ẢO (khung của đoạn `/que…` đang gõ) — lý do không\n * gắn thẳng vào `document.body` ở đầu `suggestion-store.ts`.\n */\n\nimport type { Editor } from \"@tiptap/core\";\nimport * as React from \"react\";\nimport { Popover, PopoverAnchor, PopoverContent } from \"../components/ui/popover.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useAsRef } from \"../lib/use-as-ref.js\";\nimport type { SuggestionSnapshot, SuggestionStore } from \"./suggestion-store.js\";\n\nexport interface SuggestionMenuProps<Item, Selected> {\n editor: Editor;\n store: SuggestionStore<Item, Selected>;\n /** Tên của listbox cho trình đọc màn hình */\n label: string;\n emptyLabel: string;\n loadingLabel?: string;\n getKey: (item: Item) => string;\n renderItem: (item: Item) => React.ReactNode;\n /**\n * Ẩn hẳn menu (và thoát chế độ gợi ý) khi KHÔNG có kết quả. Dùng cho `@`: tên người có dấu cách\n * nên chữ lọc được phép chứa dấu cách — khi không còn ai khớp, người dùng đang viết câu thường\n * chứ không còn tìm người, và một ô \"Không tìm thấy\" bám theo từng chữ là phiền.\n */\n exitWhenEmpty?: (query: string) => boolean;\n className?: string;\n}\n\nexport function SuggestionMenu<Item, Selected>({\n editor,\n store,\n label,\n emptyLabel,\n loadingLabel,\n getKey,\n renderItem,\n exitWhenEmpty,\n className\n}: SuggestionMenuProps<Item, Selected>) {\n const snapshot: SuggestionSnapshot<Item> | null = React.useSyncExternalStore(\n store.subscribe,\n store.getSnapshot,\n store.getServerSnapshot\n );\n\n /*\n * Giữ ảnh chụp CUỐI để vẽ trong lúc `Popover` chạy hiệu ứng đóng: plugin xoá trạng thái ngay khi\n * thoát, mà nội dung biến mất trước khung thì thấy một ô rỗng co lại.\n */\n const [lastSnapshot, setLastSnapshot] = React.useState(snapshot);\n if (snapshot && snapshot !== lastSnapshot) setLastSnapshot(snapshot);\n const shown = snapshot ?? lastSnapshot;\n\n const [activeIndex, setActiveIndex] = React.useState(0);\n // Bản ĐỌC của chỉ số cho bộ xử lý phím: nó sống trong một effect, nên state trong closure cũ một nhịp.\n const activeIndexRef = useAsRef(activeIndex);\n const listId = React.useId();\n const optionId = (index: number) => `${listId}-option-${index}`;\n\n const items = shown?.items ?? [];\n const query = shown?.query ?? \"\";\n const loading = Boolean(shown?.loading);\n const hidden = Boolean(snapshot && !loading && items.length === 0 && exitWhenEmpty?.(snapshot.query));\n\n // Danh sách đổi → về mục đầu. Theo CHỮ LỌC chứ không theo mảng (mảng mới mỗi lần plugin cập nhật).\n // biome-ignore lint/correctness/useExhaustiveDependencies: chủ đích chạy lại theo chữ lọc và số mục\n React.useEffect(() => setActiveIndex(0), [query, items.length]);\n\n React.useEffect(() => {\n if (hidden) snapshot?.exit();\n }, [hidden, snapshot]);\n\n // Bàn phím: plugin giữ focus ở vùng soạn và chuyển phím vào đây.\n React.useEffect(() => {\n store.keyHandler = (event) => {\n const current = store.getSnapshot();\n const count = current?.items.length ?? 0;\n // Không có mục nào: trả phím cho vùng soạn — Enter xuống dòng như thường.\n if (!current || count === 0) return false;\n if (event.key === \"ArrowDown\") {\n setActiveIndex((index) => (index + 1) % count);\n return true;\n }\n if (event.key === \"ArrowUp\") {\n setActiveIndex((index) => (index - 1 + count) % count);\n return true;\n }\n if (event.key === \"Enter\" || event.key === \"Tab\") {\n const item = current.items[Math.min(activeIndexRef.current, count - 1)];\n if (item === undefined) return false;\n current.select(item);\n return true;\n }\n return false;\n };\n return () => {\n store.keyHandler = null;\n };\n }, [store, activeIndexRef]);\n\n // Quan hệ combobox trên chính vùng soạn — xem đầu file. Gỡ sạch khi menu đóng.\n const open = Boolean(snapshot) && !hidden;\n const activeOption = open && items.length > 0 ? optionId(Math.min(activeIndex, items.length - 1)) : null;\n React.useEffect(() => {\n const dom = editor.view.dom;\n if (!open) return;\n dom.setAttribute(\"aria-controls\", listId);\n if (activeOption) dom.setAttribute(\"aria-activedescendant\", activeOption);\n else dom.removeAttribute(\"aria-activedescendant\");\n return () => {\n dom.removeAttribute(\"aria-controls\");\n dom.removeAttribute(\"aria-activedescendant\");\n };\n }, [editor, open, listId, activeOption]);\n\n // Mục đang sáng luôn trong tầm nhìn khi đi bằng phím.\n React.useEffect(() => {\n if (activeOption) document.getElementById(activeOption)?.scrollIntoView({ block: \"nearest\" });\n }, [activeOption]);\n\n /*\n * Neo ẢO: Radix đọc `getBoundingClientRect()` của nó mỗi lần tính vị trí. Đối tượng phải ỔN ĐỊNH\n * (Popper gọi `onAnchorChange` sau mỗi render), còn khung thì đọc từ ảnh chụp MỚI NHẤT qua ref.\n *\n * ⚠ Nhớ khung CUỐI còn đo được: lúc menu đóng, plugin gỡ decoration của đoạn `/…` trước, nên\n * `clientRect()` ra `null` đúng trong lúc `Popover` còn chạy hiệu ứng đóng — trả `DOMRect` rỗng\n * ở đó là menu văng lên góc trái trên khi đang mờ dần (đã thấy trên harness).\n */\n const latest = useAsRef(shown);\n const anchorRect = React.useRef<DOMRect | null>(null);\n const anchor = React.useRef({\n getBoundingClientRect: () => {\n const rect = latest.current?.clientRect();\n if (rect) anchorRect.current = rect;\n return anchorRect.current ?? new DOMRect();\n }\n });\n\n return (\n <Popover\n open={open}\n onOpenChange={(next) => {\n // Đóng từ PHÍA MENU (Escape, bấm ra ngoài) → thoát chế độ gợi ý của plugin cho khớp.\n if (!next) snapshot?.exit();\n }}\n >\n <PopoverAnchor virtualRef={anchor} />\n <PopoverContent\n align=\"start\"\n side=\"bottom\"\n sideOffset={6}\n // Neo di chuyển theo chữ gõ (dòng xuống, khung cuộn) — đo lại mỗi khung hình khi đang mở.\n updatePositionStrategy=\"always\"\n onOpenAutoFocus={(event) => event.preventDefault()}\n onCloseAutoFocus={(event) => event.preventDefault()}\n className={cn(\"max-h-80 w-72 overflow-y-auto p-1\", className)}\n >\n {/* Không đặt `aria-multiselectable`: mỗi lần chọn là một lệnh, không phải tập chọn. */}\n <div role=\"listbox\" id={listId} aria-label={label}>\n {items.length === 0 ? (\n <div className=\"px-2 py-6 text-center text-sm text-muted-foreground\">\n {loading && loadingLabel ? loadingLabel : emptyLabel}\n </div>\n ) : (\n items.map((item, index) => {\n const active = index === activeIndex;\n return (\n // biome-ignore lint/a11y/useKeyWithClickEvents: bàn phím đi qua vùng soạn (aria-activedescendant), xem đầu file\n <div\n key={getKey(item)}\n id={optionId(index)}\n role=\"option\"\n // -1: tới được bằng mã (biome đòi `option` focus được), không vào thứ tự Tab — focus\n // thật vẫn ở vùng soạn, mục sáng đi theo `aria-activedescendant`.\n tabIndex={-1}\n aria-selected={active}\n data-selected={active || undefined}\n // Giữ focus ở vùng soạn: mousedown mặc định sẽ kéo focus ra khỏi ProseMirror.\n onMouseDown={(event) => event.preventDefault()}\n onPointerMove={() => setActiveIndex(index)}\n onClick={() => snapshot?.select(item)}\n className={cn(\n // Bộ lớp của `CommandItem`: mục sáng `bg-accent`, đệm theo trục mật độ, icon mặc định 16px.\n \"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-(--menu-item-py) text-sm outline-hidden select-none\",\n \"mt-1 first:mt-0 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground\",\n \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n \"[&_svg:not([class*='text-'])]:text-muted-foreground\"\n )}\n >\n {renderItem(item)}\n </div>\n );\n })\n )}\n </div>\n </PopoverContent>\n </Popover>\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,47 @@
1
+ import { PluginKey } from '@tiptap/pm/state';
2
+ import { SuggestionOptions } from '@tiptap/suggestion';
3
+
4
+ /**
5
+ * Cầu nối giữa plugin gợi ý của Tiptap (`@tiptap/suggestion` — vòng đời onStart/onUpdate/onExit,
6
+ * sống trong ProseMirror) và một component React (`SuggestionMenu`).
7
+ *
8
+ * Không dùng `ReactRenderer` + `props.mount()` như ví dụ của Tiptap: cách đó gắn danh sách vào
9
+ * `document.body` ngoài mọi lớp Radix, và trong một `Dialog` (form tạo việc) thì Radix coi cú bấm
10
+ * vào danh sách là bấm RA NGOÀI hộp thoại — hộp thoại đóng ngay giữa lúc chọn khối. Ở đây plugin
11
+ * chỉ ĐẨY trạng thái vào một kho nhỏ; menu là cây React bình thường của trình soạn, mở bằng `Popover`
12
+ * của gói — một lớp Radix lồng đúng thứ bậc, nên Escape đóng menu chứ không đóng hộp thoại.
13
+ */
14
+
15
+ interface SuggestionSnapshot<Item> {
16
+ items: Item[];
17
+ /** Chữ gõ sau ký tự kích hoạt */
18
+ query: string;
19
+ /** `true` khi `items()` bất đồng bộ chưa trả về */
20
+ loading: boolean;
21
+ /** Khung của đoạn đang gõ (`/que…`) — neo cho menu. `null` khi plugin chưa vẽ xong decoration. */
22
+ clientRect: () => DOMRect | null;
23
+ select: (item: Item) => void;
24
+ /** Thoát hẳn chế độ gợi ý (gỡ decoration) — dùng khi menu bị đóng từ PHÍA MENU (Escape, bấm ra ngoài). */
25
+ exit: () => void;
26
+ }
27
+ declare class SuggestionStore<Item, Selected = Item> {
28
+ private readonly pluginKey;
29
+ private readonly toSelected;
30
+ private snapshot;
31
+ private readonly listeners;
32
+ /**
33
+ * Bàn phím trong lúc menu mở: ProseMirror nhận phím (con trỏ vẫn ở trình soạn), plugin chuyển cho
34
+ * hàm này. Menu đăng ký nó khi mount. Trả `true` = đã xử lý, ProseMirror không làm gì thêm.
35
+ */
36
+ keyHandler: ((event: KeyboardEvent) => boolean) | null;
37
+ constructor(pluginKey: PluginKey, toSelected: (item: Item) => Selected);
38
+ subscribe: (listener: () => void) => () => void;
39
+ getSnapshot: () => SuggestionSnapshot<Item> | null;
40
+ getServerSnapshot: () => null;
41
+ private set;
42
+ private fromProps;
43
+ /** Truyền thẳng vào `render` của `Suggestion` / `Mention.suggestion`. */
44
+ render: NonNullable<SuggestionOptions<Item, Selected>["render"]>;
45
+ }
46
+
47
+ export { type SuggestionSnapshot, SuggestionStore };