@navette-js/forms 1.0.0-beta.4 → 1.0.0-beta.5

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,138 @@
1
+ import { Mark, Node, type Extensions } from '@tiptap/core';
2
+ import type { Action } from '@navette-js/actions';
3
+ type MaybePromise<T> = T | Promise<T>;
4
+ /** CSS color written by a user: hexadecimal, rgb()/hsl()/oklch() with plain numbers, or a CSS color name. `null` otherwise. */
5
+ export declare function sanitizeCssColor(value: unknown): string | null;
6
+ /** A text color of the editor: its label, its color in light mode, and in dark mode. */
7
+ export declare class TextColor {
8
+ readonly label: string | null;
9
+ readonly color: string | null;
10
+ readonly darkColor: string | null;
11
+ constructor(label?: string | null, color?: string | null, darkColor?: string | null);
12
+ static make(label?: string | null, color?: string | null, darkColor?: string | null): TextColor;
13
+ getLabel(): string | null;
14
+ getColor(): string | null;
15
+ getDarkColor(): string | null;
16
+ /** The Tailwind palette: shade 600, and 400 in dark mode. */
17
+ static getDefaults(): Record<string, TextColor>;
18
+ }
19
+ /** Colors for the editor's JavaScript: `{ name: { color, darkColor } }`. */
20
+ export declare const textColorsForJs: (colors: Record<string, TextColor>) => {
21
+ [k: string]: {
22
+ color: string;
23
+ darkColor: string;
24
+ };
25
+ };
26
+ /** Text colors known when content is saved and rendered (an editor's `textColors()` registers its own). */
27
+ export declare function registerTextColors(colors: Record<string, TextColor>): void;
28
+ /** Who or what can be mentioned after a trigger character (`@` for users, `#` for tags…). */
29
+ export declare class MentionProvider {
30
+ protected _char: string;
31
+ protected _items: Record<string, string> | (() => MaybePromise<Record<string, string>>);
32
+ protected _search: ((search: string) => MaybePromise<Record<string, string>>) | null;
33
+ protected _labels: ((ids: string[]) => MaybePromise<Record<string, string>>) | null;
34
+ protected _url: ((id: string, label: string) => string | null) | null;
35
+ protected _extraAttributes: Record<string, string>;
36
+ protected _messages: {
37
+ noItems?: string | null;
38
+ noSearchResults?: string | null;
39
+ searching?: string | null;
40
+ searchPrompt?: string | null;
41
+ };
42
+ constructor(char: string);
43
+ static make(char: string): MentionProvider;
44
+ getTriggerCharacter(): string;
45
+ /** Mentionable items, `{ id: label }`, offered and filtered in the browser. */
46
+ items(items: Record<string, string> | (() => MaybePromise<Record<string, string>>)): this;
47
+ /** Searches on the server as the user types (large lists). */
48
+ getSearchResultsUsing(callback: ((search: string) => MaybePromise<Record<string, string>>) | null): this;
49
+ /** Labels of mentioned ids, to show current names in stored content. */
50
+ getLabelsUsing(callback: ((ids: string[]) => MaybePromise<Record<string, string>>) | null): this;
51
+ /** Link of a mention in rendered content. */
52
+ url(callback: ((id: string, label: string) => string | null) | null): this;
53
+ extraAttributes(attributes: Record<string, string>): this;
54
+ noItemsMessage(message: string | null): this;
55
+ noSearchResultsMessage(message: string | null): this;
56
+ searchingMessage(message: string | null): this;
57
+ searchPrompt(message: string | null): this;
58
+ isSearchable(): boolean;
59
+ getItems(): Promise<Record<string, string>>;
60
+ getSearchResults(search: string): Promise<Record<string, string>>;
61
+ getLabels(ids: string[]): Promise<Record<string, string>>;
62
+ getUrl(id: string, label: string): string | null;
63
+ toJs(): Promise<Record<string, unknown>>;
64
+ }
65
+ /**
66
+ * A block users insert in the content (a call to action, a video, a product…), configured in a modal. Extend this class:
67
+ * `id` and `label`, the modal's fields in `configureEditorAction()`, the preview in the editor, and the final HTML.
68
+ */
69
+ export declare abstract class RichContentCustomBlock {
70
+ /** Unique id, stored in the content. */
71
+ static getId(): string;
72
+ static getLabel(): string;
73
+ /** Icon in the blocks panel. */
74
+ static getIcon(): string | null;
75
+ /** Group of the block in the panel (none by default). */
76
+ static getGroup(): string | null;
77
+ /** Modal of the block: add fields with `action.schema([...])`. Without fields, the block is inserted directly. */
78
+ static configureEditorAction(action: Action): Action;
79
+ /** Label of an inserted block in the editor. */
80
+ static getPreviewLabel(_config: Record<string, unknown>): string;
81
+ /** HTML of the block in the editor (build it from escaped values). */
82
+ static toPreviewHtml(_config: Record<string, unknown>): string | null;
83
+ static shouldApplyProseStylingToPreview(_config: Record<string, unknown>): boolean;
84
+ /** HTML of the block in rendered content; `data` comes from `RichContentRenderer.customBlocks()`. Build it from escaped values. */
85
+ static toHtml(_config: Record<string, unknown>, _data: Record<string, unknown>): string | null;
86
+ }
87
+ export type CustomBlockClass = typeof RichContentCustomBlock;
88
+ /** `<span class="color" data-color="red">`: the color must be a known palette name or a safe CSS color. */
89
+ export declare const TextColorMark: Mark<any, any>;
90
+ /** `<span data-type="mergeTag" data-id="name">`: replaced by its value when the content is rendered. */
91
+ export declare const MergeTagNode: Node<any, any>;
92
+ /** `<span data-type="mention" data-id="7" data-label="Ada" data-char="@">@Ada</span>`. */
93
+ export declare const MentionNode: Node<any, any>;
94
+ /** `<div data-type="customBlock" data-id="hero" data-config="{…}">`: rendered by its block class. */
95
+ export declare const CustomBlockNode: Node<any, any>;
96
+ /** Images with an optional id (attachments) and size (resizable images). */
97
+ export declare const SizedImage: Node<import("@tiptap/extension-image").ImageOptions, any>;
98
+ /** Extensions added to the editor's schema by your application (`registerRichContentExtensions()`). */
99
+ export declare const customRichContentExtensions: Extensions;
100
+ /**
101
+ * Adds TipTap extensions to the server schema of every rich editor and renderer: content using their nodes and marks is then
102
+ * kept when saved. Load the matching browser extensions with `RichEditor.extensions()`.
103
+ */
104
+ export declare function registerRichContentExtensions(...extensions: Extensions): void;
105
+ /**
106
+ * Renders stored rich content (HTML or JSON) for display: merge tags replaced by their values, mentions with their current
107
+ * labels and links, custom blocks rendered by their classes. The output is sanitized by the editor's schema.
108
+ */
109
+ export declare class RichContentRenderer {
110
+ protected content: unknown;
111
+ protected _mergeTags: Record<string, unknown | (() => unknown)>;
112
+ protected _blocks: Record<string, {
113
+ block: CustomBlockClass;
114
+ data: Record<string, unknown>;
115
+ }>;
116
+ protected _mentions: MentionProvider[];
117
+ constructor(content: unknown);
118
+ static make(content: unknown): RichContentRenderer;
119
+ /** Values of the merge tags: text (escaped), an `HtmlString` (inserted as is), or a function returning one. */
120
+ mergeTags(values: Record<string, unknown | (() => unknown)>): this;
121
+ /** Block classes, optionally with data passed to their `toHtml()`: `[HeroBlock]` or `{ hero: { block: HeroBlock, data } }`. */
122
+ customBlocks(blocks: CustomBlockClass[] | Record<string, {
123
+ block: CustomBlockClass;
124
+ data?: Record<string, unknown>;
125
+ }>): this;
126
+ mentions(providers: MentionProvider[]): this;
127
+ toHtml(): Promise<string>;
128
+ }
129
+ /** "Lead" paragraph group: `<div class="lead">`. */
130
+ export declare const LeadNode: Node<any, any>;
131
+ /** Small text: `<small>`. */
132
+ export declare const SmallMark: Mark<any, any>;
133
+ /** Columns: `<div class="grid-layout" data-cols="2">` with `<div class="grid-layout-col">` children. */
134
+ export declare const GridNode: Node<any, any>;
135
+ export declare const GridColumnNode: Node<any, any>;
136
+ /** Server schema additions of the rich editor, besides the starter kit. */
137
+ export declare const RICH_CONTENT_EXTENSIONS: Extensions;
138
+ export {};
@@ -0,0 +1,350 @@
1
+ // Rich content beyond the starter kit: text colors, merge tags, mentions, custom blocks and resizable images, on the server
2
+ // (sanitizing what the editor sends, and rendering stored content), with the matching editor configuration objects.
3
+ import { randomBytes } from 'node:crypto';
4
+ import { Mark, Node, mergeAttributes } from '@tiptap/core';
5
+ import Image from '@tiptap/extension-image';
6
+ import { Color, HtmlString } from '@navette-js/support';
7
+ import { t } from '@navette-js/support/i18n';
8
+ // ------------------------------------------------------------------ colors
9
+ /** CSS color written by a user: hexadecimal, rgb()/hsl()/oklch() with plain numbers, or a CSS color name. `null` otherwise. */
10
+ export function sanitizeCssColor(value) {
11
+ if (typeof value !== 'string')
12
+ return null;
13
+ const color = value.trim();
14
+ if (/^#[0-9a-f]{3,8}$/i.test(color))
15
+ return color;
16
+ if (/^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\(\s*[-0-9.%\s,/a-z]{1,80}\)$/i.test(color) && !/(url|expression|var|attr)\s*\(/i.test(color.slice(color.indexOf('(') + 1)))
17
+ return color;
18
+ if (/^[a-z]{3,20}$/i.test(color))
19
+ return color.toLowerCase();
20
+ return null;
21
+ }
22
+ /** A text color of the editor: its label, its color in light mode, and in dark mode. */
23
+ export class TextColor {
24
+ label;
25
+ color;
26
+ darkColor;
27
+ constructor(label = null, color = null, darkColor = null) { this.label = label; this.color = color; this.darkColor = darkColor; }
28
+ static make(label = null, color = null, darkColor = null) { return new this(label, color, darkColor); }
29
+ getLabel() { return this.label; }
30
+ getColor() { return this.color; }
31
+ getDarkColor() { return this.darkColor ?? this.color; }
32
+ /** The Tailwind palette: shade 600, and 400 in dark mode. */
33
+ static getDefaults() {
34
+ return Object.fromEntries(Object.entries(Color).map(([name, palette]) => {
35
+ const key = name.toLowerCase();
36
+ const translation = t(`filament-forms::components.rich_editor.actions.text_color.modal.form.color.options.${key}`);
37
+ return [key, TextColor.make(translation.includes('::') ? name : translation, palette[600], palette[400] ?? null)];
38
+ }));
39
+ }
40
+ }
41
+ /** Colors for the editor's JavaScript: `{ name: { color, darkColor } }`. */
42
+ export const textColorsForJs = (colors) => Object.fromEntries(Object.entries(colors).map(([name, color]) => [name, { color: color.getColor(), darkColor: color.getDarkColor() }]));
43
+ let defaultTextColors = null;
44
+ const extraTextColors = {};
45
+ const knownTextColors = () => ({ ...(defaultTextColors ??= TextColor.getDefaults()), ...extraTextColors });
46
+ /** Text colors known when content is saved and rendered (an editor's `textColors()` registers its own). */
47
+ export function registerTextColors(colors) { Object.assign(extraTextColors, colors); }
48
+ // ------------------------------------------------------------------ mentions
49
+ /** Who or what can be mentioned after a trigger character (`@` for users, `#` for tags…). */
50
+ export class MentionProvider {
51
+ _char;
52
+ _items = {};
53
+ _search = null;
54
+ _labels = null;
55
+ _url = null;
56
+ _extraAttributes = {};
57
+ _messages = {};
58
+ constructor(char) { this._char = char; }
59
+ static make(char) { return new this(char); }
60
+ getTriggerCharacter() { return this._char; }
61
+ /** Mentionable items, `{ id: label }`, offered and filtered in the browser. */
62
+ items(items) { this._items = items; return this; }
63
+ /** Searches on the server as the user types (large lists). */
64
+ getSearchResultsUsing(callback) { this._search = callback; return this; }
65
+ /** Labels of mentioned ids, to show current names in stored content. */
66
+ getLabelsUsing(callback) { this._labels = callback; return this; }
67
+ /** Link of a mention in rendered content. */
68
+ url(callback) { this._url = callback; return this; }
69
+ extraAttributes(attributes) { this._extraAttributes = attributes; return this; }
70
+ noItemsMessage(message) { this._messages.noItems = message; return this; }
71
+ noSearchResultsMessage(message) { this._messages.noSearchResults = message; return this; }
72
+ searchingMessage(message) { this._messages.searching = message; return this; }
73
+ searchPrompt(message) { this._messages.searchPrompt = message; return this; }
74
+ isSearchable() { return this._search !== null; }
75
+ async getItems() {
76
+ const items = typeof this._items === 'function' ? await this._items() : this._items;
77
+ return Object.fromEntries(Object.entries(items ?? {}).map(([id, label]) => [String(id), String(label)]));
78
+ }
79
+ async getSearchResults(search) {
80
+ if (this._search)
81
+ return Object.fromEntries(Object.entries((await this._search(search)) ?? {}).map(([id, label]) => [String(id), String(label)]));
82
+ const items = await this.getItems();
83
+ const needle = search.toLowerCase();
84
+ return needle ? Object.fromEntries(Object.entries(items).filter(([, label]) => label.toLowerCase().includes(needle))) : items;
85
+ }
86
+ async getLabels(ids) {
87
+ const labels = this._labels ? await this._labels(ids) : Object.fromEntries(Object.entries(await this.getItems()).filter(([id]) => ids.includes(id)));
88
+ return Object.fromEntries(Object.entries(labels ?? {}).map(([id, label]) => [String(id), String(label)]));
89
+ }
90
+ getUrl(id, label) { return this._url ? this._url(id, label) : null; }
91
+ async toJs() {
92
+ return {
93
+ triggerCharacter: this._char,
94
+ extraAttributes: this._extraAttributes,
95
+ isSearchable: this.isSearchable(),
96
+ items: this.isSearchable() ? [] : Object.entries(await this.getItems()).map(([id, label]) => ({ id, label })),
97
+ noOptionsMessage: this._messages.noItems ?? t('filament-forms::components.rich_editor.mentions.no_options_message'),
98
+ noSearchResultsMessage: this._messages.noSearchResults ?? t('filament-forms::components.rich_editor.mentions.no_search_results_message'),
99
+ searchPrompt: this._messages.searchPrompt ?? t('filament-forms::components.rich_editor.mentions.search_prompt'),
100
+ searchingMessage: this._messages.searching ?? t('filament-forms::components.rich_editor.mentions.searching_message'),
101
+ };
102
+ }
103
+ }
104
+ // ------------------------------------------------------------------ custom blocks
105
+ /**
106
+ * A block users insert in the content (a call to action, a video, a product…), configured in a modal. Extend this class:
107
+ * `id` and `label`, the modal's fields in `configureEditorAction()`, the preview in the editor, and the final HTML.
108
+ */
109
+ export class RichContentCustomBlock {
110
+ /** Unique id, stored in the content. */
111
+ static getId() { throw new Error(`${this.name}.getId() must be defined.`); }
112
+ static getLabel() { return this.getId().replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); }
113
+ /** Icon in the blocks panel. */
114
+ static getIcon() { return null; }
115
+ /** Group of the block in the panel (none by default). */
116
+ static getGroup() { return null; }
117
+ /** Modal of the block: add fields with `action.schema([...])`. Without fields, the block is inserted directly. */
118
+ static configureEditorAction(action) { return action.modalHidden(); }
119
+ /** Label of an inserted block in the editor. */
120
+ static getPreviewLabel(_config) { return this.getLabel(); }
121
+ /** HTML of the block in the editor (build it from escaped values). */
122
+ static toPreviewHtml(_config) { return null; }
123
+ static shouldApplyProseStylingToPreview(_config) { return false; }
124
+ /** HTML of the block in rendered content; `data` comes from `RichContentRenderer.customBlocks()`. Build it from escaped values. */
125
+ static toHtml(_config, _data) { return null; }
126
+ }
127
+ // ------------------------------------------------------------------ server-side extensions
128
+ const json = (value) => { try {
129
+ return JSON.stringify(value ?? null);
130
+ }
131
+ catch {
132
+ return 'null';
133
+ } };
134
+ const parseConfig = (value) => { try {
135
+ const parsed = JSON.parse(value ?? 'null');
136
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
137
+ }
138
+ catch {
139
+ return null;
140
+ } };
141
+ /** `<span class="color" data-color="red">`: the color must be a known palette name or a safe CSS color. */
142
+ export const TextColorMark = Mark.create({
143
+ name: 'textColor',
144
+ parseHTML() { return [{ tag: 'span', getAttrs: (element) => (String(element.getAttribute?.('class') ?? '').split(/\s+/).includes('color') ? null : false) }]; },
145
+ addAttributes() {
146
+ return {
147
+ 'data-color': {
148
+ default: null,
149
+ parseHTML: (element) => { const value = element.getAttribute('data-color'); return knownTextColors()[value] ? value : sanitizeCssColor(value); },
150
+ renderHTML: (attributes) => (attributes['data-color'] ? { 'data-color': attributes['data-color'] } : {}),
151
+ },
152
+ };
153
+ },
154
+ renderHTML({ HTMLAttributes }) {
155
+ const name = HTMLAttributes['data-color'];
156
+ const known = typeof name === 'string' ? knownTextColors()[name] : undefined;
157
+ const color = known ? null : sanitizeCssColor(name);
158
+ const style = known ? `--color: ${known.getColor()}; --dark-color: ${known.getDarkColor()}` : color ? `--color: ${color}; --dark-color: ${color}` : null;
159
+ return ['span', mergeAttributes({ class: 'color' }, HTMLAttributes, style ? { style } : {}), 0];
160
+ },
161
+ });
162
+ /** `<span data-type="mergeTag" data-id="name">`: replaced by its value when the content is rendered. */
163
+ export const MergeTagNode = Node.create({
164
+ name: 'mergeTag',
165
+ group: 'inline', inline: true, atom: true, selectable: false,
166
+ addAttributes() { return { id: { default: null, parseHTML: (element) => element.getAttribute('data-id') || null, renderHTML: (attributes) => ({ 'data-id': attributes.id }) } }; },
167
+ parseHTML() { return [{ tag: 'span[data-type="mergeTag"]' }]; },
168
+ renderHTML({ HTMLAttributes }) { return ['span', mergeAttributes({ 'data-type': 'mergeTag' }, HTMLAttributes)]; },
169
+ renderText({ node }) { return `{{ ${node.attrs.id} }}`; },
170
+ });
171
+ /** `<span data-type="mention" data-id="7" data-label="Ada" data-char="@">@Ada</span>`. */
172
+ export const MentionNode = Node.create({
173
+ name: 'mention',
174
+ group: 'inline', inline: true, atom: true, selectable: false,
175
+ addAttributes() {
176
+ return {
177
+ id: { default: null, parseHTML: (element) => element.getAttribute('data-id') || null, renderHTML: (attributes) => ({ 'data-id': attributes.id }) },
178
+ label: { default: null, parseHTML: (element) => element.getAttribute('data-label') || null, renderHTML: (attributes) => (attributes.label ? { 'data-label': attributes.label } : {}) },
179
+ char: { default: '@', parseHTML: (element) => String(element.getAttribute('data-char') || '@').slice(0, 3), renderHTML: (attributes) => ({ 'data-char': attributes.char ?? '@' }) },
180
+ href: { default: null, parseHTML: () => null, renderHTML: (attributes) => (attributes.href ? { href: attributes.href } : {}) },
181
+ };
182
+ },
183
+ parseHTML() { return [{ tag: 'span[data-type="mention"]' }, { tag: 'a[data-type="mention"]' }]; },
184
+ renderHTML({ node, HTMLAttributes }) { return [node.attrs.href ? 'a' : 'span', mergeAttributes({ 'data-type': 'mention' }, HTMLAttributes), `${node.attrs.char ?? '@'}${node.attrs.label ?? ''}`]; },
185
+ renderText({ node }) { return `${node.attrs.char ?? '@'}${node.attrs.label ?? ''}`; },
186
+ });
187
+ /** `<div data-type="customBlock" data-id="hero" data-config="{…}">`: rendered by its block class. */
188
+ export const CustomBlockNode = Node.create({
189
+ name: 'customBlock',
190
+ group: 'block', atom: true,
191
+ addAttributes() {
192
+ return {
193
+ id: { default: null, parseHTML: (element) => element.getAttribute('data-id') || null, renderHTML: (attributes) => ({ 'data-id': attributes.id }) },
194
+ config: { default: null, parseHTML: (element) => parseConfig(element.getAttribute('data-config')), renderHTML: (attributes) => ({ 'data-config': json(attributes.config) }) },
195
+ };
196
+ },
197
+ parseHTML() { return [{ tag: 'div[data-type="customBlock"]' }]; },
198
+ renderHTML({ HTMLAttributes }) { return ['div', mergeAttributes({ 'data-type': 'customBlock' }, HTMLAttributes)]; },
199
+ });
200
+ const lengthOf = (value) => { const text = String(value ?? '').trim(); return /^\d{1,5}(\.\d{1,3})?(px|%|em|rem)?$/.test(text) ? text : null; };
201
+ const styleLength = (value) => (/^\d+(\.\d+)?$/.test(value) ? `${value}px` : value);
202
+ /** Images with an optional id (attachments) and size (resizable images). */
203
+ export const SizedImage = Image.extend({
204
+ addAttributes() {
205
+ return {
206
+ ...this.parent?.(),
207
+ id: { default: null, parseHTML: (element) => element.getAttribute('data-id'), renderHTML: (attributes) => (attributes.id ? { 'data-id': attributes.id } : {}) },
208
+ width: {
209
+ default: null,
210
+ parseHTML: (element) => lengthOf(element.getAttribute('width') ?? /(?:^|;)\s*width:\s*([^;]+)/.exec(element.getAttribute('style') ?? '')?.[1]),
211
+ renderHTML: (attributes) => (attributes.width ? { width: attributes.width, style: `width: ${styleLength(attributes.width)}` } : {}),
212
+ },
213
+ height: {
214
+ default: null,
215
+ parseHTML: (element) => lengthOf(element.getAttribute('height') ?? /(?:^|;)\s*height:\s*([^;]+)/.exec(element.getAttribute('style') ?? '')?.[1]),
216
+ renderHTML: (attributes) => (attributes.height ? { height: attributes.height, style: `height: ${styleLength(attributes.height)}` } : {}),
217
+ },
218
+ };
219
+ },
220
+ });
221
+ /** Extensions added to the editor's schema by your application (`registerRichContentExtensions()`). */
222
+ export const customRichContentExtensions = [];
223
+ /**
224
+ * Adds TipTap extensions to the server schema of every rich editor and renderer: content using their nodes and marks is then
225
+ * kept when saved. Load the matching browser extensions with `RichEditor.extensions()`.
226
+ */
227
+ export function registerRichContentExtensions(...extensions) { customRichContentExtensions.push(...extensions); }
228
+ /**
229
+ * Renders stored rich content (HTML or JSON) for display: merge tags replaced by their values, mentions with their current
230
+ * labels and links, custom blocks rendered by their classes. The output is sanitized by the editor's schema.
231
+ */
232
+ export class RichContentRenderer {
233
+ content;
234
+ _mergeTags = {};
235
+ _blocks = {};
236
+ _mentions = [];
237
+ constructor(content) { this.content = content; }
238
+ static make(content) { return new this(content); }
239
+ /** Values of the merge tags: text (escaped), an `HtmlString` (inserted as is), or a function returning one. */
240
+ mergeTags(values) { this._mergeTags = values; return this; }
241
+ /** Block classes, optionally with data passed to their `toHtml()`: `[HeroBlock]` or `{ hero: { block: HeroBlock, data } }`. */
242
+ customBlocks(blocks) {
243
+ this._blocks = Array.isArray(blocks)
244
+ ? Object.fromEntries(blocks.map((block) => [block.getId(), { block, data: {} }]))
245
+ : Object.fromEntries(Object.entries(blocks).map(([id, entry]) => [id, { block: entry.block, data: entry.data ?? {} }]));
246
+ return this;
247
+ }
248
+ mentions(providers) { this._mentions = providers; return this; }
249
+ async toHtml() {
250
+ const { richHtmlToDocument, richDocumentToHtml, renderRichDocument } = await import("./index.js");
251
+ // Sanitized first: only what the schema accepts goes through.
252
+ const sanitized = richDocumentToHtml(typeof this.content === 'string' ? richHtmlToDocument(this.content) : this.content);
253
+ if (sanitized === null)
254
+ return '';
255
+ const document = richHtmlToDocument(sanitized);
256
+ const raw = new Map();
257
+ const placeholder = (html) => { const key = `navette-raw-${randomBytes(8).toString('hex')}`; raw.set(key, html); return { type: 'paragraph', content: [{ type: 'text', text: key }] }; };
258
+ const inlinePlaceholder = (html) => { const key = `navette-raw-${randomBytes(8).toString('hex')}`; raw.set(key, html); return { type: 'text', text: key }; };
259
+ // Current labels of the mentions, by trigger character.
260
+ const mentioned = new Map();
261
+ const collect = (node) => { if (node.type === 'mention' && node.attrs?.id != null) {
262
+ const char = node.attrs.char ?? '@';
263
+ if (!mentioned.has(char))
264
+ mentioned.set(char, new Set());
265
+ mentioned.get(char).add(String(node.attrs.id));
266
+ } node.content?.forEach(collect); };
267
+ collect(document);
268
+ const labels = new Map();
269
+ for (const [char, ids] of mentioned) {
270
+ const provider = this._mentions.find((candidate) => candidate.getTriggerCharacter() === char);
271
+ if (provider)
272
+ labels.set(char, await provider.getLabels([...ids]));
273
+ }
274
+ const transform = async (node) => {
275
+ if (node.type === 'mergeTag') {
276
+ const id = String(node.attrs?.id ?? '');
277
+ if (!Object.hasOwn(this._mergeTags, id))
278
+ return { type: 'text', text: `{{ ${id} }}` };
279
+ let value = this._mergeTags[id];
280
+ if (typeof value === 'function')
281
+ value = await value();
282
+ if (value instanceof HtmlString)
283
+ return inlinePlaceholder(value.html);
284
+ const text = value === null || value === undefined ? '' : String(value);
285
+ return text === '' ? null : { type: 'text', text, ...(node.marks ? { marks: node.marks } : {}) };
286
+ }
287
+ if (node.type === 'mention') {
288
+ const char = node.attrs?.char ?? '@', id = String(node.attrs?.id ?? '');
289
+ const label = labels.get(char)?.[id] ?? node.attrs?.label ?? '';
290
+ const url = this._mentions.find((candidate) => candidate.getTriggerCharacter() === char)?.getUrl(id, label) ?? null;
291
+ return { ...node, attrs: { ...node.attrs, label, href: url && /^(https?:\/\/|\/(?!\/)|mailto:)/i.test(url) ? url : null } };
292
+ }
293
+ if (node.type === 'customBlock') {
294
+ const entry = this._blocks[String(node.attrs?.id ?? '')];
295
+ if (!entry)
296
+ return null;
297
+ const config = node.attrs?.config && typeof node.attrs.config === 'object' ? node.attrs.config : {};
298
+ const html = entry.block.toHtml(config, entry.data);
299
+ return html ? placeholder(html) : null;
300
+ }
301
+ if (!node.content)
302
+ return node;
303
+ const children = [];
304
+ for (const child of node.content) {
305
+ const result = await transform(child);
306
+ if (result)
307
+ children.push(result);
308
+ }
309
+ return { ...node, content: children };
310
+ };
311
+ const html = renderRichDocument(await transform(document));
312
+ let output = html;
313
+ for (const [key, value] of raw)
314
+ output = output.replace(`<p>${key}</p>`, () => value).replace(key, () => value);
315
+ return output;
316
+ }
317
+ }
318
+ /** "Lead" paragraph group: `<div class="lead">`. */
319
+ export const LeadNode = Node.create({
320
+ name: 'lead', group: 'block', content: 'block+',
321
+ parseHTML() { return [{ tag: 'div', getAttrs: (element) => (String(element.getAttribute?.('class') ?? '').split(/\s+/).includes('lead') ? null : false) }]; },
322
+ renderHTML({ HTMLAttributes }) { return ['div', mergeAttributes({ class: 'lead' }, HTMLAttributes), 0]; },
323
+ });
324
+ /** Small text: `<small>`. */
325
+ export const SmallMark = Mark.create({
326
+ name: 'small',
327
+ parseHTML() { return [{ tag: 'small' }]; },
328
+ renderHTML({ HTMLAttributes }) { return ['small', HTMLAttributes, 0]; },
329
+ });
330
+ const count = (value, max) => Math.min(max, Math.max(1, Math.floor(Number(value)) || 1));
331
+ /** Columns: `<div class="grid-layout" data-cols="2">` with `<div class="grid-layout-col">` children. */
332
+ export const GridNode = Node.create({
333
+ name: 'grid', group: 'block', content: 'gridColumn+', isolating: true,
334
+ addAttributes() {
335
+ return {
336
+ 'data-cols': { default: 2, parseHTML: (element) => count(element.getAttribute('data-cols'), 12) },
337
+ 'data-from-breakpoint': { default: 'md', parseHTML: (element) => (/^(sm|md|lg|xl|2xl)$/.test(element.getAttribute('data-from-breakpoint') ?? '') ? element.getAttribute('data-from-breakpoint') : 'md') },
338
+ };
339
+ },
340
+ parseHTML() { return [{ tag: 'div', getAttrs: (element) => (String(element.getAttribute?.('class') ?? '').split(/\s+/).includes('grid-layout') ? null : false) }]; },
341
+ renderHTML({ HTMLAttributes }) { return ['div', mergeAttributes({ class: 'grid-layout', style: `--cols: repeat(${count(HTMLAttributes['data-cols'], 12)}, minmax(0, 1fr))` }, HTMLAttributes), 0]; },
342
+ });
343
+ export const GridColumnNode = Node.create({
344
+ name: 'gridColumn', content: 'block+', isolating: true,
345
+ addAttributes() { return { 'data-col-span': { default: 1, parseHTML: (element) => count(element.getAttribute('data-col-span'), 12) } }; },
346
+ parseHTML() { return [{ tag: 'div', getAttrs: (element) => (String(element.getAttribute?.('class') ?? '').split(/\s+/).includes('grid-layout-col') ? null : false) }]; },
347
+ renderHTML({ HTMLAttributes }) { const span = count(HTMLAttributes['data-col-span'], 12); return ['div', mergeAttributes({ class: 'grid-layout-col', style: `--col-span: span ${span} / span ${span}` }, HTMLAttributes), 0]; },
348
+ });
349
+ /** Server schema additions of the rich editor, besides the starter kit. */
350
+ export const RICH_CONTENT_EXTENSIONS = [TextColorMark, SmallMark, MergeTagNode, MentionNode, CustomBlockNode, LeadNode, GridNode, GridColumnNode];
@@ -0,0 +1,60 @@
1
+ /** Plain description of a tool (the built-in tools are generated from Filament's definitions). */
2
+ export type RichEditorToolDefinition = {
3
+ name: string;
4
+ /** Translation key of the label. */
5
+ label?: string;
6
+ jsHandler?: string;
7
+ /** Arguments of the field action mounted on click, as a JavaScript object expression. */
8
+ action?: string;
9
+ toggle?: boolean;
10
+ icon?: string;
11
+ activeKey?: string;
12
+ activeOptions?: Record<string, unknown>;
13
+ activeJsExpression?: string;
14
+ activeStyling?: boolean;
15
+ disabledWhenNotActive?: boolean;
16
+ extraAttributes?: Record<string, string>;
17
+ };
18
+ /**
19
+ * A toolbar button. `jsHandler()` runs code in the browser, where `$getEditor()` is the TipTap editor;
20
+ * `action()` mounts an action of the field (add it with `RichEditor.registerActions()`), with the editor selection.
21
+ */
22
+ export declare class RichEditorTool {
23
+ protected _name: string;
24
+ protected _label: string | null;
25
+ protected _labelKey: string | null;
26
+ protected _icon: string | null;
27
+ protected _jsHandler: string | null;
28
+ protected _toggle: boolean;
29
+ protected _activeKey: string | null;
30
+ protected _activeOptions: Record<string, unknown>;
31
+ protected _activeJsExpression: string | null;
32
+ protected _activeStyling: boolean;
33
+ protected _disabledWhenNotActive: boolean;
34
+ protected _hiddenLabel: boolean;
35
+ protected _extraAttributes: Record<string, string>;
36
+ constructor(name: string);
37
+ static make(name: string): RichEditorTool;
38
+ static fromDefinition(definition: RichEditorToolDefinition): RichEditorTool;
39
+ getName(): string;
40
+ label(label: string | null): this;
41
+ getLabel(): string;
42
+ /** Shows the label next to the icon (by default, it is a tooltip). */
43
+ hiddenLabel(condition?: boolean): this;
44
+ icon(name: string | null): this;
45
+ /** JavaScript run on click: `$getEditor()?.chain().focus().toggleBold().run()`. */
46
+ jsHandler(handler: string | null): this;
47
+ /** Mounts the field action `name` (the tool's name by default) with the editor selection and these arguments (a JavaScript object expression). */
48
+ action(name?: string | null, argumentsJs?: string | null): this;
49
+ /** The button is a toggle (`aria-pressed`). */
50
+ toggle(condition?: boolean): this;
51
+ /** Node or mark whose presence marks the button as active (its name by default), with its attributes. */
52
+ activeKey(key: string | null): this;
53
+ activeOptions(options: Record<string, unknown>): this;
54
+ /** JavaScript expression telling whether the button is active. */
55
+ activeJsExpression(expression: string | null): this;
56
+ activeStyling(condition?: boolean): this;
57
+ disabledWhenNotActive(condition?: boolean): this;
58
+ extraAttributes(attributes: Record<string, string>): this;
59
+ toHtml(): string;
60
+ }
@@ -0,0 +1,89 @@
1
+ // Rich editor toolbar tools: a button running JavaScript in the editor, or mounting an action of the field.
2
+ import { Attributes, e, icon, js } from '@navette-js/support';
3
+ import { t } from '@navette-js/support/i18n';
4
+ /**
5
+ * A toolbar button. `jsHandler()` runs code in the browser, where `$getEditor()` is the TipTap editor;
6
+ * `action()` mounts an action of the field (add it with `RichEditor.registerActions()`), with the editor selection.
7
+ */
8
+ export class RichEditorTool {
9
+ _name;
10
+ _label = null;
11
+ _labelKey = null;
12
+ _icon = null;
13
+ _jsHandler = null;
14
+ _toggle = false;
15
+ _activeKey = null;
16
+ _activeOptions = {};
17
+ _activeJsExpression = null;
18
+ _activeStyling = true;
19
+ _disabledWhenNotActive = false;
20
+ _hiddenLabel = true;
21
+ _extraAttributes = {};
22
+ constructor(name) { this._name = name; }
23
+ static make(name) { return new this(name); }
24
+ static fromDefinition(definition) {
25
+ const tool = new this(definition.name);
26
+ tool._labelKey = definition.label ?? null;
27
+ tool._icon = definition.icon ?? null;
28
+ if (definition.jsHandler)
29
+ tool.jsHandler(definition.jsHandler);
30
+ if (definition.action !== undefined)
31
+ tool.action(null, definition.action);
32
+ tool._toggle = definition.toggle === true;
33
+ tool._activeKey = definition.activeKey ?? null;
34
+ tool._activeOptions = definition.activeOptions ?? {};
35
+ tool._activeJsExpression = definition.activeJsExpression ?? null;
36
+ tool._activeStyling = definition.activeStyling !== false;
37
+ tool._disabledWhenNotActive = definition.disabledWhenNotActive === true;
38
+ tool._extraAttributes = { ...(definition.extraAttributes ?? {}) };
39
+ return tool;
40
+ }
41
+ getName() { return this._name; }
42
+ label(label) { this._label = label; this._labelKey = null; return this; }
43
+ getLabel() {
44
+ if (this._label !== null)
45
+ return this._label;
46
+ if (this._labelKey)
47
+ return t(`filament-forms::${this._labelKey.replace(/^filament-forms::/, '')}`);
48
+ return this._name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[-_]/g, ' ').replace(/^./, (c) => c.toUpperCase());
49
+ }
50
+ /** Shows the label next to the icon (by default, it is a tooltip). */
51
+ hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
52
+ icon(name) { this._icon = name; return this; }
53
+ /** JavaScript run on click: `$getEditor()?.chain().focus().toggleBold().run()`. */
54
+ jsHandler(handler) { this._jsHandler = handler; return this; }
55
+ /** Mounts the field action `name` (the tool's name by default) with the editor selection and these arguments (a JavaScript object expression). */
56
+ action(name = null, argumentsJs = null) {
57
+ this._jsHandler = `mountToolAction(${js(name ?? this._name)}, { editorSelection, ...${argumentsJs ?? '{}'} }, $el)`;
58
+ return this;
59
+ }
60
+ /** The button is a toggle (`aria-pressed`). */
61
+ toggle(condition = true) { this._toggle = condition; return this; }
62
+ /** Node or mark whose presence marks the button as active (its name by default), with its attributes. */
63
+ activeKey(key) { this._activeKey = key; return this; }
64
+ activeOptions(options) { this._activeOptions = options; return this; }
65
+ /** JavaScript expression telling whether the button is active. */
66
+ activeJsExpression(expression) { this._activeJsExpression = expression; return this; }
67
+ activeStyling(condition = true) { this._activeStyling = condition; return this; }
68
+ disabledWhenNotActive(condition = true) { this._disabledWhenNotActive = condition; return this; }
69
+ extraAttributes(attributes) { Object.assign(this._extraAttributes, attributes); return this; }
70
+ toHtml() {
71
+ const active = this._activeJsExpression
72
+ ? `editorUpdatedAt && (${this._activeJsExpression})`
73
+ : `editorUpdatedAt && $getEditor()?.isActive(${js(this._activeKey ?? this._name)}, ${Object.keys(this._activeOptions).length ? js(this._activeOptions) : '[]'})`;
74
+ const label = this.getLabel();
75
+ const attributes = new Attributes(this._extraAttributes).merge({
76
+ 'data-rich-editor-tool': this._name,
77
+ tabindex: '-1',
78
+ type: 'button',
79
+ 'aria-label': label,
80
+ 'aria-pressed': this._toggle ? 'false' : null,
81
+ 'x-bind:class': `{ 'fi-active': ${this._activeStyling ? active : 'false'} }`,
82
+ 'x-bind:aria-pressed': this._toggle ? `(${active}) ? 'true' : 'false'` : null,
83
+ 'x-bind:disabled': this._disabledWhenNotActive ? `!(${active})` : null,
84
+ 'x-on:click': this._jsHandler,
85
+ 'x-tooltip': label && this._hiddenLabel ? `{ content: ${js(label)}, theme: $store.theme }` : null,
86
+ }).class(['fi-fo-rich-editor-tool', { 'fi-fo-rich-editor-tool-with-label': !this._hiddenLabel }]);
87
+ return `<button ${attributes.toHtml()}>${this._icon ? icon(this._icon) : ''}${this._hiddenLabel ? '' : `<span class="fi-fo-rich-editor-tool-label">${e(label)}</span>`}</button>`;
88
+ }
89
+ }
@@ -0,0 +1,2 @@
1
+ import type { RichEditorToolDefinition } from './rich-editor-tool.ts';
2
+ export declare const RICH_EDITOR_TOOL_DEFINITIONS: RichEditorToolDefinition[];