@riducms/plugin-richtext 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +7 -1
  2. package/package.json +7 -5
  3. package/src/block/rich-text-block-card.scss +139 -0
  4. package/src/block/rich-text-block-card.svelte +42 -21
  5. package/src/block/rich-text-block-clipboard.ts +1 -1
  6. package/src/block/rich-text-block-node.ts +1 -1
  7. package/src/block/rich-text-blocks-plugin.svelte +5 -2
  8. package/src/document-validation.ts +7 -5
  9. package/src/field/reference-node-placement.ts +49 -0
  10. package/src/field/rich-text-blocks.ts +1 -5
  11. package/src/field/rich-text-config.ts +1 -4
  12. package/src/field/rich-text-document.ts +1 -4
  13. package/src/field/rich-text-editor.svelte +105 -57
  14. package/src/field/rich-text-footer.svelte +7 -12
  15. package/src/field/rich-text-markdown.ts +62 -0
  16. package/src/link/link-url.ts +30 -0
  17. package/src/link/rich-text-link-plugin.svelte +528 -0
  18. package/src/link/rich-text-link.scss +144 -0
  19. package/src/link/safe-link-transform.ts +20 -0
  20. package/src/link-url-policy.ts +14 -0
  21. package/src/menu/rich-text-block-toolbar.svelte +403 -70
  22. package/src/menu/rich-text-commands.ts +10 -1
  23. package/src/menu/rich-text-menu.scss +92 -0
  24. package/src/menu/rich-text-menu.svelte +62 -69
  25. package/src/menu/rich-text-option-filter.ts +30 -0
  26. package/src/menu/rich-text-options.ts +81 -84
  27. package/src/menu/rich-text-slash-menu.svelte +15 -11
  28. package/src/messages.ts +72 -27
  29. package/src/relationship/rich-text-relationship-node.scss +98 -0
  30. package/src/relationship/rich-text-relationship-node.svelte +38 -32
  31. package/src/relationship/rich-text-relationship-plugin.svelte +24 -40
  32. package/src/render/rich-text.svelte +10 -5
  33. package/src/render.ts +3 -8
  34. package/src/styles/integration.scss +384 -0
  35. package/src/toolbar/PAYLOAD-LICENSE.md +22 -0
  36. package/src/toolbar/rich-text-floating-toolbar.svelte +331 -169
  37. package/src/toolbar/rich-text-toolbar.scss +80 -0
  38. package/src/toolbar/toolbar-icon.svelte +200 -30
  39. package/src/upload/rich-text-upload-node.scss +308 -0
  40. package/src/upload/rich-text-upload-node.svelte +96 -125
  41. package/src/upload/rich-text-upload-node.ts +1 -0
  42. package/src/upload/rich-text-upload-plugin.svelte +10 -33
  43. package/src/styles/integration.css +0 -83
@@ -5,8 +5,10 @@
5
5
  useLexicalNodeSelection,
6
6
  } from "@hvniel/lexical-svelte";
7
7
  import { getAdminI18n } from "@riducms/plugin";
8
- import type { NodeKey } from "lexical";
9
- import { Textarea, TooltipContent, TooltipRoot, TooltipTrigger } from "@riducms/ui";
8
+ import type { ElementFormatType, NodeKey } from "lexical";
9
+ import { Button, Textarea } from "@riducms/ui";
10
+ import ReplaceIcon from "~icons/lucide/refresh-cw";
11
+ import RemoveIcon from "~icons/lucide/x";
10
12
 
11
13
  import { getRichTextAuthoringHost } from "@plugin-richtext/field/rich-text-context.svelte";
12
14
  import {
@@ -14,13 +16,21 @@
14
16
  REMOVE_UPLOAD_COMMAND,
15
17
  UPDATE_UPLOAD_CAPTION_COMMAND,
16
18
  } from "@plugin-richtext/menu/rich-text-commands";
19
+ import "@plugin-richtext/upload/rich-text-upload-node.scss";
17
20
 
18
21
  let {
19
22
  caption,
20
23
  documentID,
24
+ format,
21
25
  nodeKey,
22
26
  relationTo,
23
- }: { caption: string; documentID: string; nodeKey: NodeKey; relationTo: string } = $props();
27
+ }: {
28
+ caption: string;
29
+ documentID: string;
30
+ format: ElementFormatType;
31
+ nodeKey: NodeKey;
32
+ relationTo: string;
33
+ } = $props();
24
34
  const editor = useLexicalComposerContext()[0];
