@riducms/plugin-richtext 0.2.3 → 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
package/README.md CHANGED
@@ -15,7 +15,7 @@ Add `richtext.New()` to `Config.Plugins` and author fields with `richtext.Field`
15
15
  registry imports `richTextAdminPlugin`; do not register it again. Generation rejects incompatible Go
16
16
  and npm pairing metadata before the editor loads.
17
17
 
18
- Run `npm run dev`, verify the editor, then create and review the adapter migration before deployment:
18
+ Run `bun run dev`, verify the editor, then create and review the adapter migration before deployment:
19
19
 
20
20
  ```sh
21
21
  ridu migrate create --name add-rich-text
@@ -33,3 +33,9 @@ Portable types are available from `@riducms/plugin-richtext/document`, HTML rend
33
33
  `@riducms/plugin-richtext/render`, and optional typed Svelte rendering from
34
34
  `@riducms/plugin-richtext/svelte`. These entries do not load the admin editor. See the rich-text
35
35
  guide for strict payloads, localization, typed renderers and experimental-content migration.
36
+
37
+ The supported authoring UI uses semantic SCSS, compact grouped insertion menus, six heading levels,
38
+ Markdown shortcuts, interactive checklists, custom-URL link drawers and intrinsic-ratio media cards.
39
+ See [customizing admin CSS](https://riducms.com/docs/admin/customizing-css/) for styling and the
40
+ [rich-text guide](https://riducms.com/docs/rich-text/) for supported features and limits.
41
+ No utility stylesheet is needed for this plugin's UI.
package/package.json CHANGED
@@ -9,12 +9,13 @@
9
9
  "@lexical/history": "0.49.0",
10
10
  "@lexical/link": "0.49.0",
11
11
  "@lexical/list": "0.49.0",
12
+ "@lexical/markdown": "0.49.0",
12
13
  "@lexical/rich-text": "0.49.0",
13
14
  "@lexical/selection": "0.49.0",
14
15
  "@lexical/utils": "0.49.0",
15
- "@riducms/plugin": "0.2.3",
16
- "@riducms/protocol": "0.2.3",
17
- "@riducms/ui": "0.2.3",
16
+ "@riducms/plugin": "0.3.0",
17
+ "@riducms/protocol": "0.3.0",
18
+ "@riducms/ui": "0.3.0",
18
19
  "@types/prismjs": "^1.26.5",
19
20
  "lexical": "0.49.0",
20
21
  "prismjs": "^1.30.0",
@@ -22,7 +23,7 @@
22
23
  },
23
24
  "description": "Svelte and Lexical admin integration for Ridu's official rich-text plugin.",
24
25
  "devDependencies": {
25
- "@riducms/build": "0.2.3"
26
+ "@riducms/build": "0.3.0"
26
27
  },
27
28
  "exports": {
28
29
  ".": "./src/index.ts",
@@ -38,6 +39,7 @@
38
39
  "imports": {
39
40
  "#richtext/document": "./src/document.ts",
40
41
  "#richtext/document-validation": "./src/document-validation.ts",
42
+ "#richtext/link-url-policy": "./src/link-url-policy.ts",
41
43
  "#richtext/render": "./src/render.ts",
42
44
  "#richtext/rich-text.svelte": "./src/render/rich-text.svelte",
43
45
  "#richtext/svelte": "./src/render/index.ts"
@@ -59,5 +61,5 @@
59
61
  "test": "bun test"
60
62
  },
61
63
  "type": "module",
62
- "version": "0.2.3"
64
+ "version": "0.3.0"
63
65
  }
@@ -0,0 +1,139 @@
1
+ @use "@riducms/ui/styles.scss" as ui;
2
+
3
+ @layer ridu.components {
4
+ .ridu-richtext-block-card {
5
+ position: relative;
6
+ z-index: 1;
7
+ margin-block: 10px;
8
+ padding: 15px;
9
+ border: 1px solid var(--control-border);
10
+ border-radius: var(--control-radius);
11
+ background: var(--control-surface);
12
+ box-shadow: var(--control-shadow);
13
+ color: var(--foreground);
14
+ font-family: var(--font-sans);
15
+ line-height: var(--line-height-body);
16
+ transition:
17
+ border-color 100ms,
18
+ box-shadow 100ms;
19
+
20
+ &:hover {
21
+ border-color: var(--control-border-hover);
22
+ }
23
+
24
+ &.is-selected {
25
+ border-color: var(--foreground-muted);
26
+ box-shadow:
27
+ var(--control-shadow),
28
+ 0 0 0 1px var(--foreground-muted);
29
+ }
30
+
31
+ &.has-issues {
32
+ border-color: var(--control-invalid-border);
33
+ }
34
+
35
+ &.has-name-field {
36
+ white-space: normal;
37
+ }
38
+ }
39
+
40
+ .ridu-richtext-block-card__name {
41
+ margin-block-end: 10px;
42
+ }
43
+
44
+ .ridu-richtext-block-card__header {
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: space-between;
48
+ gap: 12px;
49
+ min-width: 0;
50
+ }
51
+
52
+ .ridu-richtext-block-card__select {
53
+ min-width: 96px;
54
+ flex: 1;
55
+ border: 0;
56
+ border-radius: var(--control-radius);
57
+ padding: 2px;
58
+ background: transparent;
59
+ color: inherit;
60
+ font: inherit;
61
+ text-align: start;
62
+ cursor: pointer;
63
+
64
+ &:focus-visible {
65
+ @include ui.focus-outline;
66
+ }
67
+ }
68
+
69
+ .ridu-richtext-block-card__label,
70
+ .ridu-richtext-block-card__summary {
71
+ display: block;
72
+ overflow: hidden;
73
+ text-overflow: ellipsis;
74
+ white-space: nowrap;
75
+ }
76
+
77
+ .ridu-richtext-block-card__label {
78
+ color: var(--foreground-muted);
79
+ font-size: 12px;
80
+ font-weight: 500;
81
+ line-height: 16px;
82
+ }
83
+
84
+ .ridu-richtext-block-card__summary {
85
+ margin-block-start: 4px;
86
+ color: var(--foreground);
87
+ font-size: var(--font-size-body);
88
+ line-height: var(--line-height-body);
89
+ }
90
+
91
+ .ridu-richtext-block-card__actions {
92
+ display: flex;
93
+ align-items: center;
94
+ flex-shrink: 0;
95
+ gap: 2px;
96
+ }
97
+
98
+ .ridu-richtext-block-card__action {
99
+ padding-inline: 8px;
100
+ }
101
+
102
+ .ridu-richtext-block-card__status {
103
+ margin: 10px 0 0;
104
+ color: var(--destructive);
105
+ font-size: var(--font-size-body);
106
+ line-height: var(--line-height-body);
107
+ }
108
+
109
+ .ridu-richtext-block-message {
110
+ margin: 8px 0;
111
+ color: var(--destructive);
112
+ font-size: var(--font-size-body);
113
+ line-height: var(--line-height-body);
114
+ }
115
+
116
+ .ridu-richtext-block-card__recovery {
117
+ display: flex;
118
+ align-items: flex-start;
119
+ justify-content: space-between;
120
+ gap: 12px;
121
+ margin-block-start: 10px;
122
+
123
+ .ridu-richtext-block-card__status {
124
+ margin: 0;
125
+ }
126
+ }
127
+
128
+ @media (max-width: ui.$small) {
129
+ .ridu-richtext-block-card__header,
130
+ .ridu-richtext-block-card__recovery {
131
+ align-items: stretch;
132
+ flex-direction: column;
133
+ }
134
+
135
+ .ridu-richtext-block-card__actions {
136
+ flex-wrap: wrap;
137
+ }
138
+ }
139
+ }
@@ -20,6 +20,7 @@
20
20
  MOVE_BLOCK_COMMAND,
21
21
  UPDATE_BLOCK_NAME_COMMAND,
22
22
  } from "@plugin-richtext/menu/rich-text-commands";
23
+ import "@plugin-richtext/block/rich-text-block-card.scss";
23
24
 
24
25
  let {
25
26
  nodeKey,
@@ -133,10 +134,10 @@
133
134
 
134
135
  <article
135
136
  class={[
136
- "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",
137
- nameField !== undefined && "whitespace-normal",
138
- selected() && "border-primary",
139
- issues.length > 0 && "border-destructive",
137
+ "ridu-richtext-embedded-card ridu-richtext-block-card",
138
+ nameField !== undefined && "has-name-field",
139
+ selected() && "is-selected",
140
+ issues.length > 0 && "has-issues",
140
141
  ]}
141
142
  data-block-key={identity}
142
143
  data-block-type={blockType}
@@ -146,7 +147,7 @@
146
147
  >
147
148
  {#if nameField !== undefined && authoring?.schemaHeader !== undefined}
148
149
  <div
149
- class="mb-2"
150
+ class="ridu-richtext-block-card__name"
150
151
  onfocuscapture={nameHistory.reset.bind(nameHistory)}
151
152
  onblurcapture={nameHistory.reset.bind(nameHistory)}
152
153
  onkeydowncapture={nameKeydown}
@@ -159,11 +160,11 @@
159
160
  })}
160
161
  </div>
161
162
  {/if}
162
- <div class="flex flex-wrap items-center justify-between gap-3">
163
+ <div class="ridu-richtext-block-card__header">
163
164
  <button
164
165
  type="button"
165
166
  data-block-select
166
- class="min-w-24 flex-1 text-start rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
167
+ class="ridu-richtext-block-card__select"
167
168
  aria-label={i18n.t("plugin.richtext:block.select", {
168
169
  label: type?.labels.singular ?? blockType,
169
170
  })}
@@ -171,36 +172,56 @@
171
172
  onclickcapture={select}
172
173
  onkeydowncapture={keydown}
173
174
  >
174
- <span class="block text-xs font-medium text-foreground-muted">
175
+ <span class="ridu-richtext-block-card__label">
175
176
  {type?.labels.singular ?? blockType}
176
177
  </span>
177
- {#if nameField === undefined}<span class="block mt-1 truncate text-sm text-foreground">
178
+ {#if nameField === undefined}
179
+ <span class="ridu-richtext-block-card__summary">
178
180
  {summary}
179
- </span>{/if}
181
+ </span>
182
+ {/if}
180
183
  </button>
181
184
  {#if editable()}
182
- <div class="flex shrink-0 gap-1">
183
- <Button size="sm" variant="ghost" onclick={edit} disabled={recovery}>
185
+ <div
186
+ class="ridu-richtext-block-card__actions"
187
+ role="group"
188
+ aria-label={i18n.t("plugin.richtext:editor.blockActions")}
189
+ >
190
+ <Button
191
+ class="ridu-richtext-block-card__action"
192
+ size="sm"
193
+ variant="ghost"
194
+ onclick={edit}
195
+ disabled={recovery}
196
+ >
184
197
  {i18n.t("plugin.richtext:block.edit")}
185
198
  </Button>
186
- <Button size="sm" variant="ghost" onclick={duplicate} disabled={recovery}>
199
+ <Button
200
+ class="ridu-richtext-block-card__action"
201
+ size="sm"
202
+ variant="ghost"
203
+ onclick={duplicate}
204
+ disabled={recovery}
205
+ >
187
206
  {i18n.t("plugin.richtext:block.duplicate")}
188
207
  </Button>
189
- <Button size="sm" variant="ghost" onclick={remove}>
208
+ <Button class="ridu-richtext-block-card__action" size="sm" variant="ghost" onclick={remove}>
190
209
  {i18n.t("plugin.richtext:block.remove")}
191
210
  </Button>
192
211
  </div>
193
212
  {/if}
194
213
  </div>
195
214
  {#if recovery}
196
- <p role="alert" class="mt-2 text-sm text-destructive">
197
- {i18n.t("plugin.richtext:block.recovery")}
198
- </p>
199
- <Button size="sm" variant="outline" onclick={exportBlock}>
200
- {i18n.t("plugin.richtext:block.export")}
201
- </Button>
215
+ <div class="ridu-richtext-block-card__recovery">
216
+ <p role="alert" class="ridu-richtext-block-card__status">
217
+ {i18n.t("plugin.richtext:block.recovery")}
218
+ </p>
219
+ <Button size="sm" variant="outline" onclick={exportBlock}>
220
+ {i18n.t("plugin.richtext:block.export")}
221
+ </Button>
222
+ </div>
202
223
  {:else if issues.length > 0}
203
- <p class="mt-2 text-sm text-destructive">
224
+ <p class="ridu-richtext-block-card__status">
204
225
  {i18n.t("plugin.richtext:block.issues", { count: issues.length })}
205
226
  </p>
206
227
  {/if}
@@ -1,7 +1,7 @@
1
+ import { isRecord } from "@riducms/protocol";
1
2
  import type { FieldAuthoringHost } from "@riducms/plugin";
2
3
  import type { SchemaBlockType } from "@riducms/protocol";
3
4
  import type { SerializedLexicalNode } from "lexical";
4
- import { isRecord } from "@plugin-richtext/field/rich-text-blocks";
5
5
  import { documentRecoveryIssue } from "@plugin-richtext/document-validation";
6
6
 
7
7
  /** Validates first, then copies only declared payloads through the generic schema host. */
@@ -1,3 +1,4 @@
1
+ import { isRecord } from "@riducms/protocol";
1
2
  import type { Decorator } from "@hvniel/lexical-svelte";
2
3
  import {
3
4
  $applyNodeReplacement,
@@ -9,7 +10,6 @@ import {
9
10
  type SerializedLexicalNode,
10
11
  } from "lexical";
11
12
  import RichTextBlockCard from "@plugin-richtext/block/rich-text-block-card.svelte";
12
- import { isRecord } from "@plugin-richtext/field/rich-text-blocks";
13
13
 
14
14
  /** Retains the received envelope verbatim so historical content can be exported safely. */
15
15
  export type SerializedBlockNode = SerializedLexicalNode & Record<string, unknown>;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { isRecord } from "@riducms/protocol";
2
3
  import { getAdminI18n, type EmbeddedSchemaDraft, type FieldAuthoringHost } from "@riducms/plugin";
3
4
  import type { SchemaField } from "@riducms/protocol";
4
5
  import { useLexicalComposerContext } from "@hvniel/lexical-svelte";
@@ -9,7 +10,6 @@
9
10
  $insertGeneratedNodes,
10
11
  } from "@lexical/clipboard";
11
12
  import { copyBlockClipboardNodes } from "@plugin-richtext/block/rich-text-block-clipboard";
12
- import { isRecord } from "@plugin-richtext/field/rich-text-blocks";
13
13
  import { $insertNodeToNearestRoot, mergeRegister } from "@lexical/utils";
14
14
  import {
15
15
  $addUpdateTag,
@@ -46,6 +46,7 @@
46
46
  MOVE_BLOCK_COMMAND,
47
47
  UPDATE_BLOCK_NAME_COMMAND,
48
48
  } from "@plugin-richtext/menu/rich-text-commands";
49
+ import "@plugin-richtext/block/rich-text-block-card.scss";
49
50
 
50
51
  let { authoring, field }: { authoring: FieldAuthoringHost | undefined; field: SchemaField } =
51
52
  $props();
@@ -319,7 +320,9 @@
319
320
  });
320
321
  </script>
321
322
 
322
- {#if message !== ""}<p role="alert" class="text-sm text-destructive">{message}</p>{/if}
323
+ {#if message !== ""}
324
+ <p role="alert" class="ridu-richtext-block-message">{message}</p>
325
+ {/if}
323
326
  {#if session !== undefined && authoring?.schemaDraftEditor !== undefined}
324
327
  {@render authoring.schemaDraftEditor({
325
328
  draft: session.draft,
@@ -1,4 +1,6 @@
1
+ import { isRecord } from "@riducms/protocol";
1
2
  import type { RichTextDocument } from "#richtext/document";
3
+ import { renderableRichTextURL } from "#richtext/link-url-policy";
2
4
  const base = ["type", "version"];
3
5
  const element = [...base, "children", "direction", "format", "indent"];
4
6
  const container = [...element, "textFormat", "textStyle"];
@@ -21,10 +23,6 @@ const properties: Readonly<Record<string, readonly string[]>> = {
21
23
  const integers = new Set(["detail", "textFormat", "indent", "start", "value"]);
22
24
  const nullableStrings = new Set(["target", "rel", "title", "direction"]);
23
25
 
24
- function isRecord(value: unknown): value is Record<string, unknown> {
25
- return value !== null && typeof value === "object" && !Array.isArray(value);
26
- }
27
-
28
26
  /** Return the first unsupported envelope path without interpreting schema-owned payloads.
29
27
  * Both editor admission and portable rendering use this lossless JSON boundary.
30
28
  */
@@ -76,7 +74,11 @@ export function documentRecoveryIssue(
76
74
  !["", "left", "start", "center", "right", "end", "justify"].includes(String(node.format))
77
75
  )
78
76
  return `${path}.format`;
79
- if (type === "link" && typeof node.url !== "string") return `${path}.url`;
77
+ if (
78
+ type === "link" &&
79
+ (typeof node.url !== "string" || renderableRichTextURL(node.url) === undefined)
80
+ )
81
+ return `${path}.url`;
80
82
  if (type === "heading" && !/^h[1-6]$/.test(String(node.tag))) return `${path}.tag`;
81
83
  if (type === "list" && !["bullet", "number", "check"].includes(String(node.listType)))
82
84
  return `${path}.listType`;
@@ -0,0 +1,49 @@
1
+ import { $insertNodeToNearestRoot } from "@lexical/utils";
2
+ import {
3
+ $createParagraphNode,
4
+ $getPreviousSelection,
5
+ $getRoot,
6
+ $getSelection,
7
+ $isElementNode,
8
+ $isParagraphNode,
9
+ $isRangeSelection,
10
+ type LexicalNode,
11
+ } from "lexical";
12
+
13
+ /** Inserts a reference block and a usable trailing selection within a Lexical update scope. */
14
+ export function insertReferenceNode(node: LexicalNode) {
15
+ const selection = $getSelection() ?? $getPreviousSelection();
16
+ if (!$isRangeSelection(selection)) $getRoot().selectEnd();
17
+ const focusNode = $isRangeSelection(selection) ? selection.focus.getNode() : undefined;
18
+
19
+ $insertNodeToNearestRoot(node);
20
+ const next = node.getNextSibling();
21
+ if ($isParagraphNode(focusNode) && focusNode.getChildrenSize() === 0 && focusNode !== next) {
22
+ focusNode.remove();
23
+ }
24
+
25
+ const trailing = node.getNextSibling();
26
+ if ($isParagraphNode(trailing) && trailing.getChildrenSize() === 0) {
27
+ trailing.select();
28
+ return;
29
+ }
30
+
31
+ const paragraph = $createParagraphNode();
32
+ node.insertAfter(paragraph);
33
+ paragraph.select();
34
+ }
35
+
36
+ /** Removes a reference block and selects a valid neighbour within a Lexical update scope. */
37
+ export function removeReferenceNode(node: LexicalNode) {
38
+ const next = node.getNextSibling();
39
+ const previous = node.getPreviousSibling();
40
+ node.remove();
41
+
42
+ if ($isElementNode(next)) next.selectStart();
43
+ else if ($isElementNode(previous)) previous.selectEnd();
44
+ else {
45
+ const paragraph = $createParagraphNode();
46
+ $getRoot().append(paragraph);
47
+ paragraph.select();
48
+ }
49
+ }
@@ -1,4 +1,4 @@
1
- import { resolveBlockTypes } from "@riducms/protocol";
1
+ import { isRecord, resolveBlockTypes } from "@riducms/protocol";
2
2
  import type { SchemaBlockType, SchemaField } from "@riducms/protocol";
3
3
 
4
4
  /** The allowlist comes from resolved executable schemas, never plugin settings. */
@@ -12,10 +12,6 @@ export function richTextBlockTypes(field: SchemaField): readonly SchemaBlockType
12
12
  );
13
13
  }
14
14
 
15
- export function isRecord(value: unknown): value is Record<string, unknown> {
16
- return typeof value === "object" && value !== null && !Array.isArray(value);
17
- }
18
-
19
15
  export function blockSummary(fields: Record<string, unknown>, type?: SchemaBlockType): string {
20
16
  // Named cards render their guarded header instead. Never mirror an editorial name
21
17
  // into plugin-owned text, because the configured field may be unreadable.
@@ -1,3 +1,4 @@
1
+ import { isRecord } from "@riducms/protocol";
1
2
  export type RichTextFeature =
2
3
  "links" | "lists" | "code" | "horizontal-rule" | "uploads" | "relationships" | "blocks";
3
4
 
@@ -44,7 +45,3 @@ export function hasRichTextFeature(config: RichTextConfig, feature: RichTextFeat
44
45
  function isRichTextFeature(value: unknown): value is RichTextFeature {
45
46
  return typeof value === "string" && features.has(value as RichTextFeature);
46
47
  }
47
-
48
- function isRecord(value: unknown): value is Record<string, unknown> {
49
- return typeof value === "object" && value !== null && !Array.isArray(value);
50
- }
@@ -1,3 +1,4 @@
1
+ import { isRecord } from "@riducms/protocol";
1
2
  import { documentRecoveryIssue } from "@plugin-richtext/document-validation";
2
3
  import type { RichTextConfig } from "@plugin-richtext/field/rich-text-config";
3
4
 
@@ -38,7 +39,3 @@ function withElementDefaults(node: Record<string, unknown>): Record<string, unkn
38
39
  indent: node.indent ?? 0,
39
40
  };
40
41
  }
41
-
42
- function isRecord(value: unknown): value is Record<string, unknown> {
43
- return typeof value === "object" && value !== null && !Array.isArray(value);
44
- }