@riducms/plugin-richtext 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,3 +24,12 @@ npm run ridu -- migrate create --name add-rich-text
24
24
  See the [Rich text guide](../../website/src/content/docs/rich-text.md) for new- and
25
25
  existing-project setup, feature selection, uploads/relationships, portable values, constraints,
26
26
  safe Go rendering, migrations, and verification.
27
+
28
+ Configure block-level embeds with executable `richtext.Config.Blocks` definitions. The paired
29
+ editor uses the resolved allowlist for slash/toolbar insertion and an atomic card with a scoped
30
+ Apply/Cancel drawer. The parent form owns saving; Lexical owns selection and history.
31
+
32
+ Portable types are available from `@riducms/plugin-richtext/document`, HTML rendering from
33
+ `@riducms/plugin-richtext/render`, and optional typed Svelte rendering from
34
+ `@riducms/plugin-richtext/svelte`. These entries do not load the admin editor. See the rich-text
35
+ guide for strict payloads, localization, typed renderers and experimental-content migration.
package/package.json CHANGED
@@ -4,6 +4,7 @@
4
4
  },
5
5
  "dependencies": {
6
6
  "@hvniel/lexical-svelte": "^0.1.1",
7
+ "@lexical/clipboard": "0.49.0",
7
8
  "@lexical/code": "0.49.0",
8
9
  "@lexical/history": "0.49.0",
9
10
  "@lexical/link": "0.49.0",
@@ -11,9 +12,9 @@
11
12
  "@lexical/rich-text": "0.49.0",
12
13
  "@lexical/selection": "0.49.0",
13
14
  "@lexical/utils": "0.49.0",
14
- "@riducms/plugin": "0.1.5",
15
- "@riducms/protocol": "0.1.5",
16
- "@riducms/ui": "0.1.5",
15
+ "@riducms/plugin": "0.2.0",
16
+ "@riducms/protocol": "0.2.0",
17
+ "@riducms/ui": "0.2.0",
17
18
  "@types/prismjs": "^1.26.5",
18
19
  "lexical": "0.49.0",
19
20
  "prismjs": "^1.30.0",
@@ -21,16 +22,26 @@
21
22
  },
22
23
  "description": "Svelte and Lexical admin integration for Ridu's official rich-text plugin.",
23
24
  "devDependencies": {
24
- "@riducms/build": "0.1.5"
25
+ "@riducms/build": "0.2.0"
25
26
  },
26
27
  "exports": {
27
- ".": "./src/index.ts"
28
+ ".": "./src/index.ts",
29
+ "./document": "./src/document.ts",
30
+ "./render": "./src/render.ts",
31
+ "./svelte": "./src/render/index.ts"
28
32
  },
29
33
  "files": [
30
34
  "src",
31
35
  "LICENSE"
32
36
  ],
33
37
  "homepage": "https://riducms.com",
38
+ "imports": {
39
+ "#richtext/document": "./src/document.ts",
40
+ "#richtext/document-validation": "./src/document-validation.ts",
41
+ "#richtext/render": "./src/render.ts",
42
+ "#richtext/rich-text.svelte": "./src/render/rich-text.svelte",
43
+ "#richtext/svelte": "./src/render/index.ts"
44
+ },
34
45
  "license": "MIT",
35
46
  "name": "@riducms/plugin-richtext",
36
47
  "publishConfig": {
@@ -48,5 +59,5 @@
48
59
  "test": "bun test"
49
60
  },
50
61
  "type": "module",
51
- "version": "0.1.5"
62
+ "version": "0.2.0"
52
63
  }
