@riducms/plugin-richtext 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/package.json +52 -0
- package/src/field/rich-text-config.ts +47 -0
- package/src/field/rich-text-context.svelte.ts +8 -0
- package/src/field/rich-text-document.ts +20 -0
- package/src/field/rich-text-field.svelte +187 -0
- package/src/field/rich-text-footer.svelte +49 -0
- package/src/index.ts +33 -0
- package/src/menu/rich-text-block-toolbar.svelte +323 -0
- package/src/menu/rich-text-commands.ts +32 -0
- package/src/menu/rich-text-menu.svelte +107 -0
- package/src/menu/rich-text-options.ts +244 -0
- package/src/menu/rich-text-slash-menu.svelte +124 -0
- package/src/messages.ts +240 -0
- package/src/relationship/rich-text-relationship-node.svelte +141 -0
- package/src/relationship/rich-text-relationship-node.ts +105 -0
- package/src/relationship/rich-text-relationship-plugin.svelte +174 -0
- package/src/styles/integration.css +83 -0
- package/src/toolbar/rich-text-floating-toolbar.svelte +322 -0
- package/src/toolbar/toolbar-icon.svelte +47 -0
- package/src/upload/rich-text-upload-node.svelte +282 -0
- package/src/upload/rich-text-upload-node.ts +144 -0
- package/src/upload/rich-text-upload-plugin.svelte +183 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
useLexicalComposerContext,
|
|
4
|
+
useLexicalEditable,
|
|
5
|
+
useLexicalNodeSelection,
|
|
6
|
+
} from "@hvniel/lexical-svelte";
|
|
7
|
+
import { getAdminI18n } from "@riducms/plugin";
|
|
8
|
+
import type { NodeKey } from "lexical";
|
|
9
|
+
import { Textarea, TooltipContent, TooltipRoot, TooltipTrigger } from "@riducms/ui";
|
|
10
|
+
|
|
11
|
+
import { getRichTextAuthoringHost } from "@plugin-richtext/field/rich-text-context.svelte";
|
|
12
|
+
import {
|
|
13
|
+
OPEN_UPLOAD_BROWSER_COMMAND,
|
|
14
|
+
REMOVE_UPLOAD_COMMAND,
|
|
15
|
+
UPDATE_UPLOAD_CAPTION_COMMAND,
|
|
16
|
+
} from "@plugin-richtext/menu/rich-text-commands";
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
caption,
|
|
20
|
+
documentID,
|
|
21
|
+
nodeKey,
|
|
22
|
+
relationTo,
|
|
23
|
+
}: { caption: string; documentID: string; nodeKey: NodeKey; relationTo: string } = $props();
|
|
24
|
+
const editor = useLexicalComposerContext()[0];
|
|
25
|
+
const isEditable = useLexicalEditable();
|
|
26
|
+
const i18n = getAdminI18n();
|
|
27
|
+
// A decorator node key is fixed for this component's lifetime.
|
|
28
|
+
// svelte-ignore state_referenced_locally
|
|
29
|
+
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
|
|
30
|
+
const authoring = getRichTextAuthoringHost();
|
|
31
|
+
let document = $state.raw<Record<string, unknown>>();
|
|
32
|
+
let loadError = $state(false);
|
|
33
|
+
const collection = $derived(
|
|
34
|
+
authoring?.collections.find((candidate) => candidate.slug === relationTo)
|
|
35
|
+
);
|
|
36
|
+
const filename = $derived(String(document?.filename ?? documentID));
|
|
37
|
+
const altText = $derived(typeof document?.alt === "string" ? document.alt.trim() : "");
|
|
38
|
+
const extension = $derived(fileExtension(filename));
|
|
39
|
+
const imageURL = $derived(
|
|
40
|
+
typeof document?.url === "string" &&
|
|
41
|
+
typeof document.mimeType === "string" &&
|
|
42
|
+
document.mimeType.startsWith("image/")
|
|
43
|
+
? document.url
|
|
44
|
+
: undefined
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
$effect(() => {
|
|
48
|
+
if (authoring === undefined) return;
|
|
49
|
+
void authoring.documentRevision;
|
|
50
|
+
const request = new AbortController();
|
|
51
|
+
authoring
|
|
52
|
+
.findDocument(relationTo, documentID, request.signal)
|
|
53
|
+
.then((value) => {
|
|
54
|
+
if (request.signal.aborted) return;
|
|
55
|
+
document = value;
|
|
56
|
+
loadError = false;
|
|
57
|
+
})
|
|
58
|
+
.catch(() => {
|
|
59
|
+
if (request.signal.aborted) return;
|
|
60
|
+
loadError = true;
|
|
61
|
+
});
|
|
62
|
+
return () => request.abort();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function selectNode(event?: MouseEvent) {
|
|
66
|
+
if (event?.shiftKey !== true) clearSelection();
|
|
67
|
+
setSelected(true);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function openDocument(event: MouseEvent) {
|
|
71
|
+
selectNode(event);
|
|
72
|
+
editor.dispatchCommand(OPEN_UPLOAD_BROWSER_COMMAND, {
|
|
73
|
+
collectionSlug: relationTo,
|
|
74
|
+
documentID,
|
|
75
|
+
mode: "edit",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function replaceDocument() {
|
|
80
|
+
editor.dispatchCommand(OPEN_UPLOAD_BROWSER_COMMAND, {
|
|
81
|
+
collectionSlug: relationTo,
|
|
82
|
+
documentID,
|
|
83
|
+
mode: "replace",
|
|
84
|
+
nodeKey,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function removeUpload() {
|
|
89
|
+
editor.dispatchCommand(REMOVE_UPLOAD_COMMAND, nodeKey);
|
|
90
|
+
queueMicrotask(() => editor.focus());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function updateCaption(event: Event) {
|
|
94
|
+
if (!(event.currentTarget instanceof HTMLTextAreaElement)) return;
|
|
95
|
+
editor.dispatchCommand(UPDATE_UPLOAD_CAPTION_COMMAND, {
|
|
96
|
+
caption: event.currentTarget.value,
|
|
97
|
+
nodeKey,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function handleCaptionKeydown(event: KeyboardEvent) {
|
|
102
|
+
event.stopPropagation();
|
|
103
|
+
if (event.key !== "Escape" || !(event.currentTarget instanceof HTMLTextAreaElement)) return;
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
event.currentTarget.blur();
|
|
106
|
+
queueMicrotask(() => editor.focus());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function fileExtension(value: string) {
|
|
110
|
+
const match = /\.([a-z0-9]{1,8})$/i.exec(value);
|
|
111
|
+
return match?.[1]?.toLocaleUpperCase(i18n.language) ?? i18n.t("plugin.richtext:asset.file");
|
|
112
|
+
}
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<figure
|
|
116
|
+
class={[
|
|
117
|
+
"group/upload relative my-[1.85rem] w-full overflow-hidden rounded-[4px] border border-control-border bg-control leading-[0] transition-colors duration-150 hover:border-primary/50 focus-within:border-primary/50",
|
|
118
|
+
isSelected() && "border-primary/50",
|
|
119
|
+
]}
|
|
120
|
+
role="group"
|
|
121
|
+
aria-label={i18n.t("plugin.richtext:asset.label", {
|
|
122
|
+
collection: collection?.labels.singular ?? relationTo,
|
|
123
|
+
filename,
|
|
124
|
+
})}
|
|
125
|
+
>
|
|
126
|
+
<div class="relative w-full">
|
|
127
|
+
{#if isEditable()}
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
class="block w-full cursor-pointer border-0 bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-primary/65 focus-visible:ring-inset"
|
|
131
|
+
aria-label={i18n.t("plugin.richtext:editor.editNamed", { name: filename })}
|
|
132
|
+
onclick={openDocument}
|
|
133
|
+
>
|
|
134
|
+
<span
|
|
135
|
+
class={[
|
|
136
|
+
"ridu-richtext-upload-preview relative grid aspect-[16/7] w-full place-items-center overflow-hidden bg-background",
|
|
137
|
+
document === undefined && !loadError && "is-loading",
|
|
138
|
+
]}
|
|
139
|
+
>
|
|
140
|
+
{#if imageURL !== undefined}
|
|
141
|
+
<img
|
|
142
|
+
class="absolute inset-0 block size-full object-cover"
|
|
143
|
+
src={imageURL}
|
|
144
|
+
alt={altText}
|
|
145
|
+
/>
|
|
146
|
+
{:else}
|
|
147
|
+
<span
|
|
148
|
+
class="grid max-w-[80%] place-items-center gap-[0.45rem] font-mono text-[11px] leading-normal tracking-[0.12em] text-foreground-muted uppercase"
|
|
149
|
+
aria-hidden="true"
|
|
150
|
+
>
|
|
151
|
+
<strong class="text-[13px] font-medium">
|
|
152
|
+
{loadError ? i18n.t("plugin.richtext:asset.unavailable") : extension}
|
|
153
|
+
</strong>
|
|
154
|
+
<span class="max-w-full truncate text-foreground-faint">
|
|
155
|
+
{loadError ? i18n.t("plugin.richtext:asset.couldNotLoad") : filename}
|
|
156
|
+
</span>
|
|
157
|
+
</span>
|
|
158
|
+
{/if}
|
|
159
|
+
</span>
|
|
160
|
+
</button>
|
|
161
|
+
{:else}
|
|
162
|
+
<span
|
|
163
|
+
class={[
|
|
164
|
+
"ridu-richtext-upload-preview relative grid aspect-[16/7] w-full place-items-center overflow-hidden bg-background",
|
|
165
|
+
document === undefined && !loadError && "is-loading",
|
|
166
|
+
]}
|
|
167
|
+
>
|
|
168
|
+
{#if imageURL !== undefined}
|
|
169
|
+
<img class="absolute inset-0 block size-full object-cover" src={imageURL} alt={altText} />
|
|
170
|
+
{:else}
|
|
171
|
+
<span
|
|
172
|
+
class="font-mono text-[11px] leading-normal tracking-[0.12em] text-foreground-muted uppercase"
|
|
173
|
+
>
|
|
174
|
+
{loadError ? i18n.t("plugin.richtext:asset.unavailable") : extension}
|
|
175
|
+
</span>
|
|
176
|
+
{/if}
|
|
177
|
+
</span>
|
|
178
|
+
{/if}
|
|
179
|
+
|
|
180
|
+
{#if isEditable()}
|
|
181
|
+
<div
|
|
182
|
+
class={[
|
|
183
|
+
"pointer-events-none absolute top-3 left-1/2 z-2 flex max-w-[calc(100%-1.5rem)] -translate-x-1/2 items-center gap-1 rounded-[4px] border border-media-foreground/10 bg-media-overlay p-1 text-media-foreground opacity-0 shadow-[var(--shadow-popover)] backdrop-blur-md transition-opacity duration-150 group-hover/upload:pointer-events-auto group-hover/upload:opacity-100 group-focus-within/upload:pointer-events-auto group-focus-within/upload:opacity-100",
|
|
184
|
+
isSelected() && "pointer-events-auto opacity-100",
|
|
185
|
+
]}
|
|
186
|
+
aria-label={i18n.t("plugin.richtext:asset.actions")}
|
|
187
|
+
>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
class="min-w-0 cursor-pointer truncate rounded-[3px] border-0 bg-transparent px-2 py-1.5 text-start text-[11.5px] leading-[1.2] text-media-foreground/88 hover:bg-media-foreground/10 focus-visible:bg-media-foreground/10 focus-visible:outline-none"
|
|
191
|
+
onclick={openDocument}
|
|
192
|
+
aria-label={i18n.t("plugin.richtext:editor.editNamed", { name: filename })}
|
|
193
|
+
>
|
|
194
|
+
{filename}
|
|
195
|
+
</button>
|
|
196
|
+
<span class="mx-0.5 h-4 w-px shrink-0 bg-media-foreground/16" aria-hidden="true"></span>
|
|
197
|
+
<TooltipRoot>
|
|
198
|
+
<TooltipTrigger>
|
|
199
|
+
{#snippet child({ props })}
|
|
200
|
+
<button
|
|
201
|
+
{...props}
|
|
202
|
+
type="button"
|
|
203
|
+
class="grid size-7 shrink-0 cursor-pointer place-items-center rounded-[3px] border-0 bg-transparent text-media-foreground/75 hover:bg-media-foreground/10 hover:text-media-foreground focus-visible:bg-media-foreground/10 focus-visible:text-media-foreground focus-visible:outline-none"
|
|
204
|
+
onclick={replaceDocument}
|
|
205
|
+
aria-label={i18n.t("plugin.richtext:editor.replaceNamed", { name: filename })}
|
|
206
|
+
>
|
|
207
|
+
<svg
|
|
208
|
+
class="size-3.5"
|
|
209
|
+
viewBox="0 0 24 24"
|
|
210
|
+
fill="none"
|
|
211
|
+
stroke="currentColor"
|
|
212
|
+
stroke-width="2"
|
|
213
|
+
aria-hidden="true"
|
|
214
|
+
>
|
|
215
|
+
<path
|
|
216
|
+
d="M20 11a8.1 8.1 0 0 0-15.5-2M4 4v5h5M4 13a8.1 8.1 0 0 0 15.5 2M20 20v-5h-5"
|
|
217
|
+
/>
|
|
218
|
+
</svg>
|
|
219
|
+
</button>
|
|
220
|
+
{/snippet}
|
|
221
|
+
</TooltipTrigger>
|
|
222
|
+
<TooltipContent>{i18n.t("plugin.richtext:asset.replace")}</TooltipContent>
|
|
223
|
+
</TooltipRoot>
|
|
224
|
+
<TooltipRoot>
|
|
225
|
+
<TooltipTrigger>
|
|
226
|
+
{#snippet child({ props })}
|
|
227
|
+
<button
|
|
228
|
+
{...props}
|
|
229
|
+
type="button"
|
|
230
|
+
class="grid size-7 shrink-0 cursor-pointer place-items-center rounded-[3px] border-0 bg-transparent text-media-foreground/75 hover:bg-destructive/25 hover:text-media-foreground focus-visible:bg-destructive/25 focus-visible:text-media-foreground focus-visible:outline-none"
|
|
231
|
+
onclick={removeUpload}
|
|
232
|
+
aria-label={i18n.t("plugin.richtext:editor.removeNamed", { name: filename })}
|
|
233
|
+
>
|
|
234
|
+
<svg
|
|
235
|
+
class="size-3.5"
|
|
236
|
+
viewBox="0 0 24 24"
|
|
237
|
+
fill="none"
|
|
238
|
+
stroke="currentColor"
|
|
239
|
+
stroke-width="2"
|
|
240
|
+
aria-hidden="true"
|
|
241
|
+
>
|
|
242
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
243
|
+
</svg>
|
|
244
|
+
</button>
|
|
245
|
+
{/snippet}
|
|
246
|
+
</TooltipTrigger>
|
|
247
|
+
<TooltipContent>{i18n.t("plugin.richtext:asset.remove")}</TooltipContent>
|
|
248
|
+
</TooltipRoot>
|
|
249
|
+
</div>
|
|
250
|
+
{/if}
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{#if isEditable() || caption !== ""}
|
|
254
|
+
<figcaption class="border-t border-border bg-background/45 px-[0.85rem] py-[0.55rem]">
|
|
255
|
+
{#if isEditable()}
|
|
256
|
+
<div class="flex items-start gap-2">
|
|
257
|
+
<svg
|
|
258
|
+
class="mt-[0.3rem] size-3 shrink-0 text-foreground-faint"
|
|
259
|
+
viewBox="0 0 24 24"
|
|
260
|
+
fill="none"
|
|
261
|
+
stroke="currentColor"
|
|
262
|
+
stroke-width="2"
|
|
263
|
+
aria-hidden="true"
|
|
264
|
+
>
|
|
265
|
+
<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z" />
|
|
266
|
+
</svg>
|
|
267
|
+
<Textarea
|
|
268
|
+
class="min-h-[1.45rem] w-full resize-none overflow-hidden border-0 bg-transparent p-0 text-start text-[13px] leading-[1.45] text-foreground-muted outline-none field-sizing-content placeholder:text-foreground-faint focus:text-foreground"
|
|
269
|
+
rows={1}
|
|
270
|
+
value={caption}
|
|
271
|
+
oninput={updateCaption}
|
|
272
|
+
onkeydown={handleCaptionKeydown}
|
|
273
|
+
aria-label={i18n.t("plugin.richtext:asset.captionFor", { filename })}
|
|
274
|
+
placeholder={i18n.t("plugin.richtext:asset.addCaption")}
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
{:else}
|
|
278
|
+
<p class="text-start text-[13px] leading-[1.45] text-foreground-muted">{caption}</p>
|
|
279
|
+
{/if}
|
|
280
|
+
</figcaption>
|
|
281
|
+
{/if}
|
|
282
|
+
</figure>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Decorator } from "@hvniel/lexical-svelte";
|
|
2
|
+
import { DecoratorBlockNode } from "@hvniel/lexical-svelte";
|
|
3
|
+
import {
|
|
4
|
+
$applyNodeReplacement,
|
|
5
|
+
$getDocument,
|
|
6
|
+
type ElementFormatType,
|
|
7
|
+
type LexicalNode,
|
|
8
|
+
type LexicalUpdateJSON,
|
|
9
|
+
type NodeKey,
|
|
10
|
+
type SerializedLexicalNode,
|
|
11
|
+
type Spread,
|
|
12
|
+
} from "lexical";
|
|
13
|
+
|
|
14
|
+
import RichTextUploadNodeComponent from "@plugin-richtext/upload/rich-text-upload-node.svelte";
|
|
15
|
+
|
|
16
|
+
export type SerializedUploadNode = Spread<
|
|
17
|
+
{
|
|
18
|
+
caption?: string;
|
|
19
|
+
format: ElementFormatType;
|
|
20
|
+
id: string;
|
|
21
|
+
relationTo: string;
|
|
22
|
+
},
|
|
23
|
+
SerializedLexicalNode
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
export class UploadNode extends DecoratorBlockNode {
|
|
27
|
+
__caption: string;
|
|
28
|
+
__relationTo: string;
|
|
29
|
+
__documentID: string;
|
|
30
|
+
|
|
31
|
+
static getType(): string {
|
|
32
|
+
return "upload";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static clone(node: UploadNode): UploadNode {
|
|
36
|
+
return new UploadNode(
|
|
37
|
+
node.__relationTo,
|
|
38
|
+
node.__documentID,
|
|
39
|
+
node.__caption,
|
|
40
|
+
node.__format,
|
|
41
|
+
node.__key
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static importJSON(serializedNode: SerializedUploadNode): UploadNode {
|
|
46
|
+
return createUploadNode({
|
|
47
|
+
caption: serializedNode.caption ?? "",
|
|
48
|
+
documentID: serializedNode.id,
|
|
49
|
+
format: serializedNode.format,
|
|
50
|
+
relationTo: serializedNode.relationTo,
|
|
51
|
+
}).updateFromJSON(serializedNode);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
relationTo: string,
|
|
56
|
+
documentID: string,
|
|
57
|
+
caption = "",
|
|
58
|
+
format?: ElementFormatType,
|
|
59
|
+
key?: NodeKey
|
|
60
|
+
) {
|
|
61
|
+
super(format, key);
|
|
62
|
+
this.__relationTo = relationTo;
|
|
63
|
+
this.__documentID = documentID;
|
|
64
|
+
this.__caption = caption;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedUploadNode>): this {
|
|
68
|
+
const node = super.updateFromJSON(serializedNode);
|
|
69
|
+
node.__relationTo = serializedNode.relationTo;
|
|
70
|
+
node.__documentID = serializedNode.id;
|
|
71
|
+
node.__caption = serializedNode.caption ?? "";
|
|
72
|
+
return node;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
exportJSON(): SerializedUploadNode {
|
|
76
|
+
return {
|
|
77
|
+
...super.exportJSON(),
|
|
78
|
+
...(this.__caption === "" ? {} : { caption: this.__caption }),
|
|
79
|
+
id: this.__documentID,
|
|
80
|
+
relationTo: this.__relationTo,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
createDOM(): HTMLElement {
|
|
85
|
+
const element = $getDocument().createElement("div");
|
|
86
|
+
element.className = "ridu-richtext-upload";
|
|
87
|
+
return element;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
decorate(): Decorator<typeof RichTextUploadNodeComponent> {
|
|
91
|
+
return {
|
|
92
|
+
component: RichTextUploadNodeComponent,
|
|
93
|
+
props: {
|
|
94
|
+
caption: this.__caption,
|
|
95
|
+
documentID: this.__documentID,
|
|
96
|
+
nodeKey: this.getKey(),
|
|
97
|
+
relationTo: this.__relationTo,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
getDocumentID(): string {
|
|
103
|
+
return this.getLatest().__documentID;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
getCaption(): string {
|
|
107
|
+
return this.getLatest().__caption;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
getRelationTo(): string {
|
|
111
|
+
return this.getLatest().__relationTo;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setReference(relationTo: string, documentID: string): this {
|
|
115
|
+
const writable = this.getWritable();
|
|
116
|
+
writable.__relationTo = relationTo;
|
|
117
|
+
writable.__documentID = documentID;
|
|
118
|
+
return writable;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setCaption(caption: string): this {
|
|
122
|
+
const writable = this.getWritable();
|
|
123
|
+
writable.__caption = caption;
|
|
124
|
+
return writable;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createUploadNode({
|
|
129
|
+
caption = "",
|
|
130
|
+
documentID,
|
|
131
|
+
format,
|
|
132
|
+
relationTo,
|
|
133
|
+
}: {
|
|
134
|
+
caption?: string;
|
|
135
|
+
documentID: string;
|
|
136
|
+
format?: ElementFormatType;
|
|
137
|
+
relationTo: string;
|
|
138
|
+
}): UploadNode {
|
|
139
|
+
return $applyNodeReplacement(new UploadNode(relationTo, documentID, caption, format));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function isUploadNode(node: LexicalNode | null | undefined): node is UploadNode {
|
|
143
|
+
return node instanceof UploadNode;
|
|
144
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { FieldAuthoringHost } from "@riducms/plugin";
|
|
3
|
+
import type { SchemaCollection, SchemaField } from "@riducms/protocol";
|
|
4
|
+
import { $insertNodeToNearestRoot, mergeRegister } from "@lexical/utils";
|
|
5
|
+
import {
|
|
6
|
+
$createParagraphNode,
|
|
7
|
+
$getNodeByKey,
|
|
8
|
+
$getPreviousSelection,
|
|
9
|
+
$getRoot,
|
|
10
|
+
$getSelection,
|
|
11
|
+
$isElementNode,
|
|
12
|
+
$isParagraphNode,
|
|
13
|
+
$isRangeSelection,
|
|
14
|
+
COMMAND_PRIORITY_EDITOR,
|
|
15
|
+
type NodeKey,
|
|
16
|
+
} from "lexical";
|
|
17
|
+
import { useLexicalComposerContext } from "@hvniel/lexical-svelte";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
OPEN_UPLOAD_BROWSER_COMMAND,
|
|
21
|
+
REMOVE_UPLOAD_COMMAND,
|
|
22
|
+
UPDATE_UPLOAD_CAPTION_COMMAND,
|
|
23
|
+
} from "@plugin-richtext/menu/rich-text-commands";
|
|
24
|
+
import type { RichTextConfig } from "@plugin-richtext/field/rich-text-config";
|
|
25
|
+
import {
|
|
26
|
+
createUploadNode,
|
|
27
|
+
isUploadNode,
|
|
28
|
+
UploadNode,
|
|
29
|
+
} from "@plugin-richtext/upload/rich-text-upload-node";
|
|
30
|
+
|
|
31
|
+
let {
|
|
32
|
+
authoring,
|
|
33
|
+
config,
|
|
34
|
+
field,
|
|
35
|
+
}: {
|
|
36
|
+
authoring: FieldAuthoringHost | undefined;
|
|
37
|
+
config: RichTextConfig;
|
|
38
|
+
field: SchemaField;
|
|
39
|
+
} = $props();
|
|
40
|
+
const editor = useLexicalComposerContext()[0];
|
|
41
|
+
let browserOpen = $state(false);
|
|
42
|
+
let targetCollection = $state<SchemaCollection>();
|
|
43
|
+
let documentID = $state<string>();
|
|
44
|
+
let browserMode = $state<"edit" | "insert" | "replace">("insert");
|
|
45
|
+
let replaceNodeKey: NodeKey | undefined;
|
|
46
|
+
const uploadCollections = $derived(
|
|
47
|
+
(authoring?.collections ?? []).filter(
|
|
48
|
+
(collection) =>
|
|
49
|
+
collection.capabilities.upload &&
|
|
50
|
+
(config.uploadCollections.length === 0 ||
|
|
51
|
+
config.uploadCollections.includes(collection.slug))
|
|
52
|
+
)
|
|
53
|
+
);
|
|
54
|
+
const browserField = $derived(
|
|
55
|
+
targetCollection === undefined ? undefined : createBrowserField(field, targetCollection)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
$effect(() => {
|
|
59
|
+
if (!editor.hasNodes([UploadNode])) {
|
|
60
|
+
throw new Error("RichTextUploadPlugin requires UploadNode to be registered");
|
|
61
|
+
}
|
|
62
|
+
return mergeRegister(
|
|
63
|
+
editor.registerCommand(
|
|
64
|
+
OPEN_UPLOAD_BROWSER_COMMAND,
|
|
65
|
+
(payload) => {
|
|
66
|
+
const collection = uploadCollections.find(
|
|
67
|
+
(candidate) => candidate.slug === payload.collectionSlug
|
|
68
|
+
);
|
|
69
|
+
if (collection === undefined) return false;
|
|
70
|
+
browserMode =
|
|
71
|
+
payload.mode ??
|
|
72
|
+
(payload.nodeKey !== undefined
|
|
73
|
+
? "replace"
|
|
74
|
+
: payload.documentID !== undefined
|
|
75
|
+
? "edit"
|
|
76
|
+
: "insert");
|
|
77
|
+
targetCollection = collection;
|
|
78
|
+
documentID = payload.documentID;
|
|
79
|
+
replaceNodeKey = browserMode === "replace" ? payload.nodeKey : undefined;
|
|
80
|
+
browserOpen = true;
|
|
81
|
+
return true;
|
|
82
|
+
},
|
|
83
|
+
COMMAND_PRIORITY_EDITOR
|
|
84
|
+
),
|
|
85
|
+
editor.registerCommand(
|
|
86
|
+
UPDATE_UPLOAD_CAPTION_COMMAND,
|
|
87
|
+
({ caption, nodeKey }) => {
|
|
88
|
+
const node = $getNodeByKey(nodeKey);
|
|
89
|
+
if (!isUploadNode(node)) return false;
|
|
90
|
+
node.setCaption(caption);
|
|
91
|
+
return true;
|
|
92
|
+
},
|
|
93
|
+
COMMAND_PRIORITY_EDITOR
|
|
94
|
+
),
|
|
95
|
+
editor.registerCommand(
|
|
96
|
+
REMOVE_UPLOAD_COMMAND,
|
|
97
|
+
(nodeKey) => {
|
|
98
|
+
const node = $getNodeByKey(nodeKey);
|
|
99
|
+
if (!isUploadNode(node)) return false;
|
|
100
|
+
const next = node.getNextSibling();
|
|
101
|
+
const previous = node.getPreviousSibling();
|
|
102
|
+
node.remove();
|
|
103
|
+
if ($isElementNode(next)) next.selectStart();
|
|
104
|
+
else if ($isElementNode(previous)) previous.selectEnd();
|
|
105
|
+
else {
|
|
106
|
+
const paragraph = $createParagraphNode();
|
|
107
|
+
$getRoot().append(paragraph);
|
|
108
|
+
paragraph.select();
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
},
|
|
112
|
+
COMMAND_PRIORITY_EDITOR
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
function commit(ids: string[]) {
|
|
118
|
+
const id = ids[0];
|
|
119
|
+
const collection = targetCollection;
|
|
120
|
+
if (id === undefined || collection === undefined) return;
|
|
121
|
+
if (browserMode === "edit") {
|
|
122
|
+
closeBrowser();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
editor.update(() => {
|
|
126
|
+
if (replaceNodeKey !== undefined) {
|
|
127
|
+
const current = $getNodeByKey(replaceNodeKey);
|
|
128
|
+
if (isUploadNode(current)) current.setReference(collection.slug, id);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const selection = $getSelection() ?? $getPreviousSelection();
|
|
132
|
+
if (!$isRangeSelection(selection)) $getRoot().selectEnd();
|
|
133
|
+
const focusNode = $isRangeSelection(selection) ? selection.focus.getNode() : undefined;
|
|
134
|
+
const upload = createUploadNode({ documentID: id, relationTo: collection.slug });
|
|
135
|
+
$insertNodeToNearestRoot(upload);
|
|
136
|
+
if ($isParagraphNode(focusNode) && focusNode.getChildrenSize() === 0) focusNode.remove();
|
|
137
|
+
const paragraph = $createParagraphNode();
|
|
138
|
+
upload.insertAfter(paragraph);
|
|
139
|
+
paragraph.select();
|
|
140
|
+
});
|
|
141
|
+
closeBrowser();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function closeBrowser() {
|
|
145
|
+
browserOpen = false;
|
|
146
|
+
browserMode = "insert";
|
|
147
|
+
documentID = undefined;
|
|
148
|
+
replaceNodeKey = undefined;
|
|
149
|
+
queueMicrotask(() => editor.focus());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function createBrowserField(field: SchemaField, collection: SchemaCollection): SchemaField {
|
|
153
|
+
return {
|
|
154
|
+
id: `${field.id}-richtext-upload`,
|
|
155
|
+
name: `${field.name}Upload`,
|
|
156
|
+
path: `${field.path}.__upload`,
|
|
157
|
+
type: "upload",
|
|
158
|
+
category: "upload",
|
|
159
|
+
required: false,
|
|
160
|
+
unique: false,
|
|
161
|
+
admin: { label: collection.labels.singular },
|
|
162
|
+
upload: {
|
|
163
|
+
collectionId: collection.id,
|
|
164
|
+
collectionSlug: collection.slug,
|
|
165
|
+
onDelete: "nullify",
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
{#if browserOpen && authoring !== undefined && targetCollection !== undefined && browserField !== undefined}
|
|
172
|
+
{const ReferenceBrowser = authoring.referenceBrowser}
|
|
173
|
+
<ReferenceBrowser
|
|
174
|
+
open
|
|
175
|
+
field={browserField}
|
|
176
|
+
collection={targetCollection}
|
|
177
|
+
hasMany={false}
|
|
178
|
+
selectedIDs={documentID === undefined ? [] : [documentID]}
|
|
179
|
+
{...browserMode === "edit" && documentID !== undefined ? { initialDocumentID: documentID } : {}}
|
|
180
|
+
onCommit={commit}
|
|
181
|
+
onClose={closeBrowser}
|
|
182
|
+
/>
|
|
183
|
+
{/if}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|