@olddevs/ui 4.5.1 → 4.6.1
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/CHANGELOG.md +26 -0
- package/dist/editor/RichTextEditor.d.ts +2 -2
- package/dist/editor/RichTextEditor.d.ts.map +1 -1
- package/dist/editor/RichTextEditor.js +3 -0
- package/dist/editor/RichTextEditor.js.map +1 -1
- package/dist/editor/core/blockTypes.d.ts +2 -8
- package/dist/editor/core/blockTypes.d.ts.map +1 -1
- package/dist/editor/core/blockTypes.js +11 -54
- package/dist/editor/core/blockTypes.js.map +1 -1
- package/dist/editor/core/containers.d.ts.map +1 -1
- package/dist/editor/core/containers.js +1 -0
- package/dist/editor/core/containers.js.map +1 -1
- package/dist/editor/core/customMarkdown.js +1 -1
- package/dist/editor/core/customMarkdown.js.map +1 -1
- package/dist/editor/core/customTool.d.ts +6 -6
- package/dist/editor/core/customTool.d.ts.map +1 -1
- package/dist/editor/core/customToolEngine.d.ts +2 -2
- package/dist/editor/core/extensions.d.ts +7 -2
- package/dist/editor/core/extensions.d.ts.map +1 -1
- package/dist/editor/core/extensions.js +96 -64
- package/dist/editor/core/extensions.js.map +1 -1
- package/dist/editor/core/fieldNotice.d.ts +1 -1
- package/dist/editor/core/insertItems.d.ts +5 -28
- package/dist/editor/core/insertItems.d.ts.map +1 -1
- package/dist/editor/core/insertItems.js +3 -94
- package/dist/editor/core/insertItems.js.map +1 -1
- package/dist/editor/core/insertMenuTool.d.ts +13 -0
- package/dist/editor/core/insertMenuTool.d.ts.map +1 -0
- package/dist/editor/core/insertMenuTool.js +20 -0
- package/dist/editor/core/insertMenuTool.js.map +1 -0
- package/dist/editor/core/schema.d.ts +20 -5
- package/dist/editor/core/schema.d.ts.map +1 -1
- package/dist/editor/core/schema.js +18 -3
- package/dist/editor/core/schema.js.map +1 -1
- package/dist/editor/core/shortcuts.d.ts +56 -5
- package/dist/editor/core/shortcuts.d.ts.map +1 -1
- package/dist/editor/core/shortcuts.js +91 -156
- package/dist/editor/core/shortcuts.js.map +1 -1
- package/dist/editor/core/skeleton.d.ts +1 -1
- package/dist/editor/core/skeleton.d.ts.map +1 -1
- package/dist/editor/core/skeleton.js +1 -0
- package/dist/editor/core/skeleton.js.map +1 -1
- package/dist/editor/core/toolButtons.d.ts +3 -3
- package/dist/editor/core/toolDefinition.d.ts +3 -3
- package/dist/editor/core/toolDescriptor.d.ts +49 -30
- package/dist/editor/core/toolDescriptor.d.ts.map +1 -1
- package/dist/editor/core/toolDescriptor.js +2 -5
- package/dist/editor/core/toolDescriptor.js.map +1 -1
- package/dist/editor/core/toolLoader.d.ts +1 -1
- package/dist/editor/core/toolLoader.d.ts.map +1 -1
- package/dist/editor/core/toolOptions.d.ts +19 -2
- package/dist/editor/core/toolOptions.d.ts.map +1 -1
- package/dist/editor/core/toolOptions.js.map +1 -1
- package/dist/editor/core/tools.d.ts +10 -2
- package/dist/editor/core/tools.d.ts.map +1 -1
- package/dist/editor/core/tools.js +1 -0
- package/dist/editor/core/tools.js.map +1 -1
- package/dist/editor/features/builtins.d.ts +6 -8
- package/dist/editor/features/builtins.d.ts.map +1 -1
- package/dist/editor/features/builtins.js +48 -21
- package/dist/editor/features/builtins.js.map +1 -1
- package/dist/editor/features/code/codeBlockTool.d.ts +5 -6
- package/dist/editor/features/code/codeBlockTool.d.ts.map +1 -1
- package/dist/editor/features/code/codeBlockTool.js +10 -1
- package/dist/editor/features/code/codeBlockTool.js.map +1 -1
- package/dist/editor/features/color/colorTools.d.ts +14 -0
- package/dist/editor/features/color/colorTools.d.ts.map +1 -0
- package/dist/editor/features/color/colorTools.js +39 -0
- package/dist/editor/features/color/colorTools.js.map +1 -0
- package/dist/editor/features/details/details.d.ts +68 -0
- package/dist/editor/features/details/details.d.ts.map +1 -0
- package/dist/editor/features/details/details.js +237 -0
- package/dist/editor/features/details/details.js.map +1 -0
- package/dist/editor/features/details/detailsMarkdown.d.ts +17 -0
- package/dist/editor/features/details/detailsMarkdown.d.ts.map +1 -0
- package/dist/editor/features/details/detailsMarkdown.js +84 -0
- package/dist/editor/features/details/detailsMarkdown.js.map +1 -0
- package/dist/editor/features/details/detailsTool.d.ts +16 -0
- package/dist/editor/features/details/detailsTool.d.ts.map +1 -0
- package/dist/editor/features/details/detailsTool.js +39 -0
- package/dist/editor/features/details/detailsTool.js.map +1 -0
- package/dist/editor/features/emoji/emojiTool.d.ts +1 -1
- package/dist/editor/features/heading/headingTool.d.ts +18 -0
- package/dist/editor/features/heading/headingTool.d.ts.map +1 -0
- package/dist/editor/features/heading/headingTool.js +59 -0
- package/dist/editor/features/heading/headingTool.js.map +1 -0
- package/dist/editor/features/history/historyTool.d.ts +15 -0
- package/dist/editor/features/history/historyTool.d.ts.map +1 -0
- package/dist/editor/features/history/historyTool.js +28 -0
- package/dist/editor/features/history/historyTool.js.map +1 -0
- package/dist/editor/features/image/imageTool.d.ts +19 -0
- package/dist/editor/features/image/imageTool.d.ts.map +1 -0
- package/dist/editor/features/image/imageTool.js +28 -0
- package/dist/editor/features/image/imageTool.js.map +1 -0
- package/dist/editor/features/layout/layoutTool.d.ts +11 -0
- package/dist/editor/features/layout/layoutTool.d.ts.map +1 -0
- package/dist/editor/features/layout/layoutTool.js +20 -0
- package/dist/editor/features/layout/layoutTool.js.map +1 -0
- package/dist/editor/features/lists/listTools.d.ts +15 -0
- package/dist/editor/features/lists/listTools.d.ts.map +1 -0
- package/dist/editor/features/lists/listTools.js +78 -0
- package/dist/editor/features/lists/listTools.js.map +1 -0
- package/dist/editor/features/marks/markTools.d.ts +16 -0
- package/dist/editor/features/marks/markTools.d.ts.map +1 -0
- package/dist/editor/features/marks/markTools.js +43 -0
- package/dist/editor/features/marks/markTools.js.map +1 -0
- package/dist/editor/features/search/searchTool.d.ts +1 -1
- package/dist/editor/features/table/tableTool.d.ts +3 -3
- package/dist/editor/features/wrapper/wrapperTool.d.ts +11 -0
- package/dist/editor/features/wrapper/wrapperTool.d.ts.map +1 -0
- package/dist/editor/features/wrapper/wrapperTool.js +32 -0
- package/dist/editor/features/wrapper/wrapperTool.js.map +1 -0
- package/dist/editor/index.d.ts +1 -1
- package/dist/editor/index.d.ts.map +1 -1
- package/dist/editor/index.js.map +1 -1
- package/dist/editor/schema-entry.d.ts +1 -1
- package/dist/editor/styles/prose.d.ts.map +1 -1
- package/dist/editor/styles/prose.js +26 -0
- package/dist/editor/styles/prose.js.map +1 -1
- package/dist/editor/ui/EditorToolbar.d.ts.map +1 -1
- package/dist/editor/ui/EditorToolbar.js +5 -38
- package/dist/editor/ui/EditorToolbar.js.map +1 -1
- package/dist/editor/ui/SelectionBubble.d.ts.map +1 -1
- package/dist/editor/ui/SelectionBubble.js +1 -1
- package/dist/editor/ui/SelectionBubble.js.map +1 -1
- package/dist/editor/ui/ToolButtonView.d.ts.map +1 -1
- package/dist/editor/ui/ToolButtonView.js +1 -1
- package/dist/editor/ui/ToolButtonView.js.map +1 -1
- package/dist/i18n/defaults.d.ts.map +1 -1
- package/dist/i18n/defaults.js +3 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/messages.d.ts +16 -1
- package/dist/i18n/messages.d.ts.map +1 -1
- package/dist/icons.d.ts +8 -1
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +1 -1
- package/dist/icons.js.map +1 -1
- package/dist/locales/ru.d.ts.map +1 -1
- package/dist/locales/ru.js +3 -0
- package/dist/locales/ru.js.map +1 -1
- package/docs/api/editor-extension.en.md +68 -68
- package/docs/api/editor-extension.md +55 -55
- package/docs/api/messages.en.md +4 -1
- package/docs/api/messages.md +3 -0
- package/docs/api/migration-notes.en.md +11 -11
- package/docs/api/migration-notes.md +2 -2
- package/docs/api/utilities.en.md +6 -6
- package/docs/components/actions/split-button.en.md +9 -9
- package/docs/components/content/data-display.en.md +1 -1
- package/docs/components/content/layout-primitives.en.md +3 -3
- package/docs/components/content/rich-text-toc.en.md +1 -1
- package/docs/components/content/rich-text-view.en.md +23 -17
- package/docs/components/content/rich-text-view.md +17 -11
- package/docs/components/content/virtual-grid.en.md +2 -2
- package/docs/components/date-time/date-picker.en.md +1 -1
- package/docs/components/inputs/rich-text-editor.en.md +283 -230
- package/docs/components/inputs/rich-text-editor.md +139 -86
- package/docs/components/navigation/mega-menu.en.md +10 -10
- package/docs/components/overlays/menu.en.md +2 -2
- package/docs/components/overlays/shortcuts-help.en.md +1 -1
- package/docs/components/table/data-table.en.md +3 -3
- package/docs/components/table/filter-bar.en.md +1 -1
- package/docs/components/table/toolbar.en.md +1 -1
- package/docs/conventions.en.md +2 -2
- package/docs/conventions.md +1 -1
- package/docs/getting-started.en.md +9 -9
- package/docs/getting-started.md +8 -8
- package/docs/glossary.en.md +193 -133
- package/docs/glossary.md +191 -132
- package/docs/icons.en.md +12 -9
- package/docs/icons.md +3 -0
- package/docs/palette.en.md +3 -3
- package/docs/theming.en.md +6 -6
- package/package.json +2 -1
- package/src/editor/RichTextEditor.tsx +9 -6
- package/src/editor/core/atomText.ts +2 -2
- package/src/editor/core/blockTypes.ts +19 -83
- package/src/editor/core/containers.ts +4 -2
- package/src/editor/core/customMarkdown.ts +2 -2
- package/src/editor/core/customTool.ts +6 -6
- package/src/editor/core/customToolEngine.ts +9 -9
- package/src/editor/core/extensions.ts +165 -110
- package/src/editor/core/fieldNotice.ts +1 -1
- package/src/editor/core/handle.ts +2 -2
- package/src/editor/core/insertItems.ts +12 -158
- package/src/editor/core/insertMenuTool.ts +32 -0
- package/src/editor/core/jsonDocument.ts +1 -1
- package/src/editor/core/schema.ts +42 -7
- package/src/editor/core/shortcuts.ts +116 -177
- package/src/editor/core/skeleton.ts +4 -3
- package/src/editor/core/toolButtons.ts +3 -3
- package/src/editor/core/toolDefinition.ts +4 -4
- package/src/editor/core/toolDescriptor.ts +65 -52
- package/src/editor/core/toolLoader.ts +3 -3
- package/src/editor/core/toolOptions.ts +21 -2
- package/src/editor/core/tools.ts +10 -1
- package/src/editor/core/viewIsland.ts +1 -1
- package/src/editor/features/builtins.ts +76 -41
- package/src/editor/features/code/codeBlockTool.ts +16 -8
- package/src/editor/features/color/colorTools.ts +68 -0
- package/src/editor/features/details/details.ts +389 -0
- package/src/editor/features/details/detailsMarkdown.ts +158 -0
- package/src/editor/features/details/detailsTool.ts +69 -0
- package/src/editor/features/emoji/emojiTool.tsx +1 -1
- package/src/editor/features/heading/headingTool.ts +96 -0
- package/src/editor/features/history/historyTool.ts +42 -0
- package/src/editor/features/image/imageTool.ts +49 -0
- package/src/editor/features/layout/layoutTool.ts +40 -0
- package/src/editor/features/lists/listTools.ts +112 -0
- package/src/editor/features/marks/markTools.ts +78 -0
- package/src/editor/features/search/searchTool.ts +1 -1
- package/src/editor/features/table/tableTool.ts +3 -3
- package/src/editor/features/toc/tocTool.ts +1 -1
- package/src/editor/features/wrapper/wrapperTool.ts +52 -0
- package/src/editor/index.ts +1 -0
- package/src/editor/schema-entry.ts +1 -1
- package/src/editor/styles/prose.ts +49 -0
- package/src/editor/ui/EditorToolbar.tsx +7 -50
- package/src/editor/ui/SelectionBubble.tsx +2 -5
- package/src/editor/ui/ToolButtonView.tsx +2 -1
- package/src/i18n/defaults.ts +3 -0
- package/src/i18n/messages.ts +14 -1
- package/src/icons.ts +7 -0
- package/src/locales/ru.ts +3 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Details, DetailsContent, DetailsSummary } from "@tiptap/extension-details";
|
|
4
|
+
import { type Editor, type Extensions } from "@tiptap/core";
|
|
5
|
+
import { Fragment, type Node as PmNode } from "@tiptap/pm/model";
|
|
6
|
+
import {
|
|
7
|
+
NodeSelection,
|
|
8
|
+
Plugin,
|
|
9
|
+
PluginKey,
|
|
10
|
+
Selection,
|
|
11
|
+
TextSelection,
|
|
12
|
+
type Transaction,
|
|
13
|
+
} from "@tiptap/pm/state";
|
|
14
|
+
import { placeContainerBlock } from "../../core/containers.js";
|
|
15
|
+
import { detailsTokenizer, parseDetails, renderDetails } from "./detailsMarkdown.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Сворачиваемый Блок (Details) — Блок-контейнер из двух детей: заголовок
|
|
19
|
+
* (`detailsSummary`, строчное содержимое) и содержимое (`detailsContent`, Блоки).
|
|
20
|
+
* Движок — расширение Details из Tiptap (API по нему, ADR-0048: заказчика при
|
|
21
|
+
* выпуске не было); кит добавляет Схему, санитайзер, Markdown и вставку.
|
|
22
|
+
*
|
|
23
|
+
* HTML — КАК У TIPTAP, ТЕГАМИ КИТА, без классов и `style`:
|
|
24
|
+
*
|
|
25
|
+
* ```html
|
|
26
|
+
* <details>
|
|
27
|
+
* <summary>Заголовок</summary>
|
|
28
|
+
* <div data-type="detailsContent"><p>Блоки</p></div>
|
|
29
|
+
* </details>
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* `open` пишется только с Опцией `persist` (по умолчанию нет): раскрытый при
|
|
33
|
+
* правке Блок не публикуется раскрытым без спроса. Обёртка содержимого
|
|
34
|
+
* (`div[data-type="detailsContent"]`) нужна движку, чтобы отличить содержимое от
|
|
35
|
+
* заголовка; чужой HTML без неё (GitHub, браузер: `<details><summary>…</summary>
|
|
36
|
+
* <p>…</p></details>`) читается так же, Схема достраивает её сама.
|
|
37
|
+
*
|
|
38
|
+
* ВЛОЖЕННОСТЬ — КАК У ЛЮБОГО БЛОКА. Details входит в группу `block`: он бывает на
|
|
39
|
+
* верхнем уровне, в колонке Раскладки, Обёртке, цитате, ячейке и в содержимом
|
|
40
|
+
* другого Details. Раскладки и Обёртки внутри него нет — их принимают только
|
|
41
|
+
* Документ и колонка. `isolating`, как у Обёртки: Backspace в начале Блока не
|
|
42
|
+
* выводит текст наружу.
|
|
43
|
+
*
|
|
44
|
+
* Вид Блока в Редакторе — представление узла из Tiptap: `div` с кнопкой
|
|
45
|
+
* раскрытия и классом `is-open`; открытость при `persist: false` живёт только в
|
|
46
|
+
* этом DOM, не в Документе и не в истории правок.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** Имена узлов Схемы движка. */
|
|
50
|
+
export const DETAILS_NODE = "details";
|
|
51
|
+
export const DETAILS_SUMMARY_NODE = "detailsSummary";
|
|
52
|
+
export const DETAILS_CONTENT_NODE = "detailsContent";
|
|
53
|
+
|
|
54
|
+
/** Имя обёртки содержимого в `data-type`: так её пишет Tiptap. */
|
|
55
|
+
export const DETAILS_CONTENT_TYPE = DETAILS_CONTENT_NODE;
|
|
56
|
+
|
|
57
|
+
export interface DetailsEngineOptions {
|
|
58
|
+
/** Пишется ли открытость Блока в Документ (Опция `persist`). */
|
|
59
|
+
persist: boolean;
|
|
60
|
+
/** Имя кнопки раскрытия по тексту заголовка: из каталога, читается при каждой перерисовке. */
|
|
61
|
+
toggleLabel: (text: string) => string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Метка транзакции: позиция только что вставленного Блока, который надо раскрыть. */
|
|
65
|
+
const OPEN_ON_INSERT = new PluginKey<number | null>("detailsOpenOnInsert");
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Раскрыть Блок, вставленный из Меню вставки: открытость при `persist: false` —
|
|
69
|
+
* класс в DOM представления, а не атрибут узла, поэтому раскрывает её щелчок по
|
|
70
|
+
* кнопке, как у автора. Пустой Блок, который не раскрыт, — заголовок без места
|
|
71
|
+
* под содержимое, а набирать его автор пришёл не в заголовок. Делается после
|
|
72
|
+
* обновления DOM и вне транзакции: кнопка Tiptap возвращает фокус в Документ.
|
|
73
|
+
*/
|
|
74
|
+
const openOnInsert = new Plugin<number | null>({
|
|
75
|
+
key: OPEN_ON_INSERT,
|
|
76
|
+
state: {
|
|
77
|
+
init: () => null,
|
|
78
|
+
apply: (tr) => (tr.getMeta(OPEN_ON_INSERT) as number | undefined) ?? null,
|
|
79
|
+
},
|
|
80
|
+
view: () => ({
|
|
81
|
+
update(view) {
|
|
82
|
+
const pos = OPEN_ON_INSERT.getState(view.state);
|
|
83
|
+
if (pos === null || pos === undefined) return;
|
|
84
|
+
queueMicrotask(() => {
|
|
85
|
+
if (view.isDestroyed) return;
|
|
86
|
+
const dom = view.nodeDOM(pos);
|
|
87
|
+
if (!(dom instanceof HTMLElement) || dom.getAttribute("data-type") !== DETAILS_NODE) return;
|
|
88
|
+
if (dom.classList.contains("is-open")) return;
|
|
89
|
+
dom.querySelector<HTMLButtonElement>(":scope > button")?.click();
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** Текст заголовка Блока — для имени кнопки. */
|
|
96
|
+
function summaryText(node: PmNode): string {
|
|
97
|
+
return node.firstChild?.type.name === DETAILS_SUMMARY_NODE ? node.firstChild.textContent : "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Виден ли узел на позиции: свёрнутое содержимое спрятано, и у него нет `offsetParent`. */
|
|
101
|
+
function isVisibleAt(editor: Editor, pos: number): boolean {
|
|
102
|
+
const { node } = editor.view.domAtPos(pos);
|
|
103
|
+
const element = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement;
|
|
104
|
+
return element !== null && element.offsetParent !== null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Enter в заголовке раскрытого Блока, где содержимое начинается пустым Блоком:
|
|
109
|
+
* каретка встаёт в него. Не в заголовке, свёрнутый Блок и непустое начало —
|
|
110
|
+
* `false`, дело Tiptap.
|
|
111
|
+
*/
|
|
112
|
+
function moveIntoEmptyContent(editor: Editor): boolean {
|
|
113
|
+
const { selection } = editor.state;
|
|
114
|
+
const { $head } = selection;
|
|
115
|
+
if (!selection.empty || $head.parent.type.name !== DETAILS_SUMMARY_NODE) return false;
|
|
116
|
+
const content = $head.node(-1).lastChild;
|
|
117
|
+
const first = content?.type.name === DETAILS_CONTENT_NODE ? content.firstChild : null;
|
|
118
|
+
if (!first?.isTextblock || first.content.size > 0) return false;
|
|
119
|
+
const start = $head.after() + 1;
|
|
120
|
+
if (!isVisibleAt(editor, start)) return false;
|
|
121
|
+
const { tr } = editor.state;
|
|
122
|
+
editor.view.dispatch(tr.setSelection(Selection.near(tr.doc.resolve(start), 1)).scrollIntoView());
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Backspace в заголовке. Safari стирает при нём весь текст `summary` (баг
|
|
128
|
+
* WebKit), поэтому Tiptap удаляет знак сам — но один знак UTF-16, и эмодзи с
|
|
129
|
+
* суррогатной парой терял половину. Здесь удаляется последний графем:
|
|
130
|
+
* `Intl.Segmenter`, а без него — кодовая точка.
|
|
131
|
+
*/
|
|
132
|
+
function lastGraphemeLength(text: string): number {
|
|
133
|
+
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
|
|
134
|
+
const parts = Array.from(
|
|
135
|
+
new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)
|
|
136
|
+
);
|
|
137
|
+
return parts.at(-1)?.segment.length ?? 1;
|
|
138
|
+
}
|
|
139
|
+
return Array.from(text).at(-1)?.length ?? 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
declare module "@tiptap/core" {
|
|
143
|
+
interface Commands<ReturnType> {
|
|
144
|
+
detailsBlock: {
|
|
145
|
+
/**
|
|
146
|
+
* Вставить пустой сворачиваемый Блок под текущим Блоком; пустой абзац
|
|
147
|
+
* заменяется. Каретка — в заголовке. Блок раскрыт: с `persist` — атрибутом,
|
|
148
|
+
* без него — сразу после вставки. Из заголовка, списка и ячейки он встаёт под
|
|
149
|
+
* их Блоком, из Обёртки, колонки и цитаты — внутри них.
|
|
150
|
+
*/
|
|
151
|
+
insertDetails: () => ReturnType;
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Узел Details кита: Tiptap плюс вставка, Backspace без потери знака,
|
|
158
|
+
* раскрытие вставленного и Markdown сырым HTML.
|
|
159
|
+
*/
|
|
160
|
+
function createDetails({ persist, toggleLabel }: DetailsEngineOptions) {
|
|
161
|
+
return Details.extend({
|
|
162
|
+
// Свой Markdown (`detailsMarkdown.ts`): вместо `:::details` — сырой HTML.
|
|
163
|
+
markdownTokenizer: detailsTokenizer,
|
|
164
|
+
parseMarkdown(token, helpers) {
|
|
165
|
+
return parseDetails(token, helpers, this.options.persist);
|
|
166
|
+
},
|
|
167
|
+
renderMarkdown(node, helpers) {
|
|
168
|
+
return renderDetails(node, helpers, this.options.persist);
|
|
169
|
+
},
|
|
170
|
+
addCommands() {
|
|
171
|
+
return {
|
|
172
|
+
...this.parent?.(),
|
|
173
|
+
insertDetails:
|
|
174
|
+
() =>
|
|
175
|
+
({ tr, dispatch }) => {
|
|
176
|
+
if (dispatch) placeDetails(tr, this.options.persist);
|
|
177
|
+
return true;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
addKeyboardShortcuts() {
|
|
182
|
+
const upstream = this.parent?.() ?? {};
|
|
183
|
+
return {
|
|
184
|
+
...upstream,
|
|
185
|
+
/*
|
|
186
|
+
Enter в заголовке раскрытого Блока Tiptap ставит НОВЫЙ пустой абзац в
|
|
187
|
+
начало содержимого. У только что вставленного Блока содержимое — уже
|
|
188
|
+
один пустой абзац, и новый оставил бы пустую строку в конце после
|
|
189
|
+
набранного: каретка идёт в тот абзац, что есть.
|
|
190
|
+
*/
|
|
191
|
+
Enter: (props) => moveIntoEmptyContent(props.editor) || (upstream.Enter?.(props) ?? false),
|
|
192
|
+
Backspace: () => {
|
|
193
|
+
const { selection } = this.editor.state;
|
|
194
|
+
const { empty, $anchor } = selection;
|
|
195
|
+
if (!empty || $anchor.parent.type.name !== DETAILS_SUMMARY_NODE) return false;
|
|
196
|
+
// Начало заголовка — Блок распадается (`unsetDetails`).
|
|
197
|
+
if ($anchor.parentOffset === 0) return this.editor.commands.unsetDetails();
|
|
198
|
+
const before = $anchor.parent.textBetween(0, $anchor.parentOffset);
|
|
199
|
+
const size = lastGraphemeLength(before);
|
|
200
|
+
return this.editor.commands.command(({ tr }) => {
|
|
201
|
+
tr.delete($anchor.pos - size, $anchor.pos);
|
|
202
|
+
return true;
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
addNodeView() {
|
|
208
|
+
const parentView = this.parent?.();
|
|
209
|
+
if (!parentView) return null;
|
|
210
|
+
return (props) => {
|
|
211
|
+
const view = parentView(props);
|
|
212
|
+
const toggle = view.dom.querySelector<HTMLButtonElement>(":scope > button");
|
|
213
|
+
/*
|
|
214
|
+
Кнопка раскрытия лежит внутри Документа, и без этого её события
|
|
215
|
+
забирал бы ProseMirror: `Enter` на кнопке резал бы строку, а щелчок
|
|
216
|
+
двигал каретку. Кнопку обслуживает только она сама.
|
|
217
|
+
*/
|
|
218
|
+
const stopEvent = (event: Event) =>
|
|
219
|
+
event.target instanceof Node && !!toggle && toggle.contains(event.target);
|
|
220
|
+
/*
|
|
221
|
+
Кнопка стоит внутри редактируемого Документа и без этого сама
|
|
222
|
+
редактируема. Chrome при любой клавише переводит фокус с такой кнопки
|
|
223
|
+
на редактируемый корень ещё до `keydown`: Enter и Space не доходили до
|
|
224
|
+
неё, и Блок не раскрывался с клавиатуры. Нередактируемая кнопка
|
|
225
|
+
сохраняет фокус и получает клавишу.
|
|
226
|
+
*/
|
|
227
|
+
toggle?.setAttribute("contenteditable", "false");
|
|
228
|
+
/*
|
|
229
|
+
Щелчок Tiptap возвращает фокус в Документ — мышь это не задевает, а
|
|
230
|
+
клавиатуру выбросило бы с кнопки. Клавиатурный щелчок узнаём по Enter
|
|
231
|
+
или Space на самой кнопке: `detail` равен нулю и у `click()` из кода
|
|
232
|
+
(раскрытие вставленного Блока), а там фокус должен остаться в
|
|
233
|
+
Документе. Фокус Tiptap ставит кадром позже (`requestAnimationFrame`),
|
|
234
|
+
поэтому и возвращаем его кадром: колбэк стоит в очереди после Tiptap.
|
|
235
|
+
`aria-expanded` озвучивается там, где человек.
|
|
236
|
+
*/
|
|
237
|
+
let byKey = false;
|
|
238
|
+
toggle?.addEventListener("keydown", (event) => {
|
|
239
|
+
if (event.key === "Enter" || event.key === " ") byKey = true;
|
|
240
|
+
});
|
|
241
|
+
toggle?.addEventListener("blur", () => {
|
|
242
|
+
byKey = false;
|
|
243
|
+
});
|
|
244
|
+
toggle?.addEventListener("click", () => {
|
|
245
|
+
if (!byKey) return;
|
|
246
|
+
byKey = false;
|
|
247
|
+
/*
|
|
248
|
+
До кадра человек может уйти дальше (Tab к кнопке следующего Блока),
|
|
249
|
+
а фокус Tiptap кадром позже утащит его и оттуда в Документ. Поэтому
|
|
250
|
+
запоминаем, куда фокус перевёл сам человек, и возвращаем туда, а не
|
|
251
|
+
на эту кнопку: иначе следующая клавиша ушла бы не той кнопке.
|
|
252
|
+
*/
|
|
253
|
+
const owner = toggle.ownerDocument;
|
|
254
|
+
const root = view.dom.closest(".ProseMirror");
|
|
255
|
+
let target: HTMLElement = toggle;
|
|
256
|
+
const follow = (event: FocusEvent) => {
|
|
257
|
+
if (event.target instanceof HTMLElement && event.target !== root) target = event.target;
|
|
258
|
+
};
|
|
259
|
+
owner.addEventListener("focusin", follow, true);
|
|
260
|
+
requestAnimationFrame(() => {
|
|
261
|
+
owner.removeEventListener("focusin", follow, true);
|
|
262
|
+
const active = owner.activeElement;
|
|
263
|
+
// Фокус в Документе — его поставил Tiptap; где-то ещё — его увёл человек.
|
|
264
|
+
if (target.isConnected && (active === root || active === target)) {
|
|
265
|
+
target.focus({ preventScroll: true });
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
return { ...view, stopEvent };
|
|
270
|
+
};
|
|
271
|
+
},
|
|
272
|
+
addProseMirrorPlugins() {
|
|
273
|
+
return [...(this.parent?.() ?? []), ...(this.options.persist ? [] : [openOnInsert])];
|
|
274
|
+
},
|
|
275
|
+
}).configure({
|
|
276
|
+
persist,
|
|
277
|
+
renderToggleButton: ({ element, isOpen, node }) => {
|
|
278
|
+
// Имя кнопки — заголовок Блока; раскрыт он или нет, говорит `aria-expanded`.
|
|
279
|
+
element.setAttribute("aria-label", toggleLabel(summaryText(node)));
|
|
280
|
+
element.setAttribute("aria-expanded", String(isOpen));
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Заголовок: `details > summary`. Tiptap разбирает любой `summary`, а `summary`
|
|
287
|
+
* вне Блока — это текст, который не должен превратиться в Блок целиком.
|
|
288
|
+
*/
|
|
289
|
+
const KitSummary = DetailsSummary.extend({
|
|
290
|
+
parseHTML: () => [{ tag: "details > summary" }],
|
|
291
|
+
// Markdown Блока пишет и читает сам Блок (`detailsMarkdown.ts`): `:::detailsSummary`
|
|
292
|
+
// Tiptap кит не принимает.
|
|
293
|
+
markdownTokenizer: {
|
|
294
|
+
name: DETAILS_SUMMARY_NODE,
|
|
295
|
+
level: "block",
|
|
296
|
+
start: () => -1,
|
|
297
|
+
tokenize: () => undefined,
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const KitContent = DetailsContent.extend({
|
|
302
|
+
/*
|
|
303
|
+
Содержимое — контейнер Блоков (`isBlockContainer`, `blockCommands.ts`): его
|
|
304
|
+
дети — Блоки со своей ручкой, и «Вверх», «Вниз», «Добавить ниже» и `⌥⇧↑/↓`
|
|
305
|
+
остаются внутри него. Флаг в Схеме ставит кит из Признака описания
|
|
306
|
+
(`blocks`, `extensions.ts`).
|
|
307
|
+
*/
|
|
308
|
+
markdownTokenizer: {
|
|
309
|
+
name: DETAILS_CONTENT_NODE,
|
|
310
|
+
level: "block",
|
|
311
|
+
start: () => -1,
|
|
312
|
+
tokenize: () => undefined,
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
/** Всё, что нужно Блоку: узел, заголовок и содержимое. */
|
|
317
|
+
export function detailsExtensions(options: DetailsEngineOptions): Extensions {
|
|
318
|
+
return [createDetails(options), KitSummary, KitContent];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Пустой Блок с одним абзацем в содержимом — под текущим Блоком, в ближайшем
|
|
323
|
+
* вверх узле, который его принимает (правила места общие с Раскладкой и
|
|
324
|
+
* Обёрткой, `placeContainerBlock`). Каретка — в заголовке.
|
|
325
|
+
*/
|
|
326
|
+
function placeDetails(tr: Transaction, persist: boolean): void {
|
|
327
|
+
const { schema } = tr.doc.type;
|
|
328
|
+
const details = schema.nodes[DETAILS_NODE]!.create(persist ? { open: true } : null, [
|
|
329
|
+
schema.nodes[DETAILS_SUMMARY_NODE]!.create(),
|
|
330
|
+
schema.nodes[DETAILS_CONTENT_NODE]!.create(null, schema.nodes.paragraph!.create()),
|
|
331
|
+
]);
|
|
332
|
+
placeContainerBlock(tr, details);
|
|
333
|
+
if (persist) return;
|
|
334
|
+
// Позиция Блока — над кареткой, в заголовке: два уровня вверх.
|
|
335
|
+
const { $from } = tr.selection;
|
|
336
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
337
|
+
if ($from.node(depth).type.name === DETAILS_NODE) {
|
|
338
|
+
tr.setMeta(OPEN_ON_INSERT, $from.before(depth));
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Сворачиваемый Блок под позицией `pos`: сам Блок или Блок прямо в его содержимом. */
|
|
345
|
+
function detailsAt(tr: Transaction, pos: number): { from: number; node: PmNode } | null {
|
|
346
|
+
const node = tr.doc.nodeAt(pos);
|
|
347
|
+
if (node?.type.name === DETAILS_NODE) return { from: pos, node };
|
|
348
|
+
const $pos = tr.doc.resolve(pos);
|
|
349
|
+
if ($pos.parent.type.name !== DETAILS_CONTENT_NODE) return null;
|
|
350
|
+
const from = $pos.before($pos.depth - 1);
|
|
351
|
+
const details = tr.doc.nodeAt(from);
|
|
352
|
+
return details?.type.name === DETAILS_NODE ? { from, node: details } : null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Снять Блок в `pos` (самого Блока или Блока в его содержимом): заголовок
|
|
357
|
+
* становится абзацем, Блоки содержимого идут за ним. Одна правка — отмена одна.
|
|
358
|
+
* Каретка остаётся в своём тексте (в заголовке — в абзаце из него); выделенный
|
|
359
|
+
* узлом Блок исчезает, и каретка встаёт в начало абзаца. `false` — Блока в `pos` нет.
|
|
360
|
+
*/
|
|
361
|
+
export function unwrapDetailsAt(tr: Transaction, pos: number): boolean {
|
|
362
|
+
const found = detailsAt(tr, pos);
|
|
363
|
+
if (!found) return false;
|
|
364
|
+
const { from, node } = found;
|
|
365
|
+
const to = from + node.nodeSize;
|
|
366
|
+
const { schema } = tr.doc.type;
|
|
367
|
+
const summary = node.firstChild;
|
|
368
|
+
const content = node.lastChild;
|
|
369
|
+
const lead = schema.nodes.paragraph!.create(null, summary?.content ?? Fragment.empty);
|
|
370
|
+
const blocks = content?.type.name === DETAILS_CONTENT_NODE ? content.content : Fragment.empty;
|
|
371
|
+
const before = tr.selection;
|
|
372
|
+
const wasNode = before instanceof NodeSelection && before.from === from;
|
|
373
|
+
// Позиции внутри Блока после замены: заголовок на шаг левее (нет тега `details`),
|
|
374
|
+
// Блоки содержимого — за абзацем вместо `details`, `summary` и обёртки.
|
|
375
|
+
const summaryEnd = from + 1 + (summary?.nodeSize ?? 0);
|
|
376
|
+
const delta = from + lead.nodeSize - (summaryEnd + 1);
|
|
377
|
+
const shift = (at: number) => (at < summaryEnd ? at - 1 : at + delta);
|
|
378
|
+
const inside = before.from > from && before.to < to;
|
|
379
|
+
tr.replaceWith(from, to, Fragment.from(lead).append(blocks));
|
|
380
|
+
if (wasNode || !inside) {
|
|
381
|
+
tr.setSelection(TextSelection.create(tr.doc, from + 1));
|
|
382
|
+
} else if (before instanceof NodeSelection) {
|
|
383
|
+
tr.setSelection(NodeSelection.create(tr.doc, shift(before.from)));
|
|
384
|
+
} else {
|
|
385
|
+
tr.setSelection(TextSelection.create(tr.doc, shift(before.anchor), shift(before.head)));
|
|
386
|
+
}
|
|
387
|
+
tr.scrollIntoView();
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
JSONContent,
|
|
5
|
+
MarkdownLexerConfiguration,
|
|
6
|
+
MarkdownParseHelpers,
|
|
7
|
+
MarkdownRendererHelpers,
|
|
8
|
+
MarkdownToken,
|
|
9
|
+
MarkdownTokenizer,
|
|
10
|
+
} from "@tiptap/core";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Сворачиваемый Блок в Markdown — СЫРЫМ HTML, как на GitHub:
|
|
14
|
+
*
|
|
15
|
+
* ```
|
|
16
|
+
* <details>
|
|
17
|
+
* <summary>Заголовок</summary>
|
|
18
|
+
*
|
|
19
|
+
* Содержимое — Markdown.
|
|
20
|
+
*
|
|
21
|
+
* </details>
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* ПОЧЕМУ СВОЙ РАЗБОР. Расширение Tiptap пишет Блок синтаксисом `:::details`
|
|
25
|
+
* (Pandoc), которого не знает ни GitHub, ни другой Markdown. А общий разбор
|
|
26
|
+
* сырого HTML в `@tiptap/markdown` режет Блок на куски по пустым строкам: HTML-блок
|
|
27
|
+
* CommonMark кончается на первой пустой строке, и открывающий тег, Markdown
|
|
28
|
+
* внутри и закрывающий тег приходят тремя токенами, которые обратно не
|
|
29
|
+
* склеиваются. Поэтому Блок разбирает отдельный токенизатор: от `<details>` до
|
|
30
|
+
* парного `</details>` с учётом вложенных Блоков, а содержимое между ними — снова
|
|
31
|
+
* Markdown. Заголовок (`summary`) — строчный Markdown одной строкой; GitHub его
|
|
32
|
+
* как Markdown не читает (звёздочки останутся звёздочками), но круг через
|
|
33
|
+
* `setMarkdown` и `getMarkdown` от этого без потерь.
|
|
34
|
+
*
|
|
35
|
+
* Модуль без Tiptap-значений: только типы и чистые функции над строкой.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Открывающий тег Блока с любыми атрибутами. */
|
|
39
|
+
const OPEN_TAG = /^<details(?:\s[^<>]*)?>/i;
|
|
40
|
+
/** Любой тег Блока — для подсчёта вложенности. */
|
|
41
|
+
const ANY_TAG = /<(\/?)details(?:\s[^<>]*)?>/gi;
|
|
42
|
+
/** `summary` сразу после открывающего тега, пробелы и переводы строк до него не в счёт. */
|
|
43
|
+
const SUMMARY = /^\s*<summary(?:\s[^<>]*)?>([\s\S]*?)<\/summary>[ \t]*/i;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Копия строки, где всё, что тегами Блока быть не может, заменено пробелами:
|
|
47
|
+
* кодовые заборы и строчный код. Длина и позиции те же, поэтому найденные в
|
|
48
|
+
* копии теги указывают в исходную строку. Иначе `<details>` в примере кода внутри
|
|
49
|
+
* Блока сдвинул бы вложенность и Блок закрылся бы не там.
|
|
50
|
+
*/
|
|
51
|
+
function maskCode(src: string): string {
|
|
52
|
+
let fence: string | null = null;
|
|
53
|
+
const lines = src.split("\n").map((line) => {
|
|
54
|
+
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
|
|
55
|
+
if (fence === null && marker) {
|
|
56
|
+
fence = marker[0]!.repeat(3);
|
|
57
|
+
return " ".repeat(line.length);
|
|
58
|
+
}
|
|
59
|
+
if (fence !== null) {
|
|
60
|
+
if (marker && marker.startsWith(fence)) fence = null;
|
|
61
|
+
return " ".repeat(line.length);
|
|
62
|
+
}
|
|
63
|
+
return line.replace(/(`+)[^`\n]*?\1/g, (span) => " ".repeat(span.length));
|
|
64
|
+
});
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Позиция конца парного `</details>` (после тега) для Блока, открытого в начале `src`. */
|
|
69
|
+
function closingEnd(src: string, afterOpen: number): number {
|
|
70
|
+
const masked = maskCode(src);
|
|
71
|
+
let level = 1;
|
|
72
|
+
ANY_TAG.lastIndex = afterOpen;
|
|
73
|
+
for (let match = ANY_TAG.exec(masked); match; match = ANY_TAG.exec(masked)) {
|
|
74
|
+
level += match[1] ? -1 : 1;
|
|
75
|
+
if (level === 0) return match.index + match[0].length;
|
|
76
|
+
}
|
|
77
|
+
return -1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Заголовок одной строкой: переводы строк и повторы пробелов — в один пробел. */
|
|
81
|
+
function oneLine(text: string): string {
|
|
82
|
+
return text.replace(/\s+/g, " ").trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Токенизатор Блока `details` для `marked` (`markdownTokenizer` расширения). */
|
|
86
|
+
export const detailsTokenizer: MarkdownTokenizer = {
|
|
87
|
+
name: "details",
|
|
88
|
+
level: "block",
|
|
89
|
+
start: (src) => src.search(/^<details[\s>]/im),
|
|
90
|
+
tokenize(src, _tokens, lexer: MarkdownLexerConfiguration) {
|
|
91
|
+
const open = OPEN_TAG.exec(src);
|
|
92
|
+
if (!open) return undefined;
|
|
93
|
+
const end = closingEnd(src, open[0].length);
|
|
94
|
+
if (end < 0) return undefined;
|
|
95
|
+
// Конец Блока — строка целиком: хвост после `</details>` не наше дело.
|
|
96
|
+
const raw = src.slice(0, end);
|
|
97
|
+
const closeTag = /<\/details(?:\s[^<>]*)?>$/i.exec(raw)?.[0] ?? "";
|
|
98
|
+
const inner = raw.slice(open[0].length, raw.length - closeTag.length);
|
|
99
|
+
const summary = SUMMARY.exec(inner);
|
|
100
|
+
const body = summary ? inner.slice(summary[0].length) : inner;
|
|
101
|
+
const tokens: MarkdownToken[] = body.trim() ? lexer.blockTokens(body) : [];
|
|
102
|
+
// Строчные токены блоков без своих `tokens` (абзац, заголовок) разбирает
|
|
103
|
+
// сам `marked` внутри `blockTokens`; здесь дозаполняется только то, что он
|
|
104
|
+
// оставил текстом, как в `createBlockMarkdownSpec` Tiptap.
|
|
105
|
+
for (const token of tokens) {
|
|
106
|
+
if (token.text && (!token.tokens || token.tokens.length === 0)) {
|
|
107
|
+
token.tokens = lexer.inlineTokens(token.text);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
type: "details",
|
|
112
|
+
raw,
|
|
113
|
+
// `open` — если он в открывающем теге: `<details open>`.
|
|
114
|
+
open: /^<details(?:\s[^<>]*)?\sopen(?:[\s=>/]|$)/i.test(open[0]),
|
|
115
|
+
summary: summary ? lexer.inlineTokens(oneLine(summary[1] ?? "")) : [],
|
|
116
|
+
tokens,
|
|
117
|
+
} as MarkdownToken;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Токен — в узел Блока: заголовок из текста с Метками, содержимое Блоками (пустое
|
|
123
|
+
* — одним пустым абзацем). Атрибут `open` — только когда он в Схеме (`persist`).
|
|
124
|
+
*/
|
|
125
|
+
export function parseDetails(
|
|
126
|
+
token: MarkdownToken,
|
|
127
|
+
helpers: MarkdownParseHelpers,
|
|
128
|
+
persist: boolean
|
|
129
|
+
): JSONContent {
|
|
130
|
+
const title = helpers
|
|
131
|
+
.parseInline((token.summary as MarkdownToken[] | undefined) ?? [])
|
|
132
|
+
// Заголовок — только текст с Метками: перенос и картинка в нём не живут.
|
|
133
|
+
.filter((node) => node.type === "text");
|
|
134
|
+
const blocks = helpers.parseChildren(token.tokens ?? []);
|
|
135
|
+
return helpers.createNode("details", persist && token.open === true ? { open: true } : {}, [
|
|
136
|
+
helpers.createNode("detailsSummary", {}, title),
|
|
137
|
+
helpers.createNode("detailsContent", {}, blocks.length > 0 ? blocks : [{ type: "paragraph" }]),
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Узел — в Markdown: открывающий тег с `summary` на своей строке, пустая строка,
|
|
143
|
+
* Блоки через пустую строку, пустая строка, закрывающий тег. Пустая строка после
|
|
144
|
+
* `summary` — то, что заставляет GitHub читать содержимое как Markdown.
|
|
145
|
+
* `<` в заголовке пишется как `<`: иначе текст «a </summary> b» закрыл бы
|
|
146
|
+
* заголовок раньше времени.
|
|
147
|
+
*/
|
|
148
|
+
export function renderDetails(
|
|
149
|
+
node: JSONContent,
|
|
150
|
+
helpers: MarkdownRendererHelpers,
|
|
151
|
+
persist: boolean
|
|
152
|
+
): string {
|
|
153
|
+
const [summary, content] = node.content ?? [];
|
|
154
|
+
const title = summary?.content ? helpers.renderChildren(summary.content) : "";
|
|
155
|
+
const body = content?.content ? helpers.renderChildren(content.content, "\n\n") : "";
|
|
156
|
+
const open = persist && node.attrs?.open ? " open" : "";
|
|
157
|
+
return `<details${open}>\n<summary>${title.replace(/</g, "<")}</summary>\n\n${body}\n\n</details>`;
|
|
158
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { DetailsIcon, UnwrapIcon } from "../../../icons.js";
|
|
4
|
+
import type { ToolDescriptor } from "../../core/toolDescriptor.js";
|
|
5
|
+
import type { DetailsToolOptions } from "../../core/toolOptions.js";
|
|
6
|
+
import {
|
|
7
|
+
DETAILS_CONTENT_NODE,
|
|
8
|
+
DETAILS_NODE,
|
|
9
|
+
detailsExtensions,
|
|
10
|
+
unwrapDetailsAt,
|
|
11
|
+
} from "./details.js";
|
|
12
|
+
|
|
13
|
+
/** Умолчания Опций: открытость в Документ не пишется (как `persist` у Tiptap). */
|
|
14
|
+
export const DETAILS_DEFAULTS: DetailsToolOptions = { persist: false };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Сворачиваемый Блок на описании Инструмента (ADR-0048): узлы и их клавиатура
|
|
18
|
+
* (`details.ts`), Markdown сырым HTML (`detailsMarkdown.ts`), пункт Меню вставки,
|
|
19
|
+
* Действие «Снять сворачивание» и Признак «контейнер» для содержимого.
|
|
20
|
+
*
|
|
21
|
+
* Своей группы в модели Документа у Блока нет: он входит в `block`, как абзац.
|
|
22
|
+
* Меню ручки — как у Обёртки: «Вверх», «Вниз», «Дублировать», «Удалить» общие, к
|
|
23
|
+
* ним «Снять сворачивание» у самого Блока и у Блока в его содержимом. «Тон ›» у
|
|
24
|
+
* Блока нет — тонов он не знает.
|
|
25
|
+
*/
|
|
26
|
+
export const DETAILS_TOOL: ToolDescriptor = {
|
|
27
|
+
name: "details",
|
|
28
|
+
defaults: DETAILS_DEFAULTS,
|
|
29
|
+
checks: { persist: (value) => typeof value === "boolean" },
|
|
30
|
+
/*
|
|
31
|
+
Без Инструмента `details` из вставки и `value` не разбирается: заголовок
|
|
32
|
+
становится абзацем, Блоки содержимого идут за ним (расширения в Схеме нет, и
|
|
33
|
+
браузерный разбор берёт содержимое тегов, как у любого неизвестного).
|
|
34
|
+
*/
|
|
35
|
+
engine: (context, options) =>
|
|
36
|
+
detailsExtensions({
|
|
37
|
+
persist: (options as DetailsToolOptions | undefined)?.persist === true,
|
|
38
|
+
toggleLabel: context.detailsToggle,
|
|
39
|
+
}),
|
|
40
|
+
// Содержимое — контейнер Блоков: дети со своей Ручкой (`blockContainer`).
|
|
41
|
+
blocks: { [DETAILS_CONTENT_NODE]: { container: true } },
|
|
42
|
+
// Теги Схемы кита; обёртка содержимого — `div[data-type="detailsContent"]`.
|
|
43
|
+
kitTags: ["details", "summary"],
|
|
44
|
+
insertItems: [
|
|
45
|
+
{
|
|
46
|
+
id: "details",
|
|
47
|
+
section: "insert",
|
|
48
|
+
label: (m) => m.details,
|
|
49
|
+
icon: DetailsIcon,
|
|
50
|
+
aliases: ["details", "summary", "toggle", "accordion", "spoiler", "collapse"],
|
|
51
|
+
run: (chain) => chain.insertDetails(),
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
blockActions: [
|
|
55
|
+
{
|
|
56
|
+
id: "unwrapDetails",
|
|
57
|
+
label: (messages) => messages.unwrapDetails,
|
|
58
|
+
icon: UnwrapIcon,
|
|
59
|
+
blocks: [DETAILS_NODE, DETAILS_CONTENT_NODE],
|
|
60
|
+
// Одна правка: заголовок — абзац, содержимое на месте Блока, отмена — одна.
|
|
61
|
+
run: (editor, pos) =>
|
|
62
|
+
void editor
|
|
63
|
+
.chain()
|
|
64
|
+
.focus()
|
|
65
|
+
.command(({ tr }) => unwrapDetailsAt(tr, pos))
|
|
66
|
+
.run(),
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
@@ -37,7 +37,7 @@ export function insertEmoji(editor: Editor, char: string): boolean {
|
|
|
37
37
|
/**
|
|
38
38
|
* Эмодзи на описании Инструмента (ADR-0058, п. 10.4) — проверка
|
|
39
39
|
* кнопки с поповером на той же форме, что у своего Инструмента. Своей Схемы и
|
|
40
|
-
*
|
|
40
|
+
* Extensions нет: эмодзи — обычный текст.
|
|
41
41
|
*
|
|
42
42
|
* Поповер — сетка `EmojiGrid` в рамке кита у кнопки. Выбор вставляет эмодзи и
|
|
43
43
|
* закрывает поповер; фокус возвращается в Документ, а не на кнопку: человек
|