@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.
package/lib/index.d.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { HtmlString, type ColorManager, type Lazy } from '@navette-js/support';
2
- import { Component, Schema, type ComponentContext } from '@navette-js/schemas';
2
+ import { Component, Schema, type ComponentContext, type ComponentSlot } from '@navette-js/schemas';
3
3
  import { Action } from '@navette-js/actions';
4
+ import { TextColor, MentionProvider, type CustomBlockClass } from './rich-content.ts';
5
+ export { CustomBlockNode, GridColumnNode, GridNode, LeadNode, MentionNode, MentionProvider, MergeTagNode, registerRichContentExtensions, RichContentCustomBlock, RichContentRenderer, sanitizeCssColor, SizedImage, SmallMark, TextColor, TextColorMark, type CustomBlockClass } from './rich-content.ts';
6
+ import { RichEditorTool } from './rich-editor-tool.ts';
7
+ export { RichEditorTool, type RichEditorToolDefinition } from './rich-editor-tool.ts';
8
+ export { RICH_EDITOR_TOOL_DEFINITIONS } from './rich-editor-tools.ts';
4
9
  export declare const useColorManager: (manager: ColorManager) => void;
5
10
  import { type Rule } from './rules.ts';
6
11
  export type { Rule } from './rules.ts';
@@ -39,6 +44,9 @@ export declare abstract class Field extends Component {
39
44
  default(value: unknown): this;
40
45
  /** Helper text shown under the field. */
41
46
  helperText(text: string | null): this;
47
+ protected _belowContent: ComponentSlot;
48
+ /** Text, components or actions under the control (after the helper text). Strings are escaped. */
49
+ belowContent(content: ComponentSlot): this;
42
50
  /** Helper text id, linked to the control through `aria-describedby`. */
43
51
  protected getHelperTextId(): string | null;
44
52
  /** Keeps the label for screen readers only. */
@@ -876,9 +884,18 @@ export declare function richHtmlToDocument(html: string | null | undefined): Ric
876
884
  * TipTap document → HTML to save, or null if empty or invalid. The document comes from the browser,
877
885
  * so it is rendered then re-parsed to keep only what the schema accepts.
878
886
  */
887
+ /** HTML of a document already sanitized (`RichContentRenderer`): no re-parsing. */
888
+ export declare function renderRichDocument(document: unknown): string;
879
889
  export declare function richDocumentToHtml(document: unknown): string | null;
880
890
  /** Tools of the rich editor's toolbar, in their default groups. */
881
891
  export declare const RICH_EDITOR_TOOLS: string[][];
892
+ /** Custom blocks of an editor: a list, or groups of blocks by label. */
893
+ export type CustomBlocks = CustomBlockClass[] | Record<string, CustomBlockClass[]>;
894
+ /**
895
+ * Rich text editor (TipTap): the browser works on a document, the database receives HTML (or JSON with `json()`).
896
+ * Besides the starter kit: text colors, merge tags, mentions, custom blocks, columns, resizable images, and your own
897
+ * tools and TipTap extensions.
898
+ */
882
899
  export declare class RichEditor extends Field {
883
900
  private _toolbar;
884
901
  private _disabledTools;
@@ -888,12 +905,34 @@ export declare class RichEditor extends Field {
888
905
  private _attachmentsDirectory;
889
906
  private _attachmentTypes;
890
907
  private _attachmentMaxSize;
908
+ private _customTools;
909
+ private _textColors;
910
+ private _customTextColors;
911
+ private _mergeTags;
912
+ private _noMergeTagResults;
913
+ private _mentions;
914
+ private _customBlocks;
915
+ private _minimalCustomBlockControls;
916
+ private _customBlocksGrid;
917
+ private _searchableCustomBlocks;
918
+ private _activePanel;
919
+ private _stickyToolbar;
920
+ private _resizableImages;
921
+ private _jsExtensions;
922
+ private _editorActions;
891
923
  static make(name: string): RichEditor;
892
- /** Toolbar tools, by group (`[['bold', 'italic', 'link'], ['h2', 'h3'], ['undo', 'redo']]`); see `RICH_EDITOR_TOOLS`. */
924
+ /** Toolbar tools, by group (`[['bold', 'italic', 'link'], ['h2', 'h3'], ['undo', 'redo']]`); see `RICH_EDITOR_TOOLS` and `RICH_EDITOR_TOOL_DEFINITIONS`. */
893
925
  toolbarButtons(groups: string[][] | string[]): this;
926
+ private assertKnownTools;
894
927
  /** Removes buttons; with no argument, removes them all. */
895
928
  disableToolbarButtons(tools?: string[]): this;
896
929
  disableAllToolbarButtons(condition?: boolean): this;
930
+ /** Your own toolbar buttons (or replacements of built-in ones, by name); place them with `toolbarButtons()`. */
931
+ tools(tools: RichEditorTool[]): this;
932
+ /** Actions mounted by your tools (`RichEditorTool.make('x').action()`), run with the editor selection in `arguments.editorSelection`. */
933
+ editorActions(actions: Action[]): this;
934
+ /** Browser TipTap extensions: URLs of ES modules exporting an extension (or an array of them). Register their server side with `registerRichContentExtensions()`. */
935
+ extensions(urls: string[]): this;
897
936
  /** Saves the content as the editor's JSON document instead of HTML. */
898
937
  json(condition?: boolean): this;
899
938
  placeholder(text: string | null): this;
@@ -904,25 +943,73 @@ export declare class RichEditor extends Field {
904
943
  fileAttachmentsAcceptedFileTypes(types: string[]): this;
905
944
  /** Maximum size of attached files, in kilobytes. */
906
945
  fileAttachmentsMaxSize(kilobytes: number): this;
946
+ /** Text colors offered by the `textColor` tool (the Tailwind palette by default): `{ brand: TextColor.make('Brand', '#0ea5e9', '#38bdf8') }`. */
947
+ textColors(colors: Record<string, TextColor> | null): this;
948
+ getTextColors(): Record<string, TextColor>;
949
+ /** Lets users pick any color, besides the listed ones. */
950
+ customTextColors(condition?: boolean): this;
951
+ /** Merge tags users insert (`mergeTags` tool and `{{` in the text), replaced by values when the content is rendered: `['name', 'today']` or `{ name: 'Name' }`. */
952
+ mergeTags(tags: string[] | Record<string, string>): this;
953
+ noMergeTagSearchResultsMessage(message: string | null): this;
954
+ /** Mentions: `[MentionProvider.make('@').items({ 1: 'Ada' })]`. */
955
+ mentions(providers: MentionProvider[]): this;
956
+ /** Custom blocks (`customBlocks` tool), as a list or grouped by label. */
957
+ customBlocks(blocks: CustomBlocks): this;
958
+ minimalCustomBlockControls(condition?: boolean): this;
959
+ /** Shows the blocks of the panel in a grid. */
960
+ customBlocksGrid(condition?: boolean): this;
961
+ /** Adds a search field to the blocks panel. */
962
+ searchableCustomBlocks(condition?: boolean): this;
963
+ /** Panel open when the editor loads (`customBlocks`, `mergeTags`). */
964
+ activePanel(panel: string | null): this;
965
+ /** Keeps the toolbar visible while scrolling the content. */
966
+ stickyToolbar(condition?: boolean): this;
967
+ /** Lets users resize images with handles; the size is saved on the image. */
968
+ resizableImages(condition?: boolean): this;
907
969
  private attachmentsStorage;
908
- private tools;
970
+ private findTool;
971
+ private toolGroups;
972
+ private blockGroups;
973
+ getCustomBlock(id: unknown): CustomBlockClass | null;
909
974
  hydrateState(value: unknown): unknown;
910
975
  getDefaultState(): unknown;
911
976
  /** HTML (sanitized by the editor's schema), or the JSON document with `json()` (normalized through the same schema). */
912
977
  dehydrateState(state: unknown): unknown;
913
978
  getValidationRules(): Promise<Rule[]>;
914
979
  getExposedMethods(): string[];
915
- getMentionSearchResultsForJs(): unknown[];
916
- getMentionLabelsForJs(): unknown[];
917
- getCustomBlockPreviewsForJs(): unknown[];
980
+ private mentionProvider;
981
+ /** Mention search, for the editor's JavaScript: `[{ id, label }]`. */
982
+ getMentionSearchResultsForJs(search?: unknown, triggerCharacter?: unknown): Promise<{
983
+ id: string;
984
+ label: string;
985
+ }[]>;
986
+ /** Current labels of mentioned ids, by trigger character. */
987
+ getMentionLabelsForJs(mentions?: unknown): Promise<Record<string, Record<string, string>>>;
988
+ /** Previews of inserted blocks, for the editor's JavaScript. */
989
+ getCustomBlockPreviewsForJs(customBlocks?: unknown): {
990
+ key: number;
991
+ label: string;
992
+ preview: string;
993
+ shouldApplyProseStylingToPreview: boolean;
994
+ }[];
918
995
  /**
919
996
  * URL of a file the editor just uploaded: the file is checked (type from its content, size), stored, and its public URL returned.
920
997
  * It stays stored even if the form is not saved.
921
998
  */
922
999
  getUploadedFileAttachmentTemporaryUrl(args?: unknown): Promise<string | null>;
923
1000
  saveUploadedFileAttachmentAndGetUrl(args?: unknown): Promise<string | null>;
924
- /** Toolbar "link" action: a modal (URL, new tab), then commands sent back to the browser editor. */
1001
+ /** Sends commands to the editor in the browser (after an action), on the given selection. */
1002
+ runCommands(livewire: any, commands: {
1003
+ name: string;
1004
+ arguments?: unknown[];
1005
+ }[], editorSelection?: unknown): void;
1006
+ /** Toolbar actions: link, text color, custom block, and the ones of `editorActions()`. */
925
1007
  getActions(): Action[];
1008
+ private textColorAction;
1009
+ /** Modal of a custom block, built from the block class named in the arguments (`id`): insert or edit. */
1010
+ private customBlockAction;
1011
+ private customBlocksPanelHtml;
1012
+ private mergeTagsPanelHtml;
926
1013
  toHtml(): Promise<string>;
927
1014
  }
928
1015
  /** Color picker (hex format). */
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Attributes, HtmlString, cls, e, evaluate, icon, js, sanitizeHtml, views, configured } from '@navette-js/support';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { t } from '@navette-js/support/i18n';
5
- import { Component, Icon as SchemaIcon, Schema, Text, childSchema, renderInlineItems, slug } from '@navette-js/schemas';
5
+ import { Component, Icon as SchemaIcon, Schema, Text, childSchema, renderInlineItems, renderSlot, slug } from '@navette-js/schemas';
6
6
  import { Action } from '@navette-js/actions';
7
7
  import { loadingIndicator } from '@navette-js/support';
8
8
  import { randomUUID } from 'node:crypto';
@@ -12,7 +12,12 @@ import TextAlign from '@tiptap/extension-text-align';
12
12
  import Subscript from '@tiptap/extension-subscript';
13
13
  import Superscript from '@tiptap/extension-superscript';
14
14
  import Highlight from '@tiptap/extension-highlight';
15
- import Image from '@tiptap/extension-image';
15
+ import { RICH_CONTENT_EXTENSIONS, SizedImage, customRichContentExtensions, registerTextColors, sanitizeCssColor, TextColor, textColorsForJs } from "./rich-content.js";
16
+ export { CustomBlockNode, GridColumnNode, GridNode, LeadNode, MentionNode, MentionProvider, MergeTagNode, registerRichContentExtensions, RichContentCustomBlock, RichContentRenderer, sanitizeCssColor, SizedImage, SmallMark, TextColor, TextColorMark } from "./rich-content.js";
17
+ import { RichEditorTool } from "./rich-editor-tool.js";
18
+ export { RichEditorTool } from "./rich-editor-tool.js";
19
+ import { RICH_EDITOR_TOOL_DEFINITIONS } from "./rich-editor-tools.js";
20
+ export { RICH_EDITOR_TOOL_DEFINITIONS } from "./rich-editor-tools.js";
16
21
  import { TableKit } from '@tiptap/extension-table';
17
22
  import { fileURLToPath } from 'node:url';
18
23
  import nodePath from 'node:path';
@@ -61,6 +66,9 @@ export class Field extends Component {
61
66
  default(value) { this._default = value; return this; }
62
67
  /** Helper text shown under the field. */
63
68
  helperText(text) { this._helperText = text; return this; }
69
+ _belowContent = null;
70
+ /** Text, components or actions under the control (after the helper text). Strings are escaped. */
71
+ belowContent(content) { this._belowContent = content; return this; }
64
72
  /** Helper text id, linked to the control through `aria-describedby`. */
65
73
  getHelperTextId() { return this._helperText !== null ? `${this.getId()}-helper-text` : null; }
66
74
  /** Keeps the label for screen readers only. */
@@ -299,6 +307,8 @@ export class Field extends Component {
299
307
  // Helper text: a small inline schema under the control.
300
308
  if (this._helperText !== null)
301
309
  html += `<div id="${e(this.getHelperTextId())}" class="fi-sc fi-inline fi-sc-has-gap fi-sc-dense"><div x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path: this.getStatePath(), containerPath: this.container.getStatePath() }))})"><div class="fi-sc-component"><span class="fi-sc-text">${e(this._helperText)}</span></div></div></div>`;
310
+ if (this._belowContent !== null)
311
+ html += await renderSlot(this, this._belowContent, this.getKey());
302
312
  return views.render('filament-forms::components.field-wrapper', {
303
313
  id: this.getId(), label: await this.getLabel(), required: (await this.isMarkedAsRequired()) && !(await this.isDisabled()),
304
314
  labelPrefix: options.labelPrefix ?? '', hiddenLabel: this._hiddenLabel, after: options.after ?? '', afterLabel: this._hiddenLabel ? '' : await this.renderAfterLabel(), labelAsDiv: options.labelAsDiv === true, focusable: options.focusable === true, html, errorMessage: errors[0] ?? null, wrapperClass: options.wrapperClass ?? null,
@@ -1884,24 +1894,25 @@ export class ToggleButtons extends Field {
1884
1894
  return this.wrap(`<div ${container.toHtml()}>${grid ? `<div${grid}>${items.join('')}</div>` : items.join('')}</div>`, { wrapperClass: 'fi-fo-toggle-buttons-wrp', labelAsDiv: true, focusable: true, after });
1885
1895
  }
1886
1896
  }
1887
- // Server-side TipTap schema: the nodes and marks the rich editor's default toolbar can produce.
1888
- // Not supported yet: grids, custom blocks, mentions, merge tags, text color, "lead" and "small".
1889
- const RICH_EXTENSIONS = [StarterKit, TextAlign.configure({ types: ['heading', 'paragraph'], defaultAlignment: 'start' }), Subscript, Superscript, Highlight, Image, TableKit];
1897
+ // Server-side TipTap schema: the nodes and marks the rich editor can produce, and the extensions your application registers.
1898
+ const richExtensions = () => [StarterKit, TextAlign.configure({ types: ['heading', 'paragraph'], defaultAlignment: 'start' }), Subscript, Superscript, Highlight, SizedImage, TableKit, ...RICH_CONTENT_EXTENSIONS, ...customRichContentExtensions];
1890
1899
  const isRichDocument = (value) => !!value && typeof value === 'object' && value.type === 'doc';
1891
1900
  const richText = (node) => (typeof node?.text === 'string' ? node.text : '') + (Array.isArray(node?.content) ? node.content.map(richText).join('') : '');
1892
- const hasRichContent = (node) => node?.type === 'image' || node?.type === 'horizontalRule' || (Array.isArray(node?.content) && node.content.some(hasRichContent));
1901
+ const hasRichContent = (node) => ['image', 'horizontalRule', 'mergeTag', 'mention', 'customBlock'].includes(node?.type) || (Array.isArray(node?.content) && node.content.some(hasRichContent));
1893
1902
  /** Stored HTML → TipTap document. Parsing drops anything the schema does not know (scripts, event attributes, `javascript:` links). */
1894
- export function richHtmlToDocument(html) { return generateJSON(html ?? '', RICH_EXTENSIONS); }
1903
+ export function richHtmlToDocument(html) { return generateJSON(html ?? '', richExtensions()); }
1895
1904
  /**
1896
1905
  * TipTap document → HTML to save, or null if empty or invalid. The document comes from the browser,
1897
1906
  * so it is rendered then re-parsed to keep only what the schema accepts.
1898
1907
  */
1908
+ /** HTML of a document already sanitized (`RichContentRenderer`): no re-parsing. */
1909
+ export function renderRichDocument(document) { return generateHTML(document, richExtensions()); }
1899
1910
  export function richDocumentToHtml(document) {
1900
1911
  if (!isRichDocument(document))
1901
1912
  return null;
1902
1913
  try {
1903
- const clean = generateJSON(generateHTML(document, RICH_EXTENSIONS), RICH_EXTENSIONS);
1904
- return richText(clean).trim() === '' && !hasRichContent(clean) ? null : generateHTML(clean, RICH_EXTENSIONS);
1914
+ const clean = generateJSON(generateHTML(document, richExtensions()), richExtensions());
1915
+ return richText(clean).trim() === '' && !hasRichContent(clean) ? null : generateHTML(clean, richExtensions());
1905
1916
  }
1906
1917
  catch {
1907
1918
  return null;
@@ -1936,6 +1947,15 @@ async function storeEditorAttachment(field, id, options) {
1936
1947
  }
1937
1948
  /** Tools of the rich editor's toolbar, in their default groups. */
1938
1949
  export const RICH_EDITOR_TOOLS = [['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link'], ['h2', 'h3'], ['alignStart', 'alignCenter', 'alignEnd'], ['blockquote', 'codeBlock', 'bulletList', 'orderedList'], ['table', 'attachFiles'], ['undo', 'redo']];
1950
+ /** The editor selection sent by the browser with a toolbar action. */
1951
+ const selectionOf = (args) => (args.editorSelection ?? null);
1952
+ // `details` needs a TipTap extension the server schema does not have: its content would be lost on save.
1953
+ const BUILT_IN_TOOLS = new Map(RICH_EDITOR_TOOL_DEFINITIONS.filter((definition) => definition.name !== 'details').map((definition) => [definition.name, definition]));
1954
+ /**
1955
+ * Rich text editor (TipTap): the browser works on a document, the database receives HTML (or JSON with `json()`).
1956
+ * Besides the starter kit: text colors, merge tags, mentions, custom blocks, columns, resizable images, and your own
1957
+ * tools and TipTap extensions.
1958
+ */
1939
1959
  export class RichEditor extends Field {
1940
1960
  _toolbar = null;
1941
1961
  _disabledTools = [];
@@ -1945,22 +1965,47 @@ export class RichEditor extends Field {
1945
1965
  _attachmentsDirectory = null;
1946
1966
  _attachmentTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
1947
1967
  _attachmentMaxSize = 12288;
1968
+ _customTools = [];
1969
+ _textColors = null;
1970
+ _customTextColors = false;
1971
+ _mergeTags = {};
1972
+ _noMergeTagResults = null;
1973
+ _mentions = [];
1974
+ _customBlocks = [];
1975
+ _minimalCustomBlockControls = false;
1976
+ _customBlocksGrid = false;
1977
+ _searchableCustomBlocks = false;
1978
+ _activePanel = null;
1979
+ _stickyToolbar = false;
1980
+ _resizableImages = false;
1981
+ _jsExtensions = [];
1982
+ _editorActions = [];
1948
1983
  static make(name) { return configured(new RichEditor(name)); }
1949
- /** Toolbar tools, by group (`[['bold', 'italic', 'link'], ['h2', 'h3'], ['undo', 'redo']]`); see `RICH_EDITOR_TOOLS`. */
1984
+ /** Toolbar tools, by group (`[['bold', 'italic', 'link'], ['h2', 'h3'], ['undo', 'redo']]`); see `RICH_EDITOR_TOOLS` and `RICH_EDITOR_TOOL_DEFINITIONS`. */
1950
1985
  toolbarButtons(groups) {
1951
1986
  const list = (groups.length && typeof groups[0] === 'string' ? [groups] : groups);
1952
- const known = new Set(RICH_EDITOR_TOOLS.flat());
1953
- const unknown = list.flat().filter((tool) => !known.has(tool));
1954
- if (unknown.length)
1955
- throw new Error(`RichEditor: unknown toolbar buttons ${unknown.join(', ')} (available: ${[...known].join(', ')}).`);
1956
1987
  this._toolbar = list;
1988
+ this.assertKnownTools();
1957
1989
  return this;
1958
1990
  }
1991
+ assertKnownTools() {
1992
+ if (!this._toolbar)
1993
+ return;
1994
+ const unknown = this._toolbar.flat().filter((tool) => !this.findTool(tool));
1995
+ if (unknown.length)
1996
+ throw new Error(`RichEditor: unknown toolbar buttons ${unknown.join(', ')} (available: ${[...BUILT_IN_TOOLS.keys(), ...this._customTools.map((tool) => tool.getName())].join(', ')}).`);
1997
+ }
1959
1998
  /** Removes buttons; with no argument, removes them all. */
1960
1999
  disableToolbarButtons(tools = []) { if (!tools.length)
1961
2000
  this._toolbar = []; this._disabledTools = [...this._disabledTools, ...tools]; return this; }
1962
2001
  disableAllToolbarButtons(condition = true) { if (condition)
1963
2002
  this._toolbar = []; return this; }
2003
+ /** Your own toolbar buttons (or replacements of built-in ones, by name); place them with `toolbarButtons()`. */
2004
+ tools(tools) { this._customTools = [...this._customTools.filter((tool) => !tools.some((other) => other.getName() === tool.getName())), ...tools]; this.assertKnownTools(); return this; }
2005
+ /** Actions mounted by your tools (`RichEditorTool.make('x').action()`), run with the editor selection in `arguments.editorSelection`. */
2006
+ editorActions(actions) { this._editorActions = [...this._editorActions, ...actions]; return this; }
2007
+ /** Browser TipTap extensions: URLs of ES modules exporting an extension (or an array of them). Register their server side with `registerRichContentExtensions()`. */
2008
+ extensions(urls) { this._jsExtensions = [...this._jsExtensions, ...urls]; return this; }
1964
2009
  /** Saves the content as the editor's JSON document instead of HTML. */
1965
2010
  json(condition = true) { this._json = condition; return this; }
1966
2011
  placeholder(text) { this._richPlaceholder = text; return this; }
@@ -1971,8 +2016,41 @@ export class RichEditor extends Field {
1971
2016
  fileAttachmentsAcceptedFileTypes(types) { this._attachmentTypes = types.filter((type) => type.startsWith('image/') && type !== 'image/svg+xml'); return this; }
1972
2017
  /** Maximum size of attached files, in kilobytes. */
1973
2018
  fileAttachmentsMaxSize(kilobytes) { this._attachmentMaxSize = kilobytes; return this; }
2019
+ /** Text colors offered by the `textColor` tool (the Tailwind palette by default): `{ brand: TextColor.make('Brand', '#0ea5e9', '#38bdf8') }`. */
2020
+ textColors(colors) { this._textColors = colors; if (colors)
2021
+ registerTextColors(colors); return this; }
2022
+ getTextColors() { return this._textColors ?? TextColor.getDefaults(); }
2023
+ /** Lets users pick any color, besides the listed ones. */
2024
+ customTextColors(condition = true) { this._customTextColors = condition; return this; }
2025
+ /** Merge tags users insert (`mergeTags` tool and `{{` in the text), replaced by values when the content is rendered: `['name', 'today']` or `{ name: 'Name' }`. */
2026
+ mergeTags(tags) { this._mergeTags = Array.isArray(tags) ? Object.fromEntries(tags.map((tag) => [tag, tag])) : { ...tags }; return this; }
2027
+ noMergeTagSearchResultsMessage(message) { this._noMergeTagResults = message; return this; }
2028
+ /** Mentions: `[MentionProvider.make('@').items({ 1: 'Ada' })]`. */
2029
+ mentions(providers) { this._mentions = providers; return this; }
2030
+ /** Custom blocks (`customBlocks` tool), as a list or grouped by label. */
2031
+ customBlocks(blocks) { this._customBlocks = blocks; return this; }
2032
+ minimalCustomBlockControls(condition = true) { this._minimalCustomBlockControls = condition; return this; }
2033
+ /** Shows the blocks of the panel in a grid. */
2034
+ customBlocksGrid(condition = true) { this._customBlocksGrid = condition; return this; }
2035
+ /** Adds a search field to the blocks panel. */
2036
+ searchableCustomBlocks(condition = true) { this._searchableCustomBlocks = condition; return this; }
2037
+ /** Panel open when the editor loads (`customBlocks`, `mergeTags`). */
2038
+ activePanel(panel) { this._activePanel = panel; return this; }
2039
+ /** Keeps the toolbar visible while scrolling the content. */
2040
+ stickyToolbar(condition = true) { this._stickyToolbar = condition; return this; }
2041
+ /** Lets users resize images with handles; the size is saved on the image. */
2042
+ resizableImages(condition = true) { this._resizableImages = condition; return this; }
1974
2043
  attachmentsStorage() { return this._attachmentsStorage ?? FileUpload.storage; }
1975
- tools() { return (this._toolbar ?? RICH_EDITOR_TOOLS).map((group) => group.filter((tool) => !this._disabledTools.includes(tool) && (tool !== 'attachFiles' || this.attachmentsStorage() !== null))).filter((group) => group.length); }
2044
+ findTool(name) {
2045
+ const custom = this._customTools.find((tool) => tool.getName() === name);
2046
+ if (custom)
2047
+ return custom;
2048
+ const definition = BUILT_IN_TOOLS.get(name);
2049
+ return definition ? RichEditorTool.fromDefinition(definition) : null;
2050
+ }
2051
+ toolGroups() { return (this._toolbar ?? RICH_EDITOR_TOOLS).map((group) => group.filter((tool) => !this._disabledTools.includes(tool) && (tool !== 'attachFiles' || this.attachmentsStorage() !== null))).filter((group) => group.length); }
2052
+ blockGroups() { return Array.isArray(this._customBlocks) ? [['', this._customBlocks]] : Object.entries(this._customBlocks); }
2053
+ getCustomBlock(id) { return this.blockGroups().flatMap(([, blocks]) => blocks).find((block) => block.getId() === id) ?? null; }
1976
2054
  hydrateState(value) {
1977
2055
  if (typeof value === 'string' && value.trim().startsWith('{')) {
1978
2056
  try {
@@ -1997,9 +2075,43 @@ export class RichEditor extends Field {
1997
2075
  return [...rules, ...this._rules];
1998
2076
  }
1999
2077
  getExposedMethods() { return ['getMentionSearchResultsForJs', 'getMentionLabelsForJs', 'getCustomBlockPreviewsForJs', 'getUploadedFileAttachmentTemporaryUrl', 'saveUploadedFileAttachmentAndGetUrl']; }
2000
- getMentionSearchResultsForJs() { return []; }
2001
- getMentionLabelsForJs() { return []; }
2002
- getCustomBlockPreviewsForJs() { return []; }
2078
+ mentionProvider(char) { return this._mentions.find((provider) => provider.getTriggerCharacter() === char) ?? this._mentions[0] ?? null; }
2079
+ /** Mention search, for the editor's JavaScript: `[{ id, label }]`. */
2080
+ async getMentionSearchResultsForJs(search = '', triggerCharacter = '@') {
2081
+ const provider = this.mentionProvider(triggerCharacter ?? '@');
2082
+ if (!provider || (await this.isDisabled()))
2083
+ return [];
2084
+ return Object.entries(await provider.getSearchResults(typeof search === 'string' ? search.slice(0, 200) : '')).slice(0, 50).map(([id, label]) => ({ id, label }));
2085
+ }
2086
+ /** Current labels of mentioned ids, by trigger character. */
2087
+ async getMentionLabelsForJs(mentions = []) {
2088
+ const byChar = new Map();
2089
+ for (const mention of Array.isArray(mentions) ? mentions.slice(0, 500) : []) {
2090
+ const char = String(mention?.triggerCharacter ?? '@'), id = mention?.id;
2091
+ if (id === null || id === undefined)
2092
+ continue;
2093
+ byChar.set(char, [...(byChar.get(char) ?? []), String(id)]);
2094
+ }
2095
+ const labels = {};
2096
+ for (const [char, ids] of byChar) {
2097
+ const provider = this.mentionProvider(char);
2098
+ if (provider)
2099
+ labels[char] = await provider.getLabels(ids);
2100
+ }
2101
+ return labels;
2102
+ }
2103
+ /** Previews of inserted blocks, for the editor's JavaScript. */
2104
+ getCustomBlockPreviewsForJs(customBlocks = []) {
2105
+ const previews = [];
2106
+ for (const item of Array.isArray(customBlocks) ? customBlocks.slice(0, 200) : []) {
2107
+ const block = this.getCustomBlock(item?.id);
2108
+ const config = item?.config && typeof item.config === 'object' && !Array.isArray(item.config) ? item.config : {};
2109
+ if (!block || !Number.isInteger(item?.key))
2110
+ continue;
2111
+ previews.push({ key: item.key, label: block.getPreviewLabel(config), preview: Buffer.from(block.toPreviewHtml(config) ?? '').toString('base64'), shouldApplyProseStylingToPreview: block.shouldApplyProseStylingToPreview(config) });
2112
+ }
2113
+ return previews;
2114
+ }
2003
2115
  /**
2004
2116
  * URL of a file the editor just uploaded: the file is checked (type from its content, size), stored, and its public URL returned.
2005
2117
  * It stays stored even if the form is not saved.
@@ -2007,15 +2119,19 @@ export class RichEditor extends Field {
2007
2119
  async getUploadedFileAttachmentTemporaryUrl(args = {}) {
2008
2120
  const id = String(args?.attachment ?? '');
2009
2121
  const storage = this.attachmentsStorage();
2010
- if (!storage || !/^[A-Za-z0-9_-]{1,64}$/.test(id) || !this.tools().flat().includes('attachFiles') || (await this.isDisabled()))
2122
+ if (!storage || !/^[A-Za-z0-9_-]{1,64}$/.test(id) || !this.toolGroups().flat().includes('attachFiles') || (await this.isDisabled()))
2011
2123
  return null;
2012
2124
  return storeEditorAttachment(this, id, { storage, directory: this._attachmentsDirectory, types: this._attachmentTypes, maxSize: this._attachmentMaxSize });
2013
2125
  }
2014
2126
  async saveUploadedFileAttachmentAndGetUrl(args = {}) { return this.getUploadedFileAttachmentTemporaryUrl(args); }
2015
- /** Toolbar "link" action: a modal (URL, new tab), then commands sent back to the browser editor. */
2127
+ /** Sends commands to the editor in the browser (after an action), on the given selection. */
2128
+ runCommands(livewire, commands, editorSelection = null) {
2129
+ const key = this.getKey();
2130
+ livewire.dispatch('run-rich-editor-commands', { awaitSchemaComponent: key, livewireId: livewire.id, key, editorSelection, commands: commands.map((command) => ({ name: command.name, arguments: command.arguments ?? [] })) });
2131
+ }
2132
+ /** Toolbar actions: link, text color, custom block, and the ones of `editorActions()`. */
2016
2133
  getActions() {
2017
2134
  const tr = (name) => t(`filament-forms::components.rich_editor.actions.link.${name}`);
2018
- const key = this.getKey();
2019
2135
  return [
2020
2136
  ...this.getHintActions(),
2021
2137
  Action.make('link').label(tr('label')).modalHeading(tr('modal.heading')).modalWidth('lg')
@@ -2027,20 +2143,107 @@ export class RichEditor extends Field {
2027
2143
  Checkbox.make('shouldOpenInNewTab').label(tr('modal.form.should_open_in_new_tab.label')),
2028
2144
  ])
2029
2145
  .action(({ livewire, arguments: args, data }) => {
2030
- const selection = (args.editorSelection ?? null);
2146
+ const selection = selectionOf(args);
2031
2147
  const single = selection?.head === selection?.anchor;
2032
2148
  const url = typeof data.url === 'string' && data.url !== '' ? data.url : null;
2033
- const commands = [
2149
+ this.runCommands(livewire, [
2034
2150
  ...(single ? [{ name: 'extendMarkRange', arguments: ['link'] }] : []),
2035
- url ? { name: 'setLink', arguments: [{ href: url, target: data.shouldOpenInNewTab ? '_blank' : null }] } : { name: 'unsetLink', arguments: [] },
2036
- ];
2037
- livewire.dispatch('run-rich-editor-commands', { awaitSchemaComponent: key, livewireId: livewire.id, key, editorSelection: selection, commands });
2151
+ url ? { name: 'setLink', arguments: [{ href: url, target: data.shouldOpenInNewTab ? '_blank' : null }] } : { name: 'unsetLink' },
2152
+ ], selection);
2038
2153
  }),
2154
+ this.textColorAction(),
2155
+ this.customBlockAction(),
2156
+ ...this._editorActions,
2039
2157
  ];
2040
2158
  }
2159
+ textColorAction() {
2160
+ const tr = (name) => t(`filament-forms::components.rich_editor.actions.text_color.${name}`);
2161
+ return Action.make('textColor').label(tr('label')).modalHeading(tr('modal.heading')).modalWidth('lg')
2162
+ .fillForm(({ arguments: args }) => {
2163
+ const color = typeof args.color === 'string' ? args.color : null;
2164
+ if (!color)
2165
+ return {};
2166
+ const known = Object.hasOwn(this.getTextColors(), color);
2167
+ return { color: known ? color : null, customColor: this._customTextColors && !known ? color : null };
2168
+ })
2169
+ .schema(() => {
2170
+ const options = Object.fromEntries(Object.entries(this.getTextColors()).map(([name, color]) => [name, `<div class="fi-fo-rich-editor-text-color-select-option"><div class="fi-fo-rich-editor-text-color-select-option-preview" style="--color: ${e(sanitizeCssColor(color.getColor()) ?? '')}; --dark-color: ${e(sanitizeCssColor(color.getDarkColor()) ?? '')}"></div><div>${e(color.getLabel() ?? name)}</div></div>`]));
2171
+ return [
2172
+ ...(Object.keys(options).length ? [Select.make('color').label(tr('modal.form.color.label')).options(options).allowHtml().native(false).live()] : []),
2173
+ ...(this._customTextColors ? [ColorPicker.make('customColor').label(tr('modal.form.custom_color.label')).visible(({ get }) => !get('color'))] : []),
2174
+ ];
2175
+ })
2176
+ .action(({ livewire, arguments: args, data }) => {
2177
+ const selection = selectionOf(args);
2178
+ const single = selection?.head === selection?.anchor;
2179
+ const picked = typeof data.color === 'string' && Object.hasOwn(this.getTextColors(), data.color) ? data.color : this._customTextColors ? sanitizeCssColor(data.customColor) : null;
2180
+ this.runCommands(livewire, picked
2181
+ ? [...(single ? [{ name: 'extendMarkRange', arguments: ['textColor'] }] : []), { name: 'setTextColor', arguments: [{ color: picked }] }]
2182
+ : [...(single ? [{ name: 'extendMarkRange', arguments: ['textColor'] }] : []), { name: 'unsetTextColor' }], selection);
2183
+ });
2184
+ }
2185
+ /** Modal of a custom block, built from the block class named in the arguments (`id`): insert or edit. */
2186
+ customBlockAction() {
2187
+ const blockOf = (args) => this.getCustomBlock(args?.id);
2188
+ const configured = (args) => { const block = blockOf(args); return block ? block.configureEditorAction(Action.make('customBlock')) : null; };
2189
+ const argumentsOf = (livewire) => livewire?.mountedActions?.find?.((mounted) => mounted.name === 'customBlock')?.arguments ?? {};
2190
+ return Action.make('customBlock').modalWidth('lg')
2191
+ .modalHeading(({ arguments: args }) => blockOf(args)?.getLabel() ?? null)
2192
+ .modalSubmitActionLabel(({ arguments: args }) => t(`filament-forms::components.rich_editor.actions.custom_block.modal.actions.${args.mode === 'edit' ? 'save' : 'insert'}.label`))
2193
+ .modalHidden(async (ctx) => { const action = configured(ctx.arguments); return !action || (await action.isModalHidden(ctx)); })
2194
+ .fillForm(({ arguments: args }) => (args.config && typeof args.config === 'object' && !Array.isArray(args.config) ? args.config : {}))
2195
+ .schema((schema) => configured(argumentsOf(schema.livewire))?.applySchema(schema) ?? schema.components([]))
2196
+ .action(({ livewire, arguments: args, data }) => {
2197
+ const block = blockOf(args);
2198
+ if (!block)
2199
+ return;
2200
+ const config = data ?? {};
2201
+ const content = { type: 'customBlock', attrs: { config, id: block.getId(), label: block.getPreviewLabel(config), preview: Buffer.from(block.toPreviewHtml(config) ?? '').toString('base64'), shouldApplyProseStylingToPreview: block.shouldApplyProseStylingToPreview(config) } };
2202
+ const selection = { ...(selectionOf(args) ?? {}) };
2203
+ if (args.dragPosition !== undefined && args.dragPosition !== null) {
2204
+ this.runCommands(livewire, [{ name: 'insertContentAt', arguments: [args.dragPosition, content] }]);
2205
+ return;
2206
+ }
2207
+ if (selection.type === 'node' && args.mode === 'insert') {
2208
+ this.runCommands(livewire, [{ name: 'insertContentAt', arguments: [Number(selection.anchor ?? -1) + 1, content] }]);
2209
+ return;
2210
+ }
2211
+ if (args.mode === 'edit' && selection.type !== 'node') {
2212
+ selection.type = 'node';
2213
+ selection.anchor = Number(selection.anchor ?? 1) - 1;
2214
+ delete selection.head;
2215
+ }
2216
+ this.runCommands(livewire, [{ name: 'insertContent', arguments: [content] }], selection);
2217
+ });
2218
+ }
2219
+ customBlocksPanelHtml(key) {
2220
+ const searchable = this._searchableCustomBlocks;
2221
+ const groups = this.blockGroups();
2222
+ const allLabels = groups.flatMap(([label, blocks]) => [label, ...blocks.map((block) => block.getLabel())]).filter(Boolean);
2223
+ const parts = [];
2224
+ if (searchable)
2225
+ parts.push(`<div class="fi-fo-rich-editor-custom-blocks-search">${icon('m-magnifying-glass')}<input type="search" x-model="customBlockSearch" x-on:keydown.enter.prevent aria-label="${e(t('filament-forms::components.rich_editor.custom_blocks.search_label'))}" placeholder="${e(t('filament-forms::components.rich_editor.custom_blocks.search_prompt'))}" class="fi-fo-rich-editor-custom-blocks-search-input" /></div>`);
2226
+ const list = groups.map(([groupLabel, blocks]) => {
2227
+ const groupLabels = [groupLabel, ...blocks.map((block) => block.getLabel())];
2228
+ const show = (labels) => (searchable ? ` x-show="${e(`matchesCustomBlockSearch(${js(labels)})`)}"` : '');
2229
+ const header = groupLabel ? `<h4${show(groupLabels)} class="fi-fo-rich-editor-custom-blocks-group-header">${e(groupLabel)}</h4>` : '';
2230
+ const buttons = blocks.map((block) => {
2231
+ const id = block.getId();
2232
+ const blockIcon = block.getIcon();
2233
+ return `<button draggable="true" type="button" data-block-id="${e(id)}" x-data="{ isLoading: false }"${show([groupLabel, block.getLabel()])} x-on:click="${e(`isLoading = true\n$wire.mountAction('customBlock', { editorSelection, id: ${js(id)}, mode: 'insert' }, { schemaComponent: ${js(key)} })`)}" x-on:dragstart="${e(`$event.dataTransfer.setData('customBlock', ${js(id)})`)}" x-on:open-modal.window="isLoading = false" x-on:run-rich-editor-commands.window="isLoading = false" class="fi-fo-rich-editor-custom-block-btn">${blockIcon ? `<span x-show="! isLoading" class="fi-fo-rich-editor-custom-block-icon">${icon(blockIcon)}</span>` : ''}${loadingIndicator(new Attributes({ 'x-show': 'isLoading', 'x-cloak': '' }))}<span>${e(block.getLabel())}</span></button>`;
2234
+ }).join('');
2235
+ return `${header}<div${show(groupLabels)} class="${cls(['fi-fo-rich-editor-custom-blocks-list', { 'fi-fo-rich-editor-custom-blocks-grid': this._customBlocksGrid }])}">${buttons}</div>`;
2236
+ }).join('');
2237
+ const noResults = searchable ? `<p x-show="${e(`! matchesCustomBlockSearch(${js(allLabels)})`)}" x-cloak role="status" class="fi-fo-rich-editor-custom-blocks-no-results">${e(t('filament-forms::components.rich_editor.custom_blocks.no_search_results_message'))}</p>` : '';
2238
+ return `${parts.join('')}<div class="fi-fo-rich-editor-custom-blocks-ctn">${list}${noResults}</div>`;
2239
+ }
2240
+ mergeTagsPanelHtml() {
2241
+ return Object.entries(this._mergeTags).map(([id, label]) => `<button draggable="true" type="button" x-on:click="${e(`insertMergeTag(${js(id)})`)}" x-on:dragstart="${e(`$event.dataTransfer.setData('mergeTag', ${js(id)})`)}" class="fi-fo-rich-editor-merge-tag-btn"><span data-type="mergeTag" data-id="${e(id)}">${e(label)}</span></button>`).join('');
2242
+ }
2041
2243
  async toHtml() {
2042
- let html = views.render('filament-forms::components.rich-editor', { livewireId: this.livewire.id, key: this.getKey(), statePath: this.getStatePath(), label: await this.getLabel(), js });
2043
- // Toolbar: the snapshot's buttons, regrouped as configured.
2244
+ const key = this.getKey();
2245
+ let html = views.render('filament-forms::components.rich-editor', { livewireId: this.livewire.id, key, statePath: this.getStatePath(), label: await this.getLabel(), js });
2246
+ // Toolbar: the configured groups, each tool rendered from its definition.
2044
2247
  const toolbarStart = html.indexOf('data-rich-editor-toolbar="main"');
2045
2248
  if (toolbarStart >= 0) {
2046
2249
  const open = html.indexOf('>', toolbarStart) + 1;
@@ -2050,24 +2253,42 @@ export class RichEditor extends Field {
2050
2253
  if (close < 0)
2051
2254
  throw new Error('RichEditor: unexpected view markup (toolbar end not found).');
2052
2255
  const body = html.slice(open, close);
2053
- const buttons = new Map();
2054
- for (const match of body.matchAll(/<button data-rich-editor-tool="([A-Za-z0-9]+)"[\s\S]*?<\/button>/g))
2055
- buttons.set(match[1], match[0]);
2056
- const groups = this.tools().map((group) => `<div class="fi-fo-rich-editor-toolbar-group">${group.map((tool) => buttons.get(tool) ?? '').join('')}</div>`).join('');
2256
+ const groups = this.toolGroups().map((group) => `<div class="fi-fo-rich-editor-toolbar-group">${group.map((tool) => this.findTool(tool)?.toHtml() ?? '').join('')}</div>`).join('');
2057
2257
  const endOfToolbar = body.lastIndexOf('</div>');
2058
2258
  html = html.slice(0, open) + groups + body.slice(endOfToolbar) + html.slice(close);
2259
+ if (this._stickyToolbar)
2260
+ html = html.replace('class="fi-fo-rich-editor-toolbar "', 'class="fi-fo-rich-editor-toolbar fi-fo-rich-editor-sticky-toolbar"');
2261
+ }
2262
+ // Panels: custom blocks and merge tags.
2263
+ const blocksStart = html.indexOf('<div class="fi-fo-rich-editor-custom-blocks-ctn">');
2264
+ if (blocksStart >= 0) {
2265
+ const blocksEnd = html.indexOf('</div>', blocksStart) + '</div>'.length;
2266
+ html = html.slice(0, blocksStart) + this.customBlocksPanelHtml(key) + html.slice(blocksEnd);
2267
+ }
2268
+ const tagsStart = html.indexOf('<div class="fi-fo-rich-editor-merge-tags-list">');
2269
+ if (tagsStart >= 0) {
2270
+ const open = html.indexOf('>', tagsStart) + 1;
2271
+ html = html.slice(0, open) + this.mergeTagsPanelHtml() + html.slice(html.indexOf('</div>', open));
2059
2272
  }
2060
- const attach = this.tools().flat().includes('attachFiles');
2061
- const tr = (key, replace = {}) => t(`filament-forms::components.rich_editor.${key}`, replace);
2273
+ const attach = this.toolGroups().flat().includes('attachFiles');
2274
+ const mentions = await Promise.all(this._mentions.map((provider) => provider.toJs()));
2062
2275
  html = html.replace('canAttachFiles: true,', `canAttachFiles: ${attach ? 'true' : 'false'},`)
2063
2276
  .replace('maxFileSize: 12288,', `maxFileSize: ${this._attachmentMaxSize},`)
2064
2277
  .replace(/maxFileSizeValidationMessage: '[^']*',/, () => `maxFileSizeValidationMessage: ${js(`Uploaded files must not be greater than ${this._attachmentMaxSize} kilobytes.`)},`)
2065
2278
  .replace(/acceptedFileTypes: JSON\.parse\('[^']*'\),/, () => `acceptedFileTypes: ${js(this._attachmentTypes)},`)
2066
2279
  .replace(/acceptedFileTypesValidationMessage: '[^']*',/, () => `acceptedFileTypesValidationMessage: ${js(`Uploaded files must be of type: ${this._attachmentTypes.join(', ')}.`)},`)
2280
+ .replace('activePanel: null,', () => `activePanel: ${js(this._activePanel)},`)
2281
+ .replace('extensions: [],', () => `extensions: ${js(this._jsExtensions)},`)
2282
+ .replace('hasResizableImages: false,', `hasResizableImages: ${this._resizableImages ? 'true' : 'false'},`)
2283
+ .replace('hasMinimalCustomBlockControls: false,', `hasMinimalCustomBlockControls: ${this._minimalCustomBlockControls ? 'true' : 'false'},`)
2284
+ .replace('hasStickyToolbar: false,', `hasStickyToolbar: ${this._stickyToolbar ? 'true' : 'false'},`)
2285
+ .replace('mentions: [],', () => `mentions: ${js(mentions)},`)
2286
+ .replace('mergeTags: [],', () => `mergeTags: ${js(Object.keys(this._mergeTags).length ? this._mergeTags : [])},`)
2287
+ .replace(/noMergeTagSearchResultsMessage: '[^']*',/, () => `noMergeTagSearchResultsMessage: ${js(this._noMergeTagResults ?? t('filament-forms::components.rich_editor.no_merge_tag_search_results_message'))},`)
2288
+ .replace(/textColors: JSON\.parse\('[^']*'\),/, () => `textColors: ${js(textColorsForJs(this.getTextColors()))},`)
2067
2289
  .replace('placeholder: null,', `placeholder: ${js(this._richPlaceholder)},`)
2068
2290
  .replace('isLiveOnBlur: false,', `isLiveOnBlur: ${this._liveMode === 'blur' ? 'true' : 'false'},`)
2069
2291
  .replace(/state: \$wire\.\$entangle\(('[^']*'), false\)/, (_m, path) => `state: $wire.$entangle(${path}, ${this._liveMode === 'live' ? 'true' : 'false'})`);
2070
- void tr;
2071
2292
  return this.wrap(html, { labelAsDiv: true });
2072
2293
  }
2073
2294
  }