@tarviks/lexical-rich-editor 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import React from 'react';
2
+ import { InitialConfigType } from '@lexical/react/LexicalComposer';
3
+ import { LexicalEditor } from 'lexical';
2
4
 
3
5
  /** How the suggestion was accepted */
4
6
  /**
@@ -83,45 +85,203 @@ type BlockSpec = {
83
85
  * Allows programmatic read/write, focus control, and state checks.
84
86
  */
85
87
  type ContentEditorRef = {
86
- /** Replace the entire document content with the provided HTML. */
88
+ /**
89
+ * Replace the entire document with the provided HTML string.
90
+ *
91
+ * The HTML is sanitized before import, so event handlers and dangerous
92
+ * URL schemes are automatically stripped.
93
+ *
94
+ * ⚠️ Do NOT call this inside an effect that watches the same `value` prop —
95
+ * it will create an infinite update loop. Use the controlled `value` prop
96
+ * for external state synchronization instead.
97
+ *
98
+ * @param html - HTML to load. Pass an empty string to clear the editor.
99
+ *
100
+ * @example
101
+ * editorRef.current?.setValue('<p>Hello <strong>world</strong></p>');
102
+ */
87
103
  setValue: (html: string) => void;
88
- /** Return the current document as HTML. */
104
+ /**
105
+ * Return the current document as an HTML string.
106
+ *
107
+ * The returned HTML is Lexical-normalized (class names, data-attributes, etc.)
108
+ * and is safe to store and later reload via `value` prop or `setValue()`.
109
+ *
110
+ * @example
111
+ * const html = editorRef.current?.getValue();
112
+ * await saveToDatabase(html);
113
+ */
89
114
  getValue: () => string;
90
- /** Clear the document to an empty state. */
115
+ /**
116
+ * Clear the editor to a completely empty state.
117
+ *
118
+ * Equivalent to `setValue('')` but more efficient — skips HTML parsing.
119
+ */
91
120
  clear: () => void;
92
- /** Move focus to the editable area. */
121
+ /**
122
+ * Move keyboard focus into the editable content area.
123
+ *
124
+ * Useful for auto-focus after a modal opens or a dialog is confirmed.
125
+ */
93
126
  focus: () => void;
94
- /** Remove focus from the editable area. */
127
+ /**
128
+ * Remove keyboard focus from the editable content area.
129
+ */
95
130
  blur: () => void;
96
- /** Whether the editor currently contains no user content. */
131
+ /**
132
+ * Returns `true` when the editor contains no visible user content.
133
+ *
134
+ * Note: system blocks inserted via `upsertBlock` may still affect this check
135
+ * unless the implementation explicitly excludes `HtmlBlockNode` content.
136
+ *
137
+ * @example
138
+ * if (editorRef.current?.isEmpty()) {
139
+ * alert('Please write something before sending.');
140
+ * }
141
+ */
97
142
  isEmpty: () => boolean;
98
- /** Whether the editor currently has input focus. */
99
- isFocused: () => boolean;
100
143
  /**
101
- * Display one or more error messages inside the editor immediately.
102
- * These are shown alongside any prop-based `errors`.
103
- * Example: ref.current.setErrors(['Attachment too large', 'Subject is required'])
144
+ * Returns `true` when the editor's content area currently has input focus.
104
145
  */
105
- setErrors: (messages: string[]) => void;
106
- /** Remove all errors that were set via `setErrors`. */
107
- clearErrors: () => void;
146
+ isFocused: () => boolean;
108
147
  /**
109
- * Access the underlying Lexical editor instance for advanced usage.
110
- * Returned type is `any` to avoid leaking internal Lexical types.
148
+ * Access the underlying Lexical `LexicalEditor` instance.
149
+ *
150
+ * Use only for advanced integrations (custom commands, plugins, etc.).
151
+ * The return type is `any` to avoid coupling consumers to Lexical's internal
152
+ * types; cast to `LexicalEditor` from the `lexical` package if needed.
153
+ *
154
+ * @example
155
+ * import { $getRoot } from 'lexical';
156
+ * const lexical = editorRef.current?.getEditor();
157
+ * lexical?.getEditorState().read(() => console.log($getRoot().getTextContent()));
111
158
  */
112
159
  getEditor: () => any;
113
160
  /**
114
- * Insert or update a block identified by `spec.kind`.
161
+ * Insert or update a named system block in the document.
162
+ *
163
+ * System blocks (signature, footer, banner, etc.) are tracked by `kind` and
164
+ * rendered as non-editable regions. The user can delete them but cannot type
165
+ * inside them. They do **not** affect `checkDirty()` or `isEmpty()`.
166
+ *
167
+ * If a block with the same `kind` already exists it is replaced in-place;
168
+ * otherwise a new block is appended (or prepended if `position = 'start'`).
115
169
  *
116
- * If a block with the same `kind` exists, it will be updated;
117
- * otherwise a new block is inserted. `position` controls whether
118
- * the insertion occurs at the document 'start' or 'end'.
170
+ * @param spec.kind - Unique identifier, e.g. `'signature'` or `'footer'`.
171
+ * @param spec.html - HTML content for the block. Sanitized before import.
172
+ * @param spec.position - Where to insert when creating: `'start'` | `'end'` (default `'end'`).
173
+ *
174
+ * @example
175
+ * editorRef.current?.upsertBlock({
176
+ * kind: 'signature',
177
+ * html: '<p><strong>Vikram Singh</strong></p><p>Senior Engineer</p>',
178
+ * position: 'end',
179
+ * });
119
180
  */
120
181
  upsertBlock: (spec: BlockSpec) => void;
121
- /** Remove a previously inserted block by its `kind` identifier. */
182
+ /**
183
+ * Remove a system block by its `kind` identifier.
184
+ *
185
+ * No-op if no block with that `kind` exists.
186
+ *
187
+ * @param kind - The same identifier used when calling `upsertBlock`.
188
+ *
189
+ * @example
190
+ * editorRef.current?.removeBlock('signature');
191
+ */
122
192
  removeBlock: (kind: string) => void;
123
- /** Check whether a block with the given `kind` exists. */
193
+ /**
194
+ * Returns `true` if a system block with the given `kind` is present in the document.
195
+ *
196
+ * @example
197
+ * const hasSig = editorRef.current?.hasBlock('signature'); // true | false
198
+ */
124
199
  hasBlock: (kind: string) => boolean;
200
+ /**
201
+ * Returns `true` when the editor's user content differs from the clean baseline.
202
+ *
203
+ * **What counts as user content:**
204
+ * Text the user typed in paragraphs, headings, lists, etc., plus any media
205
+ * (images, YouTube embeds) inserted via the toolbar. System blocks added via
206
+ * `upsertBlock` are **excluded** from the comparison.
207
+ *
208
+ * **What is always `false` (clean):**
209
+ * - Empty editor (placeholder visible).
210
+ * - Editor containing only a default / pre-loaded value that has not been changed.
211
+ * - Editor cleared back to empty after the user had typed something.
212
+ *
213
+ * **Baseline:** captured automatically after the editor finishes loading its
214
+ * initial content. Call `markClean()` to reset it after a successful save.
215
+ *
216
+ * @example
217
+ * window.onbeforeunload = () => {
218
+ * if (editorRef.current?.checkDirty()) return 'You have unsaved changes.';
219
+ * };
220
+ */
221
+ checkDirty: () => boolean;
222
+ /**
223
+ * Reset the dirty baseline to the editor's current content.
224
+ *
225
+ * Call this after successfully saving so that `checkDirty()` compares against
226
+ * the saved state going forward, rather than the original mount-time value.
227
+ *
228
+ * @example
229
+ * const html = editorRef.current?.getValue();
230
+ * await api.save(html);
231
+ * editorRef.current?.markClean(); // checkDirty() now returns false
232
+ */
233
+ markClean: () => void;
234
+ };
235
+ /**
236
+ * Built-in default messages used when no custom `validationMessages` entry is
237
+ * provided for that rule. Export this to inspect or copy individual defaults.
238
+ */
239
+ declare const DEFAULT_VALIDATION_MESSAGES: {
240
+ readonly required: "This field is required.";
241
+ readonly minWords: (current: number, min: number) => string;
242
+ readonly maxWords: (current: number, max: number) => string;
243
+ readonly minChars: (current: number, min: number) => string;
244
+ readonly maxChars: (current: number, max: number) => string;
245
+ readonly noImages: "Images are not allowed in this field.";
246
+ readonly maxImages: (current: number, max: number) => string;
247
+ readonly noLinks: "Hyperlinks are not allowed in this field.";
248
+ readonly maxLinks: (current: number, max: number) => string;
249
+ readonly noTables: "Tables are not allowed in this field.";
250
+ readonly imageTooLarge: (fileMB: number, maxMB: number) => string;
251
+ };
252
+ /** A single active validation error on the editor. */
253
+ type EditorValidationError = {
254
+ type: 'required' | 'minWords' | 'maxWords' | 'minChars' | 'maxChars' | 'noImages' | 'maxImages' | 'noLinks' | 'maxLinks' | 'noTables';
255
+ message: string;
256
+ };
257
+ /**
258
+ * Custom messages for each validation rule.
259
+ * Each entry accepts either a static string or a function that receives
260
+ * the relevant counts and returns a string.
261
+ */
262
+ type ValidationMessages = {
263
+ /** Shown when the editor is empty and `required` is true. */
264
+ required?: string;
265
+ /** Shown when word count is below `minWords`. Receives (current, min). */
266
+ minWords?: string | ((current: number, min: number) => string);
267
+ /** Shown when word count exceeds `maxWords`. Receives (current, max). */
268
+ maxWords?: string | ((current: number, max: number) => string);
269
+ /** Shown when character count is below `minChars`. Receives (current, min). */
270
+ minChars?: string | ((current: number, min: number) => string);
271
+ /** Shown when character count exceeds `maxChars`. Receives (current, max). */
272
+ maxChars?: string | ((current: number, max: number) => string);
273
+ /** Shown when content contains images and `noImages` is true. */
274
+ noImages?: string;
275
+ /** Shown when image count exceeds `maxImages`. Receives (current, max). */
276
+ maxImages?: string | ((current: number, max: number) => string);
277
+ /** Shown when content contains links and `noLinks` is true. */
278
+ noLinks?: string;
279
+ /** Shown when link count exceeds `maxLinks`. Receives (current, max). */
280
+ maxLinks?: string | ((current: number, max: number) => string);
281
+ /** Shown when content contains tables and `noTables` is true. */
282
+ noTables?: string;
283
+ /** Shown in the image dialog when an uploaded file exceeds `maxImageSizeMB`. Receives (fileMB, maxMB). */
284
+ imageTooLarge?: string | ((fileMB: number, maxMB: number) => string);
125
285
  };
