@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.
Files changed (156) hide show
  1. package/README.md +4 -0
  2. package/docs/extensions.md +194 -0
  3. package/package.json +15 -3
  4. package/src/article-body.ts +49 -37
  5. package/src/article-render.ts +25 -2
  6. package/src/article.ts +44 -0
  7. package/src/config.ts +12 -2
  8. package/src/editor/App.svelte +98 -97
  9. package/src/editor/blocks/extension.ts +29 -0
  10. package/src/editor/blocks/index.ts +17 -15
  11. package/src/editor/blocks/video.ts +2 -1
  12. package/src/editor/i18n/README.md +23 -0
  13. package/src/editor/i18n/de/connection.json +136 -0
  14. package/src/editor/i18n/de/defaults.json +6 -0
  15. package/src/editor/i18n/de/fields.json +130 -0
  16. package/src/editor/i18n/de/local.json +49 -0
  17. package/src/editor/i18n/de/media.json +166 -0
  18. package/src/editor/i18n/de/richtext.json +116 -0
  19. package/src/editor/i18n/de/shell.json +181 -0
  20. package/src/editor/i18n/de/system.json +14 -0
  21. package/src/editor/i18n/de/validation.json +82 -0
  22. package/src/editor/i18n/en/connection.json +136 -0
  23. package/src/editor/i18n/en/defaults.json +6 -0
  24. package/src/editor/i18n/en/fields.json +130 -0
  25. package/src/editor/i18n/en/local.json +49 -0
  26. package/src/editor/i18n/en/media.json +166 -0
  27. package/src/editor/i18n/en/richtext.json +116 -0
  28. package/src/editor/i18n/en/shell.json +181 -0
  29. package/src/editor/i18n/en/system.json +14 -0
  30. package/src/editor/i18n/en/validation.json +82 -0
  31. package/src/editor/i18n/es/connection.json +136 -0
  32. package/src/editor/i18n/es/defaults.json +6 -0
  33. package/src/editor/i18n/es/fields.json +130 -0
  34. package/src/editor/i18n/es/local.json +49 -0
  35. package/src/editor/i18n/es/media.json +166 -0
  36. package/src/editor/i18n/es/richtext.json +116 -0
  37. package/src/editor/i18n/es/shell.json +181 -0
  38. package/src/editor/i18n/es/system.json +14 -0
  39. package/src/editor/i18n/es/validation.json +82 -0
  40. package/src/editor/i18n/format.ts +26 -0
  41. package/src/editor/i18n/fr/connection.json +136 -0
  42. package/src/editor/i18n/fr/defaults.json +6 -0
  43. package/src/editor/i18n/fr/fields.json +130 -0
  44. package/src/editor/i18n/fr/local.json +49 -0
  45. package/src/editor/i18n/fr/media.json +166 -0
  46. package/src/editor/i18n/fr/richtext.json +116 -0
  47. package/src/editor/i18n/fr/shell.json +181 -0
  48. package/src/editor/i18n/fr/system.json +14 -0
  49. package/src/editor/i18n/fr/validation.json +82 -0
  50. package/src/editor/i18n/index.ts +18 -0
  51. package/src/editor/i18n/it/connection.json +136 -0
  52. package/src/editor/i18n/it/defaults.json +6 -0
  53. package/src/editor/i18n/it/fields.json +130 -0
  54. package/src/editor/i18n/it/local.json +49 -0
  55. package/src/editor/i18n/it/media.json +166 -0
  56. package/src/editor/i18n/it/richtext.json +116 -0
  57. package/src/editor/i18n/it/shell.json +181 -0
  58. package/src/editor/i18n/it/system.json +14 -0
  59. package/src/editor/i18n/it/validation.json +82 -0
  60. package/src/editor/i18n/ja/connection.json +136 -0
  61. package/src/editor/i18n/ja/defaults.json +6 -0
  62. package/src/editor/i18n/ja/fields.json +130 -0
  63. package/src/editor/i18n/ja/local.json +49 -0
  64. package/src/editor/i18n/ja/media.json +166 -0
  65. package/src/editor/i18n/ja/richtext.json +116 -0
  66. package/src/editor/i18n/ja/shell.json +181 -0
  67. package/src/editor/i18n/ja/system.json +14 -0
  68. package/src/editor/i18n/ja/validation.json +82 -0
  69. package/src/editor/i18n/locales.ts +13 -0
  70. package/src/editor/i18n/nl/connection.json +136 -0
  71. package/src/editor/i18n/nl/defaults.json +6 -0
  72. package/src/editor/i18n/nl/fields.json +130 -0
  73. package/src/editor/i18n/nl/local.json +49 -0
  74. package/src/editor/i18n/nl/media.json +166 -0
  75. package/src/editor/i18n/nl/richtext.json +116 -0
  76. package/src/editor/i18n/nl/shell.json +181 -0
  77. package/src/editor/i18n/nl/system.json +14 -0
  78. package/src/editor/i18n/nl/validation.json +82 -0
  79. package/src/editor/i18n/ru/connection.json +138 -0
  80. package/src/editor/i18n/ru/defaults.json +6 -0
  81. package/src/editor/i18n/ru/fields.json +132 -0
  82. package/src/editor/i18n/ru/local.json +49 -0
  83. package/src/editor/i18n/ru/media.json +166 -0
  84. package/src/editor/i18n/ru/richtext.json +116 -0
  85. package/src/editor/i18n/ru/shell.json +181 -0
  86. package/src/editor/i18n/ru/system.json +14 -0
  87. package/src/editor/i18n/ru/validation.json +82 -0
  88. package/src/editor/i18n/server.ts +13 -0
  89. package/src/editor/i18n/zh/connection.json +136 -0
  90. package/src/editor/i18n/zh/defaults.json +6 -0
  91. package/src/editor/i18n/zh/fields.json +130 -0
  92. package/src/editor/i18n/zh/local.json +49 -0
  93. package/src/editor/i18n/zh/media.json +166 -0
  94. package/src/editor/i18n/zh/richtext.json +116 -0
  95. package/src/editor/i18n/zh/shell.json +181 -0
  96. package/src/editor/i18n/zh/system.json +14 -0
  97. package/src/editor/i18n/zh/validation.json +82 -0
  98. package/src/editor/lib/ActionDialog.svelte +5 -4
  99. package/src/editor/lib/BlockEditor.svelte +305 -88
  100. package/src/editor/lib/CollectionPanel.svelte +17 -15
  101. package/src/editor/lib/DatePicker.svelte +15 -12
  102. package/src/editor/lib/ExtensionMount.svelte +12 -0
  103. package/src/editor/lib/Field.svelte +3 -2
  104. package/src/editor/lib/ImagePicker.svelte +82 -81
  105. package/src/editor/lib/MarkdownEditor.svelte +25 -24
  106. package/src/editor/lib/MediaPicker.svelte +6 -5
  107. package/src/editor/lib/MenuEditor.svelte +31 -30
  108. package/src/editor/lib/PreviewFrame.svelte +11 -6
  109. package/src/editor/lib/PropertiesTabs.svelte +3 -2
  110. package/src/editor/lib/PublishDialog.svelte +15 -14
  111. package/src/editor/lib/ReferenceSelect.svelte +3 -2
  112. package/src/editor/lib/SchemaField.svelte +15 -13
  113. package/src/editor/lib/SchemaPanel.svelte +20 -19
  114. package/src/editor/lib/Select.svelte +3 -2
  115. package/src/editor/lib/SeoGuidance.svelte +6 -5
  116. package/src/editor/lib/Settings.svelte +54 -54
  117. package/src/editor/lib/Shortcuts.svelte +7 -6
  118. package/src/editor/lib/SitePreview.svelte +10 -9
  119. package/src/editor/lib/TopBar.svelte +18 -17
  120. package/src/editor/lib/UnsavedChanges.svelte +7 -6
  121. package/src/editor/lib/VisualEditor.svelte +14 -13
  122. package/src/editor/lib/calendar.ts +10 -4
  123. package/src/editor/lib/embed-preview.ts +23 -5
  124. package/src/editor/lib/extension-preview.ts +121 -0
  125. package/src/editor/lib/github-auth.ts +23 -22
  126. package/src/editor/lib/github-backend.ts +96 -91
  127. package/src/editor/lib/images.ts +33 -31
  128. package/src/editor/lib/menu-tree.ts +5 -4
  129. package/src/editor/lib/preview-frame.ts +4 -3
  130. package/src/editor/lib/schema-edit.ts +3 -2
  131. package/src/editor/lib/settings.ts +5 -3
  132. package/src/editor/lib/slash-command.ts +1 -0
  133. package/src/editor/lib/tooltip.ts +1 -1
  134. package/src/editor/main.ts +9 -0
  135. package/src/editor/styles.css +6 -3
  136. package/src/env.d.ts +11 -1
  137. package/src/extensions.ts +160 -0
  138. package/src/manifest/collections.ts +6 -5
  139. package/src/manifest/menu.ts +12 -10
  140. package/src/manifest/navigation.ts +9 -8
  141. package/src/manifest/references.ts +8 -7
  142. package/src/manifest/seo.ts +4 -3
  143. package/src/manifest/types.ts +1 -0
  144. package/src/preview.ts +22 -2
  145. package/src/routes/local-api.ts +5 -3
  146. package/src/routes/local-documents.ts +20 -16
  147. package/src/routes/local-media.ts +21 -16
  148. package/src/routes/local-menu.ts +9 -7
  149. package/src/routes/local-post.ts +18 -12
  150. package/src/routes/media-files.ts +10 -8
  151. package/src/routes/rigel-page.astro +2 -1
  152. package/src/schema/store.ts +22 -20
  153. package/src/schema/types.ts +1 -0
  154. package/src/schema/validate.ts +73 -69
  155. package/src/slugs.ts +11 -10
  156. 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: "Bold" },
