@riducms/plugin-richtext 0.1.4 → 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.
@@ -0,0 +1,104 @@
1
+ import type { RichTextDocument } from "#richtext/document";
2
+ const base = ["type", "version"];
3
+ const element = [...base, "children", "direction", "format", "indent"];
4
+ const container = [...element, "textFormat", "textStyle"];
5
+ const properties: Readonly<Record<string, readonly string[]>> = {
6
+ root: element,
7
+ paragraph: container,
8
+ heading: [...container, "tag"],
9
+ quote: container,
10
+ link: [...container, "url", "target", "rel", "title"],
11
+ list: [...container, "listType", "start", "tag"],
12
+ listitem: [...container, "value", "checked"],
13
+ code: [...container, "language", "theme"],
14
+ text: [...base, "text", "format", "detail", "mode", "style"],
15
+ linebreak: base,
16
+ horizontalrule: base,
17
+ upload: [...base, "relationTo", "id", "caption", "format"],
18
+ relationship: [...base, "relationTo", "id", "format"],
19
+ block: [...base, "fields"],
20
+ };
21
+ const integers = new Set(["detail", "textFormat", "indent", "start", "value"]);
22
+ const nullableStrings = new Set(["target", "rel", "title", "direction"]);
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return value !== null && typeof value === "object" && !Array.isArray(value);
26
+ }
27
+
28
+ /** Return the first unsupported envelope path without interpreting schema-owned payloads.
29
+ * Both editor admission and portable rendering use this lossless JSON boundary.
30
+ */
31
+ export function documentRecoveryIssue(
32
+ value: unknown,
33
+ allowedNodeTypes?: ReadonlySet<string>
34
+ ): string | undefined {
35
+ if (!isRecord(value)) return "document";
36
+ if (value.version !== 1) return "version";
37
+ const extra = Object.keys(value).find((key) => key !== "version" && key !== "root");
38
+ if (extra !== undefined) return extra;
39
+ let count = 0;
40
+ const visit = (node: unknown, path: string, depth: number): string | undefined => {
41
+ if (++count > 10_000 || depth > 64) return path;
42
+ if (!isRecord(node)) return path;
43
+ const type = node.type;
44
+ if (typeof type !== "string" || !Object.hasOwn(properties, type)) return `${path}.type`;
45
+ if ((depth === 0) !== (type === "root")) return `${path}.type`;
46
+ if (allowedNodeTypes !== undefined && !allowedNodeTypes.has(type)) return `${path}.type`;
47
+ if (("version" in node && node.version !== 1) || (type === "block" && node.version !== 1))
48
+ return `${path}.version`;
49
+ for (const [key, value] of Object.entries(node)) {
50
+ if (!properties[type]!.includes(key)) return `${path}.${key}`;
51
+ if (key === "type" || key === "version" || key === "children") continue;
52
+ if (key === "fields") {
53
+ if (!isRecord(value)) return `${path}.fields`;
54
+ } else if (integers.has(key) || (key === "format" && type === "text")) {
55
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) return `${path}.${key}`;
56
+ } else if (key === "checked") {
57
+ if (typeof value !== "boolean") return `${path}.${key}`;
58
+ } else if (!(nullableStrings.has(key) && value === null) && typeof value !== "string") {
59
+ return `${path}.${key}`;
60
+ }
61
+ }
62
+ if (type === "block") return isRecord(node.fields) ? undefined : `${path}.fields`;
63
+ if (type === "text" && typeof node.text !== "string") return `${path}.text`;
64
+ if ("mode" in node && !["normal", "token", "segmented"].includes(String(node.mode)))
65
+ return `${path}.mode`;
66
+ if (
67
+ "direction" in node &&
68
+ node.direction !== null &&
69
+ node.direction !== "ltr" &&
70
+ node.direction !== "rtl"
71
+ )
72
+ return `${path}.direction`;
73
+ if (
74
+ type !== "text" &&
75
+ "format" in node &&
76
+ !["", "left", "start", "center", "right", "end", "justify"].includes(String(node.format))
77
+ )
78
+ return `${path}.format`;
79
+ if (type === "link" && typeof node.url !== "string") return `${path}.url`;
80
+ if (type === "heading" && !/^h[1-6]$/.test(String(node.tag))) return `${path}.tag`;
81
+ if (type === "list" && !["bullet", "number", "check"].includes(String(node.listType)))
82
+ return `${path}.listType`;
83
+ if (type === "upload" || type === "relationship") {
84
+ if (typeof node.relationTo !== "string") return `${path}.relationTo`;
85
+ if (typeof node.id !== "string") return `${path}.id`;
86
+ }
87
+ if (properties[type]!.includes("children")) {
88
+ if (!Array.isArray(node.children)) return `${path}.children`;
89
+ for (let index = 0; index < node.children.length; index++) {
90
+ const issue = visit(node.children[index], `${path}.children.${index}`, depth + 1);
91
+ if (issue !== undefined) return issue;
92
+ }
93
+ }
94
+ return undefined;
95
+ };
96
+ return visit(value.root, "root", 0);
97
+ }
98
+
99
+ /** Decode the portable envelope; embedded payload semantics remain schema-owned. */
100
+ export function decodeRichTextDocument(value: unknown): RichTextDocument<unknown> {
101
+ const issue = documentRecoveryIssue(value);
102
+ if (issue !== undefined) throw new Error(`Invalid rich-text document at ${issue}.`);
103
+ return value as RichTextDocument<unknown>;
104
+ }
@@ -0,0 +1,76 @@
1
+ /** Portable JSON contracts. This entry point imports no editor, Svelte or DOM code. */
2
+ export interface RichTextDocument<Payload = never> {
3
+ version: 1;
4
+ root: RichTextRootNode<Payload>;
5
+ }
6
+
7
+ /** Payload is instantiated with generated input variants (required values and canonical IDs). */
8
+ export type RichTextDocumentInput<Payload = never> = RichTextDocument<Payload>;
9
+
10
+ export type RichTextElementFormat = "" | "left" | "center" | "right" | "justify" | "start" | "end";
11
+
12
+ export interface RichTextRootNode<Payload = never> {
13
+ type: "root";
14
+ version?: 1;
15
+ children: RichTextNode<Payload>[];
16
+ direction?: "ltr" | "rtl" | null;
17
+ format?: RichTextElementFormat;
18
+ indent?: number;
19
+ }
20
+
21
+ export type RichTextBlockNode<Payload> = [Payload] extends [never]
22
+ ? never
23
+ : { type: "block"; version: 1; fields: Payload };
24
+
25
+ export interface RichTextTextNode {
26
+ type: "text";
27
+ version?: 1;
28
+ text: string;
29
+ format?: number;
30
+ detail?: number;
31
+ mode?: "normal" | "token" | "segmented";
32
+ style?: string;
33
+ }
34
+
35
+ export interface RichTextElementNode<Payload = never> {
36
+ type: "paragraph" | "heading" | "quote" | "link" | "list" | "listitem" | "code";
37
+ version?: 1;
38
+ children: RichTextNode<Payload>[];
39
+ direction?: "ltr" | "rtl" | null;
40
+ format?: RichTextElementFormat;
41
+ indent?: number;
42
+ tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "ol" | "ul";
43
+ url?: string;
44
+ target?: string | null;
45
+ rel?: string | null;
46
+ title?: string | null;
47
+ listType?: "bullet" | "number" | "check";
48
+ start?: number;
49
+ value?: number;
50
+ checked?: boolean;
51
+ language?: string;
52
+ theme?: string;
53
+ textFormat?: number;
54
+ textStyle?: string;
55
+ }
56
+
57
+ export interface RichTextReferenceNode {
58
+ type: "upload" | "relationship";
59
+ version?: 1;
60
+ relationTo: string;
61
+ id: string;
62
+ caption?: string;
63
+ format?: RichTextElementFormat;
64
+ }
65
+
66
+ export type RichTextNode<Payload = never> =
67
+ | RichTextTextNode
68
+ | RichTextElementNode<Payload>
69
+ | RichTextReferenceNode
70
+ | { type: "linebreak" | "horizontalrule"; version?: 1 }
71
+ | RichTextBlockNode<Payload>;
72
+
73
+ /** Checked renderer dispatch retains the selected variant's exact payload. */
74
+ export type RichTextBlockRenderers<Payload extends { blockType: string }, Result> = {
75
+ [Slug in Payload["blockType"]]: (block: Extract<Payload, { blockType: Slug }>) => Result;
76
+ };
@@ -0,0 +1,37 @@
1
+ import { resolveBlockTypes } from "@riducms/protocol";
2
+ import type { SchemaBlockType, SchemaField } from "@riducms/protocol";
3
+
4
+ /** The allowlist comes from resolved executable schemas, never plugin settings. */
5
+ export function richTextBlockTypes(field: SchemaField): readonly SchemaBlockType[] {
6
+ return (
7
+ resolveBlockTypes(
8
+ field.plugin?.embeddedTrees
9
+ ?.find((tree) => tree.key === "blocks")
10
+ ?.cases.find((entry) => entry.tagValue === "block")
11
+ ) ?? []
12
+ );
13
+ }
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
+ export function blockSummary(fields: Record<string, unknown>, type?: SchemaBlockType): string {
20
+ const summary = type?.admin?.rowLabel;
21
+ if (summary !== undefined) {
22
+ const value = summary
23
+ .split(".")
24
+ .reduce<unknown>((value, key) => (isRecord(value) ? value[key] : undefined), fields);
25
+ if (typeof value === "string" && value.trim() !== "") return value;
26
+ }
27
+ for (const field of type?.fields ?? []) {
28
+ const value = fields[field.name];
29
+ if (
30
+ (field.type === "text" || field.type === "textarea") &&
31
+ typeof value === "string" &&
32
+ value.trim() !== ""
33
+ )
34
+ return value;
35
+ }
36
+ return "";
37
+ }
@@ -17,20 +17,23 @@ const features = new Set<RichTextFeature>([
17
17
  "blocks",
18
18
  ]);