126
286
  interface ContentEditorProps {
127
287
  /** Optional editor namespace used by Lexical for scoping. */
@@ -142,6 +302,118 @@ interface ContentEditorProps {
142
302
  contentHeight?: string;
143
303
  /** Feature level controlling which toolbar groups are enabled. */
144
304
  level?: ContentEditorLevel;
305
+ /**
306
+ * Fully custom toolbar layout that overrides the `level` preset.
307
+ *
308
+ * Pass a 2-D array of **token strings**. Each inner array is rendered in order.
309
+ * Use `'|'` inside a group (including at the end) to render a divider.
310
+ * Every token renders as its own **standalone** control — there are no
311
+ * hidden dropdowns. Items like `'Strikethrough'` or `'H1'` that were
312
+ * previously only reachable inside aggregate dropdowns can now be placed
313
+ * directly in the toolbar. Unknown tokens are silently ignored.
314
+ *
315
+ * ---
316
+ * ### Text formatting (standalone toggle buttons)
317
+ *
318
+ * | Token | What it renders |
319
+ * |------------------|----------------------------------|
320
+ * | `'Bold'` | Bold toggle |
321
+ * | `'Italic'` | Italic toggle |
322
+ * | `'Underline'` | Underline toggle |
323
+ * | `'Strikethrough'`| Strikethrough toggle |
324
+ * | `'Subscript'` | Subscript toggle |
325
+ * | `'Superscript'` | Superscript toggle |
326
+ * | `'Highlight'` | Highlight toggle |
327
+ * | `'Uppercase'` | Uppercase transform toggle |
328
+ * | `'Lowercase'` | Lowercase transform toggle |
329
+ * | `'Capitalize'` | Capitalize transform toggle |
330
+ *
331
+ * ### Lists (standalone toggle buttons)
332
+ *
333
+ * | Token | What it renders |
334
+ * |---------------------|--------------------------|
335
+ * | `'BulletList'` | Unordered list toggle |
336
+ * | `'NumberList'` | Ordered list toggle |
337
+ * | `'AlphabeticalList'`| Alphabetical list toggle |
338
+ *
339
+ * ### Block-level (standalone buttons)
340
+ *
341
+ * | Token | What it renders |
342
+ * |--------------|-------------------------------------|
343
+ * | `'Quote'` | Blockquote toggle |
344
+ * | `'PageBreak'`| Insert page-break |
345
+ *
346
+ * ### Heading levels (standalone toggle buttons)
347
+ *
348
+ * Clicking an active heading level reverts the block to normal paragraph.
349
+ *
350
+ * | Token | What it renders |
351
+ * |-------|-------------------------|
352
+ * | `'H1'`| Heading 1 toggle button |
353
+ * | `'H2'`| Heading 2 toggle button |
354
+ * | `'H3'`| Heading 3 toggle button |
355
+ * | `'H4'`| Heading 4 toggle button |
356
+ * | `'H5'`| Heading 5 toggle button |
357
+ * | `'H6'`| Heading 6 toggle button |
358
+ *
359
+ * ### Dropdowns / rich plugins
360
+ *
361
+ * | Token | What it renders |
362
+ * |-----------------|---------------------------------------------------|
363
+ * | `'Heading'` | Dropdown: Normal + H1–H6 (all heading levels) |
364
+ * | `'Decorators'` | Dropdown: all text / list / block decorator items |
365
+ * | `'Align'` | Dropdown: left / center / right / justify |
366
+ * | `'FontFamily'` | Font-family dropdown |
367
+ * | `'FontSize'` | Font-size dropdown |
368
+ * | `'ColorPicker'` | Text color picker |
369
+ * | `'Link'` | Insert / edit hyperlink |
370
+ * | `'Table'` | Insert table |
371
+ * | `'Image'` | Upload block image |
372
+ * | `'InlineImage'` | Insert inline image |
373
+ * | `'Youtube'` | Embed YouTube video |
374
+ * | `'|'` | Inline divider within a group |
375
+ *
376
+ * ---
377
+ * ### Examples
378
+ *
379
+ * **Only bold, italic, underline and strikethrough as standalone buttons:**
380
+ * ```tsx
381
+ * customToolbar={[['Bold', 'Italic', 'Underline', 'Strikethrough']]}
382
+ * ```
383
+ *
384
+ * **Pick specific heading levels without a dropdown:**
385
+ * ```tsx
386
+ * customToolbar={[['H1', 'H2', 'H3'], ['Bold', 'Italic']]}
387
+ * ```
388
+ *
389
+ * **Mix standalone items and aggregate dropdowns:**
390
+ * ```tsx
391
+ * customToolbar={[
392
+ * ['Bold', 'Italic', 'Underline', 'Strikethrough', 'Highlight'],
393
+ * ['BulletList', 'NumberList'],
394
+ * ['H1', 'H2', 'H3'],
395
+ * ['Link', 'Image'],
396
+ * ]}
397
+ * ```
398
+ *
399
+ * **Full toolbar using aggregate dropdowns:**
400
+ * ```tsx
401
+ * customToolbar={[
402
+ * ['Heading', 'FontFamily', 'FontSize'],
403
+ * ['Bold', 'Italic', 'Underline', '|', 'ColorPicker'],
404
+ * ['Align'],
405
+ * ['Link', 'Image', 'InlineImage', 'Youtube', 'Table'],
406
+ * ['Decorators'],
407
+ * ]}
408
+ * ```
409
+ *
410
+ * ---
411
+ * ### Notes
412
+ * - When `customToolbar` is set the `level` prop has no effect.
413
+ * - Token order within each group is preserved exactly as written.
414
+ * - Avoid duplicate tokens in the same render (each renders once per occurrence).
415
+ */
416
+ customToolbar?: string[][];
145
417
  /**
146
418
  * @deprecated Not used by the component. Prefer styling overrides instead.
147
419
  */
@@ -260,64 +532,92 @@ interface ContentEditorProps {
260
532
  spellCheckEnabled?: boolean;
261
533
  /** When true, shows the floating formatting toolbar near text selections. */
262
534
  showFloatingToolbar?: boolean;
263
- /** Maximum words allowed. Shows a live counter; turns red when exceeded. */
535
+ /**
536
+ * Maximum word count allowed. When set, a live counter is shown
537
+ * below the editor. Exceeding the limit turns the counter red.
538
+ * Example: wordLimit={500}
539
+ */
264
540
  wordLimit?: number;
265
- /** Minimum words required. Error shown after blur if not met. */
266
- minWords?: number;
267
- /** Maximum characters allowed. Error shown when exceeded. */
268
- maxChars?: number;
269
- /** Minimum characters required. Error shown after blur if not met. */
270
- minChars?: number;
271
- /** When true, shows a required-field error after blur if the editor is empty. */
272
- required?: boolean;
273
541
  /**
274
542
  * Fires when the content crosses the configured word limit boundary.
275
- * exceeded: true → user just exceeded; false → came back within limit.
543
+ *
544
+ * - exceeded: true -> user just exceeded the limit
545
+ * - exceeded: false -> user came back within the limit
276
546
  */
277
547
  onWordLimitExceeded?: (info: {
278
548
  wordCount: number;
279
549
  wordLimit: number;
280
550
  exceeded: boolean;
281
551
  }) => void;
552
+ /** When true, the editor must not be empty. Equivalent to `minWords: 1`. */
553
+ required?: boolean;
554
+ /** Minimum number of words required. */
555
+ minWords?: number;
556
+ /** Maximum number of words allowed. Shows a validation error when exceeded. */
557
+ maxWords?: number;
558
+ /** Minimum number of characters required. */
559
+ minChars?: number;
560
+ /** Maximum number of characters allowed. Shows a validation error when exceeded. */
561
+ maxChars?: number;
562
+ /** When true, the editor must not contain any images. */
563
+ noImages?: boolean;
564
+ /** Maximum number of images allowed in the content. */
565
+ maxImages?: number;
566
+ /** When true, the editor must not contain any hyperlinks. */
567
+ noLinks?: boolean;
568
+ /** Maximum number of hyperlinks allowed in the content. */
569
+ maxLinks?: number;
570
+ /** When true, the editor must not contain any tables. */
571
+ noTables?: boolean;
282
572
  /**
283
- * Extra error messages to display (on top of any built-in validation).
284
- * Use this for errors that originate outside the editor (e.g. file too large,
285
- * subject empty, API failure).
286
- *
287
- * Example: errors={['Attachment exceeds 5 MB', 'Subject is required']}
573
+ * Maximum image file size in megabytes. When set, the image upload dialogs
574
+ * reject files that are larger and display an error inside the dialog.
288
575
  */
289
- errors?: string[];
576
+ maxImageSizeMB?: number;
290
577
  /**
291
- * Override the text of any built-in validation message.
292
- * Every key accepts a plain string OR a function receiving the live counts.
293
- *
294
- * Covered errors:
295
- * - wordLimitExceeded — fires when wordCount > wordLimit
296
- * - required — fires on blur when editor is empty and required=true
297
- * - minWords — fires on blur when wordCount < minWords
298
- * - maxCharsExceeded — fires when charCount > maxChars
299
- * - minCharsRequired — fires on blur when charCount < minChars
578
+ * Override the default error messages shown for each validation rule.
579
+ * Accepts static strings or functions for dynamic messages.
580
+ */
581
+ validationMessages?: ValidationMessages;
582
+ /**
583
+ * Called whenever the set of active validation errors changes.
584
+ * Receives the current array of errors (empty array means valid).
585
+ */
586
+ onValidationChange?: (errors: EditorValidationError[]) => void;
587
+ /**
588
+ * Pass any string here to display it as a custom error message below the
589
+ * editor and turn the border red. Useful for server-side or form-level
590
+ * errors that live outside the component's own validation rules.
300
591
  *
301
592
  * Example:
302
593
  * ```tsx
303
- * errorMessages={{
304
- * wordLimitExceeded: (count, limit) => `Too long — ${count}/${limit} words used.`,
305
- * required: 'Email body cannot be empty.',
306
- * minWords: (count, min) => `Write at least ${min} words (${count} so far).`,
307
- * maxCharsExceeded: (count, max) => `${count}/${max} characters — please shorten.`,
308
- * minCharsRequired: (count, min) => `At least ${min} characters needed (${count} entered).`,
309
- * }}
594
+ * errorMessage={submitFailed ? 'Please fill in the email body before sending.' : undefined}
310
595
  * ```
311
596
  */
312
- errorMessages?: {
313
- wordLimitExceeded?: string | ((wordCount: number, wordLimit: number) => string);
314
- required?: string;
315
- minWords?: string | ((wordCount: number, minWords: number) => string);
316
- maxCharsExceeded?: string | ((charCount: number, maxChars: number) => string);
317
- minCharsRequired?: string | ((charCount: number, minChars: number) => string);
318
- };
597
+ errorMessage?: string;
598
+ /**
599
+ * Called once before `LexicalComposer` is initialized.
600
+ *
601
+ * Receives the `InitialConfigType` object that will be passed to
602
+ * `LexicalComposer`. You may mutate it (e.g. add custom nodes, override the
603
+ * theme or namespace) — Lexical reads this config only once on mount, so
604
+ * this is the only safe window to modify it.
605
+ *
606
+ * Note: called only on the initial mount, never on re-renders.
607
+ */
608
+ onBeforeInitialize?: (config: InitialConfigType) => void;
609
+ /**
610
+ * Called once when the Lexical editor instance is ready for use.
611
+ *
612
+ * Receives the live `LexicalEditor` instance. Use it for operations that
613
+ * require the editor to be mounted — programmatic focus, registering
614
+ * external commands, or handing the instance to an external controller.
615
+ *
616
+ * Note: called only once on mount, never on re-renders.
617
+ */
618
+ onReady?: (editor: LexicalEditor) => void;
319
619
  }
320
620
 
321
621
  declare const ContentEditorComponent: React.ForwardRefExoticComponent<ContentEditorProps & React.RefAttributes<ContentEditorRef>>;
322
622
 
323
- export { type AcceptMethod, ContentEditorComponent, ContentEditorLevel, type ContentEditorProps, type ContentEditorRef, type SearchPromise, type SpellCheckIssue, type SpellCheckPayload, type SpellCheckResult };
623
+ export { type AcceptMethod, ContentEditorComponent, ContentEditorLevel, type ContentEditorProps, type ContentEditorRef, DEFAULT_VALIDATION_MESSAGES, type EditorValidationError, type SearchPromise, type SpellCheckIssue, type SpellCheckPayload, type SpellCheckResult, type ValidationMessages };