@rigel-cms/rigel 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/docs/extensions.md +194 -0
- package/package.json +15 -3
- package/src/article-body.ts +49 -37
- package/src/article-render.ts +25 -2
- package/src/article.ts +44 -0
- package/src/config.ts +12 -2
- package/src/editor/App.svelte +98 -97
- package/src/editor/blocks/extension.ts +29 -0
- package/src/editor/blocks/index.ts +17 -15
- package/src/editor/blocks/video.ts +2 -1
- package/src/editor/i18n/README.md +23 -0
- package/src/editor/i18n/de/connection.json +136 -0
- package/src/editor/i18n/de/defaults.json +6 -0
- package/src/editor/i18n/de/fields.json +130 -0
- package/src/editor/i18n/de/local.json +49 -0
- package/src/editor/i18n/de/media.json +166 -0
- package/src/editor/i18n/de/richtext.json +116 -0
- package/src/editor/i18n/de/shell.json +181 -0
- package/src/editor/i18n/de/system.json +14 -0
- package/src/editor/i18n/de/validation.json +82 -0
- package/src/editor/i18n/en/connection.json +136 -0
- package/src/editor/i18n/en/defaults.json +6 -0
- package/src/editor/i18n/en/fields.json +130 -0
- package/src/editor/i18n/en/local.json +49 -0
- package/src/editor/i18n/en/media.json +166 -0
- package/src/editor/i18n/en/richtext.json +116 -0
- package/src/editor/i18n/en/shell.json +181 -0
- package/src/editor/i18n/en/system.json +14 -0
- package/src/editor/i18n/en/validation.json +82 -0
- package/src/editor/i18n/es/connection.json +136 -0
- package/src/editor/i18n/es/defaults.json +6 -0
- package/src/editor/i18n/es/fields.json +130 -0
- package/src/editor/i18n/es/local.json +49 -0
- package/src/editor/i18n/es/media.json +166 -0
- package/src/editor/i18n/es/richtext.json +116 -0
- package/src/editor/i18n/es/shell.json +181 -0
- package/src/editor/i18n/es/system.json +14 -0
- package/src/editor/i18n/es/validation.json +82 -0
- package/src/editor/i18n/format.ts +26 -0
- package/src/editor/i18n/fr/connection.json +136 -0
- package/src/editor/i18n/fr/defaults.json +6 -0
- package/src/editor/i18n/fr/fields.json +130 -0
- package/src/editor/i18n/fr/local.json +49 -0
- package/src/editor/i18n/fr/media.json +166 -0
- package/src/editor/i18n/fr/richtext.json +116 -0
- package/src/editor/i18n/fr/shell.json +181 -0
- package/src/editor/i18n/fr/system.json +14 -0
- package/src/editor/i18n/fr/validation.json +82 -0
- package/src/editor/i18n/index.ts +18 -0
- package/src/editor/i18n/it/connection.json +136 -0
- package/src/editor/i18n/it/defaults.json +6 -0
- package/src/editor/i18n/it/fields.json +130 -0
- package/src/editor/i18n/it/local.json +49 -0
- package/src/editor/i18n/it/media.json +166 -0
- package/src/editor/i18n/it/richtext.json +116 -0
- package/src/editor/i18n/it/shell.json +181 -0
- package/src/editor/i18n/it/system.json +14 -0
- package/src/editor/i18n/it/validation.json +82 -0
- package/src/editor/i18n/ja/connection.json +136 -0
- package/src/editor/i18n/ja/defaults.json +6 -0
- package/src/editor/i18n/ja/fields.json +130 -0
- package/src/editor/i18n/ja/local.json +49 -0
- package/src/editor/i18n/ja/media.json +166 -0
- package/src/editor/i18n/ja/richtext.json +116 -0
- package/src/editor/i18n/ja/shell.json +181 -0
- package/src/editor/i18n/ja/system.json +14 -0
- package/src/editor/i18n/ja/validation.json +82 -0
- package/src/editor/i18n/locales.ts +13 -0
- package/src/editor/i18n/nl/connection.json +136 -0
- package/src/editor/i18n/nl/defaults.json +6 -0
- package/src/editor/i18n/nl/fields.json +130 -0
- package/src/editor/i18n/nl/local.json +49 -0
- package/src/editor/i18n/nl/media.json +166 -0
- package/src/editor/i18n/nl/richtext.json +116 -0
- package/src/editor/i18n/nl/shell.json +181 -0
- package/src/editor/i18n/nl/system.json +14 -0
- package/src/editor/i18n/nl/validation.json +82 -0
- package/src/editor/i18n/ru/connection.json +138 -0
- package/src/editor/i18n/ru/defaults.json +6 -0
- package/src/editor/i18n/ru/fields.json +132 -0
- package/src/editor/i18n/ru/local.json +49 -0
- package/src/editor/i18n/ru/media.json +166 -0
- package/src/editor/i18n/ru/richtext.json +116 -0
- package/src/editor/i18n/ru/shell.json +181 -0
- package/src/editor/i18n/ru/system.json +14 -0
- package/src/editor/i18n/ru/validation.json +82 -0
- package/src/editor/i18n/server.ts +13 -0
- package/src/editor/i18n/zh/connection.json +136 -0
- package/src/editor/i18n/zh/defaults.json +6 -0
- package/src/editor/i18n/zh/fields.json +130 -0
- package/src/editor/i18n/zh/local.json +49 -0
- package/src/editor/i18n/zh/media.json +166 -0
- package/src/editor/i18n/zh/richtext.json +116 -0
- package/src/editor/i18n/zh/shell.json +181 -0
- package/src/editor/i18n/zh/system.json +14 -0
- package/src/editor/i18n/zh/validation.json +82 -0
- package/src/editor/lib/ActionDialog.svelte +5 -4
- package/src/editor/lib/BlockEditor.svelte +305 -88
- package/src/editor/lib/CollectionPanel.svelte +17 -15
- package/src/editor/lib/DatePicker.svelte +15 -12
- package/src/editor/lib/ExtensionMount.svelte +12 -0
- package/src/editor/lib/Field.svelte +3 -2
- package/src/editor/lib/ImagePicker.svelte +82 -81
- package/src/editor/lib/MarkdownEditor.svelte +25 -24
- package/src/editor/lib/MediaPicker.svelte +6 -5
- package/src/editor/lib/MenuEditor.svelte +31 -30
- package/src/editor/lib/PreviewFrame.svelte +11 -6
- package/src/editor/lib/PropertiesTabs.svelte +3 -2
- package/src/editor/lib/PublishDialog.svelte +15 -14
- package/src/editor/lib/ReferenceSelect.svelte +3 -2
- package/src/editor/lib/SchemaField.svelte +15 -13
- package/src/editor/lib/SchemaPanel.svelte +20 -19
- package/src/editor/lib/Select.svelte +3 -2
- package/src/editor/lib/SeoGuidance.svelte +6 -5
- package/src/editor/lib/Settings.svelte +54 -54
- package/src/editor/lib/Shortcuts.svelte +7 -6
- package/src/editor/lib/SitePreview.svelte +10 -9
- package/src/editor/lib/TopBar.svelte +18 -17
- package/src/editor/lib/UnsavedChanges.svelte +7 -6
- package/src/editor/lib/VisualEditor.svelte +14 -13
- package/src/editor/lib/calendar.ts +10 -4
- package/src/editor/lib/embed-preview.ts +23 -5
- package/src/editor/lib/extension-preview.ts +121 -0
- package/src/editor/lib/github-auth.ts +23 -22
- package/src/editor/lib/github-backend.ts +96 -91
- package/src/editor/lib/images.ts +33 -31
- package/src/editor/lib/menu-tree.ts +5 -4
- package/src/editor/lib/preview-frame.ts +4 -3
- package/src/editor/lib/schema-edit.ts +3 -2
- package/src/editor/lib/settings.ts +5 -3
- package/src/editor/lib/slash-command.ts +1 -0
- package/src/editor/lib/tooltip.ts +1 -1
- package/src/editor/main.ts +9 -0
- package/src/editor/styles.css +6 -3
- package/src/env.d.ts +11 -1
- package/src/extensions.ts +160 -0
- package/src/manifest/collections.ts +6 -5
- package/src/manifest/menu.ts +12 -10
- package/src/manifest/navigation.ts +9 -8
- package/src/manifest/references.ts +8 -7
- package/src/manifest/seo.ts +4 -3
- package/src/manifest/types.ts +1 -0
- package/src/preview.ts +22 -2
- package/src/routes/local-api.ts +5 -3
- package/src/routes/local-documents.ts +20 -16
- package/src/routes/local-media.ts +21 -16
- package/src/routes/local-menu.ts +9 -7
- package/src/routes/local-post.ts +18 -12
- package/src/routes/media-files.ts +10 -8
- package/src/routes/rigel-page.astro +2 -1
- package/src/schema/store.ts +22 -20
- package/src/schema/types.ts +1 -0
- package/src/schema/validate.ts +73 -69
- package/src/slugs.ts +11 -10
- package/src/vite/manifest-plugin.ts +33 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { t } from "../i18n";
|
|
3
|
+
import { subscribeMediaLabels } from "./embed-preview";
|
|
4
|
+
import { slashCommand } from "./slash-command";
|
|
2
5
|
import { onMount, tick, untrack } from "svelte";
|
|
3
6
|
import * as L from "lexical";
|
|
4
7
|
import * as Rich from "@lexical/rich-text";
|
|
@@ -9,6 +12,10 @@
|
|
|
9
12
|
import { ImageNode, type ImageData } from "../blocks/image";
|
|
10
13
|
import { VideoNode, type VideoData } from "../blocks/video";
|
|
11
14
|
import { EmbedNode, type EmbedData } from "../blocks/embed";
|
|
15
|
+
import { ExtensionNode, cloneBlockData } from "../blocks/extension";
|
|
16
|
+
import { extensionRegistry, extensionBlock, registerExtensionNodes } from "./extension-preview";
|
|
17
|
+
import { resolveExtensions, type ExtensionCommand, type ExtensionContext, type ExtensionSelection } from "../../extensions";
|
|
18
|
+
import extensionModules from "virtual:rigel-extensions";
|
|
12
19
|
import ImagePicker from "./ImagePicker.svelte";
|
|
13
20
|
import MediaPicker from "./MediaPicker.svelte";
|
|
14
21
|
import type { StockResult } from "./images";
|
|
@@ -35,11 +42,26 @@
|
|
|
35
42
|
let last = untrack(() => JSON.stringify(value));
|
|
36
43
|
let received = last;
|
|
37
44
|
let error = $state("");
|
|
45
|
+
let extensionError = $state("");
|
|
46
|
+
let extensionBusy = $state(false);
|
|
47
|
+
let extensionDisposed = false;
|
|
48
|
+
let extensionRevision = 0;
|
|
49
|
+
const extensionCommands = Object.values(extensionRegistry.commands);
|
|
50
|
+
const toolbarCommands = extensionCommands.filter(command => command.toolbar === true || (command.group && command.toolbar !== false));
|
|
51
|
+
const toolbarGroups = resolveExtensions(extensionModules).groups.map(group => ({ ...group, commands: toolbarCommands.filter(command => command.group === group.name) })).filter(group => group.commands.length);
|
|
52
|
+
let extensionMenu: HTMLDivElement;
|
|
53
|
+
let extensionTrigger = $state<HTMLButtonElement>();
|
|
54
|
+
let clickedExtensionTrigger: HTMLButtonElement;
|
|
55
|
+
let extensionGroup = $state("");
|
|
56
|
+
let extensionPosition = $state("");
|
|
57
|
+
const activeExtensionGroup = $derived(toolbarGroups.find(group => group.name === extensionGroup));
|
|
58
|
+
|
|
38
59
|
let markdownView = $state(false);
|
|
39
60
|
let activeKey = $state("");
|
|
40
61
|
let hoverKey = $state("");
|
|
41
62
|
let insertion = $state<{ after: string; top: number; left: number } | null>(null);
|
|
42
63
|
let blockType = $state("paragraph");
|
|
64
|
+
let canInsertBlock = $state(false);
|
|
43
65
|
let listType = $state("");
|
|
44
66
|
let handleTop = $state(0);
|
|
45
67
|
let handleLeft = $state(0);
|
|
@@ -67,20 +89,21 @@
|
|
|
67
89
|
let embedData = $state<EmbedData | null>(null);
|
|
68
90
|
let videoData = $state<VideoData | null>(null);
|
|
69
91
|
const selectedVideo = $derived(embedData ?? videoData);
|
|
92
|
+
const extensionOnly = $derived((!!selectedVideo || blockType === "extension") && toolbarCommands.length > 0);
|
|
70
93
|
let altOpen = $state(false);
|
|
71
94
|
let altText = $state("");
|
|
72
95
|
let altKey = "";
|
|
73
96
|
let altInput: HTMLInputElement;
|
|
74
97
|
const id = $props.id();
|
|
75
98
|
const marks = [
|
|
76
|
-
{ type: "bold", icon: "textB", label: "
|
|
77
|
-
{ type: "italic", icon: "textItalic", label: "
|
|
78
|
-
{ type: "code", icon: "code", label: "
|
|
79
|
-
{ type: "strikethrough", icon: "textStrikethrough", label: "
|
|
80
|
-
{ type: "underline", icon: "textUnderline", label: "
|
|
81
|
-
{ type: "highlight", icon: "highlighter", label: "
|
|
82
|
-
{ type: "subscript", icon: "textSubscript", label: "
|
|
83
|
-
{ type: "superscript", icon: "textSuperscript", label: "
|
|
99
|
+
{ type: "bold", icon: "textB", label: "richtext.bold" },
|
|
100
|
+
{ type: "italic", icon: "textItalic", label: "richtext.italic" },
|
|
101
|
+
{ type: "code", icon: "code", label: "richtext.inlineCode" },
|
|
102
|
+
{ type: "strikethrough", icon: "textStrikethrough", label: "richtext.strikethrough" },
|
|
103
|
+
{ type: "underline", icon: "textUnderline", label: "richtext.underline" },
|
|
104
|
+
{ type: "highlight", icon: "highlighter", label: "richtext.highlight" },
|
|
105
|
+
{ type: "subscript", icon: "textSubscript", label: "richtext.subscript" },
|
|
106
|
+
{ type: "superscript", icon: "textSuperscript", label: "richtext.superscript" },
|
|
84
107
|
] as const;
|
|
85
108
|
let activeMarks = $state<string[]>([]);
|
|
86
109
|
let canUndo = $state(false);
|
|
@@ -89,10 +112,11 @@
|
|
|
89
112
|
let tableRows = $state(0);
|
|
90
113
|
let tableColumns = $state(0);
|
|
91
114
|
let tableHeader = $state(false);
|
|
92
|
-
const mediaTypes = [{ value: "video", label: "
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
const
|
|
115
|
+
const mediaTypes = [{ value: "video", label: "richtext.video" }, { value: "youtube", label: "richtext.youtube" }, { value: "vimeo", label: "richtext.vimeo" }] as const;
|
|
116
|
+
const localizedBlocks = $derived.by(() => { $t; return blockTypes.map(block => ({ ...block })); });
|
|
117
|
+
const choices = $derived([...localizedBlocks, ...mediaTypes.map(block => ({ ...block, label: $t(block.label) })), ...extensionCommands.filter(command => command.slash).map(command => ({ value: `extension:${command.name}`, label: command.label, keyword: command.slash }))].filter(block => `${block.label} ${"keyword" in block ? block.keyword : ""}`.toLowerCase().includes((slash ?? "").toLowerCase())));
|
|
118
|
+
const textBlocks = $derived(localizedBlocks.filter(block => !["image", "table", "bullet", "number"].includes(block.value)));
|
|
119
|
+
const activeBlock = $derived(localizedBlocks.find(block => block.value === blockType));
|
|
96
120
|
|
|
97
121
|
function topBlock(node: L.LexicalNode | null) {
|
|
98
122
|
if (!node || L.$isRootNode(node)) return null;
|
|
@@ -108,6 +132,7 @@
|
|
|
108
132
|
const anchor = L.$isRangeSelection(selection) || Tables.$isTableSelection(selection) ? selection.anchor.getNode() : null;
|
|
109
133
|
listType = anchor ? [anchor, ...anchor.getParents()].find(Lists.$isListNode)?.getListType() ?? "" : "";
|
|
110
134
|
const node = anchor ? topBlock(anchor) : L.$isNodeSelection(selection) ? topBlock(selection.getNodes()[0]) : null;
|
|
135
|
+
canInsertBlock = L.$isRangeSelection(selection) && selection.isCollapsed() && L.$isElementNode(node) && !Tables.$isTableNode(node) && node.getTextContentSize() === 0;
|
|
111
136
|
tableCell = anchor ? Tables.$getTableCellNodeFromLexicalNode(anchor)?.getKey() ?? "" : "";
|
|
112
137
|
if (Tables.$isTableNode(node)) {
|
|
113
138
|
tableRows = node.getChildrenSize(); tableColumns = node.getColumnCount();
|
|
@@ -121,7 +146,7 @@
|
|
|
121
146
|
blockType = Rich.$isHeadingNode(node) ? node.getTag() : Lists.$isListNode(node) ? node.getListType() : node.getType();
|
|
122
147
|
activeMarks = L.$isRangeSelection(selection) ? marks.filter(mark => selection.hasFormat(mark.type)).map(mark => mark.type) : [];
|
|
123
148
|
const text = node.getTextContent();
|
|
124
|
-
const match = node.getType() === "paragraph" && L.$isRangeSelection(selection) && selection.isCollapsed() ? text.match(
|
|
149
|
+
const match = node.getType() === "paragraph" && L.$isRangeSelection(selection) && selection.isCollapsed() ? text.match(slashCommand) : null;
|
|
125
150
|
const signature = node.getKey() + text;
|
|
126
151
|
if (match && signature !== slashDismissed) { if (slash !== match[1]) slashIndex = 0; slash = match[1]; slashKey = node.getKey(); adding = false; }
|
|
127
152
|
else if (!adding) slash = null;
|
|
@@ -140,13 +165,13 @@
|
|
|
140
165
|
const selection = view.getSelection();
|
|
141
166
|
if (!root.ownerDocument.hasFocus()) { floating = ""; return; }
|
|
142
167
|
if (editorHost.contains(root.ownerDocument.activeElement) && !root.contains(root.ownerDocument.activeElement)) return;
|
|
143
|
-
if (inline || imageData || selectedVideo) {
|
|
144
|
-
if (imageData || selectedVideo ? root.ownerDocument.activeElement
|
|
168
|
+
if (inline || imageData || selectedVideo || extensionOnly) {
|
|
169
|
+
if (imageData || selectedVideo || extensionOnly ? !root.contains(root.ownerDocument.activeElement) : !selection?.rangeCount || !root.contains(selection.anchorNode) || !root.contains(selection.focusNode)) { floating = ""; return; }
|
|
145
170
|
const block = editor.getElementByKey(activeKey);
|
|
146
171
|
if (!block) { floating = ""; return; }
|
|
147
|
-
const rect = imageData || selectedVideo || selection!.isCollapsed ? block.getBoundingClientRect() : selection!.getRangeAt(0).getBoundingClientRect();
|
|
172
|
+
const rect = imageData || selectedVideo || extensionOnly || selection!.isCollapsed ? block.getBoundingClientRect() : selection!.getRangeAt(0).getBoundingClientRect();
|
|
148
173
|
if (rect.bottom < 0 || rect.top > view.innerHeight) { floating = ""; return; }
|
|
149
|
-
if (selectedVideo) { floating = ""; return; }
|
|
174
|
+
if (selectedVideo && !toolbarCommands.length) { floating = ""; return; }
|
|
150
175
|
const toolbarRect = editorHost.querySelector(imageData ? '.image-toolbar' : '.body-toolbar')?.getBoundingClientRect();
|
|
151
176
|
const toolbarHeight = toolbarRect?.height || 42, toolbarWidth = toolbarRect?.width || 330;
|
|
152
177
|
const top = rect.top >= toolbarHeight + 12 ? rect.top - toolbarHeight - 8 : rect.bottom + 8;
|
|
@@ -178,7 +203,7 @@
|
|
|
178
203
|
onMount(() => {
|
|
179
204
|
if (editableRoot) {
|
|
180
205
|
root = editableRoot; root.classList.add("block-content", "rigel-article-content"); root.contentEditable = "true";
|
|
181
|
-
root.setAttribute("role", "textbox"); root.setAttribute("aria-label", "
|
|
206
|
+
root.setAttribute("role", "textbox"); root.setAttribute("aria-label", $t("richtext.body")); root.setAttribute("aria-multiline", "true");
|
|
182
207
|
root.setAttribute("aria-keyshortcuts", "Alt+F10");
|
|
183
208
|
}
|
|
184
209
|
view = root.ownerDocument.defaultView!;
|
|
@@ -188,9 +213,9 @@
|
|
|
188
213
|
editor.setRootElement(root);
|
|
189
214
|
if (inline) { root.style.whiteSpace = whiteSpace; root.style.wordBreak = wordBreak; }
|
|
190
215
|
Tables.setScrollableTablesActive(editor, true);
|
|
191
|
-
try { validateBody(value); const state = editor.parseEditorState(JSON.stringify(value)); last = JSON.stringify(state.toJSON()); editor.setEditorState(state); }
|
|
192
|
-
catch (cause) { error = cause instanceof Error ? cause.message : "
|
|
193
|
-
const cleanups = [Rich.registerRichText(editor), Lists.registerList(editor), registerHistory(editor, createEmptyHistoryState(), 300)];
|
|
216
|
+
try { validateBody(value, $t); const state = editor.parseEditorState(JSON.stringify(value)); last = JSON.stringify(state.toJSON()); editor.setEditorState(state); }
|
|
217
|
+
catch (cause) { error = cause instanceof Error ? cause.message : $t("richtext.loadArticleError"); editor.setEditable(false); }
|
|
218
|
+
const cleanups = [subscribeMediaLabels(root), registerExtensionNodes(editor), Rich.registerRichText(editor), Lists.registerList(editor), registerHistory(editor, createEmptyHistoryState(), 300)];
|
|
194
219
|
root.contentEditable = String(editor.isEditable());
|
|
195
220
|
cleanups.push(editor.registerEditableListener(editable => { root.contentEditable = String(editable); }));
|
|
196
221
|
cleanups.push(Tables.registerTablePlugin(editor), Tables.registerTableSelectionObserver(editor, true));
|
|
@@ -211,15 +236,16 @@
|
|
|
211
236
|
refresh();
|
|
212
237
|
if (dirtyElements.size || dirtyLeaves.size) {
|
|
213
238
|
const next = editorState.toJSON(), serialized = JSON.stringify(next);
|
|
214
|
-
if (serialized !== last) { last = serialized; received = serialized; oninput(next); }
|
|
239
|
+
if (serialized !== last) { extensionRevision++; last = serialized; received = serialized; oninput(next); }
|
|
215
240
|
}
|
|
216
241
|
});
|
|
217
242
|
refresh();
|
|
218
|
-
const position = () => { positionHandle(); positionFloating(); positionInsertion(); };
|
|
243
|
+
const position = () => { positionHandle(); positionFloating(); positionInsertion(); positionExtensionMenu(); };
|
|
219
244
|
const toolbarObserver = new ResizeObserver(() => { positionFloating(); positionInsertion(); });
|
|
220
245
|
editorHost.querySelectorAll('.body-toolbar').forEach(toolbar => toolbarObserver.observe(toolbar));
|
|
221
246
|
toolbarObserver.observe(root);
|
|
222
247
|
const outside = (event: PointerEvent) => {
|
|
248
|
+
if (extensionMenu?.matches(":popover-open") && !extensionMenu.contains(event.target as Node) && !extensionTrigger?.contains(event.target as Node)) closeMenu();
|
|
223
249
|
if (linkOpen && !editorHost.querySelector('.markdown-link')?.contains(event.target as Node)) linkOpen = false;
|
|
224
250
|
if ((inline || imageData || selectedVideo) && !root.contains(event.target as Node) && !editorHost.contains(event.target as Node)) { floating = ""; linkOpen = false; altOpen = false; closeMenu(); }
|
|
225
251
|
if (slash !== null && !slashMenu?.contains(event.target as Node) && !root.contains(event.target as Node) && !(event.target as Element).closest('.block-add')) dismissSlash();
|
|
@@ -229,10 +255,17 @@
|
|
|
229
255
|
view.addEventListener("pointerdown", outside, true); view.addEventListener("keydown", escape);
|
|
230
256
|
if (root.ownerDocument !== document) document.addEventListener("pointerdown", outside, true);
|
|
231
257
|
view.addEventListener("blur", positionFloating);
|
|
258
|
+
const extensionFailure = (event: Event) => { extensionError = (event as CustomEvent<string>).detail; };
|
|
259
|
+
const extensionTeardown = () => { extensionDisposed = true; };
|
|
260
|
+
root.addEventListener("rigel-extension-error", extensionFailure);
|
|
261
|
+
view.addEventListener("pagehide", extensionTeardown);
|
|
232
262
|
root.addEventListener("pointerdown", contentClick); root.addEventListener("click", contentClick);
|
|
233
263
|
root.addEventListener("pointerleave", leaveInsertion);
|
|
234
264
|
if (inline) view.addEventListener("pointermove", hover);
|
|
235
265
|
return () => {
|
|
266
|
+
extensionDisposed = true;
|
|
267
|
+
root.removeEventListener("rigel-extension-error", extensionFailure);
|
|
268
|
+
view.removeEventListener("pagehide", extensionTeardown);
|
|
236
269
|
toolbarObserver?.disconnect();
|
|
237
270
|
view.CSS.highlights?.delete("rigel-link-selection");
|
|
238
271
|
view.removeEventListener("resize", position); view.removeEventListener("scroll", position, true);
|
|
@@ -241,17 +274,19 @@
|
|
|
241
274
|
root.removeEventListener("pointerdown", contentClick); root.removeEventListener("click", contentClick);
|
|
242
275
|
root.removeEventListener("pointerleave", leaveInsertion);
|
|
243
276
|
if (inline) view.removeEventListener("pointermove", hover);
|
|
244
|
-
endDrag(true); updates(); cleanups.forEach(cleanup => cleanup()); editor.setRootElement(null);
|
|
277
|
+
closeMenu(); endDrag(true); updates(); cleanups.forEach(cleanup => cleanup()); editor.setRootElement(null);
|
|
245
278
|
};
|
|
246
279
|
});
|
|
247
280
|
|
|
281
|
+
$effect(() => { if (editableRoot) editableRoot.setAttribute("aria-label", $t("richtext.body")); });
|
|
282
|
+
|
|
248
283
|
$effect(() => { if (!linkOpen) view?.CSS.highlights?.delete("rigel-link-selection"); });
|
|
249
284
|
|
|
250
285
|
$effect(() => {
|
|
251
286
|
const serialized = JSON.stringify(value);
|
|
252
287
|
if (editor && serialized !== received) {
|
|
253
|
-
try { validateBody(value); received = serialized; const state = editor.parseEditorState(serialized); last = JSON.stringify(state.toJSON()); editor.setEditorState(state); }
|
|
254
|
-
catch (cause) { error = cause instanceof Error ? cause.message : "
|
|
288
|
+
try { validateBody(value, $t); extensionRevision++; received = serialized; const state = editor.parseEditorState(serialized); last = JSON.stringify(state.toJSON()); editor.setEditorState(state); }
|
|
289
|
+
catch (cause) { error = cause instanceof Error ? cause.message : $t("richtext.loadArticleError"); }
|
|
255
290
|
}
|
|
256
291
|
});
|
|
257
292
|
|
|
@@ -276,13 +311,18 @@
|
|
|
276
311
|
}
|
|
277
312
|
async function addBlock() {
|
|
278
313
|
slashKey = activeKey; adding = true; slash = ""; slashIndex = 0;
|
|
279
|
-
await tick();
|
|
314
|
+
await tick(); slashMenu.querySelector<HTMLInputElement>('input')?.focus();
|
|
280
315
|
}
|
|
281
316
|
function inlineNodes(node: L.LexicalNode): L.LexicalNode[] {
|
|
282
317
|
if (!L.$isElementNode(node)) return node.getTextContent() ? [L.$createTextNode(node.getTextContent())] : [];
|
|
283
318
|
return node.getChildren().flatMap(child => L.$isElementNode(child) && !child.isInline() ? [...inlineNodes(child), L.$createLineBreakNode()] : [child]);
|
|
284
319
|
}
|
|
285
320
|
function changeBlock(type: string, fromSlash = false) {
|
|
321
|
+
if (type.startsWith("extension:")) {
|
|
322
|
+
const name = type.slice(10);
|
|
323
|
+
if (Object.hasOwn(extensionRegistry.commands, name)) void runExtension(extensionRegistry.commands[name], fromSlash);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
286
326
|
if (type === "video" || type === "youtube" || type === "vimeo") {
|
|
287
327
|
mediaJob = { key: fromSlash ? slashKey : activeKey, replace: fromSlash && !adding };
|
|
288
328
|
dismissSlash(); closeMenu();
|
|
@@ -319,6 +359,84 @@
|
|
|
319
359
|
});
|
|
320
360
|
editor.focus();
|
|
321
361
|
}
|
|
362
|
+
async function runExtension(command: ExtensionCommand, fromSlash = false) {
|
|
363
|
+
if (extensionBusy || extensionDisposed || !editor.isEditable()) return;
|
|
364
|
+
extensionBusy = true; extensionError = "";
|
|
365
|
+
const revision = extensionRevision, href = view.location.href;
|
|
366
|
+
const origin = fromSlash && !adding ? slashKey : "";
|
|
367
|
+
let selection: L.BaseSelection | null = null;
|
|
368
|
+
let key = "";
|
|
369
|
+
let snapshot: ExtensionSelection = { text: "", blockType: "" };
|
|
370
|
+
const actions: (() => void)[] = [];
|
|
371
|
+
let accepting = true;
|
|
372
|
+
const current = () => {
|
|
373
|
+
if (!accepting || extensionDisposed || extensionRevision !== revision || view.location.href !== href || !root.isConnected || !editor.isEditable() || (fromSlash && slash === null)) throw new Error("Extension command is no longer active");
|
|
374
|
+
};
|
|
375
|
+
try {
|
|
376
|
+
editor.getEditorState().read(() => {
|
|
377
|
+
selection = (savedSelection ?? L.$getSelection())?.clone() ?? null;
|
|
378
|
+
const node = L.$isRangeSelection(selection) || Tables.$isTableSelection(selection) ? topBlock(selection.anchor.getNode()) : L.$isNodeSelection(selection) ? topBlock(selection.getNodes()[0]) : null;
|
|
379
|
+
key = node?.getKey() ?? activeKey;
|
|
380
|
+
snapshot = { text: selection?.getTextContent() ?? "", blockType: node instanceof Rich.HeadingNode ? node.getTag() : node?.getType() ?? "paragraph", ...(node instanceof ExtensionNode ? { block: { name: node.__name, data: cloneBlockData(node.__data) } } : {}) };
|
|
381
|
+
});
|
|
382
|
+
} catch (cause) { extensionError = cause instanceof Error ? cause.message : String(cause); extensionBusy = false; return; }
|
|
383
|
+
const context: ExtensionContext = {
|
|
384
|
+
selection: snapshot,
|
|
385
|
+
insertText(text) {
|
|
386
|
+
current();
|
|
387
|
+
if (typeof text !== "string") throw new Error("Extension text must be a string");
|
|
388
|
+
if (!text) return;
|
|
389
|
+
actions.push(() => {
|
|
390
|
+
let target = L.$getSelection();
|
|
391
|
+
if (!L.$isRangeSelection(target)) {
|
|
392
|
+
const paragraph = L.$createParagraphNode(), previous = L.$getNodeByKey(key);
|
|
393
|
+
if (previous) previous.insertAfter(paragraph); else L.$getRoot().append(paragraph);
|
|
394
|
+
target = paragraph.select();
|
|
395
|
+
}
|
|
396
|
+
if (L.$isRangeSelection(target)) target.insertText(text);
|
|
397
|
+
});
|
|
398
|
+
},
|
|
399
|
+
insertBlock(name, raw) {
|
|
400
|
+
current();
|
|
401
|
+
const block = extensionBlock(name), data = cloneBlockData(raw);
|
|
402
|
+
if (!block) throw new Error(`Unknown extension block: ${name}`);
|
|
403
|
+
block.validate(cloneBlockData(data));
|
|
404
|
+
actions.push(() => {
|
|
405
|
+
const node = new ExtensionNode(name, data), selected = L.$getSelection();
|
|
406
|
+
const previous = L.$isRangeSelection(selected) ? topBlock(selected.anchor.getNode()) : L.$isNodeSelection(selected) ? topBlock(selected.getNodes()[0]) : L.$getNodeByKey(key);
|
|
407
|
+
if (previous && previous.getKey() === origin && !previous.getTextContentSize()) previous.replace(node);
|
|
408
|
+
else if (previous) previous.insertAfter(node);
|
|
409
|
+
else L.$getRoot().append(node);
|
|
410
|
+
const paragraph = L.$createParagraphNode(); node.insertAfter(paragraph); paragraph.select();
|
|
411
|
+
});
|
|
412
|
+
},
|
|
413
|
+
updateBlock(raw) {
|
|
414
|
+
current();
|
|
415
|
+
if (!snapshot.block) throw new Error("Select an extension block first");
|
|
416
|
+
const block = extensionBlock(snapshot.block.name), data = cloneBlockData(raw);
|
|
417
|
+
if (!block) throw new Error("Extension unavailable");
|
|
418
|
+
block.validate(cloneBlockData(data));
|
|
419
|
+
actions.push(() => { const node = L.$getNodeByKey(key); if (node instanceof ExtensionNode) node.getWritable().__data = data; });
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
try {
|
|
423
|
+
await command.run(context);
|
|
424
|
+
current();
|
|
425
|
+
if (actions.length) {
|
|
426
|
+
editor.update(() => {
|
|
427
|
+
if (selection) L.$setSelection(selection.clone());
|
|
428
|
+
const paragraph = origin ? L.$getNodeByKey(origin) : null;
|
|
429
|
+
if (paragraph instanceof L.ParagraphNode) paragraph.clear().select();
|
|
430
|
+
for (const action of actions) action();
|
|
431
|
+
const remaining = origin ? L.$getNodeByKey(origin) : null;
|
|
432
|
+
if (remaining instanceof L.ParagraphNode && remaining.isEmpty()) remaining.remove();
|
|
433
|
+
}, { discrete: true, tag: "history-push" });
|
|
434
|
+
if (fromSlash) dismissSlash();
|
|
435
|
+
editor.focus();
|
|
436
|
+
}
|
|
437
|
+
} catch (cause) { extensionError = cause instanceof Error ? cause.message : String(cause); }
|
|
438
|
+
finally { accepting = false; extensionBusy = false; }
|
|
439
|
+
}
|
|
322
440
|
function format(type: L.TextFormatType) {
|
|
323
441
|
closeMenu();
|
|
324
442
|
editor.update(() => { if (savedSelection) L.$setSelection(savedSelection.clone()); editor.dispatchCommand(L.FORMAT_TEXT_COMMAND, type); });
|
|
@@ -381,7 +499,7 @@
|
|
|
381
499
|
altOpen = false; refocus();
|
|
382
500
|
}
|
|
383
501
|
function applyLink(remove = false) {
|
|
384
|
-
if (!remove && !safePreviewUrl(linkUrl)) { linkError = "
|
|
502
|
+
if (!remove && !safePreviewUrl(linkUrl)) { linkError = $t("richtext.safeUrlError"); return; }
|
|
385
503
|
editor.update(() => {
|
|
386
504
|
if (savedSelection) L.$setSelection(savedSelection.clone());
|
|
387
505
|
const selection = L.$getSelection(), node = L.$isNodeSelection(selection) ? selection.getNodes()[0] : null;
|
|
@@ -447,7 +565,54 @@
|
|
|
447
565
|
function pickEmbed(embed: EmbedData) {
|
|
448
566
|
insertMedia(() => new EmbedNode({ ...embed, privacy: embed.privacy !== false }));
|
|
449
567
|
}
|
|
450
|
-
function closeMenu() { for (const popup of [menu, tableMenu, formatMenu]) if (popup?.matches(":popover-open")) popup.hidePopover(); }
|
|
568
|
+
function closeMenu() { for (const popup of [menu, tableMenu, formatMenu, extensionMenu]) if (popup?.matches(":popover-open")) popup.hidePopover(); }
|
|
569
|
+
function positionExtensionMenu() {
|
|
570
|
+
if (!extensionMenu?.matches(":popover-open") || !extensionTrigger?.isConnected) return;
|
|
571
|
+
const rect = extensionTrigger.getBoundingClientRect();
|
|
572
|
+
const width = Math.min(224, view.document.documentElement.clientWidth - 16);
|
|
573
|
+
const below = view.innerHeight - rect.bottom - 8, above = rect.top - 8;
|
|
574
|
+
const upward = below < 160 && above > below;
|
|
575
|
+
const height = Math.max(32, Math.min(280, upward ? above : below));
|
|
576
|
+
extensionPosition = `left:${Math.max(8, Math.min(rect.left, view.document.documentElement.clientWidth - width - 8))}px;top:${upward ? Math.max(8, rect.top - Math.min(extensionMenu.scrollHeight, height) - 4) : rect.bottom + 4}px;width:${width}px;max-height:${height}px`;
|
|
577
|
+
}
|
|
578
|
+
async function openExtensionGroup(name: string, trigger: HTMLButtonElement, last = false) {
|
|
579
|
+
if (extensionBusy || error) return;
|
|
580
|
+
if (extensionGroup === name && extensionTrigger === trigger && extensionMenu.matches(":popover-open")) { closeMenu(); trigger.focus({ preventScroll: true }); return; }
|
|
581
|
+
closeMenu(); hoverKey = ""; extensionTrigger = trigger; extensionGroup = name;
|
|
582
|
+
await tick();
|
|
583
|
+
extensionMenu.showPopover(); positionExtensionMenu();
|
|
584
|
+
await tick(); positionExtensionMenu();
|
|
585
|
+
const buttons = extensionMenu.querySelectorAll<HTMLButtonElement>("button:not(:disabled)");
|
|
586
|
+
(last ? buttons[buttons.length - 1] : buttons[0])?.focus({ preventScroll: true });
|
|
587
|
+
}
|
|
588
|
+
function extensionGroupTrigger(node: HTMLElement, state: { name: string; expanded: boolean; disabled: boolean; trigger: HTMLButtonElement | undefined }) {
|
|
589
|
+
const button = node.querySelector<HTMLButtonElement>("button");
|
|
590
|
+
function update(next: typeof state) {
|
|
591
|
+
state = next;
|
|
592
|
+
button?.setAttribute("aria-haspopup", "menu");
|
|
593
|
+
button?.setAttribute("aria-controls", `${id}-extension-group`);
|
|
594
|
+
button?.setAttribute("aria-expanded", String(state.expanded && state.trigger === button));
|
|
595
|
+
if (button) button.disabled = state.disabled;
|
|
596
|
+
}
|
|
597
|
+
const pointerdown = (event: PointerEvent) => { event.preventDefault(); };
|
|
598
|
+
const click = () => { if (button) clickedExtensionTrigger = button; };
|
|
599
|
+
const keydown = (event: KeyboardEvent) => {
|
|
600
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key) || !button) return;
|
|
601
|
+
event.preventDefault();
|
|
602
|
+
void openExtensionGroup(state.name, button, event.key === "ArrowUp" || event.key === "End");
|
|
603
|
+
};
|
|
604
|
+
update(state);
|
|
605
|
+
node.addEventListener("pointerdown", pointerdown);
|
|
606
|
+
node.addEventListener("click", click, true);
|
|
607
|
+
node.addEventListener("keydown", keydown);
|
|
608
|
+
return { update, destroy() { node.removeEventListener("pointerdown", pointerdown); node.removeEventListener("click", click, true); node.removeEventListener("keydown", keydown); } };
|
|
609
|
+
}
|
|
610
|
+
function extensionMenuItem(node: HTMLElement) {
|
|
611
|
+
node.querySelector("button")?.setAttribute("role", "menuitem");
|
|
612
|
+
const pointerdown = (event: PointerEvent) => { event.preventDefault(); };
|
|
613
|
+
node.addEventListener("pointerdown", pointerdown);
|
|
614
|
+
return { destroy() { node.removeEventListener("pointerdown", pointerdown); } };
|
|
615
|
+
}
|
|
451
616
|
function openMenu(event?: MouseEvent, popup = menu) {
|
|
452
617
|
if (suppressClick) { suppressClick = false; return; }
|
|
453
618
|
const fromToolbar = !!(event?.currentTarget as Element)?.closest('.body-toolbar, .block-float');
|
|
@@ -458,12 +623,12 @@
|
|
|
458
623
|
popup.showPopover();
|
|
459
624
|
void tick().then(() => popup.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus());
|
|
460
625
|
}
|
|
461
|
-
function formatKeydown(event: KeyboardEvent) {
|
|
462
|
-
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeMenu();
|
|
626
|
+
function formatKeydown(event: KeyboardEvent, popup = formatMenu, trigger = formatTrigger) {
|
|
627
|
+
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeMenu(); trigger?.focus({ preventScroll: true }); return; }
|
|
463
628
|
if (event.key === "Tab") { closeMenu(); return; }
|
|
464
629
|
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
|
465
630
|
event.preventDefault();
|
|
466
|
-
const buttons = [...
|
|
631
|
+
const buttons = [...popup.querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
|
|
467
632
|
const index = buttons.indexOf(root.ownerDocument.activeElement as HTMLButtonElement);
|
|
468
633
|
buttons[event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length]?.focus();
|
|
469
634
|
}
|
|
@@ -553,6 +718,11 @@
|
|
|
553
718
|
}, { discrete: true });
|
|
554
719
|
}
|
|
555
720
|
function contentClick(event: MouseEvent) {
|
|
721
|
+
const extension = (event.target as Element).closest<HTMLElement>('[data-rigel-extension-editor][data-block-key]');
|
|
722
|
+
if (extension) {
|
|
723
|
+
editor.update(() => { const selection = L.$createNodeSelection(); selection.add(extension.dataset.blockKey!); L.$setSelection(selection); });
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
556
726
|
const element = (event.target as Element).closest<HTMLElement>('figure[data-block-key],hr[data-block-key]');
|
|
557
727
|
if (element && (event.target as Element).closest('[data-rigel-edit-video]')) {
|
|
558
728
|
event.preventDefault();
|
|
@@ -571,113 +741,160 @@
|
|
|
571
741
|
|
|
572
742
|
{#snippet formatButtons()}
|
|
573
743
|
{#each marks.slice(0, 2) as mark}
|
|
574
|
-
<button type="button" aria-label={mark.label} data-tooltip={mark.label} aria-pressed={activeMarks.includes(mark.type)} onpointerdown={event => event.preventDefault()} onclick={() => format(mark.type)}><Icon name={mark.icon} size={18} /></button>
|
|
744
|
+
<button type="button" aria-label={$t(mark.label)} data-tooltip={$t(mark.label)} aria-pressed={activeMarks.includes(mark.type)} onpointerdown={event => event.preventDefault()} onclick={() => format(mark.type)}><Icon name={mark.icon} size={18} /></button>
|
|
575
745
|
{/each}
|
|
576
|
-
<button type="button" aria-label="
|
|
577
|
-
<button type="button" aria-label="
|
|
746
|
+
<button type="button" aria-label={$t("richtext.link")} data-tooltip={$t("richtext.link")} aria-expanded={linkOpen} aria-controls={`${id}-link`} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
|
|
747
|
+
<button type="button" aria-label={$t("richtext.moreFormatting")} data-tooltip={$t("richtext.moreFormatting")} aria-haspopup="menu" aria-expanded={formatsOpen} aria-controls={`${id}-formats`} popovertarget={`${id}-formats`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); formatTrigger = event.currentTarget; formatMenu.matches(':popover-open') ? closeMenu() : openMenu(event, formatMenu); }}><Icon name="caretDown" size={18} /></button>
|
|
578
748
|
{/snippet}
|
|
579
749
|
{#snippet tableControls()}
|
|
580
|
-
<div class="block-table-controls" role="group" aria-label="
|
|
581
|
-
<button type="button" disabled={!tableCell || tableRows >= MAX_TABLE_ROWS} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("row")}><Icon name="plus" />
|
|
582
|
-
<button type="button" disabled={!tableCell || tableColumns >= MAX_TABLE_COLUMNS} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("column")}><Icon name="plus" />
|
|
583
|
-
<button type="button" disabled={!tableCell || tableRows <= 1} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("delete-row")}><Icon name="minus" />
|
|
584
|
-
<button type="button" disabled={!tableCell || tableColumns <= 1} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("delete-column")}><Icon name="minus" />
|
|
585
|
-
<label class="inline"><input type="checkbox" role="switch" class="switch" checked={tableHeader} disabled={!tableCell} onchange={event => tableAction("header", event.currentTarget.checked)} /><Icon name="table" /><span>
|
|
750
|
+
<div class="block-table-controls" role="group" aria-label={$t("richtext.tableControls")}>
|
|
751
|
+
<button type="button" disabled={!tableCell || tableRows >= MAX_TABLE_ROWS} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("row")}><Icon name="plus" />{$t("richtext.addRowBelow")}</button>
|
|
752
|
+
<button type="button" disabled={!tableCell || tableColumns >= MAX_TABLE_COLUMNS} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("column")}><Icon name="plus" />{$t("richtext.addColumnRight")}</button>
|
|
753
|
+
<button type="button" disabled={!tableCell || tableRows <= 1} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("delete-row")}><Icon name="minus" />{$t("richtext.removeRow")}</button>
|
|
754
|
+
<button type="button" disabled={!tableCell || tableColumns <= 1} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("delete-column")}><Icon name="minus" />{$t("richtext.removeColumn")}</button>
|
|
755
|
+
<label class="inline"><input type="checkbox" role="switch" class="switch" checked={tableHeader} disabled={!tableCell} onchange={event => tableAction("header", event.currentTarget.checked)} /><Icon name="table" /><span>{$t("richtext.headerRow")}</span></label>
|
|
586
756
|
</div>
|
|
587
757
|
{/snippet}
|
|
758
|
+
{#snippet extensionButtons()}
|
|
759
|
+
{#each toolbarCommands.filter(command => !command.group) as command}
|
|
760
|
+
<span data-rigel-command={command.name} style="display:contents">
|
|
761
|
+
<svelte:boundary onerror={cause => { extensionError = cause instanceof Error ? cause.message : String(cause); }}>
|
|
762
|
+
{#if command.button}
|
|
763
|
+
{@const Button = command.button}
|
|
764
|
+
<Button label={command.label} disabled={extensionBusy || !!error} onclick={() => { void runExtension(command); }} />
|
|
765
|
+
{:else}
|
|
766
|
+
<button type="button" disabled={extensionBusy || !!error} aria-label={command.label} data-tooltip={command.label} onpointerdown={event => event.preventDefault()} onclick={() => { void runExtension(command); }}>{command.label}</button>
|
|
767
|
+
{/if}
|
|
768
|
+
{#snippet failed()}<span role="status">{$t("richtext.loadArticleError")}</span>{/snippet}
|
|
769
|
+
</svelte:boundary>
|
|
770
|
+
</span>
|
|
771
|
+
{/each}
|
|
772
|
+
{#each toolbarGroups as group}
|
|
773
|
+
<span data-rigel-group={group.name} class="extension-group-trigger" use:extensionGroupTrigger={{ name: group.name, expanded: extensionGroup === group.name, disabled: extensionBusy || !!error, trigger: extensionTrigger }}>
|
|
774
|
+
<svelte:boundary onerror={cause => { extensionError = cause instanceof Error ? cause.message : String(cause); }}>
|
|
775
|
+
{#if group.button}
|
|
776
|
+
{@const Button = group.button}
|
|
777
|
+
<Button label={group.label} disabled={extensionBusy || !!error} onclick={() => { void openExtensionGroup(group.name, clickedExtensionTrigger); }} />
|
|
778
|
+
{:else}
|
|
779
|
+
<button type="button" disabled={extensionBusy || !!error} onclick={event => { void openExtensionGroup(group.name, event.currentTarget); }}>{group.label}<Icon name="caretDown" size={12} /></button>
|
|
780
|
+
{/if}
|
|
781
|
+
{#snippet failed()}<span role="status">{$t("richtext.loadArticleError")}</span>{/snippet}
|
|
782
|
+
</svelte:boundary>
|
|
783
|
+
</span>
|
|
784
|
+
{/each}
|
|
785
|
+
{/snippet}
|
|
588
786
|
{#snippet toolbar(image: boolean)}
|
|
589
|
-
<div class="body-toolbar formatting-toolbar" class:image-toolbar={image} role="group" aria-label={image ? "
|
|
787
|
+
<div class="body-toolbar formatting-toolbar" class:image-toolbar={image} role="group" aria-label={image ? $t("richtext.imageEditing") : $t("richtext.bodyFormatting")} style={inline || image ? floating : undefined} hidden={markdownView || ((inline || image) && !floating) || (image && !imageData) || (inline && !!selectedVideo && !toolbarCommands.length)}>
|
|
788
|
+
{#if !extensionOnly}
|
|
590
789
|
{#if image}
|
|
591
|
-
<button type="button" aria-label="
|
|
592
|
-
<button type="button" aria-label={imageData?.url ? "
|
|
790
|
+
<button type="button" aria-label={$t("richtext.editAltText")} data-tooltip={$t("richtext.editAltText")} onpointerdown={event => event.preventDefault()} onclick={openAlt}><Icon name="textAa" size={18} /></button>
|
|
791
|
+
<button type="button" aria-label={imageData?.url ? $t("richtext.editImageLink") : $t("richtext.addImageLink")} data-tooltip={imageData?.url ? $t("richtext.editImageLink") : $t("richtext.addImageLink")} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
|
|
593
792
|
{:else}
|
|
594
|
-
<Select ariaLabel="
|
|
793
|
+
<Select ariaLabel={$t("richtext.blockType")} data-tooltip={activeBlock?.label ?? $t("richtext.blockType")} icon={activeBlock?.icon ?? "paragraph"} value={imageData || embedData ? "" : blockType} options={textBlocks} disabled={!!imageData} onchange={next => { hoverKey = ""; changeBlock(String(next)); }} />
|
|
595
794
|
{#if !inline || !["image", "video", "embed", "divider"].includes(blockType)}<span class="toolbar-separator" aria-hidden="true"></span>{@render formatButtons()}<span class="toolbar-separator" aria-hidden="true"></span>{/if}
|
|
596
795
|
{/if}
|
|
597
796
|
{#if image}
|
|
598
|
-
<ImagePicker toolbar value={imageData?.src ?? ""} altText={imageData?.alt ?? ""} triggerLabel="
|
|
797
|
+
<ImagePicker toolbar value={imageData?.src ?? ""} altText={imageData?.alt ?? ""} triggerLabel={$t("richtext.replaceImage")} onopen={() => { if (!imageJob) imageJob = { key: activeKey, replace: true }; linkOpen = false; altOpen = false; }} onpick={pickImage} onclose={() => { imageJob = null; refocus(); }} />
|
|
599
798
|
{:else}
|
|
600
|
-
<button type="button" data-media-trigger aria-label="
|
|
799
|
+
{#if !inline || canInsertBlock}<button type="button" data-media-trigger aria-label={$t("richtext.insertMedia")} data-tooltip={$t("richtext.insertMedia")} aria-haspopup="menu" onpointerdown={event => event.preventDefault()} onclick={event => mediaPicker.toggleMenu(event.currentTarget)}><Icon name="image" size={18} /></button>{/if}
|
|
601
800
|
<ImagePicker toolbar hiddenTrigger value="" onopen={() => { if (!imageJob) imageJob = { key: activeKey, replace: false }; linkOpen = false; altOpen = false; }} onpick={pickImage} onclose={() => { imageJob = null; refocus(); }} />
|
|
602
801
|
{/if}
|
|
603
802
|
{#if !image}
|
|
604
|
-
<button type="button" aria-label="
|
|
605
|
-
<button type="button" aria-label="
|
|
606
|
-
<button type="button" aria-label="
|
|
803
|
+
{#if !inline || canInsertBlock || blockType === "table"}<button type="button" aria-label={$t("richtext.table")} data-tooltip={$t("richtext.table")} aria-pressed={blockType === "table"} popovertarget={`${id}-table`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); tableMenu.matches(':popover-open') ? closeMenu() : openMenu(event, tableMenu); }}><Icon name="table" size={18} /></button>{/if}
|
|
804
|
+
<button type="button" aria-label={$t("richtext.bulletedList")} data-tooltip={$t("richtext.bulletedList")} aria-pressed={listType === "bullet"} disabled={["image", "video", "embed", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("bullet")}><Icon name="listBullets" size={18} /></button>
|
|
805
|
+
<button type="button" aria-label={$t("richtext.numberedList")} data-tooltip={$t("richtext.numberedList")} aria-pressed={listType === "number"} disabled={["image", "video", "embed", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("number")}><Icon name="listNumbers" size={18} /></button>
|
|
607
806
|
{#if !inline}
|
|
608
|
-
<button type="button" class="block-history" disabled={!canUndo} onclick={() => editor.dispatchCommand(L.UNDO_COMMAND, undefined)}>
|
|
609
|
-
<button type="button" class="block-history" disabled={!canRedo} onclick={() => editor.dispatchCommand(L.REDO_COMMAND, undefined)}>
|
|
610
|
-
<button type="button" class="block-add" aria-label="
|
|
807
|
+
<button type="button" class="block-history" disabled={!canUndo} onclick={() => editor.dispatchCommand(L.UNDO_COMMAND, undefined)}>{$t("richtext.undo")}</button>
|
|
808
|
+
<button type="button" class="block-history" disabled={!canRedo} onclick={() => editor.dispatchCommand(L.REDO_COMMAND, undefined)}>{$t("richtext.redo")}</button>
|
|
809
|
+
<button type="button" class="block-add" aria-label={$t("richtext.addBlock")} data-tooltip={$t("richtext.addBlock")} onclick={addBlock}>{$t("richtext.addBlock")}</button>
|
|
810
|
+
{/if}
|
|
611
811
|
{/if}
|
|
612
812
|
{/if}
|
|
613
|
-
{
|
|
813
|
+
{@render extensionButtons()}
|
|
814
|
+
{#if inline || image}<span class="toolbar-separator" aria-hidden="true"></span><button type="button" aria-label={$t("richtext.moreActions")} data-tooltip={$t("richtext.moreActions")} aria-haspopup="true" popovertarget={`${id}-actions`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); menu.matches(':popover-open') ? closeMenu() : openMenu(event); }}><Icon name="dotsThreeOutlineVertical" size={18} /></button>{/if}
|
|
614
815
|
</div>
|
|
615
816
|
{/snippet}
|
|
616
817
|
<div class="block-editor" bind:this={editorHost}>
|
|
617
818
|
<MediaPicker bind:this={mediaPicker} hiddenTrigger onopen={() => editor.blur()} onimage={() => { if (!imageJob) imageJob = { key: activeKey, replace: false }; editorHost.querySelector<HTMLButtonElement>('.body-toolbar:not(.image-toolbar) [data-image-trigger]')?.click(); }} onvideo={pickVideo} onembed={pickEmbed} onclose={() => { mediaJob = null; refocus(); }} />
|
|
618
|
-
{#if !inline}<div class="markdown-heading"><div class="field-label">
|
|
819
|
+
{#if !inline}<div class="markdown-heading"><div class="field-label">{$t("richtext.body")}</div><div class="markdown-modes"><button type="button" aria-pressed={markdownView} onclick={() => { markdownView = !markdownView; linkOpen = false; closeMenu(); dismissSlash(); }}>{$t("richtext.viewAsMarkdown")}</button></div></div>{/if}
|
|
619
820
|
{@render toolbar(inline && !!imageData)}
|
|
620
821
|
{#if !inline}{@render toolbar(true)}{/if}
|
|
621
822
|
|
|
622
823
|
{#if error}<p role="alert">{error}</p>{/if}
|
|
824
|
+
{#if extensionError}<p role="alert">{extensionError}</p>{/if}
|
|
623
825
|
{#if linkOpen}
|
|
624
|
-
<div class="markdown-link" class:image-form={!!imageData} style={linkPosition} id={`${id}-link`} role="group" aria-label="
|
|
625
|
-
<label for={`${id}-url`}>
|
|
826
|
+
<div class="markdown-link" class:image-form={!!imageData} style={linkPosition} id={`${id}-link`} role="group" aria-label={$t("richtext.editLink")} onkeydown={event => { if (event.key === "Escape") { event.stopPropagation(); linkOpen = false; refocus(); } if (event.key === "Enter") { event.preventDefault(); applyLink(); } }}>
|
|
827
|
+
<label for={`${id}-url`}>{$t("richtext.linkUrl")}</label>
|
|
626
828
|
<div class="markdown-link-controls">
|
|
627
829
|
<input id={`${id}-url`} type="text" placeholder="https://…" bind:this={linkInput} bind:value={linkUrl} aria-invalid={!!linkError} />
|
|
628
|
-
<button type="button" aria-label="
|
|
629
|
-
<button type="button" aria-label="
|
|
630
|
-
<button type="button" aria-label="
|
|
830
|
+
<button type="button" aria-label={$t("richtext.applyLink")} data-tooltip={$t("richtext.applyLink")} onclick={() => applyLink()}><Icon name="check" size={16} /></button>
|
|
831
|
+
<button type="button" aria-label={$t("richtext.removeLink")} data-tooltip={$t("richtext.removeLink")} onclick={() => applyLink(true)}><Icon name="linkBreak" size={16} /></button>
|
|
832
|
+
<button type="button" aria-label={$t("richtext.closeLink")} data-tooltip={$t("richtext.closeLink")} onclick={() => { linkOpen = false; refocus(); }}><Icon name="x" size={16} /></button>
|
|
631
833
|
</div>
|
|
632
834
|
{#if linkError}<p role="alert">{linkError}</p>{/if}
|
|
633
835
|
</div>
|
|
634
836
|
{/if}
|
|
635
837
|
{#if altOpen}
|
|
636
|
-
<div class="markdown-link image-form" style={linkPosition} role="group" aria-label="
|
|
637
|
-
<label for={`${id}-alt`}>
|
|
838
|
+
<div class="markdown-link image-form" style={linkPosition} role="group" aria-label={$t("richtext.editImageAltText")} onkeydown={event => { if (event.key === "Escape") { event.stopPropagation(); altOpen = false; refocus(); } if (event.key === "Enter") { event.preventDefault(); applyAlt(); } }}>
|
|
839
|
+
<label for={`${id}-alt`}>{$t("richtext.decorativeAltText")}</label>
|
|
638
840
|
<div class="markdown-link-controls">
|
|
639
|
-
<input id={`${id}-alt`} type="text" aria-label="
|
|
640
|
-
<button type="button" aria-label="
|
|
641
|
-
<button type="button" aria-label="
|
|
841
|
+
<input id={`${id}-alt`} type="text" aria-label={$t("richtext.imageAltText")} bind:this={altInput} bind:value={altText} />
|
|
842
|
+
<button type="button" aria-label={$t("richtext.applyAltText")} data-tooltip={$t("richtext.applyAltText")} onclick={applyAlt}><Icon name="check" size={16} /></button>
|
|
843
|
+
<button type="button" aria-label={$t("richtext.closeAltText")} data-tooltip={$t("richtext.closeAltText")} onclick={() => { altOpen = false; refocus(); }}><Icon name="x" size={16} /></button>
|
|
642
844
|
</div>
|
|
643
845
|
</div>
|
|
644
846
|
{/if}
|
|
645
|
-
{#if !inline && !imageData && !selectedVideo && floating && !markdownView}<div class="block-float" style={floating} role="group" aria-label="
|
|
847
|
+
{#if !inline && !imageData && (!selectedVideo || toolbarCommands.length) && floating && !markdownView}<div class="block-float" style={floating} role="group" aria-label={$t("richtext.selectedTextFormatting")}>{#if !extensionOnly}{@render formatButtons()}{/if}{@render extensionButtons()}</div>{/if}
|
|
646
848
|
{#if markdownView}
|
|
647
|
-
<div class="block-markdown-view">{#if inline}<button type="button" class="outlined" onclick={async () => { markdownView = false; await tick(); editor.focus(); }}>
|
|
849
|
+
<div class="block-markdown-view">{#if inline}<button type="button" class="outlined" onclick={async () => { markdownView = false; await tick(); editor.focus(); }}>{$t("richtext.closeMarkdownView")}</button>{/if}<pre class="block-markdown" aria-label={$t("richtext.bodyAsMarkdown")} tabindex="0">{articleMarkdown(value)}</pre></div>
|
|
648
850
|
{/if}
|
|
649
|
-
<div class="block-surface" bind:this={surface} hidden={markdownView} onpointermove={hover} role="group" aria-label="
|
|
650
|
-
{#if insertion && !markdownView}<button type="button" class="block-insert" style={`top:${insertion.top}px;left:${insertion.left}px`} aria-label="
|
|
651
|
-
{#if !inline}<div class="block-content" contenteditable="true" role="textbox" aria-label="
|
|
652
|
-
{#if hoverKey || activeKey}<button bind:this={handle} type="button" class="block-handle" style={`top:${handleTop}px;${inline ? `left:${handleLeft}px` : ''}`} aria-label="
|
|
851
|
+
<div class="block-surface" bind:this={surface} hidden={markdownView} onpointermove={hover} role="group" aria-label={$t("richtext.articleBlocks")}>
|
|
852
|
+
{#if insertion && !markdownView}<button type="button" class="block-insert" style={`top:${insertion.top}px;left:${insertion.left}px`} aria-label={$t("richtext.insertParagraphHere")} data-tooltip={$t("richtext.insertParagraph")} onpointerdown={event => event.preventDefault()} onpointerleave={leaveInsertion} onblur={() => { insertion = null; }} onclick={insertParagraph}><Icon name="plus" size={16} /></button>{/if}
|
|
853
|
+
{#if !inline}<div class="block-content" contenteditable="true" role="textbox" aria-label={$t("richtext.body")} aria-multiline="true" bind:this={root}></div>{/if}
|
|
854
|
+
{#if hoverKey || activeKey}<button bind:this={handle} type="button" class="block-handle" style={`top:${handleTop}px;${inline ? `left:${handleLeft}px` : ''}`} aria-label={$t("richtext.blockOptionsHandle")} data-tooltip={$t("richtext.dragBlockHint")} onpointerdown={startDrag} onpointermove={moveDrag} onpointerup={() => endDrag()} onpointercancel={() => endDrag(true)} onlostpointercapture={() => endDrag(true)} onclick={openMenu}><Icon name="dotsThreeOutlineVertical" size={16} /></button>{/if}
|
|
653
855
|
{#if drop}<div class="block-drop-indicator" style={`top:${drop.top}px;${inline ? `position:fixed;left:${root.getBoundingClientRect().left}px;width:${root.getBoundingClientRect().width}px;right:auto` : ''}`}></div>{/if}
|
|
654
|
-
<div bind:this={slashMenu} class="block-slash" id={`${id}-slash`} popover="manual" style={slashPosition} role="listbox" aria-label="
|
|
856
|
+
<div bind:this={slashMenu} class="block-slash" id={`${id}-slash`} popover="manual" style={slashPosition} role="listbox" aria-label={$t("richtext.insertBlock")} onkeydown={slashKeydown}>
|
|
655
857
|
{#if slash !== null}
|
|
656
|
-
<p class="hint">
|
|
657
|
-
{#if adding}<input type="search" role="combobox" aria-expanded="true" aria-controls={`${id}-slash`} aria-activedescendant={choices.length ? `${id}-slash-${slashIndex}` : undefined} aria-label="
|
|
858
|
+
<p class="hint">{$t("richtext.chooseBlock")}</p>
|
|
859
|
+
{#if adding}<input type="search" role="combobox" aria-expanded="true" aria-controls={`${id}-slash`} aria-activedescendant={choices.length ? `${id}-slash-${slashIndex}` : undefined} aria-label={$t("richtext.findBlock")} placeholder={$t("richtext.findBlockPlaceholder")} bind:value={slash} oninput={() => { slashIndex = 0; }} />{/if}
|
|
658
860
|
{#each choices as block, index}<button id={`${id}-slash-${index}`} type="button" role="option" aria-selected={index === slashIndex} onfocus={() => { slashIndex = index; }} onpointerdown={event => event.preventDefault()} onclick={() => changeBlock(block.value, true)}>{block.label}</button>{/each}
|
|
659
|
-
{#if !choices.length}<p role="status">
|
|
861
|
+
{#if !choices.length}<p role="status">{$t("richtext.noMatchingBlocks")}</p>{/if}
|
|
660
862
|
{/if}
|
|
661
863
|
</div>
|
|
662
864
|
</div>
|
|
663
|
-
<div class="block-menu block-table-menu" id={`${id}-table`} bind:this={tableMenu} popover="auto" style={menuPosition} role="group" aria-label="
|
|
664
|
-
{#if blockType === "table"}{@render tableControls()}{:else}<button type="button" onclick={() => { slashKey = activeKey; adding = true; changeBlock("table", true); }}>
|
|
865
|
+
<div class="block-menu block-table-menu" id={`${id}-table`} bind:this={tableMenu} popover="auto" style={menuPosition} role="group" aria-label={$t("richtext.table")}>
|
|
866
|
+
{#if blockType === "table"}{@render tableControls()}{:else}<button type="button" onclick={() => { slashKey = activeKey; adding = true; changeBlock("table", true); }}>{$t("richtext.addTable")}</button>{/if}
|
|
665
867
|
</div>
|
|
666
|
-
<div class="block-menu" id={`${id}-actions`} bind:this={menu} popover="auto" style={menuPosition} role="group" aria-label={inline ? "
|
|
868
|
+
<div class="block-menu" id={`${id}-actions`} bind:this={menu} popover="auto" style={menuPosition} role="group" aria-label={inline ? $t("richtext.moreActions") : $t("richtext.blockOptions")}>
|
|
667
869
|
{#if inline}
|
|
668
|
-
<button type="button" class="block-history" disabled={!canUndo} onclick={() => { editor.dispatchCommand(L.UNDO_COMMAND, undefined); closeMenu(); editor.focus(); }}>
|
|
669
|
-
<button type="button" class="block-history" disabled={!canRedo} onclick={() => { editor.dispatchCommand(L.REDO_COMMAND, undefined); closeMenu(); editor.focus(); }}>
|
|
670
|
-
<button type="button" onclick={() => { markdownView = true; closeMenu(); }}>
|
|
870
|
+
<button type="button" class="block-history" disabled={!canUndo} onclick={() => { editor.dispatchCommand(L.UNDO_COMMAND, undefined); closeMenu(); editor.focus(); }}>{$t("richtext.undo")}</button>
|
|
871
|
+
<button type="button" class="block-history" disabled={!canRedo} onclick={() => { editor.dispatchCommand(L.REDO_COMMAND, undefined); closeMenu(); editor.focus(); }}>{$t("richtext.redo")}</button>
|
|
872
|
+
<button type="button" onclick={() => { markdownView = true; closeMenu(); }}>{$t("richtext.viewAsMarkdown")}</button>
|
|
671
873
|
{/if}
|
|
672
|
-
<button type="button" onclick={() => blockAction("duplicate")}>
|
|
673
|
-
<button type="button" onclick={() => blockAction("delete")}>
|
|
674
|
-
<button type="button" onclick={() => blockAction("up")}>
|
|
675
|
-
<button type="button" onclick={() => blockAction("down")}>
|
|
676
|
-
{#if !inline}<Select ariaLabel="
|
|
874
|
+
<button type="button" onclick={() => blockAction("duplicate")}>{$t("richtext.duplicateBlock")}</button>
|
|
875
|
+
<button type="button" onclick={() => blockAction("delete")}>{$t("richtext.deleteBlock")}</button>
|
|
876
|
+
<button type="button" onclick={() => blockAction("up")}>{$t("richtext.moveUp")}</button>
|
|
877
|
+
<button type="button" onclick={() => blockAction("down")}>{$t("richtext.moveDown")}</button>
|
|
878
|
+
{#if !inline}<Select ariaLabel={$t("richtext.convertBlockType")} value={blockType} options={textBlocks} onchange={next => changeBlock(String(next))} />{/if}
|
|
879
|
+
</div>
|
|
880
|
+
<div class="block-menu extension-group-menu" id={`${id}-extension-group`} data-rigel-group-menu={extensionGroup || undefined} bind:this={extensionMenu} popover="auto" style={extensionPosition} role="menu" tabindex="-1" aria-label={activeExtensionGroup?.label} ontoggle={event => { if (!event.currentTarget.matches(':popover-open')) extensionGroup = ""; }} onkeydown={event => formatKeydown(event, extensionMenu, extensionTrigger)}>
|
|
881
|
+
{#each activeExtensionGroup?.commands ?? [] as command}
|
|
882
|
+
<span data-rigel-command={command.name} use:extensionMenuItem>
|
|
883
|
+
<svelte:boundary onerror={cause => { extensionError = cause instanceof Error ? cause.message : String(cause); }}>
|
|
884
|
+
{#if command.button}
|
|
885
|
+
{@const Button = command.button}
|
|
886
|
+
<Button label={command.label} disabled={extensionBusy || !!error} onclick={() => { closeMenu(); void runExtension(command); }} />
|
|
887
|
+
{:else}
|
|
888
|
+
<button type="button" role="menuitem" disabled={extensionBusy || !!error} onpointerdown={event => event.preventDefault()} onclick={() => { closeMenu(); void runExtension(command); }}>{command.label}</button>
|
|
889
|
+
{/if}
|
|
890
|
+
{#snippet failed()}<span role="status">{$t("richtext.loadArticleError")}</span>{/snippet}
|
|
891
|
+
</svelte:boundary>
|
|
892
|
+
</span>
|
|
893
|
+
{/each}
|
|
677
894
|
</div>
|
|
678
|
-
<div class="block-menu block-format-menu" id={`${id}-formats`} bind:this={formatMenu} popover="auto" style={menuPosition} role="menu" tabindex="-1" aria-label="
|
|
895
|
+
<div class="block-menu block-format-menu" id={`${id}-formats`} bind:this={formatMenu} popover="auto" style={menuPosition} role="menu" tabindex="-1" aria-label={$t("richtext.moreFormatting")} ontoggle={event => { formatsOpen = event.currentTarget.matches(':popover-open'); }} onkeydown={event => formatKeydown(event)}>
|
|
679
896
|
{#each marks.slice(2) as mark}
|
|
680
|
-
<button type="button" role="menuitemcheckbox" aria-label={mark.label} aria-checked={activeMarks.includes(mark.type)} onpointerdown={event => event.preventDefault()} onclick={() => format(mark.type)}><Icon name={mark.icon} size={16} /><span>{mark.label}</span>{#if activeMarks.includes(mark.type)}<Icon name="check" size={16} />{/if}</button>
|
|
897
|
+
<button type="button" role="menuitemcheckbox" aria-label={$t(mark.label)} aria-checked={activeMarks.includes(mark.type)} onpointerdown={event => event.preventDefault()} onclick={() => format(mark.type)}><Icon name={mark.icon} size={16} /><span>{$t(mark.label)}</span>{#if activeMarks.includes(mark.type)}<Icon name="check" size={16} />{/if}</button>
|
|
681
898
|
{/each}
|
|
682
899
|
</div>
|
|
683
900
|
</div>
|