@segnavia/markdown-editor 0.0.0-stage → 0.42.0-setup.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.
@@ -0,0 +1,348 @@
1
+ import { ensureEditorStyle } from "./editor-style.js";
2
+ import { MarkdownEditorFontRegistration } from "./markdown-editor-font-registration.js";
3
+ import { validateMarkdownLink } from "./markdown-editor-link.js";
4
+ import { joinLeadingHeading, normalizeMarkdownTitle, splitLeadingHeading } from "./markdown-title.js";
5
+ import { markdownEditorModel, setModelEditable, setModelMarkdown, setModelTitle, setModelTitleSource } from "./markdown-editor-model.js";
6
+ import { equalAlertLabels, equalFontReferences, equalFonts, equalLockedFences, normalizeAlertLabels, normalizeFontReferences, normalizeLockedFences } from "./markdown-editor-options.js";
7
+ import { MARKDOWN_EDITOR_COMMANDS, MARKDOWN_EDITOR_MODES, availableMarkdownMode, markdownEditorKeyAction, richModeCommandRefusal } from "./markdown-editor-policy.js";
8
+ import { RICH_COMMANDS, lockedBlockSourceOffset, richEditorState, selectedLockedBlock } from "./markdown-rich-commands.js";
9
+ import { mountRichEditor } from "./markdown-rich-editor.js";
10
+ import { parseRichDocument, saveRichDocument } from "./markdown-rich-save.js";
11
+ import { markdownSourceState } from "./markdown-source.js";
12
+ import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
13
+ import { EditorView } from "@codemirror/view";
14
+ import { sanitizeBookCss } from "@segnavia/html";
15
+ //#region src/markdown-editor.ts
16
+ const TAG = "segnavia-markdown-editor";
17
+ const COMMAND_NAMES = new Set(MARKDOWN_EDITOR_COMMANDS.map(({ name }) => name));
18
+ var MarkdownEditorElement = class extends HTMLElement {
19
+ static observedAttributes = [
20
+ "markdown",
21
+ "mode",
22
+ "title-source"
23
+ ];
24
+ root = this.attachShadow({ mode: "open" });
25
+ container = document.createElement("div");
26
+ titleField = document.createElement("input");
27
+ sourceMount = document.createElement("div");
28
+ richMount = document.createElement("div");
29
+ richStyle = document.createElement("style");
30
+ sourceView = null;
31
+ richView = null;
32
+ model = markdownEditorModel();
33
+ modeValue = "rich";
34
+ reflecting = false;
35
+ richOriginal = "";
36
+ stateKey = "";
37
+ lockedFenceOptions = {};
38
+ cssValue = "";
39
+ fontOptions;
40
+ fontReferenceOptions = [];
41
+ linkOriginOptions;
42
+ alertLabelOptions;
43
+ fontRegistration = new MarkdownEditorFontRegistration(this);
44
+ constructor() {
45
+ super();
46
+ this.container.part.add("editor");
47
+ this.titleField.part.add("title");
48
+ this.titleField.type = "text";
49
+ this.titleField.dir = "auto";
50
+ this.titleField.setAttribute("aria-label", "Title");
51
+ this.sourceMount.part.add("source");
52
+ this.richMount.part.add("rich");
53
+ this.container.append(this.titleField, this.sourceMount, this.richMount);
54
+ this.root.append(this.richStyle, this.container);
55
+ }
56
+ connectedCallback() {
57
+ ensureEditorStyle(this.container);
58
+ this.titleField.addEventListener("input", this.onTitleInput);
59
+ this.titleField.addEventListener("keydown", this.onTitleKeyDown);
60
+ this.titleField.addEventListener("paste", this.onTitlePaste);
61
+ this.mountMode();
62
+ this.renderTitle();
63
+ }
64
+ disconnectedCallback() {
65
+ this.titleField.removeEventListener("input", this.onTitleInput);
66
+ this.titleField.removeEventListener("keydown", this.onTitleKeyDown);
67
+ this.titleField.removeEventListener("paste", this.onTitlePaste);
68
+ this.sourceView?.destroy();
69
+ this.sourceView = null;
70
+ this.richView?.destroy();
71
+ this.richView = null;
72
+ this.fontRegistration.clear();
73
+ }
74
+ attributeChangedCallback(name, _old, value) {
75
+ if (this.reflecting) return;
76
+ if (name === "markdown") this.markdown = value ?? "";
77
+ else if (name === "mode") this.mode = value;
78
+ else this.titleSource = value === "leading-heading" ? "leading-heading" : "separate";
79
+ }
80
+ get markdown() {
81
+ return this.model.markdown;
82
+ }
83
+ set markdown(value) {
84
+ const model = setModelMarkdown(this.model, String(value ?? ""));
85
+ if (model === this.model) return;
86
+ this.model = model;
87
+ this.richOriginal = model.editable;
88
+ this.renderTitle();
89
+ this.resetVisibleEditor();
90
+ }
91
+ get mode() {
92
+ return this.modeValue;
93
+ }
94
+ set mode(value) {
95
+ this.setMode(availableMarkdownMode(value), false);
96
+ }
97
+ get modes() {
98
+ return MARKDOWN_EDITOR_MODES;
99
+ }
100
+ get title() {
101
+ return this.model.title;
102
+ }
103
+ set title(value) {
104
+ const model = setModelTitle(this.model, value);
105
+ if (model !== this.model) {
106
+ this.model = model;
107
+ this.renderTitle();
108
+ }
109
+ }
110
+ get titleSource() {
111
+ return this.model.titleSource;
112
+ }
113
+ set titleSource(value) {
114
+ const next = value === "leading-heading" ? value : "separate";
115
+ const model = setModelTitleSource(this.model, next);
116
+ if (model === this.model) return;
117
+ this.reflect("title-source", next === "separate" ? null : next);
118
+ this.model = model;
119
+ this.richOriginal = model.editable;
120
+ this.renderTitle();
121
+ this.resetVisibleEditor();
122
+ }
123
+ get lockedFences() {
124
+ return this.lockedFenceOptions;
125
+ }
126
+ set lockedFences(value) {
127
+ const next = normalizeLockedFences(value);
128
+ if (equalLockedFences(this.lockedFenceOptions, next)) return;
129
+ this.lockedFenceOptions = next;
130
+ if (this.modeValue === "rich") this.resetRich();
131
+ }
132
+ get css() {
133
+ return this.cssValue;
134
+ }
135
+ set css(value) {
136
+ const next = String(value ?? "");
137
+ if (next === this.cssValue) return;
138
+ const sanitized = sanitizeBookCss(next).css;
139
+ this.cssValue = next;
140
+ this.richStyle.textContent = sanitized;
141
+ for (const page of this.richMount.querySelectorAll("segnavia-page")) page.css = next;
142
+ }
143
+ get fonts() {
144
+ return this.fontOptions;
145
+ }
146
+ set fonts(value) {
147
+ if (equalFonts(this.fontOptions, value)) return;
148
+ this.fontOptions = value;
149
+ this.fontRegistration.load(this.fontReferenceOptions, this.fontOptions);
150
+ if (this.modeValue === "rich") this.resetRich();
151
+ }
152
+ get fontReferences() {
153
+ return this.fontReferenceOptions;
154
+ }
155
+ set fontReferences(value) {
156
+ const next = normalizeFontReferences(value);
157
+ if (equalFontReferences(this.fontReferenceOptions, next)) return;
158
+ this.fontReferenceOptions = next;
159
+ this.fontRegistration.load(this.fontReferenceOptions, this.fontOptions);
160
+ if (this.modeValue === "rich") this.resetRich();
161
+ }
162
+ get linkOrigins() {
163
+ return this.linkOriginOptions;
164
+ }
165
+ set linkOrigins(value) {
166
+ this.linkOriginOptions = value;
167
+ }
168
+ get alertLabels() {
169
+ return this.alertLabelOptions;
170
+ }
171
+ set alertLabels(value) {
172
+ const next = normalizeAlertLabels(value);
173
+ if (equalAlertLabels(this.alertLabelOptions, next)) return;
174
+ this.alertLabelOptions = next;
175
+ if (this.modeValue === "rich") this.resetRich();
176
+ }
177
+ get unstyled() {
178
+ return this.hasAttribute("unstyled");
179
+ }
180
+ set unstyled(value) {
181
+ this.toggleAttribute("unstyled", value);
182
+ }
183
+ run(command, args) {
184
+ if (!COMMAND_NAMES.has(command)) throw new Error(`Unknown markdown editor command ${JSON.stringify(command)}.`);
185
+ if (command === "toggle-mode") return this.setMode(this.modeValue === "rich" ? "source" : "rich", true);
186
+ if (this.modeValue === "source") return this.runSourceCommand(command);
187
+ const editor = this.richView;
188
+ if (!editor) return false;
189
+ if (command === "edit-in-source") return this.editSelectedLockedBlock();
190
+ if (command === "link") validateMarkdownLink(String(args?.href ?? ""), this.linkOriginOptions);
191
+ return RICH_COMMANDS[command].apply(editor, args);
192
+ }
193
+ runSourceCommand(command) {
194
+ if (command === "undo") return this.sourceView ? undo(this.sourceView) : false;
195
+ if (command === "redo") return this.sourceView ? redo(this.sourceView) : false;
196
+ throw richModeCommandRefusal(command);
197
+ }
198
+ setMode(mode, emit) {
199
+ if (mode === this.modeValue) return false;
200
+ this.modeValue = mode;
201
+ this.reflect("mode", mode);
202
+ this.mountMode();
203
+ if (emit) this.emit("segnavia-mode-change", { mode });
204
+ return true;
205
+ }
206
+ mountMode() {
207
+ if (!this.isConnected) return;
208
+ this.sourceMount.hidden = this.modeValue !== "source";
209
+ this.richMount.hidden = this.modeValue !== "rich";
210
+ if (this.modeValue === "source") this.mountSource();
211
+ else this.resetRich();
212
+ this.fontRegistration.load(this.fontReferenceOptions, this.fontOptions);
213
+ }
214
+ mountSource() {
215
+ this.sourceView?.destroy();
216
+ this.sourceMount.replaceChildren();
217
+ this.sourceView = new EditorView({
218
+ parent: this.sourceMount,
219
+ root: this.root,
220
+ state: this.sourceState()
221
+ });
222
+ this.emitSourceState(false, false);
223
+ }
224
+ sourceState() {
225
+ return markdownSourceState(this.model.editable, {
226
+ change: (markdown) => this.onSourceChange(markdown),
227
+ save: () => this.emit("segnavia-save"),
228
+ toggleMode: () => this.run("toggle-mode"),
229
+ state: (canUndo, canRedo) => this.emitSourceState(canUndo, canRedo)
230
+ });
231
+ }
232
+ resetVisibleEditor() {
233
+ if (this.modeValue === "source" && this.sourceView) this.sourceView.setState(this.sourceState());
234
+ if (this.modeValue === "rich") this.resetRich();
235
+ this.emitVisibleState();
236
+ }
237
+ resetRich() {
238
+ if (!this.isConnected) return;
239
+ this.richView?.destroy();
240
+ this.richMount.replaceChildren();
241
+ this.richOriginal = this.model.editable;
242
+ this.richView = mountRichEditor({
243
+ mount: this.richMount,
244
+ content: parseRichDocument(this.model.editable, { lockedFences: this.lockedFenceOptions }),
245
+ css: () => this.cssValue,
246
+ fonts: this.fontOptions,
247
+ fontReferences: this.fontReferenceOptions,
248
+ alertLabels: this.alertLabelOptions,
249
+ linkOrigins: () => this.linkOriginOptions,
250
+ change: (content) => this.onRichChange(content),
251
+ state: (state) => this.emitState(state),
252
+ linkRequest: (href) => this.emit("segnavia-link-request", { href }),
253
+ save: () => this.emit("segnavia-save"),
254
+ toggleMode: () => this.setMode("source", true),
255
+ editLocked: () => this.editSelectedLockedBlock()
256
+ });
257
+ }
258
+ onRichChange(content) {
259
+ const editable = saveRichDocument(this.richOriginal, content, { lockedFences: this.lockedFenceOptions });
260
+ const model = setModelEditable(this.model, editable);
261
+ if (model === this.model) return;
262
+ this.model = model;
263
+ this.emit("segnavia-markdown-change", { markdown: model.markdown });
264
+ }
265
+ editSelectedLockedBlock() {
266
+ const selected = this.richView ? selectedLockedBlock(this.richView) : null;
267
+ if (!selected) throw new Error("Cannot edit in source because no locked block is selected; select a locked block to clear this refusal.");
268
+ const position = lockedBlockSourceOffset(this.model.editable, selected.source, selected.occurrence);
269
+ this.setMode("source", true);
270
+ const view = this.sourceView;
271
+ if (view && position >= 0) view.dispatch({
272
+ selection: { anchor: position },
273
+ effects: EditorView.scrollIntoView(position)
274
+ });
275
+ view?.focus();
276
+ return true;
277
+ }
278
+ onSourceChange(editable) {
279
+ const model = setModelEditable(this.model, editable);
280
+ if (model === this.model) return;
281
+ this.model = model;
282
+ this.emit("segnavia-markdown-change", { markdown: model.markdown });
283
+ }
284
+ onTitleInput = () => {
285
+ const normalized = normalizeMarkdownTitle(this.titleField.value);
286
+ this.titleField.value = normalized;
287
+ const model = setModelTitle(this.model, normalized);
288
+ if (model === this.model) return;
289
+ this.model = model;
290
+ this.emit("segnavia-title-change", { title: normalized });
291
+ if (model.titleSource === "leading-heading") this.emit("segnavia-markdown-change", { markdown: model.markdown });
292
+ };
293
+ onTitleKeyDown = (event) => {
294
+ if (event.key === "Enter") event.preventDefault();
295
+ const action = markdownEditorKeyAction(event);
296
+ if (!action) return;
297
+ event.preventDefault();
298
+ if (action === "save") this.emit("segnavia-save");
299
+ else this.run("toggle-mode");
300
+ };
301
+ onTitlePaste = (event) => {
302
+ const pasted = event.clipboardData?.getData("text");
303
+ if (pasted === void 0) return;
304
+ event.preventDefault();
305
+ this.titleField.setRangeText(normalizeMarkdownTitle(pasted), this.titleField.selectionStart ?? 0, this.titleField.selectionEnd ?? 0, "end");
306
+ this.onTitleInput();
307
+ };
308
+ renderTitle() {
309
+ this.titleField.hidden = this.model.titleSource === "separate" && this.model.title === null;
310
+ this.titleField.value = this.model.title ?? "";
311
+ }
312
+ emitSourceState(canUndo = this.sourceView ? undoDepth(this.sourceView.state) > 0 : false, canRedo = this.sourceView ? redoDepth(this.sourceView.state) > 0 : false) {
313
+ this.emitState({
314
+ marks: [],
315
+ nodes: [],
316
+ canUndo,
317
+ canRedo
318
+ });
319
+ }
320
+ emitVisibleState() {
321
+ if (this.modeValue === "rich" && this.richView) this.emitState(richEditorState(this.richView));
322
+ else this.emitSourceState();
323
+ }
324
+ emitState(state) {
325
+ const key = JSON.stringify(state);
326
+ if (key === this.stateKey) return;
327
+ this.stateKey = key;
328
+ this.emit("segnavia-editor-state", state);
329
+ }
330
+ emit(name, detail) {
331
+ this.dispatchEvent(new CustomEvent(name, {
332
+ detail,
333
+ bubbles: true,
334
+ composed: true
335
+ }));
336
+ }
337
+ reflect(name, value) {
338
+ if (this.getAttribute(name) === value) return;
339
+ this.reflecting = true;
340
+ if (value === null) this.removeAttribute(name);
341
+ else this.setAttribute(name, value);
342
+ this.reflecting = false;
343
+ }
344
+ };
345
+ const SegnaviaMarkdownEditor = MarkdownEditorElement;
346
+ if (!customElements.get(TAG)) customElements.define(TAG, SegnaviaMarkdownEditor);
347
+ //#endregion
348
+ export { MARKDOWN_EDITOR_COMMANDS, MARKDOWN_EDITOR_MODES, SegnaviaMarkdownEditor, joinLeadingHeading, normalizeMarkdownTitle, splitLeadingHeading };
@@ -0,0 +1,114 @@
1
+ import { MARKDOWN_EDITOR_COMMANDS, MARKDOWN_EDITOR_KEYS } from "./markdown-editor-policy.js";
2
+ import { LOCKED_BLOCK_NAME } from "./markdown-rich-locked.js";
3
+ import { Extension } from "@tiptap/core";
4
+ import { NodeSelection } from "@tiptap/pm/state";
5
+ //#region src/markdown-rich-commands.ts
6
+ const commandKeys = Object.fromEntries(MARKDOWN_EDITOR_COMMANDS.map(({ name, key }) => [name, key]));
7
+ const RICH_KEYBOARD_COMMANDS = MARKDOWN_EDITOR_COMMANDS.filter(({ name }) => name !== "heading" && name !== "toggle-mode" && name !== "edit-in-source").map(({ name }) => name);
8
+ const RICH_COMMANDS = {
9
+ bold: { apply: (editor) => editor.chain().focus().toggleBold().run() },
10
+ italic: { apply: (editor) => editor.chain().focus().toggleItalic().run() },
11
+ code: { apply: (editor) => editor.chain().focus().toggleCode().run() },
12
+ strike: { apply: (editor) => editor.chain().focus().toggleStrike().run() },
13
+ paragraph: { apply: (editor) => editor.chain().focus().setParagraph().run() },
14
+ heading: { apply: (editor, args) => editor.chain().focus().toggleHeading({ level: Number(args?.level) }).run() },
15
+ "bullet-list": { apply: (editor) => editor.chain().focus().toggleBulletList().run() },
16
+ "ordered-list": { apply: (editor) => editor.chain().focus().toggleOrderedList().run() },
17
+ "task-list": { apply: (editor) => editor.chain().focus().toggleTaskList().run() },
18
+ quote: { apply: (editor) => editor.chain().focus().toggleBlockquote().run() },
19
+ "code-block": { apply: (editor) => editor.chain().focus().toggleCodeBlock().run() },
20
+ link: { apply: (editor, args) => editor.chain().focus().extendMarkRange("link").setLink({ href: String(args?.href ?? "") }).run() },
21
+ unlink: { apply: (editor) => editor.chain().focus().extendMarkRange("link").unsetLink().run() },
22
+ "insert-table": { apply: (editor) => editor.chain().focus().insertTable({
23
+ rows: 3,
24
+ cols: 3,
25
+ withHeaderRow: true
26
+ }).run() },
27
+ "add-row": { apply: (editor) => editor.chain().focus().addRowAfter().run() },
28
+ "delete-row": { apply: (editor) => editor.chain().focus().deleteRow().run() },
29
+ "add-column": { apply: (editor) => editor.chain().focus().addColumnAfter().run() },
30
+ "delete-column": { apply: (editor) => editor.chain().focus().deleteColumn().run() },
31
+ "delete-table": { apply: (editor) => editor.chain().focus().deleteTable().run() },
32
+ undo: { apply: (editor) => editor.chain().focus().undo().run() },
33
+ redo: { apply: (editor) => editor.chain().focus().redo().run() }
34
+ };
35
+ function selectedLockedBlock(editor) {
36
+ const selection = editor.state.selection;
37
+ if (!(selection instanceof NodeSelection) || selection.node.type.name !== "lockedBlock") return null;
38
+ const source = typeof selection.node.attrs.source === "string" ? selection.node.attrs.source : "";
39
+ let occurrence = 0;
40
+ editor.state.doc.nodesBetween(0, selection.from, (node) => {
41
+ if (node.type.name === "lockedBlock" && node.attrs.source === source) occurrence++;
42
+ });
43
+ return {
44
+ source,
45
+ occurrence
46
+ };
47
+ }
48
+ function lockedBlockSourceOffset(markdown, source, occurrence) {
49
+ let offset = -1;
50
+ for (let index = 0; index <= occurrence; index++) offset = markdown.indexOf(source, offset + 1);
51
+ return offset;
52
+ }
53
+ function richKeyboard(options) {
54
+ return Extension.create({
55
+ name: "segnaviaKeyboard",
56
+ priority: 1e3,
57
+ addKeyboardShortcuts() {
58
+ const tableOnly = (command) => this.editor.isActive("table") && RICH_COMMANDS[command].apply(this.editor, void 0);
59
+ const commandShortcuts = Object.fromEntries(RICH_KEYBOARD_COMMANDS.map((command) => [commandKeys[command], () => RICH_COMMANDS[command].apply(this.editor, void 0)]));
60
+ const saveKey = MARKDOWN_EDITOR_KEYS.find(({ action }) => action === "save")?.key;
61
+ const toggleKey = MARKDOWN_EDITOR_KEYS.find(({ action }) => action === "toggle-mode")?.key;
62
+ if (!saveKey || !toggleKey) throw new Error("Markdown editor save and mode-toggle keys must be declared.");
63
+ return {
64
+ ...commandShortcuts,
65
+ [saveKey]: () => {
66
+ options.save();
67
+ return true;
68
+ },
69
+ [toggleKey]: options.toggleMode,
70
+ [commandKeys.link]: () => {
71
+ const href = this.editor.isActive("link") ? String(this.editor.getAttributes("link").href ?? "") : null;
72
+ options.linkRequest(href);
73
+ return true;
74
+ },
75
+ [commandKeys.unlink]: () => RICH_COMMANDS.unlink.apply(this.editor, void 0),
76
+ [commandKeys["insert-table"]]: () => RICH_COMMANDS["insert-table"].apply(this.editor, void 0),
77
+ [commandKeys["add-row"]]: () => tableOnly("add-row"),
78
+ [commandKeys["delete-row"]]: () => tableOnly("delete-row"),
79
+ [commandKeys["add-column"]]: () => tableOnly("add-column"),
80
+ [commandKeys["delete-column"]]: () => tableOnly("delete-column"),
81
+ [commandKeys["delete-table"]]: () => tableOnly("delete-table"),
82
+ [commandKeys["edit-in-source"]]: () => selectedLockedBlock(this.editor) ? options.editLocked() : false
83
+ };
84
+ }
85
+ });
86
+ }
87
+ function richEditorState(editor) {
88
+ const marks = [
89
+ "bold",
90
+ "italic",
91
+ "code",
92
+ "strike",
93
+ "link"
94
+ ].filter((name) => editor.isActive(name));
95
+ const nodes = [
96
+ ["paragraph", "paragraph"],
97
+ ["bulletList", "bullet-list"],
98
+ ["orderedList", "ordered-list"],
99
+ ["taskList", "task-list"],
100
+ ["blockquote", "quote"],
101
+ ["codeBlock", "code-block"],
102
+ ["table", "table"],
103
+ [LOCKED_BLOCK_NAME, "locked-block"]
104
+ ].filter(([name]) => editor.isActive(name)).map(([, vocabulary]) => vocabulary);
105
+ for (let level = 1; level <= 6; level++) if (editor.isActive("heading", { level })) nodes.push(`heading-${level}`);
106
+ return {
107
+ marks,
108
+ nodes,
109
+ canUndo: editor.can().undo(),
110
+ canRedo: editor.can().redo()
111
+ };
112
+ }
113
+ //#endregion
114
+ export { RICH_COMMANDS, RICH_KEYBOARD_COMMANDS, lockedBlockSourceOffset, richEditorState, richKeyboard, selectedLockedBlock };
@@ -0,0 +1,79 @@
1
+ import { LockedBlock } from "./markdown-rich-locked.js";
2
+ import { richEditorState, richKeyboard } from "./markdown-rich-commands.js";
3
+ import { richExtensions } from "./markdown-rich-extensions.js";
4
+ import { safeMarkdownUrl } from "@segnavia/html";
5
+ import { HEADLESS_ELEMENTS, SegnaviaPage, createPageElement } from "@segnavia/frame";
6
+ import { Editor } from "@tiptap/core";
7
+ //#region src/markdown-rich-editor.ts
8
+ if (!customElements.get(HEADLESS_ELEMENTS.page)) customElements.define(HEADLESS_ELEMENTS.page, SegnaviaPage);
9
+ function lockedView(options) {
10
+ return LockedBlock.extend({ addNodeView() {
11
+ return ({ node }) => {
12
+ const dom = document.createElement("div");
13
+ dom.part.add("locked-block");
14
+ dom.dir = "auto";
15
+ dom.contentEditable = "false";
16
+ const label = typeof node.attrs.label === "string" ? node.attrs.label : null;
17
+ if (label) {
18
+ dom.dataset.lockedLabel = label;
19
+ const text = document.createElement("span");
20
+ text.dir = "auto";
21
+ text.textContent = label;
22
+ dom.append(text);
23
+ } else {
24
+ const page = createPageElement();
25
+ const current = options();
26
+ page.css = current.css();
27
+ page.fonts = current.fonts;
28
+ page.alertLabels = current.alertLabels;
29
+ page.page = {
30
+ markdown: String(node.attrs.source ?? ""),
31
+ scripts: "refuse",
32
+ fonts: current.fontReferences
33
+ };
34
+ dom.append(page);
35
+ }
36
+ return { dom };
37
+ };
38
+ } });
39
+ }
40
+ function extensions(options, keyboard) {
41
+ return [...richExtensions().map((extension) => extension.name === "lockedBlock" ? lockedView(options) : extension), richKeyboard(keyboard)];
42
+ }
43
+ function pastedHtml(html, linkOrigins) {
44
+ const parsed = new DOMParser().parseFromString(html, "text/html");
45
+ for (const link of parsed.querySelectorAll("a[href]")) {
46
+ if (safeMarkdownUrl("href", link.getAttribute("href") ?? "", linkOrigins)) continue;
47
+ link.replaceWith(...link.childNodes);
48
+ }
49
+ for (const image of parsed.querySelectorAll("img[src]")) if (!safeMarkdownUrl("src", image.getAttribute("src") ?? "")) image.remove();
50
+ for (const active of parsed.querySelectorAll("script,style,iframe,object,embed")) active.remove();
51
+ return parsed.body.innerHTML;
52
+ }
53
+ function mountRichEditor(options) {
54
+ const current = () => options;
55
+ const editor = new Editor({
56
+ element: options.mount,
57
+ injectCSS: false,
58
+ extensions: extensions(current, options),
59
+ content: {
60
+ type: "doc",
61
+ content: options.content
62
+ },
63
+ editorProps: {
64
+ attributes: { "aria-label": "Markdown body" },
65
+ handleDOMEvents: { click: (_view, event) => {
66
+ if (event.target instanceof Element && event.target.closest("a")) event.preventDefault();
67
+ return false;
68
+ } },
69
+ transformPastedHTML: (html) => pastedHtml(html, options.linkOrigins())
70
+ },
71
+ onUpdate: ({ editor: instance }) => options.change(Array.from(instance.state.doc.content.content)),
72
+ onTransaction: ({ editor: instance }) => options.state(richEditorState(instance)),
73
+ onSelectionUpdate: ({ editor: instance }) => options.state(richEditorState(instance))
74
+ });
75
+ options.state(richEditorState(editor));
76
+ return editor;
77
+ }
78
+ //#endregion
79
+ export { mountRichEditor };
@@ -0,0 +1,61 @@
1
+ import { LockedBlock } from "./markdown-rich-locked.js";
2
+ import { Extension, getSchema } from "@tiptap/core";
3
+ import Image from "@tiptap/extension-image";
4
+ import { TaskItem, TaskList } from "@tiptap/extension-list";
5
+ import { TableKit } from "@tiptap/extension-table";
6
+ import { MarkdownManager } from "@tiptap/markdown";
7
+ import StarterKit from "@tiptap/starter-kit";
8
+ //#region src/markdown-rich-extensions.ts
9
+ const directionalBlocks = Extension.create({
10
+ name: "directionalBlocks",
11
+ addGlobalAttributes() {
12
+ return [{
13
+ types: [
14
+ "paragraph",
15
+ "heading",
16
+ "bulletList",
17
+ "orderedList",
18
+ "taskList",
19
+ "listItem",
20
+ "taskItem",
21
+ "blockquote",
22
+ "codeBlock",
23
+ "tableCell",
24
+ "tableHeader"
25
+ ],
26
+ attributes: { dir: {
27
+ default: "auto",
28
+ renderHTML: () => ({ dir: "auto" })
29
+ } }
30
+ }];
31
+ }
32
+ });
33
+ function richExtensions() {
34
+ return [
35
+ StarterKit.configure({
36
+ trailingNode: false,
37
+ link: { openOnClick: false }
38
+ }),
39
+ TableKit,
40
+ TaskList,
41
+ TaskItem.configure({ nested: true }),
42
+ Image.configure({ inline: true }),
43
+ directionalBlocks,
44
+ LockedBlock
45
+ ];
46
+ }
47
+ let manager;
48
+ function richMarkdownManager() {
49
+ manager ??= new MarkdownManager({
50
+ extensions: richExtensions(),
51
+ markedOptions: { gfm: true }
52
+ });
53
+ return manager;
54
+ }
55
+ let schema;
56
+ function richSchema() {
57
+ schema ??= getSchema(richExtensions());
58
+ return schema;
59
+ }
60
+ //#endregion
61
+ export { richExtensions, richMarkdownManager, richSchema };