77
- { type: "italic", icon: "textItalic", label: "Italic" },
78
- { type: "code", icon: "code", label: "Inline code" },
79
- { type: "strikethrough", icon: "textStrikethrough", label: "Strikethrough" },
80
- { type: "underline", icon: "textUnderline", label: "Underline" },
81
- { type: "highlight", icon: "highlighter", label: "Highlight" },
82
- { type: "subscript", icon: "textSubscript", label: "Subscript" },
83
- { type: "superscript", icon: "textSuperscript", label: "Superscript" },
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: "Video" }, { value: "youtube", label: "YouTube" }, { value: "vimeo", label: "Vimeo" }] as const;
93
- const choices = $derived([...blockTypes, ...mediaTypes].filter(block => block.label.toLowerCase().includes((slash ?? "").toLowerCase())));
94
- const textBlocks = blockTypes.filter(block => !["image", "table", "bullet", "number"].includes(block.value));
95
- const activeBlock = $derived(blockTypes.find(block => block.value === blockType));
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(/^\/([\w ]*)$/) : null;
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 !== root : !selection?.rangeCount || !root.contains(selection.anchorNode) || !root.contains(selection.focusNode)) { floating = ""; return; }
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", "Body"); root.setAttribute("aria-multiline", "true");
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 : "Unable to load article"; editor.setEditable(false); }
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 : "Unable to load article"; }
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(); editorHost.querySelector<HTMLInputElement>('[aria-label="Find a block"]')?.focus();
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 = "Enter a safe http, https, mailto or relative URL."; return; }
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(); formatTrigger?.focus(); return; }
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 = [...formatMenu.querySelectorAll<HTMLButtonElement>("button")];
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="Link" data-tooltip="Link" aria-expanded={linkOpen} aria-controls={`${id}-link`} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
577
- <button type="button" aria-label="More formatting" data-tooltip="More formatting" 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>
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="Table controls">
581
- <button type="button" disabled={!tableCell || tableRows >= MAX_TABLE_ROWS} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("row")}><Icon name="plus" />Add row below</button>
582
- <button type="button" disabled={!tableCell || tableColumns >= MAX_TABLE_COLUMNS} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("column")}><Icon name="plus" />Add column right</button>
583
- <button type="button" disabled={!tableCell || tableRows <= 1} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("delete-row")}><Icon name="minus" />Remove row</button>
584
- <button type="button" disabled={!tableCell || tableColumns <= 1} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("delete-column")}><Icon name="minus" />Remove column</button>
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>Header row</span></label>
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 ? "Image editing" : "Body formatting"} style={inline || image ? floating : undefined} hidden={markdownView || ((inline || image) && !floating) || (image && !imageData) || (inline && !!selectedVideo)}>
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="Edit alt text" data-tooltip="Edit alt text" onpointerdown={event => event.preventDefault()} onclick={openAlt}><Icon name="textAa" size={18} /></button>
592
- <button type="button" aria-label={imageData?.url ? "Edit image link" : "Add image link"} data-tooltip={imageData?.url ? "Edit image link" : "Add image link"} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
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="Block type" data-tooltip={activeBlock?.label ?? "Block type"} icon={activeBlock?.icon ?? "paragraph"} value={imageData || embedData ? "" : blockType} options={textBlocks} disabled={!!imageData} onchange={next => { hoverKey = ""; changeBlock(String(next)); }} />
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="Replace image" onopen={() => { if (!imageJob) imageJob = { key: activeKey, replace: true }; linkOpen = false; altOpen = false; }} onpick={pickImage} onclose={() => { imageJob = null; refocus(); }} />
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="Insert media" data-tooltip="Insert media" aria-haspopup="menu" onpointerdown={event => event.preventDefault()} onclick={event => mediaPicker.toggleMenu(event.currentTarget)}><Icon name="image" size={18} /></button>
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="Table" data-tooltip="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>
605
- <button type="button" aria-label="Bulleted list" data-tooltip="Bulleted list" aria-pressed={listType === "bullet"} disabled={["image", "video", "embed", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("bullet")}><Icon name="listBullets" size={18} /></button>
606
- <button type="button" aria-label="Numbered list" data-tooltip="Numbered list" aria-pressed={listType === "number"} disabled={["image", "video", "embed", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("number")}><Icon name="listNumbers" size={18} /></button>
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)}>Undo</button>
609
- <button type="button" class="block-history" disabled={!canRedo} onclick={() => editor.dispatchCommand(L.REDO_COMMAND, undefined)}>Redo</button>
610
- <button type="button" class="block-add" aria-label="Add block" data-tooltip="Add block" onclick={addBlock}>Add block</button>
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
- {#if inline || image}<span class="toolbar-separator" aria-hidden="true"></span><button type="button" aria-label="More actions" data-tooltip="More actions" 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}
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">Body</div><div class="markdown-modes"><button type="button" aria-pressed={markdownView} onclick={() => { markdownView = !markdownView; linkOpen = false; closeMenu(); dismissSlash(); }}>View as Markdown</button></div></div>{/if}
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="Edit link" onkeydown={event => { if (event.key === "Escape") { event.stopPropagation(); linkOpen = false; refocus(); } if (event.key === "Enter") { event.preventDefault(); applyLink(); } }}>
625
- <label for={`${id}-url`}>Link URL</label>
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="Apply link" data-tooltip="Apply link" onclick={() => applyLink()}><Icon name="check" size={16} /></button>
629
- <button type="button" aria-label="Remove link" data-tooltip="Remove link" onclick={() => applyLink(true)}><Icon name="linkBreak" size={16} /></button>
630
- <button type="button" aria-label="Close link" data-tooltip="Close link" onclick={() => { linkOpen = false; refocus(); }}><Icon name="x" size={16} /></button>
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="Edit image alt text" onkeydown={event => { if (event.key === "Escape") { event.stopPropagation(); altOpen = false; refocus(); } if (event.key === "Enter") { event.preventDefault(); applyAlt(); } }}>
637
- <label for={`${id}-alt`}>Alt text (leave empty if decorative)</label>
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="Image alt text" bind:this={altInput} bind:value={altText} />
640
- <button type="button" aria-label="Apply alt text" data-tooltip="Apply alt text" onclick={applyAlt}><Icon name="check" size={16} /></button>
641
- <button type="button" aria-label="Close alt text" data-tooltip="Close alt text" onclick={() => { altOpen = false; refocus(); }}><Icon name="x" size={16} /></button>
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="Selected text formatting">{@render formatButtons()}</div>{/if}
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(); }}>Close Markdown view</button>{/if}<pre class="block-markdown" aria-label="Body as Markdown" tabindex="0">{articleMarkdown(value)}</pre></div>
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="Article blocks">
650
- {#if insertion && !markdownView}<button type="button" class="block-insert" style={`top:${insertion.top}px;left:${insertion.left}px`} aria-label="Insert paragraph here" data-tooltip="Insert paragraph" onpointerdown={event => event.preventDefault()} onpointerleave={leaveInsertion} onblur={() => { insertion = null; }} onclick={insertParagraph}><Icon name="plus" size={16} /></button>{/if}
651
- {#if !inline}<div class="block-content" contenteditable="true" role="textbox" aria-label="Body" aria-multiline="true" bind:this={root}></div>{/if}
652
- {#if hoverKey || activeKey}<button bind:this={handle} type="button" class="block-handle" style={`top:${handleTop}px;${inline ? `left:${handleLeft}px` : ''}`} aria-label="Block options and drag handle" data-tooltip="Drag to reorder; click for block options" onpointerdown={startDrag} onpointermove={moveDrag} onpointerup={() => endDrag()} onpointercancel={() => endDrag(true)} onlostpointercapture={() => endDrag(true)} onclick={openMenu}><Icon name="dotsThreeOutlineVertical" size={16} /></button>{/if}
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="Insert block" onkeydown={slashKeydown}>
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">Choose a block</p>
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="Find a block" placeholder="Find a block…" bind:value={slash} oninput={() => { slashIndex = 0; }} />{/if}
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">No matching blocks.</p>{/if}
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="Table">
664
- {#if blockType === "table"}{@render tableControls()}{:else}<button type="button" onclick={() => { slashKey = activeKey; adding = true; changeBlock("table", true); }}>Add table</button>{/if}
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 ? "More actions" : "Block options"}>
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(); }}>Undo</button>
669
- <button type="button" class="block-history" disabled={!canRedo} onclick={() => { editor.dispatchCommand(L.REDO_COMMAND, undefined); closeMenu(); editor.focus(); }}>Redo</button>
670
- <button type="button" onclick={() => { markdownView = true; closeMenu(); }}>View as Markdown</button>
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")}>Duplicate block</button>
673
- <button type="button" onclick={() => blockAction("delete")}>Delete block</button>
674
- <button type="button" onclick={() => blockAction("up")}>Move up</button>
675
- <button type="button" onclick={() => blockAction("down")}>Move down</button>
676
- {#if !inline}<Select ariaLabel="Convert block type" value={blockType} options={textBlocks} onchange={next => changeBlock(String(next))} />{/if}
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="More formatting" ontoggle={event => { formatsOpen = event.currentTarget.matches(':popover-open'); }} onkeydown={formatKeydown}>
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>