@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 +9 -0
- package/package.json +17 -6
- package/src/block/rich-text-block-card.svelte +157 -0
- package/src/block/rich-text-block-clipboard.ts +54 -0
- package/src/block/rich-text-block-node.ts +84 -0
- package/src/block/rich-text-blocks-plugin.svelte +316 -0
- package/src/document-validation.ts +104 -0
- package/src/document.ts +76 -0
- package/src/field/rich-text-blocks.ts +37 -0
- package/src/field/rich-text-config.ts +16 -13
- package/src/field/rich-text-context.svelte.ts +9 -0
- package/src/field/rich-text-document.ts +21 -1
- package/src/field/rich-text-editability-plugin.svelte +9 -0
- package/src/field/rich-text-editor.svelte +250 -0
- package/src/field/rich-text-field.svelte +57 -179
- package/src/field/rich-text-footer.svelte +5 -2
- package/src/index.ts +18 -14
- package/src/menu/rich-text-block-toolbar.svelte +17 -1
- package/src/menu/rich-text-commands.ts +11 -0
- package/src/menu/rich-text-options.ts +28 -1
- package/src/menu/rich-text-slash-menu.svelte +9 -1
- package/src/messages.ts +56 -0
- package/src/render/index.ts +6 -0
- package/src/render/rich-text.svelte +97 -0
- package/src/render.ts +164 -0
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.
|
|
15
|
-
"@riducms/protocol": "0.
|
|
16
|
-
"@riducms/ui": "0.
|
|
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.
|
|
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.
|
|
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}
|