@rigel-cms/rigel 0.0.0-stage → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +203 -0
- package/README.md +8 -2
- package/package.json +78 -4
- package/src/article-body.ts +60 -0
- package/src/article-render.ts +87 -0
- package/src/config.ts +97 -0
- package/src/content.ts +5 -0
- package/src/editor/App.svelte +972 -0
- package/src/editor/assets/rigelcms-icon.png +0 -0
- package/src/editor/assets/sidebar/LICENSE +21 -0
- package/src/editor/assets/sidebar/book-open.svg +1 -0
- package/src/editor/assets/sidebar/calendar.svg +1 -0
- package/src/editor/assets/sidebar/chart-bar.svg +1 -0
- package/src/editor/assets/sidebar/file-text.svg +1 -0
- package/src/editor/assets/sidebar/files.svg +1 -0
- package/src/editor/assets/sidebar/folder.svg +1 -0
- package/src/editor/assets/sidebar/folders.svg +1 -0
- package/src/editor/assets/sidebar/gear.svg +1 -0
- package/src/editor/assets/sidebar/globe.svg +1 -0
- package/src/editor/assets/sidebar/heart.svg +1 -0
- package/src/editor/assets/sidebar/house.svg +1 -0
- package/src/editor/assets/sidebar/images.svg +1 -0
- package/src/editor/assets/sidebar/layout.svg +1 -0
- package/src/editor/assets/sidebar/list.svg +1 -0
- package/src/editor/assets/sidebar/magnifying-glass.svg +1 -0
- package/src/editor/assets/sidebar/megaphone.svg +1 -0
- package/src/editor/assets/sidebar/newspaper.svg +1 -0
- package/src/editor/assets/sidebar/pencil-simple.svg +1 -0
- package/src/editor/assets/sidebar/shopping-bag.svg +1 -0
- package/src/editor/assets/sidebar/sidebar-simple.svg +1 -0
- package/src/editor/assets/sidebar/square-half.svg +1 -0
- package/src/editor/assets/sidebar/squares-four.svg +1 -0
- package/src/editor/assets/sidebar/tag.svg +1 -0
- package/src/editor/assets/sidebar/user.svg +1 -0
- package/src/editor/assets/sidebar/users.svg +1 -0
- package/src/editor/blocks/bulleted-list.ts +2 -0
- package/src/editor/blocks/code.ts +1 -0
- package/src/editor/blocks/divider.ts +14 -0
- package/src/editor/blocks/h1.ts +2 -0
- package/src/editor/blocks/h2.ts +2 -0
- package/src/editor/blocks/h3.ts +2 -0
- package/src/editor/blocks/h4.ts +2 -0
- package/src/editor/blocks/h5.ts +2 -0
- package/src/editor/blocks/h6.ts +2 -0
- package/src/editor/blocks/image.ts +34 -0
- package/src/editor/blocks/index.ts +40 -0
- package/src/editor/blocks/numbered-list.ts +2 -0
- package/src/editor/blocks/paragraph.ts +1 -0
- package/src/editor/blocks/quote.ts +1 -0
- package/src/editor/blocks/table.ts +5 -0
- package/src/editor/lib/ActionDialog.svelte +51 -0
- package/src/editor/lib/BlockEditor.svelte +614 -0
- package/src/editor/lib/CollectionPanel.svelte +103 -0
- package/src/editor/lib/DatePicker.svelte +106 -0
- package/src/editor/lib/Field.svelte +80 -0
- package/src/editor/lib/Icon.svelte +34 -0
- package/src/editor/lib/ImagePicker.svelte +264 -0
- package/src/editor/lib/MarkdownEditor.svelte +206 -0
- package/src/editor/lib/MenuEditor.svelte +209 -0
- package/src/editor/lib/PropertiesTabs.svelte +15 -0
- package/src/editor/lib/ReferenceSelect.svelte +17 -0
- package/src/editor/lib/SchemaField.svelte +156 -0
- package/src/editor/lib/SchemaPanel.svelte +189 -0
- package/src/editor/lib/Select.svelte +138 -0
- package/src/editor/lib/SeoGuidance.svelte +21 -0
- package/src/editor/lib/Settings.svelte +183 -0
- package/src/editor/lib/Shortcuts.svelte +29 -0
- package/src/editor/lib/SidebarIcon.svelte +8 -0
- package/src/editor/lib/SitePreview.svelte +50 -0
- package/src/editor/lib/TopBar.svelte +165 -0
- package/src/editor/lib/UnsavedChanges.svelte +38 -0
- package/src/editor/lib/VisualEditor.svelte +494 -0
- package/src/editor/lib/calendar.ts +55 -0
- package/src/editor/lib/github-auth.ts +117 -0
- package/src/editor/lib/github-backend.ts +263 -0
- package/src/editor/lib/icons.ts +111 -0
- package/src/editor/lib/images.ts +195 -0
- package/src/editor/lib/markdown.ts +71 -0
- package/src/editor/lib/media-library.ts +39 -0
- package/src/editor/lib/menu-tree.ts +37 -0
- package/src/editor/lib/rich-markdown.ts +30 -0
- package/src/editor/lib/schema-edit.ts +110 -0
- package/src/editor/lib/settings.ts +103 -0
- package/src/editor/lib/slugify.ts +10 -0
- package/src/editor/lib/storage.ts +17 -0
- package/src/editor/lib/store.svelte.ts +33 -0
- package/src/editor/main.ts +9 -0
- package/src/editor/styles.css +1280 -0
- package/src/env.d.ts +22 -0
- package/src/index.ts +128 -0
- package/src/manifest/frontmatter.ts +56 -0
- package/src/manifest/loader.ts +14 -0
- package/src/manifest/menu.ts +46 -0
- package/src/manifest/navigation.ts +94 -0
- package/src/manifest/references.ts +39 -0
- package/src/manifest/seo.ts +15 -0
- package/src/manifest/types.ts +75 -0
- package/src/media-types.ts +21 -0
- package/src/preview-urls.ts +3 -0
- package/src/preview.ts +167 -0
- package/src/routes/local-api.ts +42 -0
- package/src/routes/local-documents.ts +83 -0
- package/src/routes/local-media.ts +124 -0
- package/src/routes/local-menu.ts +81 -0
- package/src/routes/local-post.ts +80 -0
- package/src/routes/manifest.json.ts +15 -0
- package/src/routes/media-files.ts +126 -0
- package/src/routes/media-validation.ts +37 -0
- package/src/routes/media.json.ts +12 -0
- package/src/routes/rigel-page.astro +25 -0
- package/src/routes/serialize.ts +139 -0
- package/src/schema/docs.ts +33 -0
- package/src/schema/paths.ts +136 -0
- package/src/schema/store.ts +141 -0
- package/src/schema/types.ts +81 -0
- package/src/schema/validate.ts +327 -0
- package/src/vite/manifest-plugin.ts +180 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, tick, untrack } from "svelte";
|
|
3
|
+
import * as L from "lexical";
|
|
4
|
+
import * as Rich from "@lexical/rich-text";
|
|
5
|
+
import { createEmptyHistoryState, registerHistory } from "@lexical/history";
|
|
6
|
+
import * as Lists from "@lexical/list";
|
|
7
|
+
import * as Links from "@lexical/link";
|
|
8
|
+
import * as Tables from "@lexical/table";
|
|
9
|
+
import { ImageNode, type ImageData } from "../blocks/image";
|
|
10
|
+
import ImagePicker from "./ImagePicker.svelte";
|
|
11
|
+
import type { StockResult } from "./images";
|
|
12
|
+
import { safePreviewUrl } from "../../preview-urls";
|
|
13
|
+
import { blockTypes, blockNodes } from "../blocks";
|
|
14
|
+
import { validateBody, MAX_TABLE_ROWS, MAX_TABLE_COLUMNS, type ArticleBody } from "../../article-body";
|
|
15
|
+
import { articleMarkdown } from "../../article-render";
|
|
16
|
+
import Icon from "./Icon.svelte";
|
|
17
|
+
import Select from "./Select.svelte";
|
|
18
|
+
let { value, oninput, editableRoot }: { value: ArticleBody; oninput: (value: ArticleBody) => void; editableRoot?: HTMLElement } = $props();
|
|
19
|
+
const inline = $derived(!!editableRoot);
|
|
20
|
+
let root: HTMLElement;
|
|
21
|
+
let surface: HTMLDivElement;
|
|
22
|
+
let menu: HTMLDivElement;
|
|
23
|
+
let tableMenu: HTMLDivElement;
|
|
24
|
+
let formatMenu: HTMLDivElement;
|
|
25
|
+
let formatTrigger: HTMLButtonElement;
|
|
26
|
+
let formatsOpen = $state(false);
|
|
27
|
+
let slashMenu: HTMLDivElement;
|
|
28
|
+
let slashPosition = $state("");
|
|
29
|
+
let handle: HTMLButtonElement;
|
|
30
|
+
let editor: L.LexicalEditor;
|
|
31
|
+
let view: Window & typeof globalThis;
|
|
32
|
+
let last = untrack(() => JSON.stringify(value));
|
|
33
|
+
let received = last;
|
|
34
|
+
let error = $state("");
|
|
35
|
+
let markdownView = $state(false);
|
|
36
|
+
let activeKey = $state("");
|
|
37
|
+
let hoverKey = $state("");
|
|
38
|
+
let insertion = $state<{ after: string; top: number; left: number } | null>(null);
|
|
39
|
+
let blockType = $state("paragraph");
|
|
40
|
+
let listType = $state("");
|
|
41
|
+
let handleTop = $state(0);
|
|
42
|
+
let handleLeft = $state(0);
|
|
43
|
+
let menuPosition = $state("");
|
|
44
|
+
let slash = $state<string | null>(null);
|
|
45
|
+
let slashIndex = $state(0);
|
|
46
|
+
let slashKey = "";
|
|
47
|
+
let slashDismissed = "";
|
|
48
|
+
let adding = false;
|
|
49
|
+
let savedSelection: L.BaseSelection | null = null;
|
|
50
|
+
let drag: { id: number; key: string; x: number; y: number; moved: boolean } | null = null;
|
|
51
|
+
let drop = $state<{ key: string; after: boolean; top: number } | null>(null);
|
|
52
|
+
let suppressClick = false;
|
|
53
|
+
let floating = $state("");
|
|
54
|
+
let linkOpen = $state(false);
|
|
55
|
+
let linkUrl = $state("");
|
|
56
|
+
let linkError = $state("");
|
|
57
|
+
let linkInput: HTMLInputElement;
|
|
58
|
+
let linkPosition = $state("");
|
|
59
|
+
let editorHost: HTMLDivElement;
|
|
60
|
+
let imageJob: { key: string; replace: boolean } | null = null;
|
|
61
|
+
let imageData = $state<ImageData | null>(null);
|
|
62
|
+
let altOpen = $state(false);
|
|
63
|
+
let altText = $state("");
|
|
64
|
+
let altKey = "";
|
|
65
|
+
let altInput: HTMLInputElement;
|
|
66
|
+
const id = $props.id();
|
|
67
|
+
const marks = [
|
|
68
|
+
{ type: "bold", icon: "textB", label: "Bold" },
|
|
69
|
+
{ type: "italic", icon: "textItalic", label: "Italic" },
|
|
70
|
+
{ type: "code", icon: "code", label: "Inline code" },
|
|
71
|
+
{ type: "strikethrough", icon: "textStrikethrough", label: "Strikethrough" },
|
|
72
|
+
{ type: "underline", icon: "textUnderline", label: "Underline" },
|
|
73
|
+
{ type: "highlight", icon: "highlighter", label: "Highlight" },
|
|
74
|
+
{ type: "subscript", icon: "textSubscript", label: "Subscript" },
|
|
75
|
+
{ type: "superscript", icon: "textSuperscript", label: "Superscript" },
|
|
76
|
+
] as const;
|
|
77
|
+
let activeMarks = $state<string[]>([]);
|
|
78
|
+
let canUndo = $state(false);
|
|
79
|
+
let canRedo = $state(false);
|
|
80
|
+
let tableCell = $state("");
|
|
81
|
+
let tableRows = $state(0);
|
|
82
|
+
let tableColumns = $state(0);
|
|
83
|
+
let tableHeader = $state(false);
|
|
84
|
+
const choices = $derived(blockTypes.filter(block => block.label.toLowerCase().includes((slash ?? "").toLowerCase())));
|
|
85
|
+
const textBlocks = blockTypes.filter(block => !["image", "table", "bullet", "number"].includes(block.value));
|
|
86
|
+
const activeBlock = $derived(blockTypes.find(block => block.value === blockType));
|
|
87
|
+
|
|
88
|
+
function topBlock(node: L.LexicalNode | null) {
|
|
89
|
+
if (!node || L.$isRootNode(node)) return null;
|
|
90
|
+
let parent = node.getParent();
|
|
91
|
+
while (parent && !L.$isRootNode(parent)) { node = parent; parent = node.getParent(); }
|
|
92
|
+
return node;
|
|
93
|
+
}
|
|
94
|
+
function refresh() {
|
|
95
|
+
editor.getEditorState().read(() => {
|
|
96
|
+
const children = L.$getRoot().getChildren();
|
|
97
|
+
const selection = L.$getSelection();
|
|
98
|
+
if (selection) savedSelection = selection.clone();
|
|
99
|
+
const anchor = L.$isRangeSelection(selection) || Tables.$isTableSelection(selection) ? selection.anchor.getNode() : null;
|
|
100
|
+
listType = anchor ? [anchor, ...anchor.getParents()].find(Lists.$isListNode)?.getListType() ?? "" : "";
|
|
101
|
+
const node = anchor ? topBlock(anchor) : L.$isNodeSelection(selection) ? topBlock(selection.getNodes()[0]) : null;
|
|
102
|
+
tableCell = anchor ? Tables.$getTableCellNodeFromLexicalNode(anchor)?.getKey() ?? "" : "";
|
|
103
|
+
if (Tables.$isTableNode(node)) {
|
|
104
|
+
tableRows = node.getChildrenSize(); tableColumns = node.getColumnCount();
|
|
105
|
+
tableHeader = node.getFirstChildOrThrow<Tables.TableRowNode>().getChildren<Tables.TableCellNode>().every(cell => cell.hasHeaderState(Tables.TableCellHeaderStates.ROW));
|
|
106
|
+
}
|
|
107
|
+
if (node) {
|
|
108
|
+
imageData = node instanceof ImageNode ? { ...node.__image } : null;
|
|
109
|
+
activeKey = node.getKey();
|
|
110
|
+
blockType = Rich.$isHeadingNode(node) ? node.getTag() : Lists.$isListNode(node) ? node.getListType() : node.getType();
|
|
111
|
+
activeMarks = L.$isRangeSelection(selection) ? marks.filter(mark => selection.hasFormat(mark.type)).map(mark => mark.type) : [];
|
|
112
|
+
const text = node.getTextContent();
|
|
113
|
+
const match = node.getType() === "paragraph" && L.$isRangeSelection(selection) && selection.isCollapsed() ? text.match(/^\/([\w ]*)$/) : null;
|
|
114
|
+
const signature = node.getKey() + text;
|
|
115
|
+
if (match && signature !== slashDismissed) { if (slash !== match[1]) slashIndex = 0; slash = match[1]; slashKey = node.getKey(); adding = false; }
|
|
116
|
+
else if (!adding) slash = null;
|
|
117
|
+
}
|
|
118
|
+
for (const child of children) {
|
|
119
|
+
const element = editor.getElementByKey(child.getKey());
|
|
120
|
+
if (element) { element.dataset.blockKey = child.getKey(); if (inline) element.toggleAttribute('data-rigel-active-block', child.getKey() === activeKey); }
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
positionFloating();
|
|
124
|
+
positionHandle();
|
|
125
|
+
positionInsertion();
|
|
126
|
+
}
|
|
127
|
+
function positionFloating() {
|
|
128
|
+
if (imageJob) return;
|
|
129
|
+
const selection = view.getSelection();
|
|
130
|
+
if (!root.ownerDocument.hasFocus()) { floating = ""; return; }
|
|
131
|
+
if (editorHost.contains(root.ownerDocument.activeElement) && !root.contains(root.ownerDocument.activeElement)) return;
|
|
132
|
+
if (inline || imageData) {
|
|
133
|
+
if (imageData ? root.ownerDocument.activeElement !== root : !selection?.rangeCount || !root.contains(selection.anchorNode) || !root.contains(selection.focusNode)) { floating = ""; return; }
|
|
134
|
+
const block = editor.getElementByKey(activeKey);
|
|
135
|
+
if (!block) { floating = ""; return; }
|
|
136
|
+
const rect = imageData || selection!.isCollapsed ? block.getBoundingClientRect() : selection!.getRangeAt(0).getBoundingClientRect();
|
|
137
|
+
if (rect.bottom < 0 || rect.top > view.innerHeight) { floating = ""; return; }
|
|
138
|
+
const toolbarRect = editorHost.querySelector(imageData ? '.image-toolbar' : '.body-toolbar')?.getBoundingClientRect();
|
|
139
|
+
const toolbarHeight = toolbarRect?.height || 42, toolbarWidth = toolbarRect?.width || 330;
|
|
140
|
+
const top = rect.top >= toolbarHeight + 12 ? rect.top - toolbarHeight - 8 : rect.bottom + 8;
|
|
141
|
+
const minTop = !inline ? (editorHost.querySelector('.body-toolbar:not(.image-toolbar)')?.getBoundingClientRect().bottom ?? 0) + 8 : 8;
|
|
142
|
+
floating = `left:${Math.max(8, Math.min(rect.left, view.document.documentElement.clientWidth - toolbarWidth - 8))}px;top:${Math.max(minTop, Math.min(top, view.innerHeight - toolbarHeight - 8))}px`;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!root.ownerDocument.hasFocus() || !selection?.rangeCount || selection.isCollapsed || !root.contains(selection.anchorNode) || !root.contains(selection.focusNode)) { floating = ""; return; }
|
|
146
|
+
const rect = selection.getRangeAt(0).getBoundingClientRect();
|
|
147
|
+
const toolbarBottom = editorHost.querySelector('.body-toolbar')?.getBoundingClientRect().bottom ?? 0;
|
|
148
|
+
const top = rect.top - 44 >= toolbarBottom + 8 ? rect.top - 44 : Math.max(toolbarBottom + 8, rect.bottom + 8);
|
|
149
|
+
floating = `left:${Math.max(8, Math.min(rect.left, view.innerWidth - 216))}px;top:${Math.max(8, Math.min(top, view.innerHeight - 44))}px`;
|
|
150
|
+
}
|
|
151
|
+
function positionHandle() {
|
|
152
|
+
const element = editor?.getElementByKey(hoverKey || activeKey);
|
|
153
|
+
if (element && surface) {
|
|
154
|
+
const rect = element.getBoundingClientRect();
|
|
155
|
+
handleTop = inline ? rect.top : rect.top - surface.getBoundingClientRect().top;
|
|
156
|
+
if (inline) handleLeft = Math.max(0, rect.left - 28);
|
|
157
|
+
}
|
|
158
|
+
positionSlash();
|
|
159
|
+
}
|
|
160
|
+
function positionSlash() {
|
|
161
|
+
if (slash === null || !editor) return;
|
|
162
|
+
const rect = editor.getElementByKey(slashKey)?.getBoundingClientRect() ?? root.getBoundingClientRect();
|
|
163
|
+
const top = view.innerHeight - rect.bottom >= 280 ? rect.bottom + 4 : Math.max(8, rect.top - 284);
|
|
164
|
+
slashPosition = `left:${Math.max(8, Math.min(rect.left, view.innerWidth - 232))}px;top:${top}px`;
|
|
165
|
+
}
|
|
166
|
+
onMount(() => {
|
|
167
|
+
if (editableRoot) {
|
|
168
|
+
root = editableRoot; root.classList.add("block-content", "rigel-article-content"); root.contentEditable = "true";
|
|
169
|
+
root.setAttribute("role", "textbox"); root.setAttribute("aria-label", "Body"); root.setAttribute("aria-multiline", "true");
|
|
170
|
+
root.setAttribute("aria-keyshortcuts", "Alt+F10");
|
|
171
|
+
}
|
|
172
|
+
view = root.ownerDocument.defaultView!;
|
|
173
|
+
const contentStyle = view.getComputedStyle(root);
|
|
174
|
+
const whiteSpace = contentStyle.whiteSpace, wordBreak = contentStyle.wordBreak;
|
|
175
|
+
editor = L.createEditor({ namespace: "RigelArticle", nodes: blockNodes, onError: cause => { error = cause.message; }, theme: { code: "block-code", tableScrollableWrapper: "block-table-scroll", tableCellSelected: "block-table-selected", text: { bold: "block-bold", italic: "block-italic", strikethrough: "block-strike", underline: "block-underline", underlineStrikethrough: "block-underline-strike", code: "block-inline-code" } } });
|
|
176
|
+
editor.setRootElement(root);
|
|
177
|
+
if (inline) { root.style.whiteSpace = whiteSpace; root.style.wordBreak = wordBreak; }
|
|
178
|
+
Tables.setScrollableTablesActive(editor, true);
|
|
179
|
+
try { validateBody(value); const state = editor.parseEditorState(JSON.stringify(value)); last = JSON.stringify(state.toJSON()); editor.setEditorState(state); }
|
|
180
|
+
catch (cause) { error = cause instanceof Error ? cause.message : "Unable to load article"; editor.setEditable(false); }
|
|
181
|
+
const cleanups = [Rich.registerRichText(editor), Lists.registerList(editor), registerHistory(editor, createEmptyHistoryState(), 300)];
|
|
182
|
+
root.contentEditable = String(editor.isEditable());
|
|
183
|
+
cleanups.push(editor.registerEditableListener(editable => { root.contentEditable = String(editable); }));
|
|
184
|
+
cleanups.push(Tables.registerTablePlugin(editor), Tables.registerTableSelectionObserver(editor, true));
|
|
185
|
+
cleanups.push(editor.registerCommand(L.CAN_UNDO_COMMAND, available => { canUndo = available; return false; }, L.COMMAND_PRIORITY_EDITOR));
|
|
186
|
+
cleanups.push(editor.registerCommand(L.CAN_REDO_COMMAND, available => { canRedo = available; return false; }, L.COMMAND_PRIORITY_EDITOR));
|
|
187
|
+
cleanups.push(editor.registerCommand(L.PASTE_COMMAND, event => {
|
|
188
|
+
event?.preventDefault();
|
|
189
|
+
const selection = L.$getSelection();
|
|
190
|
+
if (L.$isRangeSelection(selection) && event && "clipboardData" in event) selection.insertRawText(event.clipboardData?.getData("text/plain") ?? "");
|
|
191
|
+
return true;
|
|
192
|
+
}, L.COMMAND_PRIORITY_CRITICAL));
|
|
193
|
+
cleanups.push(editor.registerCommand(L.DROP_COMMAND, event => { event.preventDefault(); return true; }, L.COMMAND_PRIORITY_CRITICAL));
|
|
194
|
+
cleanups.push(editor.registerCommand(L.KEY_DOWN_COMMAND, event => {
|
|
195
|
+
if ((inline || imageData) && event.altKey && event.key === "F10") { event.preventDefault(); editorHost.querySelector<HTMLButtonElement>(imageData ? '.image-toolbar button' : '.body-toolbar button')?.focus(); return true; }
|
|
196
|
+
return slashKeydown(event);
|
|
197
|
+
}, L.COMMAND_PRIORITY_HIGH));
|
|
198
|
+
const updates = editor.registerUpdateListener(({ editorState, dirtyElements, dirtyLeaves }) => {
|
|
199
|
+
refresh();
|
|
200
|
+
if (dirtyElements.size || dirtyLeaves.size) {
|
|
201
|
+
const next = editorState.toJSON(), serialized = JSON.stringify(next);
|
|
202
|
+
if (serialized !== last) { last = serialized; received = serialized; oninput(next); }
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
refresh();
|
|
206
|
+
const position = () => { positionHandle(); positionFloating(); positionInsertion(); };
|
|
207
|
+
const toolbarObserver = new ResizeObserver(() => { positionFloating(); positionInsertion(); });
|
|
208
|
+
editorHost.querySelectorAll('.body-toolbar').forEach(toolbar => toolbarObserver.observe(toolbar));
|
|
209
|
+
toolbarObserver.observe(root);
|
|
210
|
+
const outside = (event: PointerEvent) => {
|
|
211
|
+
if (linkOpen && !editorHost.querySelector('.markdown-link')?.contains(event.target as Node)) linkOpen = false;
|
|
212
|
+
if ((inline || imageData) && !root.contains(event.target as Node) && !editorHost.contains(event.target as Node)) { floating = ""; linkOpen = false; altOpen = false; closeMenu(); }
|
|
213
|
+
if (slash !== null && !slashMenu?.contains(event.target as Node) && !root.contains(event.target as Node) && !(event.target as Element).closest('.block-add')) dismissSlash();
|
|
214
|
+
};
|
|
215
|
+
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { endDrag(true); closeMenu(); dismissSlash(); if (inline) { floating = ""; editor.focus(); } } };
|
|
216
|
+
view.addEventListener("resize", position); view.addEventListener("scroll", position, true);
|
|
217
|
+
view.addEventListener("pointerdown", outside, true); view.addEventListener("keydown", escape);
|
|
218
|
+
if (root.ownerDocument !== document) document.addEventListener("pointerdown", outside, true);
|
|
219
|
+
view.addEventListener("blur", positionFloating);
|
|
220
|
+
root.addEventListener("pointerdown", contentClick); root.addEventListener("click", contentClick);
|
|
221
|
+
root.addEventListener("pointerleave", leaveInsertion);
|
|
222
|
+
if (inline) view.addEventListener("pointermove", hover);
|
|
223
|
+
return () => {
|
|
224
|
+
toolbarObserver?.disconnect();
|
|
225
|
+
view.CSS.highlights?.delete("rigel-link-selection");
|
|
226
|
+
view.removeEventListener("resize", position); view.removeEventListener("scroll", position, true);
|
|
227
|
+
view.removeEventListener("pointerdown", outside, true); view.removeEventListener("keydown", escape); view.removeEventListener("blur", positionFloating);
|
|
228
|
+
if (root.ownerDocument !== document) document.removeEventListener("pointerdown", outside, true);
|
|
229
|
+
root.removeEventListener("pointerdown", contentClick); root.removeEventListener("click", contentClick);
|
|
230
|
+
root.removeEventListener("pointerleave", leaveInsertion);
|
|
231
|
+
if (inline) view.removeEventListener("pointermove", hover);
|
|
232
|
+
endDrag(true); updates(); cleanups.forEach(cleanup => cleanup()); editor.setRootElement(null);
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
$effect(() => { if (!linkOpen) view?.CSS.highlights?.delete("rigel-link-selection"); });
|
|
237
|
+
|
|
238
|
+
$effect(() => {
|
|
239
|
+
const serialized = JSON.stringify(value);
|
|
240
|
+
if (editor && serialized !== received) {
|
|
241
|
+
try { validateBody(value); received = serialized; const state = editor.parseEditorState(serialized); last = JSON.stringify(state.toJSON()); editor.setEditorState(state); }
|
|
242
|
+
catch (cause) { error = cause instanceof Error ? cause.message : "Unable to load article"; }
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
$effect(() => { if (editor && inline) editor.setEditable(!markdownView && !error); });
|
|
247
|
+
|
|
248
|
+
$effect(() => {
|
|
249
|
+
if (!slashMenu) return;
|
|
250
|
+
if (slash === null) { if (slashMenu.matches(":popover-open")) slashMenu.hidePopover(); }
|
|
251
|
+
else { if (!slashMenu.matches(":popover-open")) slashMenu.showPopover(); untrack(positionSlash); }
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
function dismissSlash() {
|
|
255
|
+
if (slashKey) editor.getEditorState().read(() => { slashDismissed = slashKey + (L.$getNodeByKey(slashKey)?.getTextContent() ?? ""); });
|
|
256
|
+
slash = null; adding = false;
|
|
257
|
+
}
|
|
258
|
+
function slashKeydown(event: KeyboardEvent) {
|
|
259
|
+
if (slash === null) return false;
|
|
260
|
+
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); dismissSlash(); return true; }
|
|
261
|
+
if (["ArrowDown", "ArrowUp"].includes(event.key)) { event.preventDefault(); slashIndex = choices.length ? (slashIndex + (event.key === "ArrowDown" ? 1 : -1) + choices.length) % choices.length : 0; return true; }
|
|
262
|
+
if (event.key === "Enter" && choices.length) { event.preventDefault(); changeBlock(choices[slashIndex]?.value ?? choices[0].value, true); return true; }
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
async function addBlock() {
|
|
266
|
+
slashKey = activeKey; adding = true; slash = ""; slashIndex = 0;
|
|
267
|
+
await tick(); editorHost.querySelector<HTMLInputElement>('[aria-label="Find a block"]')?.focus();
|
|
268
|
+
}
|
|
269
|
+
function inlineNodes(node: L.LexicalNode): L.LexicalNode[] {
|
|
270
|
+
if (!L.$isElementNode(node)) return node.getTextContent() ? [L.$createTextNode(node.getTextContent())] : [];
|
|
271
|
+
return node.getChildren().flatMap(child => L.$isElementNode(child) && !child.isInline() ? [...inlineNodes(child), L.$createLineBreakNode()] : [child]);
|
|
272
|
+
}
|
|
273
|
+
function changeBlock(type: string, fromSlash = false) {
|
|
274
|
+
const definition = blockTypes.find(block => block.value === type);
|
|
275
|
+
if (!definition) return;
|
|
276
|
+
if (!fromSlash && type === blockType) { dismissSlash(); closeMenu(); return; }
|
|
277
|
+
const key = fromSlash ? slashKey : hoverKey || activeKey;
|
|
278
|
+
const insert = fromSlash && adding;
|
|
279
|
+
if (type === "image") {
|
|
280
|
+
imageJob = { key, replace: !insert && fromSlash };
|
|
281
|
+
dismissSlash(); closeMenu();
|
|
282
|
+
editorHost.querySelector<HTMLButtonElement>('[data-image-trigger]')?.click();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
dismissSlash(); closeMenu();
|
|
286
|
+
editor.update(() => {
|
|
287
|
+
const previous = L.$getNodeByKey(key);
|
|
288
|
+
const next = definition.create();
|
|
289
|
+
if (insert) { if (previous) previous.insertAfter(next); else L.$getRoot().append(next); }
|
|
290
|
+
else if (previous) {
|
|
291
|
+
if (!fromSlash && L.$isElementNode(next)) {
|
|
292
|
+
const target = Tables.$isTableNode(next) ? next.getFirstChildOrThrow<Tables.TableRowNode>().getFirstChildOrThrow<Tables.TableCellNode>().getFirstChildOrThrow<L.ParagraphNode>() : Lists.$isListNode(next) ? next.getFirstChildOrThrow<Lists.ListItemNode>() : next;
|
|
293
|
+
target.append(...inlineNodes(previous));
|
|
294
|
+
}
|
|
295
|
+
previous.replace(next);
|
|
296
|
+
} else L.$getRoot().append(next);
|
|
297
|
+
if (Tables.$isTableNode(next)) next.getFirstChildOrThrow<Tables.TableRowNode>().getFirstChildOrThrow<Tables.TableCellNode>().selectStart();
|
|
298
|
+
else if (L.$isElementNode(next)) next.selectEnd();
|
|
299
|
+
else { const paragraph = L.$createParagraphNode(); next.insertAfter(paragraph); paragraph.select(); }
|
|
300
|
+
hoverKey = "";
|
|
301
|
+
});
|
|
302
|
+
editor.focus();
|
|
303
|
+
}
|
|
304
|
+
function format(type: L.TextFormatType) {
|
|
305
|
+
closeMenu();
|
|
306
|
+
editor.update(() => { if (savedSelection) L.$setSelection(savedSelection.clone()); editor.dispatchCommand(L.FORMAT_TEXT_COMMAND, type); });
|
|
307
|
+
editor.focus();
|
|
308
|
+
}
|
|
309
|
+
function toggleList(type: "bullet" | "number") {
|
|
310
|
+
editor.update(() => {
|
|
311
|
+
if (savedSelection) L.$setSelection(savedSelection.clone());
|
|
312
|
+
editor.dispatchCommand(listType === type ? Lists.REMOVE_LIST_COMMAND : type === "bullet" ? Lists.INSERT_UNORDERED_LIST_COMMAND : Lists.INSERT_ORDERED_LIST_COMMAND, undefined);
|
|
313
|
+
});
|
|
314
|
+
hoverKey = ""; editor.focus();
|
|
315
|
+
}
|
|
316
|
+
function tableAction(action: "row" | "column" | "delete-row" | "delete-column" | "header", checked = false) {
|
|
317
|
+
editor.update(() => {
|
|
318
|
+
if (savedSelection) L.$setSelection(savedSelection.clone());
|
|
319
|
+
const table = L.$getNodeByKey(activeKey);
|
|
320
|
+
const selection = L.$getSelection();
|
|
321
|
+
const cell = L.$isRangeSelection(selection) || Tables.$isTableSelection(selection) ? Tables.$getTableCellNodeFromLexicalNode(selection.anchor.getNode()) : null;
|
|
322
|
+
if (!Tables.$isTableNode(table) || !cell || topBlock(cell)?.getKey() !== table.getKey()) return;
|
|
323
|
+
if (action === "row" && table.getChildrenSize() < MAX_TABLE_ROWS) Tables.$insertTableRowAtSelection();
|
|
324
|
+
if (action === "column" && table.getColumnCount() < MAX_TABLE_COLUMNS) Tables.$insertTableColumnAtSelection();
|
|
325
|
+
if (action === "delete-row" && table.getChildrenSize() > 1) Tables.$deleteTableRowAtSelection();
|
|
326
|
+
if (action === "delete-column" && table.getColumnCount() > 1) Tables.$deleteTableColumnAtSelection();
|
|
327
|
+
if (action === "header") Tables.$setTableRowIsHeader(table, 0, checked);
|
|
328
|
+
hoverKey = "";
|
|
329
|
+
});
|
|
330
|
+
editor.focus();
|
|
331
|
+
}
|
|
332
|
+
async function openLink() {
|
|
333
|
+
editor.getEditorState().read(() => {
|
|
334
|
+
const selection = savedSelection;
|
|
335
|
+
const node = L.$isRangeSelection(selection) ? selection.anchor.getNode() : L.$isNodeSelection(selection) ? selection.getNodes()[0] : null;
|
|
336
|
+
const link = Links.$isLinkNode(node) ? node : node?.getParent();
|
|
337
|
+
linkUrl = node instanceof ImageNode ? node.__image.url ?? "" : Links.$isLinkNode(link) ? link.getURL() : "";
|
|
338
|
+
});
|
|
339
|
+
await positionLink();
|
|
340
|
+
linkError = ""; linkOpen = true; altOpen = false;
|
|
341
|
+
await tick(); linkInput.focus();
|
|
342
|
+
}
|
|
343
|
+
async function positionLink() {
|
|
344
|
+
const selection = view.getSelection();
|
|
345
|
+
const range = !imageData && selection?.rangeCount && root.contains(selection.anchorNode) ? selection.getRangeAt(0) : null;
|
|
346
|
+
let rect = range?.getBoundingClientRect() ?? editorHost.querySelector(imageData ? '.image-toolbar' : '.body-toolbar')?.getBoundingClientRect();
|
|
347
|
+
if (range && rect && rect.bottom + 148 > view.innerHeight) {
|
|
348
|
+
range.endContainer.parentElement?.scrollIntoView({ block: "center" });
|
|
349
|
+
rect = range.getBoundingClientRect(); positionFloating();
|
|
350
|
+
await tick();
|
|
351
|
+
}
|
|
352
|
+
const toolbar = editorHost.querySelector(imageData ? '.image-toolbar' : inline ? '.body-toolbar' : '.block-float')?.getBoundingClientRect();
|
|
353
|
+
if (range && !range.collapsed) view.CSS.highlights?.set("rigel-link-selection", new view.Highlight(range.cloneRange()));
|
|
354
|
+
if (rect) linkPosition = `left:${Math.max(8, Math.min(rect.left, view.document.documentElement.clientWidth - 356))}px;top:${range ? Math.max(8, rect.bottom + 8, (toolbar?.bottom ?? 0) + 8) : Math.max(8, Math.min(rect.bottom + 4, view.innerHeight - 140))}px`;
|
|
355
|
+
}
|
|
356
|
+
async function openAlt() {
|
|
357
|
+
altKey = activeKey; altText = imageData?.alt ?? ""; await positionLink(); altOpen = true; linkOpen = false;
|
|
358
|
+
await tick(); altInput.focus();
|
|
359
|
+
}
|
|
360
|
+
function refocus() { void tick().then(() => { root.focus({ preventScroll: true }); editor.focus(); }); }
|
|
361
|
+
function applyAlt() {
|
|
362
|
+
editor.update(() => { const node = L.$getNodeByKey(altKey); if (node instanceof ImageNode) node.setImage({ alt: altText }); });
|
|
363
|
+
altOpen = false; refocus();
|
|
364
|
+
}
|
|
365
|
+
function applyLink(remove = false) {
|
|
366
|
+
if (!remove && !safePreviewUrl(linkUrl)) { linkError = "Enter a safe http, https, mailto or relative URL."; return; }
|
|
367
|
+
editor.update(() => {
|
|
368
|
+
if (savedSelection) L.$setSelection(savedSelection.clone());
|
|
369
|
+
const selection = L.$getSelection(), node = L.$isNodeSelection(selection) ? selection.getNodes()[0] : null;
|
|
370
|
+
if (node instanceof ImageNode) node.setImage({ url: remove ? undefined : linkUrl });
|
|
371
|
+
else Links.$toggleLink(remove ? null : linkUrl);
|
|
372
|
+
});
|
|
373
|
+
linkOpen = false; refocus();
|
|
374
|
+
}
|
|
375
|
+
function pickImage(url: string, alt: string, credit?: StockResult) {
|
|
376
|
+
if (!safePreviewUrl(url, true)) return;
|
|
377
|
+
const job = imageJob ?? { key: activeKey, replace: false };
|
|
378
|
+
const image: ImageData = { src: url, alt, ...(credit ? { credit: credit.credit, creditURL: credit.creditURL } : {}) };
|
|
379
|
+
editor.update(() => {
|
|
380
|
+
const previous = L.$getNodeByKey(job.key);
|
|
381
|
+
if (previous instanceof ImageNode && job.replace) {
|
|
382
|
+
previous.setImage({ ...image, credit: image.credit, creditURL: image.creditURL });
|
|
383
|
+
const selection = L.$createNodeSelection(); selection.add(previous.getKey()); L.$setSelection(selection); hoverKey = ""; return;
|
|
384
|
+
}
|
|
385
|
+
const node = new ImageNode(image), paragraph = L.$createParagraphNode();
|
|
386
|
+
if (previous && job.replace) previous.replace(node);
|
|
387
|
+
else if (previous) previous.insertAfter(node);
|
|
388
|
+
else L.$getRoot().append(node);
|
|
389
|
+
node.insertAfter(paragraph); paragraph.select(); hoverKey = "";
|
|
390
|
+
});
|
|
391
|
+
imageJob = null; editor.focus();
|
|
392
|
+
}
|
|
393
|
+
function closeMenu() { for (const popup of [menu, tableMenu, formatMenu]) if (popup?.matches(":popover-open")) popup.hidePopover(); }
|
|
394
|
+
function openMenu(event?: MouseEvent, popup = menu) {
|
|
395
|
+
if (suppressClick) { suppressClick = false; return; }
|
|
396
|
+
const fromToolbar = !!(event?.currentTarget as Element)?.closest('.body-toolbar, .block-float');
|
|
397
|
+
if (fromToolbar) hoverKey = "";
|
|
398
|
+
else editor.update(() => { const node = L.$getNodeByKey(hoverKey || activeKey); if (node) { const selection = L.$createNodeSelection(); selection.add(node.getKey()); L.$setSelection(selection); } });
|
|
399
|
+
const rect = ((event?.currentTarget as HTMLElement) ?? handle).getBoundingClientRect();
|
|
400
|
+
menuPosition = `left:${Math.max(8, Math.min(rect.left, view.innerWidth - 232))}px;top:${Math.max(8, Math.min(rect.bottom + 4, view.innerHeight - 280))}px`;
|
|
401
|
+
popup.showPopover();
|
|
402
|
+
void tick().then(() => popup.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus());
|
|
403
|
+
}
|
|
404
|
+
function formatKeydown(event: KeyboardEvent) {
|
|
405
|
+
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeMenu(); formatTrigger?.focus(); return; }
|
|
406
|
+
if (event.key === "Tab") { closeMenu(); return; }
|
|
407
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
|
408
|
+
event.preventDefault();
|
|
409
|
+
const buttons = [...formatMenu.querySelectorAll<HTMLButtonElement>("button")];
|
|
410
|
+
const index = buttons.indexOf(root.ownerDocument.activeElement as HTMLButtonElement);
|
|
411
|
+
buttons[event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length]?.focus();
|
|
412
|
+
}
|
|
413
|
+
function blockAction(action: "duplicate" | "delete" | "up" | "down") {
|
|
414
|
+
const key = hoverKey || activeKey;
|
|
415
|
+
const tree = editor.getEditorState().toJSON();
|
|
416
|
+
closeMenu();
|
|
417
|
+
editor.update(() => {
|
|
418
|
+
const node = L.$getNodeByKey(key);
|
|
419
|
+
if (!node) return;
|
|
420
|
+
if (action === "duplicate") { const index = L.$getRoot().getChildren().findIndex(child => child.getKey() === key); const copy = L.$parseSerializedNode(tree.root.children[index]); node.insertAfter(copy); if (L.$isElementNode(copy)) copy.selectEnd(); }
|
|
421
|
+
else if (action === "delete") { const next = node.getNextSibling() ?? node.getPreviousSibling(); node.remove(); if (L.$isElementNode(next)) next.selectEnd(); }
|
|
422
|
+
else { const sibling = action === "up" ? node.getPreviousSibling() : node.getNextSibling(); if (sibling) { if (action === "up") sibling.insertBefore(node); else sibling.insertAfter(node); } }
|
|
423
|
+
if (L.$getRoot().isEmpty()) L.$getRoot().append(L.$createParagraphNode());
|
|
424
|
+
hoverKey = "";
|
|
425
|
+
});
|
|
426
|
+
editor.focus();
|
|
427
|
+
}
|
|
428
|
+
function startDrag(event: PointerEvent) {
|
|
429
|
+
if (event.button !== 0) return;
|
|
430
|
+
event.preventDefault();
|
|
431
|
+
insertion = null;
|
|
432
|
+
drag = { id: event.pointerId, key: hoverKey || activeKey, x: event.clientX, y: event.clientY, moved: false };
|
|
433
|
+
handle.setPointerCapture(event.pointerId);
|
|
434
|
+
}
|
|
435
|
+
function moveDrag(event: PointerEvent) {
|
|
436
|
+
if (!drag || drag.id !== event.pointerId) return;
|
|
437
|
+
if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 5 && !drag.moved) return;
|
|
438
|
+
drag.moved = true;
|
|
439
|
+
const elements = [...root.children] as HTMLElement[];
|
|
440
|
+
const target = elements.find(element => { const rect = element.getBoundingClientRect(); return event.clientY < rect.top + rect.height / 2; });
|
|
441
|
+
const element = target ?? elements.at(-1);
|
|
442
|
+
if (!element) return;
|
|
443
|
+
const rect = element.getBoundingClientRect(), after = !target;
|
|
444
|
+
drop = { key: element.dataset.blockKey!, after, top: (after ? rect.bottom : rect.top) - surface.getBoundingClientRect().top };
|
|
445
|
+
}
|
|
446
|
+
function endDrag(cancel = false) {
|
|
447
|
+
const moving = drag, target = drop;
|
|
448
|
+
drag = null; drop = null;
|
|
449
|
+
if (moving && handle?.hasPointerCapture(moving.id)) handle.releasePointerCapture(moving.id);
|
|
450
|
+
if (!moving?.moved) return;
|
|
451
|
+
suppressClick = true;
|
|
452
|
+
if (cancel || !target || moving.key === target.key) return;
|
|
453
|
+
editor.update(() => { const node = L.$getNodeByKey(moving.key), destination = L.$getNodeByKey(target.key); if (node && destination) { if (target.after) destination.insertAfter(node); else destination.insertBefore(node); } });
|
|
454
|
+
}
|
|
455
|
+
function hover(event: PointerEvent) {
|
|
456
|
+
if (drag || (event.target as Element).closest('.block-insert')) return;
|
|
457
|
+
if ((event.target as Element).closest('.body-toolbar, .block-float, .block-menu, .select-menu, .markdown-link')) { insertion = null; return; }
|
|
458
|
+
const element = (event.target as Element).closest<HTMLElement>("[data-block-key]");
|
|
459
|
+
if (element && root.contains(element)) { hoverKey = element.dataset.blockKey!; positionHandle(); }
|
|
460
|
+
insertion = null;
|
|
461
|
+
if (!root.contains(event.target as Node)) return;
|
|
462
|
+
const elements = [...root.children] as HTMLElement[];
|
|
463
|
+
for (let index = 1; index < elements.length; index++) {
|
|
464
|
+
const before = elements[index - 1].getBoundingClientRect(), after = elements[index].getBoundingClientRect();
|
|
465
|
+
const top = (before.bottom + after.top) / 2;
|
|
466
|
+
if (Math.abs(event.clientY - top) <= 16) {
|
|
467
|
+
insertion = { after: elements[index - 1].dataset.blockKey!, top: top - 14, left: (before.left + before.right) / 2 - 14 };
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function leaveInsertion(event: PointerEvent) {
|
|
473
|
+
if (!root.contains(event.relatedTarget as Node | null) && !(event.relatedTarget as Element | null)?.closest?.('.block-insert') && !root.ownerDocument.activeElement?.closest('.block-insert')) insertion = null;
|
|
474
|
+
}
|
|
475
|
+
function positionInsertion() {
|
|
476
|
+
if (!insertion) return;
|
|
477
|
+
const previous = [...root.children].find(element => (element as HTMLElement).dataset.blockKey === insertion!.after);
|
|
478
|
+
const next = previous?.nextElementSibling;
|
|
479
|
+
if (!previous || !next) { insertion = null; return; }
|
|
480
|
+
const before = previous.getBoundingClientRect(), after = next.getBoundingClientRect(), top = (before.bottom + after.top) / 2;
|
|
481
|
+
const minTop = inline ? 14 : (editorHost.querySelector('.body-toolbar')?.getBoundingClientRect().bottom ?? 0) + 14;
|
|
482
|
+
insertion = top < minTop || top > view.innerHeight - 14 ? null : { after: insertion.after, top: top - 14, left: (before.left + before.right) / 2 - 14 };
|
|
483
|
+
}
|
|
484
|
+
function insertParagraph() {
|
|
485
|
+
const key = insertion?.after;
|
|
486
|
+
if (!key) return;
|
|
487
|
+
insertion = null; closeMenu(); dismissSlash();
|
|
488
|
+
root.focus({ preventScroll: true });
|
|
489
|
+
editor.update(() => {
|
|
490
|
+
const previous = L.$getNodeByKey(key);
|
|
491
|
+
if (!previous || !L.$isRootNode(previous.getParent())) return;
|
|
492
|
+
const paragraph = L.$createParagraphNode();
|
|
493
|
+
previous.insertAfter(paragraph);
|
|
494
|
+
const selection = paragraph.selectStart(); selection.format = 0; selection.style = "";
|
|
495
|
+
hoverKey = "";
|
|
496
|
+
}, { discrete: true });
|
|
497
|
+
}
|
|
498
|
+
function contentClick(event: MouseEvent) {
|
|
499
|
+
const element = (event.target as Element).closest<HTMLElement>('figure[data-block-key],hr[data-block-key]');
|
|
500
|
+
if (event.type === "pointerdown" && !element) {
|
|
501
|
+
editor.update(() => { if (L.$isNodeSelection(L.$getSelection())) L.$setSelection(null); }, { discrete: true });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (element || (event.target as Element).closest('a')) event.preventDefault();
|
|
505
|
+
if (element) { root.focus({ preventScroll: true }); editor.update(() => { const selection = L.$createNodeSelection(); selection.add(element.dataset.blockKey!); L.$setSelection(selection); }); }
|
|
506
|
+
}
|
|
507
|
+
</script>
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
{#snippet formatButtons()}
|
|
511
|
+
{#each marks.slice(0, 2) as mark}
|
|
512
|
+
<button type="button" aria-label={mark.label} title={mark.label} aria-pressed={activeMarks.includes(mark.type)} onpointerdown={event => event.preventDefault()} onclick={() => format(mark.type)}><Icon name={mark.icon} size={18} /></button>
|
|
513
|
+
{/each}
|
|
514
|
+
<button type="button" aria-label="Link" title="Link" aria-expanded={linkOpen} aria-controls={`${id}-link`} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
|
|
515
|
+
<button type="button" aria-label="More formatting" title="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>
|
|
516
|
+
{/snippet}
|
|
517
|
+
{#snippet tableControls()}
|
|
518
|
+
<div class="block-table-controls" role="group" aria-label="Table controls">
|
|
519
|
+
<button type="button" disabled={!tableCell || tableRows >= MAX_TABLE_ROWS} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("row")}><Icon name="plus" />Add row below</button>
|
|
520
|
+
<button type="button" disabled={!tableCell || tableColumns >= MAX_TABLE_COLUMNS} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("column")}><Icon name="plus" />Add column right</button>
|
|
521
|
+
<button type="button" disabled={!tableCell || tableRows <= 1} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("delete-row")}><Icon name="minus" />Remove row</button>
|
|
522
|
+
<button type="button" disabled={!tableCell || tableColumns <= 1} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("delete-column")}><Icon name="minus" />Remove column</button>
|
|
523
|
+
<label class="inline"><Icon name="table" /><span>Header row</span><input type="checkbox" role="switch" class="switch" checked={tableHeader} disabled={!tableCell} onchange={event => tableAction("header", event.currentTarget.checked)} /></label>
|
|
524
|
+
</div>
|
|
525
|
+
{/snippet}
|
|
526
|
+
{#snippet toolbar(image: boolean)}
|
|
527
|
+
<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)}>
|
|
528
|
+
{#if image}
|
|
529
|
+
<button type="button" aria-label="Edit alt text" title="Edit alt text" onpointerdown={event => event.preventDefault()} onclick={openAlt}><Icon name="textAa" size={18} /></button>
|
|
530
|
+
<button type="button" aria-label={imageData?.url ? "Edit image link" : "Add image link"} title={imageData?.url ? "Edit image link" : "Add image link"} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
|
|
531
|
+
{:else}
|
|
532
|
+
<Select ariaLabel="Block type" title={activeBlock?.label ?? "Block type"} icon={activeBlock?.icon ?? "paragraph"} value={imageData ? "" : blockType} options={textBlocks} disabled={!!imageData} onchange={next => { hoverKey = ""; changeBlock(String(next)); }} />
|
|
533
|
+
{#if !inline || !["image", "divider"].includes(blockType)}<span class="toolbar-separator" aria-hidden="true"></span>{@render formatButtons()}<span class="toolbar-separator" aria-hidden="true"></span>{/if}
|
|
534
|
+
{/if}
|
|
535
|
+
<ImagePicker toolbar value={image ? imageData?.src ?? "" : ""} altText={image ? imageData?.alt ?? "" : ""} triggerLabel={image ? "Replace image" : "Insert image"} onopen={() => { if (!imageJob) imageJob = { key: activeKey, replace: image }; linkOpen = false; altOpen = false; }} onpick={pickImage} onclose={() => { imageJob = null; refocus(); }} />
|
|
536
|
+
{#if !image}
|
|
537
|
+
<button type="button" aria-label="Table" title="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>
|
|
538
|
+
<button type="button" aria-label="Bulleted list" title="Bulleted list" aria-pressed={listType === "bullet"} disabled={["image", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("bullet")}><Icon name="listBullets" size={18} /></button>
|
|
539
|
+
<button type="button" aria-label="Numbered list" title="Numbered list" aria-pressed={listType === "number"} disabled={["image", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("number")}><Icon name="listNumbers" size={18} /></button>
|
|
540
|
+
{#if !inline}
|
|
541
|
+
<button type="button" class="block-history" disabled={!canUndo} onclick={() => editor.dispatchCommand(L.UNDO_COMMAND, undefined)}>Undo</button>
|
|
542
|
+
<button type="button" class="block-history" disabled={!canRedo} onclick={() => editor.dispatchCommand(L.REDO_COMMAND, undefined)}>Redo</button>
|
|
543
|
+
<button type="button" class="block-add" aria-label="Add block" title="Add block" onclick={addBlock}>Add block</button>
|
|
544
|
+
{/if}
|
|
545
|
+
{/if}
|
|
546
|
+
{#if inline || image}<span class="toolbar-separator" aria-hidden="true"></span><button type="button" aria-label="More actions" title="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}
|
|
547
|
+
</div>
|
|
548
|
+
{/snippet}
|
|
549
|
+
<div class="block-editor" bind:this={editorHost}>
|
|
550
|
+
{#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}
|
|
551
|
+
{@render toolbar(inline && !!imageData)}
|
|
552
|
+
{#if !inline}{@render toolbar(true)}{/if}
|
|
553
|
+
{#if error}<p role="alert">{error}</p>{/if}
|
|
554
|
+
{#if linkOpen}
|
|
555
|
+
<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(); } }}>
|
|
556
|
+
<label for={`${id}-url`}>Link URL</label>
|
|
557
|
+
<div class="markdown-link-controls">
|
|
558
|
+
<input id={`${id}-url`} type="text" placeholder="https://…" bind:this={linkInput} bind:value={linkUrl} aria-invalid={!!linkError} />
|
|
559
|
+
<button type="button" aria-label="Apply link" title="Apply link" onclick={() => applyLink()}><Icon name="check" size={16} /></button>
|
|
560
|
+
<button type="button" aria-label="Remove link" title="Remove link" onclick={() => applyLink(true)}><Icon name="linkBreak" size={16} /></button>
|
|
561
|
+
<button type="button" aria-label="Close link" title="Close link" onclick={() => { linkOpen = false; refocus(); }}><Icon name="x" size={16} /></button>
|
|
562
|
+
</div>
|
|
563
|
+
{#if linkError}<p role="alert">{linkError}</p>{/if}
|
|
564
|
+
</div>
|
|
565
|
+
{/if}
|
|
566
|
+
{#if altOpen}
|
|
567
|
+
<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(); } }}>
|
|
568
|
+
<label for={`${id}-alt`}>Alt text (leave empty if decorative)</label>
|
|
569
|
+
<div class="markdown-link-controls">
|
|
570
|
+
<input id={`${id}-alt`} type="text" aria-label="Image alt text" bind:this={altInput} bind:value={altText} />
|
|
571
|
+
<button type="button" aria-label="Apply alt text" title="Apply alt text" onclick={applyAlt}><Icon name="check" size={16} /></button>
|
|
572
|
+
<button type="button" aria-label="Close alt text" title="Close alt text" onclick={() => { altOpen = false; refocus(); }}><Icon name="x" size={16} /></button>
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
{/if}
|
|
576
|
+
{#if !inline && !imageData && floating && !markdownView}<div class="block-float" style={floating} role="group" aria-label="Selected text formatting">{@render formatButtons()}</div>{/if}
|
|
577
|
+
{#if markdownView}
|
|
578
|
+
<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>
|
|
579
|
+
{/if}
|
|
580
|
+
<div class="block-surface" bind:this={surface} hidden={markdownView} onpointermove={hover} role="group" aria-label="Article blocks">
|
|
581
|
+
{#if insertion && !markdownView}<button type="button" class="block-insert" style={`top:${insertion.top}px;left:${insertion.left}px`} aria-label="Insert paragraph here" title="Insert paragraph" onpointerdown={event => event.preventDefault()} onpointerleave={leaveInsertion} onblur={() => { insertion = null; }} onclick={insertParagraph}><Icon name="plus" size={16} /></button>{/if}
|
|
582
|
+
{#if !inline}<div class="block-content" contenteditable="true" role="textbox" aria-label="Body" aria-multiline="true" bind:this={root}></div>{/if}
|
|
583
|
+
{#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" title="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}
|
|
584
|
+
{#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}
|
|
585
|
+
<div bind:this={slashMenu} class="block-slash" id={`${id}-slash`} popover="manual" style={slashPosition} role="listbox" aria-label="Insert block" onkeydown={slashKeydown}>
|
|
586
|
+
{#if slash !== null}
|
|
587
|
+
<p class="hint">Choose a block</p>
|
|
588
|
+
{#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}
|
|
589
|
+
{#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}
|
|
590
|
+
{#if !choices.length}<p role="status">No matching blocks.</p>{/if}
|
|
591
|
+
{/if}
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
<div class="block-menu block-table-menu" id={`${id}-table`} bind:this={tableMenu} popover="auto" style={menuPosition} role="group" aria-label="Table">
|
|
595
|
+
{#if blockType === "table"}{@render tableControls()}{:else}<button type="button" onclick={() => { slashKey = activeKey; adding = true; changeBlock("table", true); }}>Add table</button>{/if}
|
|
596
|
+
</div>
|
|
597
|
+
<div class="block-menu" id={`${id}-actions`} bind:this={menu} popover="auto" style={menuPosition} role="group" aria-label={inline ? "More actions" : "Block options"}>
|
|
598
|
+
{#if inline}
|
|
599
|
+
<button type="button" class="block-history" disabled={!canUndo} onclick={() => { editor.dispatchCommand(L.UNDO_COMMAND, undefined); closeMenu(); editor.focus(); }}>Undo</button>
|
|
600
|
+
<button type="button" class="block-history" disabled={!canRedo} onclick={() => { editor.dispatchCommand(L.REDO_COMMAND, undefined); closeMenu(); editor.focus(); }}>Redo</button>
|
|
601
|
+
<button type="button" onclick={() => { markdownView = true; closeMenu(); }}>View as Markdown</button>
|
|
602
|
+
{/if}
|
|
603
|
+
<button type="button" onclick={() => blockAction("duplicate")}>Duplicate block</button>
|
|
604
|
+
<button type="button" onclick={() => blockAction("delete")}>Delete block</button>
|
|
605
|
+
<button type="button" onclick={() => blockAction("up")}>Move up</button>
|
|
606
|
+
<button type="button" onclick={() => blockAction("down")}>Move down</button>
|
|
607
|
+
{#if !inline}<Select ariaLabel="Convert block type" value={blockType} options={textBlocks} onchange={next => changeBlock(String(next))} />{/if}
|
|
608
|
+
</div>
|
|
609
|
+
<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}>
|
|
610
|
+
{#each marks.slice(2) as mark}
|
|
611
|
+
<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>
|
|
612
|
+
{/each}
|
|
613
|
+
</div>
|
|
614
|
+
</div>
|