25
35
  const isEditable = useLexicalEditable();
26
36
  const i18n = getAdminI18n();
@@ -37,17 +47,27 @@
37
47
  const altText = $derived(typeof document?.alt === "string" ? document.alt.trim() : "");
38
48
  const extension = $derived(fileExtension(filename));
39
49
  const imageURL = $derived(
40
- typeof document?.url === "string" &&
41
- typeof document.mimeType === "string" &&
42
- document.mimeType.startsWith("image/")
43
- ? document.url
50
+ typeof document?.mimeType === "string" && document.mimeType.startsWith("image/")
51
+ ? typeof document.thumbnailURL === "string" && document.thumbnailURL !== ""
52
+ ? document.thumbnailURL
53
+ : typeof document.url === "string"
54
+ ? document.url
55
+ : undefined
44
56
  : undefined
45
57
  );
58
+ const mediaWidth = $derived(positiveDimension(document?.width));
59
+ const mediaHeight = $derived(positiveDimension(document?.height));
60
+ const mediaAspect = $derived(
61
+ mediaWidth !== undefined && mediaHeight !== undefined ? mediaWidth / mediaHeight : 1
62
+ );
63
+ const mediaWidthLimit = $derived(`${Math.min(450, Math.max(240, 450 * mediaAspect))}px`);
46
64
 