19
19
 
20
- export function parseRichTextConfig(value: unknown): RichTextConfig {
21
- if (!isRecord(value)) return { features: [], uploadCollections: [], relationshipCollections: [] };
20
+ export function decodeRichTextConfig(value: unknown): RichTextConfig {
21
+ if (!isRecord(value)) throw new Error("Rich-text config must be an object.");
22
+ for (const key of Object.keys(value))
23
+ if (!["features", "uploadCollections", "relationshipCollections"].includes(key))
24
+ throw new Error(`Unsupported rich-text config key ${key}.`);
25
+ const strings = (key: string): string[] => {
26
+ const items = value[key] ?? [];
27
+ if (!Array.isArray(items) || items.some((item) => typeof item !== "string" || !item))
28
+ throw new Error(`${key} must be an array of nonempty strings.`);
29
+ return [...items];
30
+ };
31
+ const selected = strings("features");
32
+ if (!selected.every(isRichTextFeature)) throw new Error("Unsupported rich-text feature.");
22
33
  return {
23
- features: Array.isArray(value.features) ? value.features.filter(isRichTextFeature) : [],
24
- uploadCollections: Array.isArray(value.uploadCollections)
25
- ? value.uploadCollections.filter(
26
- (collection): collection is string => typeof collection === "string"
27
- )
28
- : [],
29
- relationshipCollections: Array.isArray(value.relationshipCollections)
30
- ? value.relationshipCollections.filter(
31
- (collection): collection is string => typeof collection === "string"
32
- )
33
- : [],
34
+ features: selected,
35
+ uploadCollections: strings("uploadCollections"),
36
+ relationshipCollections: strings("relationshipCollections"),
34
37
  };
35
38
  }
36
39
 
@@ -6,3 +6,12 @@ const [getRichTextAuthoringHost, setRichTextAuthoringHost] = createContext<
6
6
  >();
7
7
 
8
8
  export { getRichTextAuthoringHost, setRichTextAuthoringHost };
9
+
10
+ import type { FieldDocumentForm } from "@riducms/plugin";
11
+ import type { SchemaField } from "@riducms/protocol";
12
+
13
+ const [getRichTextField, setRichTextField] = createContext<{
14
+ readonly field: SchemaField;
15
+ readonly form: FieldDocumentForm;
16
+ }>();
17
+ export { getRichTextField, setRichTextField };
@@ -1,5 +1,25 @@
1
+ import { documentRecoveryIssue } from "@plugin-richtext/document-validation";
2
+ import type { RichTextConfig } from "@plugin-richtext/field/rich-text-config";
3
+
4
+ export function editorRecoveryIssue(value: unknown, config: RichTextConfig) {
5
+ if (value === undefined || value === null) return undefined;
6
+ const types = new Set(["root", "paragraph", "heading", "quote", "text", "linebreak", "block"]);
7
+ const featureTypes = {
8
+ links: ["link"],
9
+ lists: ["list", "listitem"],
10
+ code: ["code"],
11
+ "horizontal-rule": ["horizontalrule"],
12
+ uploads: ["upload"],
13
+ relationships: ["relationship"],
14
+ blocks: ["block"],
15
+ };
16
+ for (const feature of config.features) for (const type of featureTypes[feature]) types.add(type);
17
+ return documentRecoveryIssue(value, types);
18
+ }
19
+
1
20
  export function initialEditorState(value: unknown) {
2
- if (!isRecord(value) || value.version !== 1 || !isRecord(value.root)) return null;
21
+ if (!isRecord(value) || !isRecord(value.root) || documentRecoveryIssue(value) !== undefined)
22
+ return null;
3
23
  return JSON.stringify({ root: withElementDefaults(value.root) });
4
24
  }
5
25
 
@@ -0,0 +1,9 @@
1
+ <script lang="ts">
2
+ import { useLexicalComposerContext } from "@hvniel/lexical-svelte";
3
+
4
+ let { readOnly = false }: { readOnly?: boolean } = $props();
5
+ const editor = useLexicalComposerContext()[0];
6
+
7
+ // A fieldset cannot disable contenteditable; synchronize the host's live editability.
8
+ $effect(() => editor.setEditable(!readOnly));
9
+ </script>
@@ -0,0 +1,250 @@
1
+ <script lang="ts">
2
+ import type { RichTextDocument } from "@plugin-richtext/document";
3
+ import { decodeRichTextDocument } from "@plugin-richtext/document-validation";
4
+ import type { RichTextConfig } from "@plugin-richtext/field/rich-text-config";
5
+ import type { PluginFieldProps } from "@riducms/plugin";
6
+ import { CodeNode } from "@lexical/code";
7
+ import { HistoryExtension } from "@lexical/history";
8
+ import { LinkNode } from "@lexical/link";
9
+ import { ListExtension } from "@lexical/list";
10
+ import { RichTextExtension } from "@lexical/rich-text";
11
+ import {
12
+ ContentEditable,
13
+ HorizontalRuleNode,
14
+ HorizontalRulePlugin,
15
+ LexicalExtensionComposer,
16
+ LinkPlugin,
17
+ OnChangePlugin,
18
+ } from "@hvniel/lexical-svelte";
19
+ import { $createParagraphNode, $getRoot, defineExtension, type EditorState } from "lexical";
20
+ import { Button, FieldFrame, fieldControlARIA } from "@riducms/ui";
21
+
22
+ import "@plugin-richtext/styles/integration.css";
23
+ import RichTextBlockToolbar from "@plugin-richtext/menu/rich-text-block-toolbar.svelte";
24
+ import RichTextEditabilityPlugin from "@plugin-richtext/field/rich-text-editability-plugin.svelte";
25
+ import RichTextFooter from "@plugin-richtext/field/rich-text-footer.svelte";
26
+ import RichTextFloatingToolbar from "@plugin-richtext/toolbar/rich-text-floating-toolbar.svelte";
27
+ import RichTextSlashMenu from "@plugin-richtext/menu/rich-text-slash-menu.svelte";
28
+ import { hasRichTextFeature } from "@plugin-richtext/field/rich-text-config";
29
+ import {
30
+ setRichTextAuthoringHost,
31
+ setRichTextField,
32
+ } from "@plugin-richtext/field/rich-text-context.svelte";
33
+ import {
34
+ editorRecoveryIssue,
35
+ initialEditorState,
36
+ } from "@plugin-richtext/field/rich-text-document";
37
+ import { UploadNode } from "@plugin-richtext/upload/rich-text-upload-node";
38
+ import RichTextUploadPlugin from "@plugin-richtext/upload/rich-text-upload-plugin.svelte";
39
+ import { RelationshipNode } from "@plugin-richtext/relationship/rich-text-relationship-node";
40
+ import { BlockNode } from "@plugin-richtext/block/rich-text-block-node";
41
+ import RichTextBlocksPlugin from "@plugin-richtext/block/rich-text-blocks-plugin.svelte";
42
+ import RichTextRelationshipPlugin from "@plugin-richtext/relationship/rich-text-relationship-plugin.svelte";
43
+
44
+ let {
45
+ field: binding,
46
+ form,
47
+ config,
48
+ authoring,
49
+ i18n,
50
+ }: PluginFieldProps<RichTextDocument<unknown>, RichTextConfig> = $props();
51
+ const field = $derived(binding.schema);
52
+ const editingBlocked = $derived(binding.readOnly);
53
+ const issues = $derived(binding.issues);
54
+ const inputARIA = $derived(
55
+ fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
56
+ );
57
+ const placeholderText = $derived(i18n.t("plugin.richtext:editor.placeholder"));
58
+ const visiblePlaceholder = $derived(
59
+ field.admin.readOnly ? i18n.t("plugin.richtext:editor.empty") : placeholderText
60
+ );
61
+ let canvasElement = $state<HTMLElement | null>(null);
62
+ // A mounted field hydrates once; the parent remounts it for schema/revision/locale changes.
63
+ // svelte-ignore state_referenced_locally
64
+ const initialValue = binding.rawValue;
65
+ // Config belongs to this keyed editor mount.
66
+ // svelte-ignore state_referenced_locally
67
+ const recoveryIssue = editorRecoveryIssue(initialValue, config);
68
+ // The host is stable for this mounted field and decorator descendants inherit it through portals.
69
+ // svelte-ignore state_referenced_locally
70
+ setRichTextAuthoringHost(authoring);
71
+ setRichTextField({
72
+ get field() {
73
+ return field;
74
+ },
75
+ get form() {
76
+ return form;
77
+ },
78
+ });
79
+
80
+ const theme = {
81
+ heading: {
82
+ h1: "ridu-richtext-heading ridu-richtext-h1 mt-[2.1rem] mb-[0.85rem] font-serif text-[2rem] leading-[1.12] tracking-[-0.015em] text-foreground font-normal text-balance max-[34rem]:text-[1.75rem]",
83
+ h2: "ridu-richtext-heading ridu-richtext-h2 mt-[2.1rem] mb-[0.85rem] font-serif text-[1.75rem] leading-[1.12] text-foreground font-normal text-balance",
84
+ h3: "ridu-richtext-heading ridu-richtext-h3 mt-[2.1rem] mb-[0.85rem] font-sans text-lg leading-[1.35] text-foreground font-semibold text-balance",
85
+ },
86
+ code: "ridu-richtext-code-block my-6 block overflow-x-auto rounded-[3px] border border-control-border bg-control px-[1.1rem] py-4 font-mono text-[12.5px] leading-[1.7] text-foreground-strong [tab-size:2]",
87
+ hr: "ridu-richtext-rule my-8 block cursor-pointer border-0 border-t border-border py-[0.65rem]",
88
+ hrSelected: "is-selected",
89
+ link: "ridu-richtext-link cursor-pointer text-brand-primary-soft underline decoration-primary/40 decoration-1 underline-offset-[0.18em] hover:text-primary-hover hover:decoration-current",
90
+ list: {
91
+ checklist:
92
+ "ridu-richtext-checklist mt-1 mb-[1.35rem] list-none ps-0 text-[15.5px] leading-7 text-foreground-body",
93
+ listitemChecked: "ridu-richtext-list-item-checked line-through opacity-70",
94
+ listitemUnchecked: "ridu-richtext-list-item-unchecked",
95
+ listitem: "ridu-richtext-list-item ps-1",
96
+ nested: { listitem: "ridu-richtext-list-item-nested" },
97
+ ol: "ridu-richtext-list-ordered mt-1 mb-[1.35rem] list-decimal ps-[1.55rem] text-[15.5px] leading-7 text-foreground-body",
98
+ ul: "ridu-richtext-list-unordered mt-1 mb-[1.35rem] list-disc ps-[1.55rem] text-[15.5px] leading-7 text-foreground-body",
99
+ },
100
+ paragraph:
101
+ "ridu-richtext-paragraph relative mb-[0.9rem] text-[15.5px] leading-7 text-foreground-body text-pretty last:mb-0",
102
+ quote:
103
+ "ridu-richtext-quote my-[1.85rem] border-s-2 border-primary/55 py-[0.1rem] ps-5 font-serif text-[19px] italic leading-6 text-foreground-strong text-pretty",
104
+ text: {
105
+ bold: "ridu-richtext-bold font-semibold",
106
+ code: "ridu-richtext-inline-code rounded-[3px] border border-control-border bg-control px-[0.32em] py-[0.12em] font-mono text-[0.86em] text-foreground-strong",
107
+ italic: "ridu-richtext-italic italic",
108
+ subscript: "ridu-richtext-subscript",
109
+ superscript: "ridu-richtext-superscript",
110
+ strikethrough: "ridu-richtext-strikethrough line-through",
111
+ underline: "ridu-richtext-underline underline underline-offset-[0.15em]",
112
+ },
113
+ upload: "ridu-richtext-upload m-0",
114
+ };
115
+
116
+ // The extension tree is identity-bearing and belongs to this field instance.
117
+ // svelte-ignore state_referenced_locally
118
+ const extension = defineExtension({
119
+ $initialEditorState:
120
+ (recoveryIssue === undefined ? initialEditorState(initialValue) : null) ??
121
+ (() => {
122
+ // Initialize Lexical only: its first real edit must not look like initial hydration.
123
+ $getRoot().append($createParagraphNode());
124
+ }),
125
+ dependencies: [
126
+ RichTextExtension,
127
+ HistoryExtension,
128
+ ...(hasRichTextFeature(config, "lists") ? [ListExtension] : []),
129
+ ],
130
+ name: "@riducms/plugin-richtext/editor",
131
+ namespace: `ridu-${field.id}`,
132
+ nodes: [
133
+ BlockNode,
134
+ ...(hasRichTextFeature(config, "links") ? [LinkNode] : []),
135
+ ...(hasRichTextFeature(config, "code") ? [CodeNode] : []),
136
+ ...(hasRichTextFeature(config, "horizontal-rule") ? [HorizontalRuleNode] : []),
137
+ ...(hasRichTextFeature(config, "uploads") ? [UploadNode] : []),
138
+ ...(hasRichTextFeature(config, "relationships") ? [RelationshipNode] : []),
139
+ ],
140
+ editable: !editingBlocked,
141
+ theme,
142
+ onError: (error) => {
143
+ throw error;
144
+ },
145
+ });
146
+
147
+ function changed(editorState: EditorState) {
148
+ // Lexical can retain undefined optional properties after importing sparse server JSON.
149
+ // Decode its serialized wire value, where those properties are absent.
150
+ binding.set(
151
+ decodeRichTextDocument(
152
+ JSON.parse(JSON.stringify({ version: 1, root: editorState.toJSON().root }))
153
+ )
154
+ );
155
+ }
156
+ function exportDocument() {
157
+ const url = URL.createObjectURL(
158
+ new Blob([JSON.stringify(initialValue, null, 2)], { type: "application/json" })
159
+ );
160
+ const link = document.createElement("a");
161
+ link.href = url;
162
+ link.download = `${field.name}-recovery.json`;
163
+ link.click();
164
+ setTimeout(() => URL.revokeObjectURL(url), 0);
165
+ }
166
+ </script>
167
+
168
+ <div data-field-path={field.path}>
169
+ <FieldFrame
170
+ controlID={field.id}
171
+ label={field.admin.label}
172
+ required={field.required}
173
+ readOnly={field.admin.readOnly}
174
+ description={field.admin.description}
175
+ errors={issues.map((issue) => issue.message)}
176
+ class="min-w-0"
177
+ >
178
+ <div
179
+ class={[
180
+ "ridu-richtext-editor min-w-0 border-b border-transparent pb-[0.65rem] transition-colors duration-150",
181
+ issues.length > 0 && "border-b-destructive/22",
182
+ field.admin.readOnly && "pt-[1.6rem]",
183
+ issues.length > 0 && "has-error",
184
+ field.admin.readOnly && "is-read-only",
185
+ ]}
186
+ aria-invalid={inputARIA["aria-invalid"]}
187
+ >
188
+ {#if recoveryIssue !== undefined}
189
+ <p role="alert" class="my-3 text-sm text-destructive">
190
+ {i18n.t("plugin.richtext:editor.recovery", { path: `${field.path}.${recoveryIssue}` })}
191
+ </p>
192
+ <Button variant="outline" onclick={exportDocument}>
193
+ {i18n.t("plugin.richtext:editor.exportDocument")}
194
+ </Button>
195
+ {:else}
196
+ <LexicalExtensionComposer {extension} contentEditable={null}>
197
+ <div class="relative" bind:this={canvasElement}>
198
+ <ContentEditable
199
+ id={field.id}
200
+ class={[
201
+ "ridu-richtext-content relative z-1 min-h-44 pt-[1.35rem] pe-0 pb-3 ps-9 text-foreground-body caret-primary outline-none focus-visible:outline-none max-[34rem]:min-h-36 max-[34rem]:pt-4 max-[34rem]:ps-8",
202
+ field.admin.readOnly
203
+ ? "min-h-auto pt-0 ps-0 max-[34rem]:min-h-auto max-[34rem]:pt-0 max-[34rem]:ps-0"
204
+ : "border-s border-border transition-colors duration-150",
205
+ issues.length > 0 && !field.admin.readOnly && "border-s-destructive/72",
206
+ ]}
207
+ ariaLabel={field.admin.label}
208
+ ariaDescribedBy={inputARIA["aria-describedby"]}
209
+ ariaErrorMessage={inputARIA["aria-errormessage"]}
210
+ ariaInvalid={inputARIA["aria-invalid"]}
211
+ ariaRequired={field.required}
212
+ aria-keyshortcuts="Alt+Shift+ArrowUp Alt+Shift+ArrowDown"
213
+ aria-placeholder={visiblePlaceholder}
214
+ >
215
+ {#snippet placeholder()}
216
+ <span
217
+ class={[
218
+ "pointer-events-none absolute top-[1.35rem] start-9 z-0 text-[15.5px] leading-7 text-foreground-placeholder max-[34rem]:top-4 max-[34rem]:start-8",
219
+ field.admin.readOnly && "top-0 start-0 max-[34rem]:top-0 max-[34rem]:start-0",
220
+ ]}
221
+ >
222
+ {visiblePlaceholder}
223
+ </span>
224
+ {/snippet}
225
+ </ContentEditable>
226
+ </div>
227
+ {#if hasRichTextFeature(config, "links")}<LinkPlugin />{/if}
228
+ {#if hasRichTextFeature(config, "horizontal-rule")}<HorizontalRulePlugin />{/if}
229
+ {#if !editingBlocked}
230
+ <RichTextSlashMenu {authoring} {config} />
231
+ <RichTextFloatingToolbar />
232
+ {#if canvasElement !== null}
233
+ <RichTextBlockToolbar anchorElement={canvasElement} {authoring} {config} />
234
+ {/if}
235
+ {#if hasRichTextFeature(config, "uploads")}
236
+ <RichTextUploadPlugin {authoring} {config} {field} />
237
+ {/if}
238
+ {#if hasRichTextFeature(config, "relationships")}
239
+ <RichTextRelationshipPlugin {authoring} {config} {field} />
240
+ {/if}
241
+ {/if}
242
+ <RichTextEditabilityPlugin readOnly={editingBlocked} />
243
+ <RichTextBlocksPlugin {authoring} {field} />
244
+ <OnChangePlugin onChange={changed} ignoreSelectionChange />
245
+ <RichTextFooter readOnly={field.admin.readOnly === true} />
246
+ </LexicalExtensionComposer>
247
+ {/if}
248
+ </div>
249
+ </FieldFrame>
250
+ </div>