@@ -0,0 +1,157 @@
1
+ <script lang="ts">
2
+ import {
3
+ useLexicalComposerContext,
4
+ useLexicalEditable,
5
+ useLexicalNodeSelection,
6
+ } from "@hvniel/lexical-svelte";
7
+ import { $getNodeByKey, type NodeKey } from "lexical";
8
+ import { getAdminI18n } from "@riducms/plugin";
9
+ import { Button } from "@riducms/ui";
10
+ import {
11
+ getRichTextField,
12
+ getRichTextAuthoringHost,
13
+ } from "@plugin-richtext/field/rich-text-context.svelte";
14
+ import { blockSummary, richTextBlockTypes } from "@plugin-richtext/field/rich-text-blocks";
15
+ import {
16
+ OPEN_BLOCK_EDITOR_COMMAND,
17
+ DUPLICATE_BLOCK_COMMAND,
18
+ REMOVE_BLOCK_COMMAND,
19
+ MOVE_BLOCK_COMMAND,
20
+ } from "@plugin-richtext/menu/rich-text-commands";
21
+
22
+ let {
23
+ nodeKey,
24
+ fields,
25
+ validEnvelope,
26
+ }: { nodeKey: NodeKey; fields: Record<string, unknown>; validEnvelope: boolean } = $props();
27
+ const editor = useLexicalComposerContext()[0];
28
+ const i18n = getAdminI18n();
29
+ const editable = useLexicalEditable();
30
+ const context = getRichTextField();
31
+ const authoring = getRichTextAuthoringHost();
32
+ // The decorator instance owns this fixed Lexical node key.
33
+ // svelte-ignore state_referenced_locally
34
+ const [selected, setSelected, clearSelected] = useLexicalNodeSelection(nodeKey);
35
+ const identity = $derived(typeof fields._key === "string" ? fields._key : "");
36
+ const blockType = $derived(
37
+ typeof fields.blockType === "string"
38
+ ? fields.blockType
39
+ : i18n.t("plugin.richtext:block.unknown")
40
+ );
41
+ const type = $derived(
42
+ richTextBlockTypes(context.field).find((candidate) => candidate.slug === blockType)
43
+ );
44
+ const issues = $derived(authoring?.schemaIssues?.({ treeKey: "blocks", identity }) ?? []);
45
+ const recovery = $derived(type === undefined || !validEnvelope);
46
+ const summary = $derived(blockSummary(fields, type) || i18n.t("plugin.richtext:block.noSummary"));
47
+ function select(event: MouseEvent) {
48
+ event.stopPropagation();
49
+ if (!event.shiftKey) clearSelected();
50
+ setSelected(true);
51
+ }
52
+ function edit() {
53
+ editor.dispatchCommand(OPEN_BLOCK_EDITOR_COMMAND, { nodeKey });
54
+ }
55
+ function exportBlock() {
56
+ const serialized = editor.read(() => $getNodeByKey(nodeKey)?.exportJSON());
57
+ if (serialized === undefined) return;
58
+ const url = URL.createObjectURL(
59
+ new Blob([JSON.stringify(serialized, null, 2)], { type: "application/json" })
60
+ );
61
+ const link = document.createElement("a");
62
+ link.href = url;
63
+ link.download = `${blockType}-${identity || "historical"}.json`;
64
+ link.click();
65
+ setTimeout(() => URL.revokeObjectURL(url), 0);
66
+ }
67
+ function duplicate() {
68
+ editor.dispatchCommand(DUPLICATE_BLOCK_COMMAND, nodeKey);
69
+ }
70
+ function remove() {
71
+ editor.dispatchCommand(REMOVE_BLOCK_COMMAND, nodeKey);
72
+ }
73
+ function keydown(event: KeyboardEvent) {
74
+ if (
75
+ (event.key === "Delete" || event.key === "Backspace") &&
76
+ event.target === event.currentTarget &&
77
+ editable()
78
+ ) {
79
+ event.preventDefault();
80
+ event.stopPropagation();
81
+ remove();
82
+ } else if (
83
+ editable() &&
84
+ event.altKey &&
85
+ event.shiftKey &&
86
+ (event.key === "ArrowUp" || event.key === "ArrowDown")
87
+ ) {
88
+ event.preventDefault();
89
+ event.stopPropagation();
90
+ editor.dispatchCommand(MOVE_BLOCK_COMMAND, {
91
+ nodeKey,
92
+ direction: event.key === "ArrowUp" ? -1 : 1,
93
+ });
94
+ } else if (event.key === "Enter" && event.target === event.currentTarget && editable()) {
95
+ event.preventDefault();
96
+ event.stopPropagation();
97
+ edit();
98
+ }
99
+ }
100
+ </script>
101
+
102
+ <article
103
+ class={[
104
+ "ridu-richtext-embedded-card my-5 rounded-md border border-control-border bg-control p-4 outline-none focus-visible:ring-2 focus-visible:ring-ring",
105
+ selected() && "border-primary",
106
+ issues.length > 0 && "border-destructive",
107
+ ]}
108
+ data-block-key={identity}
109
+ data-block-type={blockType}
110
+ aria-label={i18n.t("plugin.richtext:block.label", { label: type?.labels.singular ?? blockType })}
111
+ data-invalid={issues.length > 0}
112
+ role="group"
113
+ >
114
+ <div class="flex items-center justify-between gap-3">
115
+ <button
116
+ type="button"
117
+ data-block-select
118
+ class="min-w-0 flex-1 text-start rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
119
+ aria-label={i18n.t("plugin.richtext:block.select", {
120
+ label: type?.labels.singular ?? blockType,
121
+ })}
122
+ aria-pressed={selected()}
123
+ onclickcapture={select}
124
+ onkeydowncapture={keydown}
125
+ >
126
+ <span class="block text-xs font-medium text-foreground-muted">
127
+ {type?.labels.singular ?? blockType}
128
+ </span>
129
+ <span class="block mt-1 truncate text-sm text-foreground">{summary}</span>
130
+ </button>
131
+ {#if editable()}
132
+ <div class="flex shrink-0 gap-1">
133
+ <Button size="sm" variant="ghost" onclick={edit} disabled={recovery}>
134
+ {i18n.t("plugin.richtext:block.edit")}
135
+ </Button>
136
+ <Button size="sm" variant="ghost" onclick={duplicate} disabled={recovery}>
137
+ {i18n.t("plugin.richtext:block.duplicate")}
138
+ </Button>
139
+ <Button size="sm" variant="ghost" onclick={remove}>
140
+ {i18n.t("plugin.richtext:block.remove")}
141
+ </Button>
142
+ </div>
143
+ {/if}
144
+ </div>
145
+ {#if recovery}
146
+ <p role="alert" class="mt-2 text-sm text-destructive">
147
+ {i18n.t("plugin.richtext:block.recovery")}
148
+ </p>
149
+ <Button size="sm" variant="outline" onclick={exportBlock}>
150
+ {i18n.t("plugin.richtext:block.export")}
151
+ </Button>
152
+ {:else if issues.length > 0}
153
+ <p class="mt-2 text-sm text-destructive">
154
+ {i18n.t("plugin.richtext:block.issues", { count: issues.length })}
155
+ </p>
156
+ {/if}
157
+ </article>
@@ -0,0 +1,54 @@
1
+ import type { FieldAuthoringHost } from "@riducms/plugin";
2
+ import type { SchemaBlockType } from "@riducms/protocol";
3
+ import type { SerializedLexicalNode } from "lexical";
4
+ import { isRecord } from "@plugin-richtext/field/rich-text-blocks";
5
+ import { documentRecoveryIssue } from "@plugin-richtext/document-validation";
6
+
7
+ /** Validates first, then copies only declared payloads through the generic schema host. */
8
+ export function copyBlockClipboardNodes(
9
+ nodes: unknown,
10
+ types: readonly SchemaBlockType[],
11
+ copy: NonNullable<FieldAuthoringHost["copySchemaPayload"]>
12
+ ): { nodes: SerializedLexicalNode[]; hasBlocks: boolean } {
13
+ if (!Array.isArray(nodes)) throw new Error("Clipboard content is not a rich-text node list.");
14
+ const issue = documentRecoveryIssue({ version: 1, root: { type: "root", children: nodes } });
15
+ if (issue !== undefined)
16
+ throw new Error(
17
+ `Clipboard content exceeds a rich-text contract or traversal limit at ${issue}.`
18
+ );
19
+ const result = structuredClone(nodes);
20
+ const pending: { node: unknown; depth: number }[] = result.map((node) => ({ node, depth: 0 }));
21
+ const blocks: Record<string, unknown>[] = [];
22
+ let count = 0;
23
+ while (pending.length > 0) {
24
+ const { node, depth } = pending.pop()!;
25
+ if (++count > 10000 || depth > 64)
26
+ throw new Error("Clipboard content exceeds the rich-text traversal limit.");
27
+ if (!isRecord(node) || typeof node.type !== "string" || typeof node.version !== "number")
28
+ throw new Error("Clipboard contains a malformed rich-text node.");
29
+ if (node.type === "block") {
30
+ const fields = node.fields;
31
+ if (
32
+ node.version !== 1 ||
33
+ !isRecord(fields) ||
34
+ typeof fields.blockType !== "string" ||
35
+ !types.some((type) => type.slug === fields.blockType)
36
+ )
37
+ throw new Error(
38
+ "This field does not support a block type on the clipboard. Paste into a field that declares it, or migrate the content first."
39
+ );
40
+ blocks.push(node);
41
+ continue;
42
+ }
43
+ if (Array.isArray(node.children))
44
+ pending.push(...node.children.map((node) => ({ node, depth: depth + 1 })));
45
+ }
46
+ for (const node of blocks) {
47
+ const fields = node.fields as Record<string, unknown> & { blockType: string };
48
+ node.fields = copy(
49
+ { treeKey: "blocks", caseTag: "block", variantSlug: fields.blockType },
50
+ fields
51
+ );
52
+ }
53
+ return { nodes: result as SerializedLexicalNode[], hasBlocks: blocks.length > 0 };
54
+ }
@@ -0,0 +1,84 @@
1
+ import type { Decorator } from "@hvniel/lexical-svelte";
2
+ import {
3
+ $applyNodeReplacement,
4
+ $getDocument,
5
+ DecoratorNode,
6
+ type LexicalNode,
7
+ type NodeKey,
8
+ type SerializedLexicalNode,
9
+ } from "lexical";
10
+ import RichTextBlockCard from "@plugin-richtext/block/rich-text-block-card.svelte";
11
+ import { isRecord } from "@plugin-richtext/field/rich-text-blocks";
12
+
13
+ /** Retains the received envelope verbatim so historical content can be exported safely. */
14
+ export type SerializedBlockNode = SerializedLexicalNode & Record<string, unknown>;
15
+
16
+ export class BlockNode extends DecoratorNode<Decorator<typeof RichTextBlockCard>> {
17
+ __envelope: SerializedBlockNode;
18
+
19
+ static getType(): string {
20
+ return "block";
21
+ }
22
+ static clone(node: BlockNode): BlockNode {
23
+ return new BlockNode(node.__envelope, node.__key);
24
+ }
25
+ static importJSON(serializedNode: SerializedBlockNode): BlockNode {
26
+ return $applyNodeReplacement(new BlockNode(structuredClone(serializedNode)));
27
+ }
28
+ constructor(envelope: SerializedBlockNode, key?: NodeKey) {
29
+ super(key);
30
+ this.__envelope = envelope;
31
+ }
32
+ exportJSON(): SerializedBlockNode {
33
+ return structuredClone(this.getLatest().__envelope);
34
+ }
35
+ createDOM(): HTMLElement {
36
+ const element = $getDocument().createElement("div");
37
+ element.className = "ridu-richtext-embedded";
38
+ return element;
39
+ }
40
+ updateDOM(): false {
41
+ return false;
42
+ }
43
+ isInline(): false {
44
+ return false;
45
+ }
46
+ isKeyboardSelectable(): true {
47
+ return true;
48
+ }
49
+ getFields(): Record<string, unknown> {
50
+ const fields = this.getLatest().__envelope.fields;
51
+ return isRecord(fields) ? fields : {};
52
+ }
53
+ setFields(fields: Record<string, unknown>): this {
54
+ const writable = this.getWritable();
55
+ writable.__envelope = { type: "block", version: 1, fields: structuredClone(fields) };
56
+ return writable;
57
+ }
58
+ decorate(): Decorator<typeof RichTextBlockCard> {
59
+ return {
60
+ component: RichTextBlockCard,
61
+ props: {
62
+ nodeKey: this.getKey(),
63
+ fields: this.getFields(),
64
+ validEnvelope:
65
+ this.__envelope.type === "block" &&
66
+ this.__envelope.version === 1 &&
67
+ Object.keys(this.__envelope).every(
68
+ (key) => key === "type" || key === "version" || key === "fields"
69
+ ) &&
70
+ typeof this.getFields()._key === "string" &&
71
+ String(this.getFields()._key).trim() !== "",
72
+ },
73
+ };
74
+ }
75
+ }
76
+
77
+ export function createBlockNode(fields: Record<string, unknown>): BlockNode {
78
+ return $applyNodeReplacement(
79
+ new BlockNode({ type: "block", version: 1, fields: structuredClone(fields) })
80
+ );
81
+ }
82
+ export function isBlockNode(node: LexicalNode | null | undefined): node is BlockNode {
83
+ return node instanceof BlockNode;
84
+ }
@@ -0,0 +1,316 @@
1
+ <script lang="ts">
2
+ import { getAdminI18n, type EmbeddedSchemaDraft, type FieldAuthoringHost } from "@riducms/plugin";
3
+ import type { SchemaField } from "@riducms/protocol";
4
+ import { useLexicalComposerContext } from "@hvniel/lexical-svelte";
5
+ import {
6
+ $generateNodesFromSerializedNodes,
7
+ $getClipboardDataFromSelection,
8
+ setLexicalClipboardDataTransfer,
9
+ $insertGeneratedNodes,
10
+ } from "@lexical/clipboard";
11
+ import { copyBlockClipboardNodes } from "@plugin-richtext/block/rich-text-block-clipboard";
12
+ import { isRecord } from "@plugin-richtext/field/rich-text-blocks";
13
+ import { $insertNodeToNearestRoot, mergeRegister } from "@lexical/utils";
14
+ import {
15
+ $addUpdateTag,
16
+ $createParagraphNode,
17
+ $getNodeByKey,
18
+ $getRoot,
19
+ $getSelection,
20
+ $isElementNode,
21
+ $isNodeSelection,
22
+ $isParagraphNode,
23
+ $isRangeSelection,
24
+ $setSelection,
25
+ COMMAND_PRIORITY_HIGH,
26
+ COMMAND_PRIORITY_EDITOR,
27
+ HISTORY_PUSH_TAG,
28
+ COPY_COMMAND,
29
+ CUT_COMMAND,
30
+ KEY_BACKSPACE_COMMAND,
31
+ KEY_DELETE_COMMAND,
32
+ PASTE_COMMAND,
33
+ type BaseSelection,
34
+ type NodeKey,
35
+ } from "lexical";
36
+ import { createBlockNode, isBlockNode } from "@plugin-richtext/block/rich-text-block-node";
37
+ import { richTextBlockTypes } from "@plugin-richtext/field/rich-text-blocks";
38
+ import {
39
+ OPEN_BLOCK_EDITOR_COMMAND,
40
+ DUPLICATE_BLOCK_COMMAND,
41
+ REMOVE_BLOCK_COMMAND,
42
+ MOVE_BLOCK_COMMAND,
43
+ } from "@plugin-richtext/menu/rich-text-commands";
44
+
45
+ let { authoring, field }: { authoring: FieldAuthoringHost | undefined; field: SchemaField } =
46
+ $props();
47
+ const editor = useLexicalComposerContext()[0];
48
+ const i18n = getAdminI18n();
49
+ let session = $state.raw<{
50
+ draft: EmbeddedSchemaDraft;
51
+ title: string;
52
+ nodeKey?: NodeKey;
53
+ selection: BaseSelection | null;
54
+ position?: { targetNodeKey: NodeKey; insertBefore: boolean };
55
+ }>();
56
+ let message = $state("");
57
+
58
+ function close() {
59
+ session?.draft.discard();
60
+ session = undefined;
61
+ queueMicrotask(() => editor.focus());
62
+ }
63
+
64
+ // @lexical-scope
65
+ function $removeBlock(nodeKey: NodeKey) {
66
+ if (!editor.isEditable()) return false;
67
+ const node = $getNodeByKey(nodeKey);
68
+ if (!isBlockNode(node)) return false;
69
+ $addUpdateTag(HISTORY_PUSH_TAG);
70
+ const next = node.getNextSibling();
71
+ const previous = node.getPreviousSibling();
72
+ node.remove();
73
+ if ($isElementNode(next)) next.selectStart();
74
+ else if ($isElementNode(previous)) previous.selectEnd();
75
+ else {
76
+ const paragraph = $createParagraphNode();
77
+ if (next !== null) next.insertBefore(paragraph);
78
+ else if (previous !== null) previous.insertAfter(paragraph);
79
+ else $getRoot().append(paragraph);
80
+ paragraph.select();
81
+ }
82
+ return true;
83
+ }
84
+
85
+ function apply(payload: Record<string, unknown>) {
86
+ const active = session;
87
+ if (active === undefined || active.draft.stale || !editor.isEditable()) return;
88
+ editor.update(
89
+ () => {
90
+ if (active.nodeKey !== undefined) {
91
+ const node = $getNodeByKey(active.nodeKey);
92
+ if (!isBlockNode(node) || node.getFields()._key !== active.draft.identity) return;
93
+ node.setFields(payload);
94
+ return;
95
+ }
96
+ if (active.position !== undefined) {
97
+ const target = $getNodeByKey(active.position.targetNodeKey);
98
+ if (target !== null) {
99
+ const node = createBlockNode(payload);
100
+ if (active.position.insertBefore) target.insertBefore(node);
101
+ else target.insertAfter(node);
102
+ return;
103
+ }
104
+ }
105
+ const selection = active.selection;
106
+ if (
107
+ $isRangeSelection(selection) &&
108
+ $getNodeByKey(selection.anchor.key) !== null &&
109
+ $getNodeByKey(selection.focus.key) !== null
110
+ )
111
+ $setSelection(selection.clone());
112
+ else $getRoot().selectEnd();
113
+ const current = $getSelection();
114
+ const anchor = $isRangeSelection(current) ? current.anchor.getNode() : undefined;
115
+ const node = createBlockNode(payload);
116
+ $insertNodeToNearestRoot(node);
117
+ if ($isParagraphNode(anchor) && anchor.getChildrenSize() === 0) anchor.remove();
118
+ const paragraph = $createParagraphNode();
119
+ node.insertAfter(paragraph);
120
+ paragraph.select();
121
+ },
122
+ { tag: HISTORY_PUSH_TAG, discrete: true }
123
+ );
124
+ session = undefined;
125
+ queueMicrotask(() => editor.focus());
126
+ }
127
+
128
+ $effect(() => {
129
+ return mergeRegister(
130
+ ...[COPY_COMMAND, CUT_COMMAND].map((command) =>
131
+ editor.registerCommand(
132
+ command,
133
+ (event) => {
134
+ if (command === CUT_COMMAND && !editor.isEditable()) return false;
135
+ const selection = $getSelection();
136
+ if (
137
+ !(event instanceof ClipboardEvent) ||
138
+ event.clipboardData === null ||
139
+ !$isNodeSelection(selection) ||
140
+ !selection.getNodes().some(isBlockNode)
141
+ )
142
+ return false;
143
+ event.preventDefault();
144
+ setLexicalClipboardDataTransfer(
145
+ event.clipboardData,
146
+ $getClipboardDataFromSelection(selection)
147
+ );
148
+ if (command === CUT_COMMAND) {
149
+ for (const node of selection.getNodes()) {
150
+ if (isBlockNode(node)) $removeBlock(node.getKey());
151
+ else node.remove();
152
+ }
153
+ }
154
+ return true;
155
+ },
156
+ COMMAND_PRIORITY_HIGH
157
+ )
158
+ ),
159
+
160
+ editor.registerCommand(
161
+ PASTE_COMMAND,
162
+ (event) => {
163
+ if (
164
+ !editor.isEditable() ||
165
+ !(event instanceof ClipboardEvent) ||
166
+ authoring?.copySchemaPayload === undefined
167
+ )
168
+ return false;
169
+ const serialized = event.clipboardData?.getData("application/x-lexical-editor");
170
+ if (!serialized) return false;
171
+ try {
172
+ if (serialized.length > 5 * 1024 * 1024)
173
+ throw new Error("Clipboard content exceeds the rich-text size limit.");
174
+ const parsed: unknown = JSON.parse(serialized);
175
+ if (!isRecord(parsed)) return false;
176
+ const copied = copyBlockClipboardNodes(
177
+ parsed.nodes,
178
+ richTextBlockTypes(field),
179
+ authoring.copySchemaPayload
180
+ );
181
+ if (!copied.hasBlocks) return false;
182
+ const selection = $getSelection();
183
+ if (selection === null) return false;
184
+ const nodes = $generateNodesFromSerializedNodes(copied.nodes);
185
+ event.preventDefault();
186
+ $addUpdateTag(HISTORY_PUSH_TAG);
187
+ $insertGeneratedNodes(editor, nodes, selection);
188
+ message = "";
189
+ return true;
190
+ } catch (error) {
191
+ event.preventDefault();
192
+ message =
193
+ error instanceof Error
194
+ ? error.message
195
+ : "This clipboard content could not be inserted safely.";
196
+ return true;
197
+ }
198
+ },
199
+ COMMAND_PRIORITY_HIGH
200
+ ),
201
+
202
+ editor.registerCommand(
203
+ OPEN_BLOCK_EDITOR_COMMAND,
204
+ ({ nodeKey, blockType, position }) => {
205
+ if (authoring?.beginSchemaDraft === undefined || !editor.isEditable()) return false;
206
+ if (session !== undefined) {
207
+ message = i18n.t("plugin.richtext:block.pendingEdit");
208
+ return true;
209
+ }
210
+ const node = nodeKey === undefined ? undefined : $getNodeByKey(nodeKey);
211
+ const fields = isBlockNode(node) ? node.getFields() : undefined;
212
+ const variantSlug = fields?.blockType ?? blockType;
213
+ const type = richTextBlockTypes(field).find((type) => type.slug === variantSlug);
214
+ if (type === undefined || (nodeKey !== undefined && !isBlockNode(node))) return false;
215
+ const draft =
216
+ fields !== undefined && typeof fields._key === "string"
217
+ ? authoring.beginSchemaDraft({ treeKey: "blocks", identity: fields._key })
218
+ : authoring.beginSchemaDraft({
219
+ treeKey: "blocks",
220
+ caseTag: "block",
221
+ variantSlug: type.slug,
222
+ });
223
+ session = {
224
+ draft,
225
+ title: i18n.t(
226
+ nodeKey === undefined
227
+ ? "plugin.richtext:block.insertTitle"
228
+ : "plugin.richtext:block.editTitle",
229
+ { label: type.labels.singular }
230
+ ),
231
+ ...(nodeKey === undefined ? {} : { nodeKey }),
232
+ selection: $getSelection()?.clone() ?? null,
233
+ ...(position === undefined ? {} : { position }),
234
+ };
235
+ message = "";
236
+ return true;
237
+ },
238
+ COMMAND_PRIORITY_EDITOR
239
+ ),
240
+ editor.registerCommand(
241
+ DUPLICATE_BLOCK_COMMAND,
242
+ (nodeKey) => {
243
+ if (authoring?.copySchemaPayload === undefined || !editor.isEditable()) return false;
244
+ const node = $getNodeByKey(nodeKey);
245
+ if (!isBlockNode(node)) return false;
246
+ const fields = node.getFields();
247
+ if (typeof fields.blockType !== "string") return false;
248
+ const payload = authoring.copySchemaPayload(
249
+ { treeKey: "blocks", caseTag: "block", variantSlug: fields.blockType },
250
+ fields
251
+ );
252
+ $addUpdateTag(HISTORY_PUSH_TAG);
253
+ const duplicate = createBlockNode(payload);
254
+ node.insertAfter(duplicate);
255
+ queueMicrotask(() =>
256
+ editor
257
+ .getElementByKey(duplicate.getKey())
258
+ ?.querySelector<HTMLElement>("[data-block-select]")
259
+ ?.focus()
260
+ );
261
+ return true;
262
+ },
263
+ COMMAND_PRIORITY_EDITOR
264
+ ),
265
+ editor.registerCommand(REMOVE_BLOCK_COMMAND, $removeBlock, COMMAND_PRIORITY_EDITOR),
266
+ editor.registerCommand(
267
+ MOVE_BLOCK_COMMAND,
268
+ ({ nodeKey, direction }) => {
269
+ if (!editor.isEditable()) return false;
270
+ const node = $getNodeByKey(nodeKey);
271
+ if (!isBlockNode(node)) return false;
272
+ const sibling = direction < 0 ? node.getPreviousSibling() : node.getNextSibling();
273
+ if (sibling === null) return true;
274
+ $addUpdateTag(HISTORY_PUSH_TAG);
275
+ if (direction < 0) sibling.insertBefore(node);
276
+ else sibling.insertAfter(node);
277
+ queueMicrotask(() =>
278
+ editor
279
+ .getElementByKey(nodeKey)
280
+ ?.querySelector<HTMLElement>("[data-block-select]")
281
+ ?.focus()
282
+ );
283
+ return true;
284
+ },
285
+ COMMAND_PRIORITY_EDITOR
286
+ ),
287
+ ...[KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND].map((command) =>
288
+ editor.registerCommand(
289
+ command,
290
+ (event) => {
291
+ if (!editor.isEditable()) return false;
292
+ const selection = $getSelection();
293
+ if (!$isNodeSelection(selection)) return false;
294
+ const nodes = selection.getNodes().filter(isBlockNode);
295
+ if (nodes.length === 0) return false;
296
+ event.preventDefault();
297
+ for (const node of nodes) $removeBlock(node.getKey());
298
+ return true;
299
+ },
300
+ COMMAND_PRIORITY_HIGH
301
+ )
302
+ ),
303
+ () => session?.draft.discard()
304
+ );
305
+ });
306
+ </script>
307
+
308
+ {#if message !== ""}<p role="alert" class="text-sm text-destructive">{message}</p>{/if}
309
+ {#if session !== undefined && authoring?.schemaDraftEditor !== undefined}
310
+ {@render authoring.schemaDraftEditor({
311
+ draft: session.draft,
312
+ title: session.title,
313
+ onApply: apply,
314
+ onCancel: close,
315
+ })}
316
+ {/if}