47
65
  $effect(() => {
48
66
  if (authoring === undefined) return;
49
67
  void authoring.documentRevision;
50
68
  const request = new AbortController();
69
+ document = undefined;
70
+ loadError = false;
51
71
  authoring
52
72
  .findDocument(relationTo, documentID, request.signal)
53
73
  .then((value) => {
@@ -110,152 +130,103 @@
110
130
  const match = /\.([a-z0-9]{1,8})$/i.exec(value);
111
131
  return match?.[1]?.toLocaleUpperCase(i18n.language) ?? i18n.t("plugin.richtext:asset.file");
112
132
  }
133
+
134
+ function positiveDimension(value: unknown) {
135
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
136
+ }
113
137
  </script>
114
138
 
115
139
  <figure
116
140
  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",
141
+ "ridu-richtext-upload-card",
142
+ isSelected() && "is-selected",
143
+ !isEditable() && "is-read-only",
119
144
  ]}
145
+ data-selected={isSelected() || undefined}
146
+ data-align={format || undefined}
147
+ aria-busy={authoring !== undefined && document === undefined && !loadError}
148
+ style:--ridu-richtext-upload-aspect={mediaAspect}
149
+ style:--ridu-richtext-upload-width={mediaWidthLimit}
120
150
  role="group"
121
151
  aria-label={i18n.t("plugin.richtext:asset.label", {
122
152
  collection: collection?.labels.singular ?? relationTo,
123
153
  filename,
124
154
  })}
125
155
  >
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
- >
156
+ <div class="ridu-richtext-upload-card__media">
157
+ <span
158
+ class={[
159
+ "ridu-richtext-upload-preview ridu-richtext-upload-card__preview",
160
+ document === undefined && !loadError && "is-loading",
161
+ ]}
162
+ >
163
+ {#if imageURL !== undefined}
164
+ <img class="ridu-richtext-upload-card__image" src={imageURL} alt={altText} />
165
+ {:else}
166
+ <span class="ridu-richtext-upload-card__file-preview">
167
+ <strong class="ridu-richtext-upload-card__extension">
174
168
  {loadError ? i18n.t("plugin.richtext:asset.unavailable") : extension}
169
+ </strong>
170
+ <span class="ridu-richtext-upload-card__preview-name">
171
+ {loadError ? i18n.t("plugin.richtext:asset.couldNotLoad") : filename}
175
172
  </span>
176
- {/if}
177
- </span>
178
- {/if}
173
+ </span>
174
+ {/if}
175
+ </span>
179
176
 
180
177
  {#if isEditable()}
181
178
  <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
- ]}
179
+ class={["ridu-richtext-upload-card__actions", isSelected() && "is-visible"]}
180
+ role="group"
186
181
  aria-label={i18n.t("plugin.richtext:asset.actions")}
187
182
  >
183
+ <Button
184
+ class="ridu-richtext-upload-card__action"
185
+ variant="ghost"
186
+ size="icon-sm"
187
+ onclick={replaceDocument}
188
+ aria-label={i18n.t("plugin.richtext:editor.replaceNamed", { name: filename })}
189
+ tooltip={i18n.t("plugin.richtext:asset.replace")}
190
+ >
191
+ <ReplaceIcon />
192
+ </Button>
193
+ <Button
194
+ class="ridu-richtext-upload-card__action"
195
+ variant="ghost"
196
+ size="icon-sm"
197
+ onclick={removeUpload}
198
+ aria-label={i18n.t("plugin.richtext:editor.removeNamed", { name: filename })}
199
+ tooltip={i18n.t("plugin.richtext:asset.remove")}
200
+ >
201
+ <RemoveIcon />
202
+ </Button>
203
+ </div>
204
+ {/if}
205
+ <div class="ridu-richtext-upload-card__metadata" aria-live="polite">
206
+ {#if isEditable()}
188
207
  <button
189
208
  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"
209
+ class="ridu-richtext-upload-card__filename"
191
210
  onclick={openDocument}
192
211
  aria-label={i18n.t("plugin.richtext:editor.editNamed", { name: filename })}
193
212
  >
194
213
  {filename}
195
214
  </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}
215
+ {:else}
216
+ <strong class="ridu-richtext-upload-card__filename">{filename}</strong>
217
+ {/if}
218
+ <span class="ridu-richtext-upload-card__collection">
219
+ {collection?.labels.singular ?? relationTo}
220
+ </span>
221
+ </div>
251
222
  </div>
252
223
 
253
224
  {#if isEditable() || caption !== ""}
254
- <figcaption class="border-t border-border bg-background/45 px-[0.85rem] py-[0.55rem]">
225
+ <figcaption class="ridu-richtext-upload-card__caption">
255
226
  {#if isEditable()}
256
- <div class="flex items-start gap-2">
227
+ <div class="ridu-richtext-upload-card__caption-editor">
257
228
  <svg
258
- class="mt-[0.3rem] size-3 shrink-0 text-foreground-faint"
229
+ class="ridu-richtext-upload-card__caption-icon"
259
230
  viewBox="0 0 24 24"
260
231
  fill="none"
261
232
  stroke="currentColor"
@@ -265,7 +236,7 @@
265
236
  <path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z" />
266
237
  </svg>
267
238
  <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"
239
+ class="ridu-richtext-upload-card__caption-input"
269
240
  rows={1}
270
241
  value={caption}
271
242
  oninput={updateCaption}
@@ -275,7 +246,7 @@
275
246
  />
276
247
  </div>
277
248
  {:else}
278
- <p class="text-start text-[13px] leading-[1.45] text-foreground-muted">{caption}</p>
249
+ <p class="ridu-richtext-upload-card__caption-text">{caption}</p>
279
250
  {/if}
280
251
  </figcaption>
281
252
  {/if}
@@ -93,6 +93,7 @@ export class UploadNode extends DecoratorBlockNode {
93
93
  props: {
94
94
  caption: this.__caption,
95
95
  documentID: this.__documentID,
96
+ format: this.__format,
96
97
  nodeKey: this.getKey(),
97
98
  relationTo: this.__relationTo,
98
99
  },
@@ -1,19 +1,8 @@
1
1
  <script lang="ts">
2
2
  import type { FieldAuthoringHost } from "@riducms/plugin";
3
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";
4
+ import { mergeRegister } from "@lexical/utils";
5
+ import { $getNodeByKey, COMMAND_PRIORITY_EDITOR, type NodeKey } from "lexical";
17
6
  import { useLexicalComposerContext } from "@hvniel/lexical-svelte";
18
7
 
19
8
  import {
@@ -22,6 +11,10 @@
22
11
  UPDATE_UPLOAD_CAPTION_COMMAND,
23
12
  } from "@plugin-richtext/menu/rich-text-commands";
24
13
  import type { RichTextConfig } from "@plugin-richtext/field/rich-text-config";
14
+ import {
15
+ insertReferenceNode,
16
+ removeReferenceNode,
17
+ } from "@plugin-richtext/field/reference-node-placement";
25
18
  import {
26
19
  createUploadNode,
27
20
  isUploadNode,
@@ -39,7 +32,7 @@
39
32
  } = $props();
40
33
  const editor = useLexicalComposerContext()[0];
41
34
  let browserOpen = $state(false);
42
- let targetCollection = $state<SchemaCollection>();
35
+ let targetCollection = $state.raw<SchemaCollection>();
43
36
  let documentID = $state<string>();
44
37
  let browserMode = $state<"edit" | "insert" | "replace">("insert");
45
38
  let replaceNodeKey: NodeKey | undefined;
@@ -97,16 +90,7 @@
97
90
  (nodeKey) => {
98
91
  const node = $getNodeByKey(nodeKey);
99
92
  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
- }
93
+ removeReferenceNode(node);
110
94
  return true;
111
95
  },
112
96
  COMMAND_PRIORITY_EDITOR
@@ -125,18 +109,11 @@
125
109
  editor.update(() => {
126
110
  if (replaceNodeKey !== undefined) {
127
111
  const current = $getNodeByKey(replaceNodeKey);
128
- if (isUploadNode(current)) current.setReference(collection.slug, id);
112
+ if (isUploadNode(current)) current.setReference(collection.slug, id).setCaption("");
129
113
  return;
130
114
  }
131
- const selection = $getSelection() ?? $getPreviousSelection();
132
- if (!$isRangeSelection(selection)) $getRoot().selectEnd();
133
- const focusNode = $isRangeSelection(selection) ? selection.focus.getNode() : undefined;
134
115
  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();
116
+ insertReferenceNode(upload);
140
117
  });
141
118
  closeBrowser();
142
119
  }
@@ -1,83 +0,0 @@
1
- .ridu-richtext-tags span + span::before {
2
- margin-inline-end: 0.5rem;
3
- color: var(--ink-faint);
4
- content: "·";
5
- }
6
-
7
- .ridu-richtext-content ::selection {
8
- background: color-mix(in srgb, var(--primary) 28%, transparent);
9
- color: var(--foreground);
10
- }
11
-
12
- .ridu-richtext-content > .ridu-richtext-heading:first-child {
13
- margin-top: 0;
14
- }
15
-
16
- .ridu-richtext-list-item::marker {
17
- color: var(--brand-primary-soft);
18
- }
19
-
20
- .ridu-richtext-checklist .ridu-richtext-list-item {
21
- position: relative;
22
- padding-inline-start: 1.75rem;
23
- }
24
-
25
- .ridu-richtext-checklist .ridu-richtext-list-item::before {
26
- position: absolute;
27
- top: 0.42rem;
28
- inset-inline-start: 0;
29
- width: 0.9rem;
30
- height: 0.9rem;
31
- border: 1px solid var(--border);
32
- border-radius: 0.25rem;
33
- content: "";
34
- }
35
-
36
- .ridu-richtext-checklist .ridu-richtext-list-item-checked::before {
37
- border-color: var(--primary);
38
- background: var(--primary);
39
- box-shadow: inset 0 0 0 2px var(--background);
40
- }
41
-
42
- .ridu-richtext-rule.is-selected {
43
- border-top-color: color-mix(in srgb, var(--primary) 60%, transparent);
44
- }
45
-
46
- /* Lexical toggles this inline; keep the toolbar measurable while preventing interaction. */
47
- .ridu-richtext-block-toolbar[style*="display: none"] {
48
- opacity: 0 !important;
49
- pointer-events: none;
50
- }
51
-
52
- /* Lexical positions the menu 4px into the canvas; cancel that offset and center the grip on its rule. */
53
- .ridu-richtext-block-toolbar {
54
- margin-inline-start: calc(-4px - 0.4rem);
55
- }
56
-
57
- .ridu-richtext-block-grip span {
58
- width: 2px;
59
- height: 2px;
60
- border-radius: 50%;
61
- background: currentColor;
62
- }
63
-
64
- .ridu-richtext-upload-preview.is-loading::after {
65
- position: absolute;
66
- inset: 0;
67
- background: color-mix(in srgb, var(--foreground) 3%, transparent);
68
- content: "";
69
- }
70
-
71
- @keyframes ridu-richtext-menu-in {
72
- from {
73
- opacity: 0;
74
- transform: translateY(0.25rem) scale(0.985);
75
- }
76
- }
77
-
78
- @keyframes ridu-richtext-toolbar-in {
79
- from {
80
- opacity: 0;
81
- transform: translateY(0.2rem) scale(0.985);
82
- }
83
- }