@riducms/plugin-richtext 0.2.1 → 0.2.3

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/package.json CHANGED
@@ -12,9 +12,9 @@
12
12
  "@lexical/rich-text": "0.49.0",
13
13
  "@lexical/selection": "0.49.0",
14
14
  "@lexical/utils": "0.49.0",
15
- "@riducms/plugin": "0.2.1",
16
- "@riducms/protocol": "0.2.1",
17
- "@riducms/ui": "0.2.1",
15
+ "@riducms/plugin": "0.2.3",
16
+ "@riducms/protocol": "0.2.3",
17
+ "@riducms/ui": "0.2.3",
18
18
  "@types/prismjs": "^1.26.5",
19
19
  "lexical": "0.49.0",
20
20
  "prismjs": "^1.30.0",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "description": "Svelte and Lexical admin integration for Ridu's official rich-text plugin.",
24
24
  "devDependencies": {
25
- "@riducms/build": "0.2.1"
25
+ "@riducms/build": "0.2.3"
26
26
  },
27
27
  "exports": {
28
28
  ".": "./src/index.ts",
@@ -59,5 +59,5 @@
59
59
  "test": "bun test"
60
60
  },
61
61
  "type": "module",
62
- "version": "0.2.1"
62
+ "version": "0.2.3"
63
63
  }
@@ -4,7 +4,7 @@
4
4
  useLexicalEditable,
5
5
  useLexicalNodeSelection,
6
6
  } from "@hvniel/lexical-svelte";
7
- import { $getNodeByKey, type NodeKey } from "lexical";
7
+ import { $getNodeByKey, REDO_COMMAND, UNDO_COMMAND, type NodeKey } from "lexical";
8
8
  import { getAdminI18n } from "@riducms/plugin";
9
9
  import { Button } from "@riducms/ui";
10
10
  import {
@@ -12,11 +12,13 @@
12
12
  getRichTextAuthoringHost,
13
13
  } from "@plugin-richtext/field/rich-text-context.svelte";
14
14
  import { blockSummary, richTextBlockTypes } from "@plugin-richtext/field/rich-text-blocks";
15
+ import { BlockNameHistorySession } from "@plugin-richtext/block/rich-text-block-name";
15
16
  import {
16
17
  OPEN_BLOCK_EDITOR_COMMAND,
17
18
  DUPLICATE_BLOCK_COMMAND,
18
19
  REMOVE_BLOCK_COMMAND,
19
20
  MOVE_BLOCK_COMMAND,
21
+ UPDATE_BLOCK_NAME_COMMAND,
20
22
  } from "@plugin-richtext/menu/rich-text-commands";
21
23
 
22
24
  let {
@@ -29,6 +31,7 @@
29
31
  const editable = useLexicalEditable();
30
32
  const context = getRichTextField();
31
33
  const authoring = getRichTextAuthoringHost();
34
+ const nameHistory = new BlockNameHistorySession();
32
35
  // The decorator instance owns this fixed Lexical node key.
33
36
  // svelte-ignore state_referenced_locally
34
37
  const [selected, setSelected, clearSelected] = useLexicalNodeSelection(nodeKey);
@@ -41,6 +44,7 @@
41
44
  const type = $derived(
42
45
  richTextBlockTypes(context.field).find((candidate) => candidate.slug === blockType)
43
46
  );
47
+ const nameField = $derived(type?.admin?.nameField);
44
48
  const issues = $derived(authoring?.schemaIssues?.({ treeKey: "blocks", identity }) ?? []);
45
49
  const recovery = $derived(type === undefined || !validEnvelope);
46
50
  const summary = $derived(blockSummary(fields, type) || i18n.t("plugin.richtext:block.noSummary"));
@@ -49,6 +53,34 @@
49
53
  if (!event.shiftKey) clearSelected();
50
54
  setSelected(true);
51
55
  }
56
+ function changeName(change: { field: string; value: string }) {
57
+ if (nameField === undefined || recovery || !editable()) return;
58
+ editor.dispatchCommand(UPDATE_BLOCK_NAME_COMMAND, {
59
+ nodeKey,
60
+ identity,
61
+ nameField,
62
+ change,
63
+ historyTag: nameHistory.nextTag(),
64
+ });
65
+ }
66
+ function nameKeydown(event: KeyboardEvent) {
67
+ if (!(event.target instanceof HTMLInputElement)) return;
68
+ event.stopPropagation();
69
+ if (event.isComposing) return;
70
+ if (event.key === "Enter") {
71
+ event.preventDefault();
72
+ return;
73
+ }
74
+ if (event.target.readOnly || event.target.disabled) return;
75
+ const key = event.key.toLowerCase();
76
+ const modifier = event.metaKey || event.ctrlKey;
77
+ const undo = modifier && key === "z" && !event.shiftKey;
78
+ const redo = modifier && ((key === "z" && event.shiftKey) || (key === "y" && !event.metaKey));
79
+ if (!undo && !redo) return;
80
+ event.preventDefault();
81
+ nameHistory.reset();
82
+ editor.dispatchCommand(undo ? UNDO_COMMAND : REDO_COMMAND, undefined);
83
+ }
52
84
  function edit() {
53
85
  editor.dispatchCommand(OPEN_BLOCK_EDITOR_COMMAND, { nodeKey });
54
86
  }
@@ -102,6 +134,7 @@
102
134
  <article
103
135
  class={[
104
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",
105
138
  selected() && "border-primary",
106
139
  issues.length > 0 && "border-destructive",
107
140
  ]}
@@ -111,11 +144,26 @@
111
144
  data-invalid={issues.length > 0}
112
145
  role="group"
113
146
  >
114
- <div class="flex items-center justify-between gap-3">
147
+ {#if nameField !== undefined && authoring?.schemaHeader !== undefined}
148
+ <div
149
+ class="mb-2"
150
+ onfocuscapture={nameHistory.reset.bind(nameHistory)}
151
+ onblurcapture={nameHistory.reset.bind(nameHistory)}
152
+ onkeydowncapture={nameKeydown}
153
+ >
154
+ {@render authoring.schemaHeader({
155
+ treeKey: "blocks",
156
+ identity,
157
+ readOnly: !editable() || recovery,
158
+ onChange: changeName,
159
+ })}
160
+ </div>
161
+ {/if}
162
+ <div class="flex flex-wrap items-center justify-between gap-3">
115
163
  <button
116
164
  type="button"
117
165
  data-block-select
118
- class="min-w-0 flex-1 text-start rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
166
+ class="min-w-24 flex-1 text-start rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
119
167
  aria-label={i18n.t("plugin.richtext:block.select", {
120
168
  label: type?.labels.singular ?? blockType,
121
169
  })}
@@ -126,7 +174,9 @@
126
174
  <span class="block text-xs font-medium text-foreground-muted">
127
175
  {type?.labels.singular ?? blockType}
128
176
  </span>
129
- <span class="block mt-1 truncate text-sm text-foreground">{summary}</span>
177
+ {#if nameField === undefined}<span class="block mt-1 truncate text-sm text-foreground">
178
+ {summary}
179
+ </span>{/if}
130
180
  </button>
131
181
  {#if editable()}
132
182
  <div class="flex shrink-0 gap-1">
@@ -0,0 +1,16 @@
1
+ import { HISTORY_MERGE_TAG, HISTORY_PUSH_TAG } from "lexical";
2
+
3
+ /** Groups one focused name-input session into one Lexical undo entry. */
4
+ export class BlockNameHistorySession {
5
+ #merge = false;
6
+
7
+ reset() {
8
+ this.#merge = false;
9
+ }
10
+
11
+ nextTag(): typeof HISTORY_PUSH_TAG | typeof HISTORY_MERGE_TAG {
12
+ const tag = this.#merge ? HISTORY_MERGE_TAG : HISTORY_PUSH_TAG;
13
+ this.#merge = true;
14
+ return tag;
15
+ }
16
+ }
@@ -2,6 +2,7 @@ import type { Decorator } from "@hvniel/lexical-svelte";
2
2
  import {
3
3
  $applyNodeReplacement,
4
4
  $getDocument,
5
+ $getNodeByKey,
5
6
  DecoratorNode,
6
7
  type LexicalNode,
7
8
  type NodeKey,
@@ -82,3 +83,23 @@ export function createBlockNode(fields: Record<string, unknown>): BlockNode {
82
83
  export function isBlockNode(node: LexicalNode | null | undefined): node is BlockNode {
83
84
  return node instanceof BlockNode;
84
85
  }
86
+
87
+ export interface BlockNameUpdate {
88
+ nodeKey: NodeKey;
89
+ identity: string;
90
+ nameField: string;
91
+ change: { field: string; value: string };
92
+ }
93
+
94
+ // @lexical-scope
95
+ export function updateBlockName(update: BlockNameUpdate): boolean {
96
+ const node = $getNodeByKey(update.nodeKey);
97
+ if (
98
+ !isBlockNode(node) ||
99
+ node.getFields()._key !== update.identity ||
100
+ update.change.field !== update.nameField
101
+ )
102
+ return false;
103
+ node.setFields({ ...node.getFields(), [update.nameField]: update.change.value });
104
+ return true;
105
+ }
@@ -33,13 +33,18 @@
33
33
  type BaseSelection,
34
34
  type NodeKey,
35
35
  } from "lexical";
36
- import { createBlockNode, isBlockNode } from "@plugin-richtext/block/rich-text-block-node";
36
+ import {
37
+ createBlockNode,
38
+ isBlockNode,
39
+ updateBlockName,
40
+ } from "@plugin-richtext/block/rich-text-block-node";
37
41
  import { richTextBlockTypes } from "@plugin-richtext/field/rich-text-blocks";
38
42
  import {
39
43
  OPEN_BLOCK_EDITOR_COMMAND,
40
44
  DUPLICATE_BLOCK_COMMAND,
41
45
  REMOVE_BLOCK_COMMAND,
42
46
  MOVE_BLOCK_COMMAND,
47
+ UPDATE_BLOCK_NAME_COMMAND,
43
48
  } from "@plugin-richtext/menu/rich-text-commands";
44
49
 
45
50
  let { authoring, field }: { authoring: FieldAuthoringHost | undefined; field: SchemaField } =
@@ -284,6 +289,15 @@
284
289
  },
285
290
  COMMAND_PRIORITY_EDITOR
286
291
  ),
292
+ editor.registerCommand(
293
+ UPDATE_BLOCK_NAME_COMMAND,
294
+ (update) => {
295
+ if (!editor.isEditable() || !updateBlockName(update)) return false;
296
+ $addUpdateTag(update.historyTag);
297
+ return true;
298
+ },
299
+ COMMAND_PRIORITY_EDITOR
300
+ ),
287
301
  ...[KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND].map((command) =>
288
302
  editor.registerCommand(
289
303
  command,
@@ -17,6 +17,9 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
17
17
  }
18
18
 
19
19
  export function blockSummary(fields: Record<string, unknown>, type?: SchemaBlockType): string {
20
+ // Named cards render their guarded header instead. Never mirror an editorial name
21
+ // into plugin-owned text, because the configured field may be unreadable.
22
+ if (type?.admin?.nameField !== undefined) return "";
20
23
  const summary = type?.admin?.rowLabel;
21
24
  if (summary !== undefined) {
22
25
  const value = summary
@@ -25,10 +25,14 @@ export function initialEditorState(value: unknown) {
25
25
 
26
26
  function withElementDefaults(node: Record<string, unknown>): Record<string, unknown> {
27
27
  if (!Array.isArray(node.children)) return node;
28
+ const children =
29
+ node.type === "root" && node.children.length === 0
30
+ ? [{ type: "paragraph", children: [], direction: null, format: "", indent: 0 }]
31
+ : node.children.map((child) => (isRecord(child) ? withElementDefaults(child) : child));
28
32
 
29
33
  return {
30
34
  ...node,
31
- children: node.children.map((child) => (isRecord(child) ? withElementDefaults(child) : child)),
35
+ children,
32
36
  direction: node.direction ?? null,
33
37
  format: node.format ?? "",
34
38
  indent: node.indent ?? 0,
@@ -1,4 +1,10 @@
1
- import { createCommand, type LexicalCommand, type NodeKey } from "lexical";
1
+ import {
2
+ createCommand,
3
+ HISTORY_MERGE_TAG,
4
+ HISTORY_PUSH_TAG,
5
+ type LexicalCommand,
6
+ type NodeKey,
7
+ } from "lexical";
2
8
 
3
9
  export interface OpenUploadBrowserPayload {
4
10
  collectionSlug: string;
@@ -41,3 +47,10 @@ export const DUPLICATE_BLOCK_COMMAND: LexicalCommand<NodeKey> =
41
47
  export const REMOVE_BLOCK_COMMAND: LexicalCommand<NodeKey> = createCommand("REMOVE_BLOCK_COMMAND");
42
48
  export const MOVE_BLOCK_COMMAND: LexicalCommand<{ nodeKey: NodeKey; direction: -1 | 1 }> =
43
49
  createCommand("MOVE_BLOCK_COMMAND");
50
+ export const UPDATE_BLOCK_NAME_COMMAND: LexicalCommand<{
51
+ nodeKey: NodeKey;
52
+ identity: string;
53
+ nameField: string;
54
+ change: { field: string; value: string };
55
+ historyTag: typeof HISTORY_PUSH_TAG | typeof HISTORY_MERGE_TAG;
56
+ }> = createCommand("UPDATE_BLOCK_NAME_COMMAND");