@sveltia/ui 0.78.4 → 0.79.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.
Files changed (181) hide show
  1. package/dist/components/alert/alert-content.svelte +51 -0
  2. package/dist/components/alert/alert-content.svelte.d.ts +39 -0
  3. package/dist/components/alert/alert.d.ts +3 -0
  4. package/dist/components/alert/alert.js +21 -0
  5. package/dist/components/alert/alert.svelte +6 -30
  6. package/dist/components/alert/infobar.svelte +7 -21
  7. package/dist/components/alert/infobar.svelte.d.ts +2 -2
  8. package/dist/components/button/button.svelte +11 -11
  9. package/dist/components/button/button.svelte.d.ts +2 -2
  10. package/dist/components/button/select-button.svelte +1 -1
  11. package/dist/components/button/select-button.svelte.d.ts +2 -2
  12. package/dist/components/button/split-button.svelte +1 -1
  13. package/dist/components/button/split-button.svelte.d.ts +3 -3
  14. package/dist/components/calendar/calendar.d.ts +2 -0
  15. package/dist/components/calendar/calendar.js +34 -0
  16. package/dist/components/calendar/calendar.svelte +49 -5
  17. package/dist/components/checkbox/checkbox.svelte +11 -4
  18. package/dist/components/checkbox/checkbox.svelte.d.ts +2 -2
  19. package/dist/components/dialog/alert-dialog.svelte +1 -1
  20. package/dist/components/dialog/alert-dialog.svelte.d.ts +2 -2
  21. package/dist/components/dialog/confirmation-dialog.svelte +1 -1
  22. package/dist/components/dialog/confirmation-dialog.svelte.d.ts +2 -2
  23. package/dist/components/dialog/dialog.svelte +8 -18
  24. package/dist/components/dialog/dialog.svelte.d.ts +2 -2
  25. package/dist/components/dialog/modal-close-button.svelte +50 -0
  26. package/dist/components/dialog/modal-close-button.svelte.d.ts +43 -0
  27. package/dist/components/dialog/prompt-dialog.svelte +1 -1
  28. package/dist/components/dialog/prompt-dialog.svelte.d.ts +4 -4
  29. package/dist/components/disclosure/disclosure.svelte +1 -1
  30. package/dist/components/drawer/drawer.svelte +12 -34
  31. package/dist/components/drawer/drawer.svelte.d.ts +1 -1
  32. package/dist/components/emoji/caret.d.ts +1 -1
  33. package/dist/components/emoji/caret.js +1 -1
  34. package/dist/components/emoji/emoji-suggestions.svelte +1 -1
  35. package/dist/components/emoji/emoji-suggestions.svelte.d.ts +3 -3
  36. package/dist/components/emoji/emoji.d.ts +2 -2
  37. package/dist/components/emoji/emoji.js +1 -1
  38. package/dist/components/emoji/position.d.ts +2 -2
  39. package/dist/components/emoji/position.js +1 -1
  40. package/dist/components/grid/grid-body.svelte +6 -2
  41. package/dist/components/grid/grid-cell.svelte +3 -9
  42. package/dist/components/grid/grid-col-header.svelte +3 -9
  43. package/dist/components/grid/grid-foot.svelte +4 -11
  44. package/dist/components/grid/grid-head.svelte +4 -11
  45. package/dist/components/grid/grid-row-header.svelte +3 -10
  46. package/dist/components/grid/grid-row.svelte +3 -16
  47. package/dist/components/listbox/listbox.svelte +1 -1
  48. package/dist/components/listbox/listbox.svelte.d.ts +1 -1
  49. package/dist/components/listbox/option.svelte +1 -1
  50. package/dist/components/listbox/option.svelte.d.ts +2 -2
  51. package/dist/components/menu/menu-button.svelte +2 -2
  52. package/dist/components/menu/menu-button.svelte.d.ts +4 -4
  53. package/dist/components/menu/menu-item-checkable.svelte +71 -0
  54. package/dist/components/menu/menu-item-checkable.svelte.d.ts +25 -0
  55. package/dist/components/menu/menu-item-checkbox.svelte +3 -36
  56. package/dist/components/menu/menu-item-checkbox.svelte.d.ts +3 -3
  57. package/dist/components/menu/menu-item-group.svelte +8 -5
  58. package/dist/components/menu/menu-item-group.svelte.d.ts +9 -1
  59. package/dist/components/menu/menu-item-radio.svelte +3 -36
  60. package/dist/components/menu/menu-item-radio.svelte.d.ts +3 -3
  61. package/dist/components/menu/menu-item.svelte +22 -6
  62. package/dist/components/menu/menu-item.svelte.d.ts +4 -4
  63. package/dist/components/radio/radio.svelte +12 -1
  64. package/dist/components/resizable-pane/resizable-handle.svelte +14 -18
  65. package/dist/components/resizable-pane/resizable-pane-group.svelte +9 -6
  66. package/dist/components/resizable-pane/resizable-pane.svelte +4 -4
  67. package/dist/components/resizable-pane/resizable-pane.svelte.d.ts +7 -6
  68. package/dist/components/resizable-pane/sizing.d.ts +5 -4
  69. package/dist/components/resizable-pane/sizing.js +84 -25
  70. package/dist/components/scroll/infinite-scroll.svelte +16 -21
  71. package/dist/components/select/combobox.svelte +43 -52
  72. package/dist/components/select/combobox.svelte.d.ts +2 -2
  73. package/dist/components/select/select-tags.svelte +7 -1
  74. package/dist/components/select/select.svelte +2 -22
  75. package/dist/components/select/select.svelte.d.ts +1 -1
  76. package/dist/components/slider/slider.d.ts +6 -0
  77. package/dist/components/slider/slider.js +55 -2
  78. package/dist/components/slider/slider.svelte +63 -32
  79. package/dist/components/table/table-cell.svelte +3 -9
  80. package/dist/components/table/table-col-header.svelte +3 -9
  81. package/dist/components/table/table-foot.svelte +4 -11
  82. package/dist/components/table/table-head.svelte +4 -11
  83. package/dist/components/table/table-part.svelte +69 -0
  84. package/dist/components/table/table-part.svelte.d.ts +48 -0
  85. package/dist/components/table/table-row-header.svelte +3 -10
  86. package/dist/components/table/table-row.svelte +3 -10
  87. package/dist/components/tabs/tab-list.svelte +12 -0
  88. package/dist/components/tabs/tab.svelte +1 -1
  89. package/dist/components/tabs/tab.svelte.d.ts +2 -2
  90. package/dist/components/text-editor/constants.d.ts +5 -5
  91. package/dist/components/text-editor/constants.js +1 -1
  92. package/dist/components/text-editor/core.d.ts +4 -5
  93. package/dist/components/text-editor/core.js +372 -285
  94. package/dist/components/text-editor/emoji-autocomplete.svelte +1 -1
  95. package/dist/components/text-editor/lexical-root.svelte +1 -1
  96. package/dist/components/text-editor/raw-markdown.d.ts +6 -33
  97. package/dist/components/text-editor/raw-markdown.js +63 -8
  98. package/dist/components/text-editor/shiki/facade.d.ts +1 -1
  99. package/dist/components/text-editor/shiki/facade.js +63 -48
  100. package/dist/components/text-editor/shiki/generated.d.ts +1 -1
  101. package/dist/components/text-editor/shiki/generated.js +1 -1
  102. package/dist/components/text-editor/shiki/highlighter.d.ts +6 -1
  103. package/dist/components/text-editor/shiki/highlighter.js +3 -4
  104. package/dist/components/text-editor/shiki/loader.d.ts +1 -1
  105. package/dist/components/text-editor/shiki/loader.js +1 -1
  106. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  107. package/dist/components/text-editor/shiki/version.js +1 -1
  108. package/dist/components/text-editor/store.svelte.d.ts +1 -1
  109. package/dist/components/text-editor/store.svelte.js +5 -2
  110. package/dist/components/text-editor/text-editor.svelte +2 -2
  111. package/dist/components/text-editor/text-editor.svelte.d.ts +3 -3
  112. package/dist/components/text-editor/toolbar/code-language-switcher.svelte +24 -16
  113. package/dist/components/text-editor/toolbar/format-text-button.svelte +7 -31
  114. package/dist/components/text-editor/toolbar/format-text-button.svelte.d.ts +1 -1
  115. package/dist/components/text-editor/toolbar/insert-component.d.ts +3 -0
  116. package/dist/components/text-editor/toolbar/insert-component.js +34 -0
  117. package/dist/components/text-editor/toolbar/insert-item-button.svelte +4 -24
  118. package/dist/components/text-editor/toolbar/insert-item-button.svelte.d.ts +1 -1
  119. package/dist/components/text-editor/toolbar/insert-link-button.svelte +9 -25
  120. package/dist/components/text-editor/toolbar/insert-menu-button.svelte +4 -24
  121. package/dist/components/text-editor/toolbar/insert-menu-button.svelte.d.ts +1 -1
  122. package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +1 -1
  123. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +31 -35
  124. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte.d.ts +1 -1
  125. package/dist/components/text-editor/toolbar/toolbar-wrapper.svelte +0 -10
  126. package/dist/components/text-editor/toolbar/toolbar-wrapper.svelte.d.ts +1 -11
  127. package/dist/components/text-editor/transformers/table.d.ts +0 -1
  128. package/dist/components/text-editor/transformers/table.js +1 -2
  129. package/dist/components/text-field/emoji-autocomplete.svelte +1 -1
  130. package/dist/components/text-field/masked-input.svelte +179 -0
  131. package/dist/components/text-field/masked-input.svelte.d.ts +42 -0
  132. package/dist/components/text-field/number-input.js +7 -1
  133. package/dist/components/text-field/number-input.svelte +13 -5
  134. package/dist/components/text-field/number-input.svelte.d.ts +3 -3
  135. package/dist/components/text-field/password-input.svelte +3 -116
  136. package/dist/components/text-field/password-input.svelte.d.ts +3 -3
  137. package/dist/components/text-field/search-bar.svelte +1 -1
  138. package/dist/components/text-field/search-bar.svelte.d.ts +3 -3
  139. package/dist/components/text-field/secret-input.svelte +3 -119
  140. package/dist/components/text-field/secret-input.svelte.d.ts +3 -3
  141. package/dist/components/text-field/text-area.svelte +1 -1
  142. package/dist/components/text-field/text-area.svelte.d.ts +3 -3
  143. package/dist/components/text-field/text-input.svelte +1 -1
  144. package/dist/components/text-field/text-input.svelte.d.ts +3 -3
  145. package/dist/components/toast/toast.svelte +11 -20
  146. package/dist/components/toast/toast.svelte.d.ts +1 -1
  147. package/dist/components/tree/tree-item.svelte +1 -1
  148. package/dist/components/tree/tree-item.svelte.d.ts +1 -1
  149. package/dist/components/tree/tree.svelte +1 -1
  150. package/dist/components/tree/tree.svelte.d.ts +1 -1
  151. package/dist/components/util/font-links.svelte +1 -0
  152. package/dist/components/util/modal.svelte +37 -1
  153. package/dist/components/util/modal.svelte.d.ts +1 -1
  154. package/dist/components/util/popup.svelte +2 -5
  155. package/dist/components/util/popup.svelte.d.ts +2 -2
  156. package/dist/components/util/visibility-observer.svelte +1 -0
  157. package/dist/locales/ar.yaml +1 -1
  158. package/dist/locales/sk.yaml +136 -0
  159. package/dist/services/group.svelte.d.ts +10 -1
  160. package/dist/services/group.svelte.js +361 -251
  161. package/dist/services/navigation.d.ts +19 -0
  162. package/dist/services/navigation.js +84 -0
  163. package/dist/services/popup-position.d.ts +90 -0
  164. package/dist/services/popup-position.js +275 -0
  165. package/dist/services/popup.svelte.d.ts +16 -17
  166. package/dist/services/popup.svelte.js +25 -169
  167. package/dist/services/scroll.d.ts +1 -0
  168. package/dist/services/scroll.js +17 -0
  169. package/dist/services/select.svelte.d.ts +2 -2
  170. package/dist/services/select.svelte.js +1 -1
  171. package/dist/services/text.d.ts +2 -0
  172. package/dist/services/text.js +28 -0
  173. package/dist/services/tree.svelte.d.ts +3 -11
  174. package/dist/services/tree.svelte.js +35 -77
  175. package/dist/services/type-ahead.d.ts +0 -4
  176. package/dist/services/type-ahead.js +3 -1
  177. package/dist/services/widget.d.ts +8 -0
  178. package/dist/services/widget.js +39 -0
  179. package/dist/shiki-engine.js +15 -15
  180. package/dist/styles/mixins.scss +126 -0
  181. package/package.json +24 -20
@@ -52,6 +52,7 @@ import {
52
52
  $getSelection as getSelection,
53
53
  INDENT_CONTENT_COMMAND,
54
54
  INSERT_PARAGRAPH_COMMAND,
55
+ KEY_ENTER_COMMAND,
55
56
  $insertNodes as insertNodes,
56
57
  $isDecoratorNode as isDecoratorNode,
57
58
  $isElementNode as isElementNode,
@@ -89,7 +90,8 @@ import { HR } from './transformers/hr.js';
89
90
  import { TABLE } from './transformers/table.js';
90
91
 
91
92
  /**
92
- * @import { LexicalEditor } from 'lexical';
93
+ * @import { LexicalEditor, LexicalNode } from 'lexical';
94
+ * @import { ListType } from '@lexical/list';
93
95
  * @import { Transformer } from '@lexical/markdown';
94
96
  * @import {
95
97
  * TextEditorBlockType,
@@ -98,7 +100,7 @@ import { TABLE } from './transformers/table.js';
98
100
  * TextEditorInlineType,
99
101
  * TextEditorNodeType,
100
102
  * TextEditorSelectionState,
101
- * } from '../../typedefs';
103
+ * } from '../../typedefs.js';
102
104
  */
103
105
 
104
106
  /**
@@ -162,7 +164,7 @@ export const isStaticDecoratorContent = (target) => {
162
164
  * block, or a new paragraph if the adjacent nodes are decorators, where nothing can be typed.
163
165
  * @returns {boolean} Whether the selection has been moved.
164
166
  */
165
- export const $moveCaretIntoBlock = () => {
167
+ const $moveCaretIntoBlock = () => {
166
168
  const selection = getSelection();
167
169
 
168
170
  if (
@@ -205,17 +207,29 @@ export const $moveCaretIntoBlock = () => {
205
207
  };
206
208
 
207
209
  /**
208
- * Move the focus to the editor, and the caret to right after the given decorator node, like an
209
- * editor component: the start of the next block, or a new paragraph if there’s none to type in.
210
+ * Move the focus to the editor, and run the given function with the node of the given element
211
+ * within an editor update.
210
212
  * @param {LexicalEditor} editor Editor instance.
211
- * @param {Element} decorator Element of the decorator node.
213
+ * @param {Element} element Element of the node.
214
+ * @param {(node: ReturnType<typeof getNearestNodeFromDOMNode>) => void} callback Function to be
215
+ * called with the node.
212
216
  */
213
- const placeCaretAfterDecorator = (editor, decorator) => {
217
+ const updateNodeWithFocus = (editor, element, callback) => {
214
218
  editor.getRootElement()?.focus({ preventScroll: true });
215
219
 
216
220
  editor.update(() => {
217
- const node = getNearestNodeFromDOMNode(decorator);
221
+ callback(getNearestNodeFromDOMNode(element));
222
+ });
223
+ };
218
224
 
225
+ /**
226
+ * Move the focus to the editor, and the caret to right after the given decorator node, like an
227
+ * editor component: the start of the next block, or a new paragraph if there’s none to type in.
228
+ * @param {LexicalEditor} editor Editor instance.
229
+ * @param {Element} decorator Element of the decorator node.
230
+ */
231
+ const placeCaretAfterDecorator = (editor, decorator) => {
232
+ updateNodeWithFocus(editor, decorator, (node) => {
219
233
  // The element has been found by its `data-lexical-decorator` attribute
220
234
  /* v8 ignore next 3 */
221
235
  if (!isDecoratorNode(node)) {
@@ -233,11 +247,7 @@ const placeCaretAfterDecorator = (editor, decorator) => {
233
247
  * @param {Element} block Element of the block node.
234
248
  */
235
249
  const placeCaretAtBlockEnd = (editor, block) => {
236
- editor.getRootElement()?.focus({ preventScroll: true });
237
-
238
- editor.update(() => {
239
- const node = getNearestNodeFromDOMNode(block);
240
-
250
+ updateNodeWithFocus(editor, block, (node) => {
241
251
  /* v8 ignore next 3 */
242
252
  if (!isElementNode(node)) {
243
253
  return;
@@ -354,6 +364,14 @@ export const isSafeLinkURL = (url) => {
354
364
  return !scheme || SAFE_LINK_URL_SCHEMES.includes(scheme.toLowerCase());
355
365
  };
356
366
 
367
+ /**
368
+ * Get the given node itself if it’s an element node, or its nearest element node ancestor.
369
+ * @param {LexicalNode} node Node.
370
+ * @returns {ElementNode | null} Element node.
371
+ */
372
+ const getNearestElementNode = (node) =>
373
+ node instanceof ElementNode ? node : getNearestNodeOfType(node, ElementNode);
374
+
357
375
  /**
358
376
  * Get the current selection’s block node key as well as block and inline level types.
359
377
  * @internal
@@ -377,7 +395,7 @@ export const getSelectionTypes = () => {
377
395
  const inlineTypes = TEXT_FORMAT_BUTTON_TYPES.filter((type) => selection.hasFormat(type));
378
396
 
379
397
  if (anchor.getType() !== 'root') {
380
- parent = anchor instanceof ElementNode ? anchor : getNearestNodeOfType(anchor, ElementNode);
398
+ parent = getNearestElementNode(anchor);
381
399
 
382
400
  if (isLinkNode(parent)) {
383
401
  inlineTypes.push('link');
@@ -531,264 +549,208 @@ export const getComponentMarkdown = ({ node, createNode, transformer, createMark
531
549
  };
532
550
 
533
551
  /**
534
- * Initialize the Lexical editor.
535
- * @param {TextEditorConfig} config Editor configuration.
536
- * @returns {InitEditorResult} Editor instance and cleanup.
552
+ * Combine the given cleanup handlers into one, which calls them in the given order. Missing
553
+ * handlers, like those of the features that are not enabled, are skipped.
554
+ * @param {...((() => void) | undefined | null)} unregisters Cleanup handlers.
555
+ * @returns {() => void} Combined cleanup handler.
537
556
  */
538
- export const initEditor = ({
539
- enabledButtons = [],
540
- components = [],
541
- useMarkdownShortcuts,
542
- isCodeEditor = false,
543
- defaultLanguage = 'plain',
544
- }) => {
545
- const hasCodeBlock = enabledButtons.includes('code-block') || isCodeEditor;
546
-
547
- const editorExtension = defineExtension({
548
- name: '@sveltia/ui/editor',
549
- namespace: 'editor',
550
- // `CodeNode` expects the editor to be built with `CodeExtension`, which also registers the
551
- // `CodeNode` and `CodeHighlightNode` nodes
552
- dependencies: hasCodeBlock ? [CodeExtension] : [],
553
- nodes: [
554
- ...components.map(({ node }) => node),
555
- ...new Set(
556
- Object.entries(NODE_MAP)
557
- .filter(([button]) => enabledButtons.includes(/** @type {TextEditorNodeType} */ (button)))
558
- .flatMap(([, nodes]) => nodes),
559
- ),
560
- ...(isCodeEditor
561
- ? [CodeNode, CodeHighlightNode]
562
- : // We haven’t implemented buttons for horizontal rules and tables yet, but we still want
563
- // to support them in Markdown, so always include them in the node list
564
- [HorizontalRuleNode, TableNode, TableCellNode, TableRowNode]),
565
- ],
566
- theme: EDITOR_THEME,
567
- /**
568
- * Log an error like `createEditor` does by default, instead of throwing it.
569
- * @param {Error} error Error.
570
- */
571
- onError: (error) => {
572
- // eslint-disable-next-line no-console
573
- console.error(error);
574
- },
575
- });
576
-
577
- /** @type {Transformer[]} */
578
- const enabledTransformers = [
579
- ...components.map(({ transformer }) => transformer),
580
- ...new Set(
581
- Object.entries(TRANSFORMER_MAP)
582
- .filter(([button]) => enabledButtons.includes(/** @type {TextEditorNodeType} */ (button)))
583
- .flatMap(([, transformers]) => transformers),
584
- ),
585
- ...(isCodeEditor
586
- ? [CODE]
587
- : // See the comment above for why we always include horizontal rules and tables
588
- [HR, TABLE]),
589
- ];
557
+ const mergeUnregisters = (...unregisters) => {
558
+ const handlers = unregisters.filter((unregister) => typeof unregister === 'function');
590
559
 
591
- const editor = buildEditorFromExtensions(editorExtension);
592
- /** @type {Array<() => void>} */
593
- const unregisters = [];
594
-
595
- /**
596
- * Add a cleanup handler if it is defined.
597
- * @param {(() => void) | undefined | null} unregister Cleanup handler.
598
- */
599
- const addUnregister = (unregister) => {
600
- /* v8 ignore next */
601
- if (typeof unregister === 'function') {
602
- unregisters.push(unregister);
603
- }
560
+ return () => {
561
+ handlers.forEach((unregister) => unregister());
604
562
  };
563
+ };
605
564
 
606
- addUnregister(registerRichText(editor));
607
- addUnregister(registerDragonSupport(editor));
608
- // Cap the undo stack, as each entry holds a snapshot of the whole document
609
- addUnregister(
610
- registerHistory(
611
- editor,
612
- createEmptyHistoryState(),
613
- 1000,
614
- undefined,
615
- undefined,
616
- HISTORY_MAX_DEPTH,
617
- ),
618
- );
619
-
620
- if (isCodeEditor) {
565
+ /**
566
+ * Register the commands and transform specific to the code editor, which only holds a single code
567
+ * block.
568
+ * @param {LexicalEditor} editor Editor instance.
569
+ * @param {string} defaultLanguage Default language of the code block.
570
+ * @returns {() => void} Cleanup handler.
571
+ */
572
+ const registerCodeEditorCommands = (editor, defaultLanguage) =>
573
+ mergeUnregisters(
621
574
  // Pressing Backspace at the very beginning of a code block converts it to a paragraph by
622
575
  // default (`CodeNode.collapseAtStart()`), which makes no sense when the editor only holds a
623
576
  // single code block, so ignore it
624
- addUnregister(
625
- editor.registerCommand(
626
- DELETE_CHARACTER_COMMAND,
627
- (isBackward) => {
628
- const selection = getSelection();
629
-
630
- if (!isBackward || !isRangeSelection(selection) || !selection.isCollapsed()) {
631
- return false;
632
- }
633
-
634
- const { offset } = selection.anchor;
635
- const node = selection.anchor.getNode();
636
- const codeNode = isCodeNode(node) ? node : getNearestNodeOfType(node, CodeNode);
637
-
638
- return (
639
- !!codeNode &&
640
- offset === 0 &&
641
- (node.is(codeNode) || !!codeNode.getFirstDescendant()?.is(node))
642
- );
643
- },
644
- COMMAND_PRIORITY_LOW,
645
- ),
646
- );
577
+ editor.registerCommand(
578
+ DELETE_CHARACTER_COMMAND,
579
+ (isBackward) => {
580
+ const selection = getSelection();
647
581
 
648
- // Make sure the editor always has a single code block. This runs within the same update that
649
- // made the change, so the user never gets a chance to type in anything else
650
- addUnregister(
651
- editor.registerNodeTransform(RootNode, (root) => {
652
- const children = root.getChildren();
653
-
654
- if (children.length === 1 && isCodeNode(children[0])) {
655
- return;
582
+ if (!isBackward || !isRangeSelection(selection) || !selection.isCollapsed()) {
583
+ return false;
656
584
  }
657
585
 
658
- const [firstChild] = children;
586
+ const { offset } = selection.anchor;
587
+ const node = selection.anchor.getNode();
588
+ const codeNode = isCodeNode(node) ? node : getNearestNodeOfType(node, CodeNode);
659
589
 
660
- if (children.length === 1 && isElementNode(firstChild)) {
661
- const node = createCodeNode(defaultLanguage);
590
+ return (
591
+ !!codeNode &&
592
+ offset === 0 &&
593
+ (node.is(codeNode) || !!codeNode.getFirstDescendant()?.is(node))
594
+ );
595
+ },
596
+ COMMAND_PRIORITY_LOW,
597
+ ),
598
+ // Pressing Enter after two blank lines at the end of a code block exits it by default
599
+ // (`CodeExtension`), adding a paragraph after it, which would be left out of the code. Insert a
600
+ // new line instead, which `CodeNode.insertNewAfter()` does without exiting when the extension
601
+ // is in place. This has to take priority over the extension’s own handler
602
+ editor.registerCommand(
603
+ KEY_ENTER_COMMAND,
604
+ (event) => {
605
+ const selection = getSelection();
606
+
607
+ if (!isRangeSelection(selection)) {
608
+ return false;
609
+ }
662
610
 
663
- // Keep the content and the selection
664
- firstChild.replace(node, true);
665
- } else if (children.length === 0) {
666
- const node = createCodeNode(defaultLanguage);
611
+ event?.preventDefault();
667
612
 
668
- root.append(node);
669
- node.selectStart();
670
- }
671
- }),
672
- );
673
- }
613
+ return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
614
+ },
615
+ COMMAND_PRIORITY_NORMAL,
616
+ ),
617
+ // Make sure the editor always has a single code block. This runs within the same update that
618
+ // made the change, so the user never gets a chance to type in anything else
619
+ editor.registerNodeTransform(RootNode, (root) => {
620
+ const children = root.getChildren();
674
621
 
675
- if (useMarkdownShortcuts) {
676
- addUnregister(registerMarkdownShortcuts(editor, enabledTransformers));
677
- }
622
+ if (children.length === 1 && isCodeNode(children[0])) {
623
+ return;
624
+ }
678
625
 
679
- if (hasCodeBlock) {
680
- addUnregister(
681
- registerCodeHighlighting(editor, {
682
- ...shikiTokenizer,
683
- defaultLanguage,
684
- defaultTheme: getCodeTheme(),
685
- }),
686
- );
626
+ const [firstChild] = children;
687
627
 
688
- addUnregister(observeCodeTheme(editor));
689
- }
628
+ if (children.length === 1 && isElementNode(firstChild)) {
629
+ const node = createCodeNode(defaultLanguage);
690
630
 
691
- // https://github.com/facebook/lexical/blob/main/packages/lexical-link/src/LexicalLinkExtension.ts
692
- if (enabledButtons.includes('link')) {
693
- addUnregister(
694
- editor.registerCommand(
695
- TOGGLE_LINK_COMMAND,
696
- (payload) => {
697
- // Ignore an unsafe URL rather than linking to it
698
- if (typeof payload === 'string' && !isSafeLinkURL(payload)) {
699
- return true;
700
- }
631
+ // Keep the content and the selection
632
+ firstChild.replace(node, true);
633
+ } else if (children.length === 0) {
634
+ const node = createCodeNode(defaultLanguage);
701
635
 
702
- toggleLink(typeof payload === 'string' ? payload : null);
636
+ root.append(node);
637
+ node.selectStart();
638
+ }
639
+ }),
640
+ );
703
641
 
642
+ /**
643
+ * Register the link command, and the paste handler that turns a pasted URL into a link.
644
+ * @param {LexicalEditor} editor Editor instance.
645
+ * @returns {() => void} Cleanup handler.
646
+ * @see https://github.com/facebook/lexical/blob/main/packages/lexical-link/src/LexicalLinkExtension.ts
647
+ */
648
+ const registerLinkCommands = (editor) =>
649
+ mergeUnregisters(
650
+ editor.registerCommand(
651
+ TOGGLE_LINK_COMMAND,
652
+ (payload) => {
653
+ // Ignore an unsafe URL rather than linking to it
654
+ if (typeof payload === 'string' && !isSafeLinkURL(payload)) {
704
655
  return true;
705
- },
706
- COMMAND_PRIORITY_NORMAL,
707
- ),
708
- );
656
+ }
709
657
 
710
- addUnregister(
711
- editor.registerCommand(
712
- PASTE_COMMAND,
713
- (event) => {
714
- const selection = getSelection();
658
+ toggleLink(typeof payload === 'string' ? payload : null);
715
659
 
716
- if (
717
- !isRangeSelection(selection) ||
718
- !objectKlassEquals(event, ClipboardEvent) ||
719
- !event.clipboardData ||
720
- /** @type {HTMLElement} */ (event.target).matches('input, textarea')
721
- ) {
722
- return false;
723
- }
660
+ return true;
661
+ },
662
+ COMMAND_PRIORITY_NORMAL,
663
+ ),
664
+ editor.registerCommand(
665
+ PASTE_COMMAND,
666
+ (event) => {
667
+ const selection = getSelection();
668
+
669
+ if (
670
+ !isRangeSelection(selection) ||
671
+ !objectKlassEquals(event, ClipboardEvent) ||
672
+ !event.clipboardData ||
673
+ /** @type {HTMLElement} */ (event.target).matches('input, textarea')
674
+ ) {
675
+ return false;
676
+ }
724
677
 
725
- const clipboardText = event.clipboardData.getData('text').trim();
678
+ const clipboardText = event.clipboardData.getData('text').trim();
726
679
 
727
- // Paste an unsafe URL as plain text rather than as a link
728
- if (!isURL(clipboardText) || !isSafeLinkURL(clipboardText)) {
729
- return false;
730
- }
680
+ // Paste an unsafe URL as plain text rather than as a link
681
+ if (!isURL(clipboardText) || !isSafeLinkURL(clipboardText)) {
682
+ return false;
683
+ }
731
684
 
732
- if (selection.isCollapsed()) {
733
- insertNodes([createTextNode(clipboardText)]);
734
- }
685
+ if (selection.isCollapsed()) {
686
+ insertNodes([createTextNode(clipboardText)]);
687
+ }
735
688
 
736
- if (
737
- !selection
738
- .getNodes()
739
- .some((node) => isElementNode(node) || (isTextNode(node) && !node.isSimpleText()))
740
- ) {
741
- editor.dispatchCommand(TOGGLE_LINK_COMMAND, clipboardText);
742
- event.preventDefault();
743
- return true;
744
- }
689
+ if (
690
+ !selection
691
+ .getNodes()
692
+ .some((node) => isElementNode(node) || (isTextNode(node) && !node.isSimpleText()))
693
+ ) {
694
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, clipboardText);
695
+ event.preventDefault();
696
+ return true;
697
+ }
745
698
 
746
- return false;
747
- },
748
- COMMAND_PRIORITY_LOW,
749
- ),
750
- );
751
- }
699
+ return false;
700
+ },
701
+ COMMAND_PRIORITY_LOW,
702
+ ),
703
+ );
752
704
 
753
- if (enabledButtons.includes('bulleted-list')) {
754
- addUnregister(
755
- editor.registerCommand(
756
- INSERT_UNORDERED_LIST_COMMAND,
757
- () => {
758
- insertList('bullet');
705
+ /**
706
+ * List buttons, along with the command to insert the list and its type.
707
+ * @type {[TextEditorBlockType, typeof INSERT_UNORDERED_LIST_COMMAND, ListType][]}
708
+ */
709
+ const LIST_COMMANDS = [
710
+ ['bulleted-list', INSERT_UNORDERED_LIST_COMMAND, 'bullet'],
711
+ ['numbered-list', INSERT_ORDERED_LIST_COMMAND, 'number'],
712
+ ];
759
713
 
760
- return true;
761
- },
762
- COMMAND_PRIORITY_NORMAL,
763
- ),
764
- );
765
- }
714
+ /**
715
+ * Register the commands for the enabled list buttons.
716
+ * @param {LexicalEditor} editor Editor instance.
717
+ * @param {TextEditorConfig['enabledButtons']} enabledButtons Enabled buttons.
718
+ * @returns {() => void} Cleanup handler.
719
+ */
720
+ const registerListCommands = (editor, enabledButtons = []) => {
721
+ const enabledLists = LIST_COMMANDS.filter(([button]) => enabledButtons.includes(button));
766
722
 
767
- if (enabledButtons.includes('numbered-list')) {
768
- addUnregister(
723
+ return mergeUnregisters(
724
+ ...enabledLists.map(([, command, listType]) =>
769
725
  editor.registerCommand(
770
- INSERT_ORDERED_LIST_COMMAND,
726
+ command,
771
727
  () => {
772
- insertList('number');
728
+ insertList(listType);
773
729
 
774
730
  return true;
775
731
  },
776
732
  COMMAND_PRIORITY_NORMAL,
777
733
  ),
778
- );
779
- }
780
-
781
- if (enabledButtons.includes('bulleted-list') || enabledButtons.includes('numbered-list')) {
734
+ ),
782
735
  // https://github.com/facebook/lexical/blob/main/packages/lexical-react/src/shared/useList.ts
783
- addUnregister(
784
- editor.registerCommand(
785
- INSERT_PARAGRAPH_COMMAND,
786
- () => handleListInsertParagraph(),
787
- COMMAND_PRIORITY_NORMAL,
788
- ),
789
- );
790
- }
736
+ enabledLists.length
737
+ ? editor.registerCommand(
738
+ INSERT_PARAGRAPH_COMMAND,
739
+ () => handleListInsertParagraph(),
740
+ COMMAND_PRIORITY_NORMAL,
741
+ )
742
+ : undefined,
743
+ );
744
+ };
791
745
 
746
+ /**
747
+ * Register an update listener that triggers the Update event with the Markdown value, debounced so
748
+ * only the latest update in a burst triggers it.
749
+ * @param {LexicalEditor} editor Editor instance.
750
+ * @param {Transformer[]} enabledTransformers Enabled Markdown transformers.
751
+ * @returns {() => void} Cleanup handler.
752
+ */
753
+ const registerUpdateEvent = (editor, enabledTransformers) => {
792
754
  /** Incremented on every update, so only the latest one in a burst triggers the Update event. */
793
755
  let updateCount = 0;
794
756
  /** Whether the content has changed since the Update event was last triggered. */
@@ -797,11 +759,10 @@ export const initEditor = ({
797
759
  let lastValue = undefined;
798
760
  let disposed = false;
799
761
 
800
- addUnregister(() => {
801
- disposed = true;
802
- });
803
-
804
- addUnregister(
762
+ return mergeUnregisters(
763
+ () => {
764
+ disposed = true;
765
+ },
805
766
  editor.registerUpdateListener(({ dirtyElements, dirtyLeaves } = /** @type {any} */ ({})) => {
806
767
  // An update that only moves the selection leaves no dirty nodes. Remember whether any update
807
768
  // in the burst changed the content, so the document is only converted again when needed.
@@ -835,52 +796,147 @@ export const initEditor = ({
835
796
  })();
836
797
  }),
837
798
  );
799
+ };
838
800
 
839
- // `editor.registerCommand(KEY_TAB_COMMAND, listener, priority)` doesn’t work for some reason, so
840
- // use another method
841
- addUnregister(
842
- editor.registerRootListener((root) => {
843
- if (!root) {
844
- return undefined;
845
- }
846
-
847
- /**
848
- * Handle Tab indentation shortcuts.
849
- * @param {KeyboardEvent} event Keydown event.
850
- */
851
- const onKeydown = (event) => {
852
- editor.update(() => {
853
- if (event.key === 'Tab') {
854
- const selection = getSelection();
801
+ /**
802
+ * Register a handler for the Tab key that indents a list item, or unindents it with Shift.
803
+ * `editor.registerCommand(KEY_TAB_COMMAND, listener, priority)` doesn’t work for some reason, so
804
+ * listen to the `keydown` event on the root element instead.
805
+ * @param {LexicalEditor} editor Editor instance.
806
+ * @returns {() => void} Cleanup handler.
807
+ */
808
+ const registerTabIndentation = (editor) =>
809
+ editor.registerRootListener((root) => {
810
+ if (!root) {
811
+ return undefined;
812
+ }
855
813
 
856
- if (!isRangeSelection(selection)) {
857
- return;
858
- }
814
+ /**
815
+ * Handle Tab indentation shortcuts.
816
+ * @param {KeyboardEvent} event Keydown event.
817
+ */
818
+ const onKeydown = (event) => {
819
+ editor.update(() => {
820
+ if (event.key === 'Tab') {
821
+ const selection = getSelection();
859
822
 
860
- const anchor = selection.anchor.getNode();
823
+ if (!isRangeSelection(selection)) {
824
+ return;
825
+ }
861
826
 
862
- const parent =
863
- anchor instanceof ElementNode ? anchor : getNearestNodeOfType(anchor, ElementNode);
827
+ const parent = getNearestElementNode(selection.anchor.getNode());
864
828
 
865
- if (isListItemNode(parent) && parent.canIndent()) {
866
- if (!event.shiftKey) {
867
- event.preventDefault();
868
- editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined);
869
- } else if (parent.getIndent() > 0) {
870
- event.preventDefault();
871
- editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
872
- }
829
+ if (isListItemNode(parent) && parent.canIndent()) {
830
+ if (!event.shiftKey) {
831
+ event.preventDefault();
832
+ editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined);
833
+ } else if (parent.getIndent() > 0) {
834
+ event.preventDefault();
835
+ editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
873
836
  }
874
837
  }
875
- });
876
- };
838
+ }
839
+ });
840
+ };
877
841
 
878
- root.addEventListener('keydown', onKeydown);
842
+ root.addEventListener('keydown', onKeydown);
879
843
 
880
- return () => {
881
- root.removeEventListener('keydown', onKeydown);
882
- };
883
- }),
844
+ return () => {
845
+ root.removeEventListener('keydown', onKeydown);
846
+ };
847
+ });
848
+
849
+ /**
850
+ * Initialize the Lexical editor.
851
+ * @param {TextEditorConfig} config Editor configuration.
852
+ * @returns {InitEditorResult} Editor instance and cleanup.
853
+ */
854
+ export const initEditor = ({
855
+ enabledButtons = [],
856
+ components = [],
857
+ useMarkdownShortcuts,
858
+ isCodeEditor = false,
859
+ defaultLanguage = 'plain',
860
+ }) => {
861
+ const hasCodeBlock = enabledButtons.includes('code-block') || isCodeEditor;
862
+
863
+ const editorExtension = defineExtension({
864
+ name: '@sveltia/ui/editor',
865
+ namespace: 'editor',
866
+ // `CodeNode` expects the editor to be built with `CodeExtension`, which also registers the
867
+ // `CodeNode` and `CodeHighlightNode` nodes
868
+ dependencies: hasCodeBlock ? [CodeExtension] : [],
869
+ // Start with an empty editor state like `createEditor` does, instead of a blank paragraph. The
870
+ // Markdown import then becomes the first state in the history, so undoing right after the
871
+ // editor loads doesn’t revert it to the blank paragraph and clear the value
872
+ $initialEditorState: null,
873
+ nodes: [
874
+ ...components.map(({ node }) => node),
875
+ ...new Set(
876
+ Object.entries(NODE_MAP)
877
+ .filter(([button]) => enabledButtons.includes(/** @type {TextEditorNodeType} */ (button)))
878
+ .flatMap(([, nodes]) => nodes),
879
+ ),
880
+ ...(isCodeEditor
881
+ ? [CodeNode, CodeHighlightNode]
882
+ : // We haven’t implemented buttons for horizontal rules and tables yet, but we still want
883
+ // to support them in Markdown, so always include them in the node list
884
+ [HorizontalRuleNode, TableNode, TableCellNode, TableRowNode]),
885
+ ],
886
+ theme: EDITOR_THEME,
887
+ /**
888
+ * Log an error like `createEditor` does by default, instead of throwing it.
889
+ * @param {Error} error Error.
890
+ */
891
+ onError: (error) => {
892
+ // eslint-disable-next-line no-console
893
+ console.error(error);
894
+ },
895
+ });
896
+
897
+ /** @type {Transformer[]} */
898
+ const enabledTransformers = [
899
+ ...components.map(({ transformer }) => transformer),
900
+ ...new Set(
901
+ Object.entries(TRANSFORMER_MAP)
902
+ .filter(([button]) => enabledButtons.includes(/** @type {TextEditorNodeType} */ (button)))
903
+ .flatMap(([, transformers]) => transformers),
904
+ ),
905
+ ...(isCodeEditor
906
+ ? [CODE]
907
+ : // See the comment above for why we always include horizontal rules and tables
908
+ [HR, TABLE]),
909
+ ];
910
+
911
+ const editor = buildEditorFromExtensions(editorExtension);
912
+
913
+ // The order matters, as listeners with the same priority are called in the registration order
914
+ const unregister = mergeUnregisters(
915
+ registerRichText(editor),
916
+ registerDragonSupport(editor),
917
+ // Cap the undo stack, as each entry holds a snapshot of the whole document
918
+ registerHistory(
919
+ editor,
920
+ createEmptyHistoryState(),
921
+ 1000,
922
+ undefined,
923
+ undefined,
924
+ HISTORY_MAX_DEPTH,
925
+ ),
926
+ isCodeEditor ? registerCodeEditorCommands(editor, defaultLanguage) : undefined,
927
+ useMarkdownShortcuts ? registerMarkdownShortcuts(editor, enabledTransformers) : undefined,
928
+ hasCodeBlock
929
+ ? registerCodeHighlighting(editor, {
930
+ ...shikiTokenizer,
931
+ defaultLanguage,
932
+ defaultTheme: getCodeTheme(),
933
+ })
934
+ : undefined,
935
+ hasCodeBlock ? observeCodeTheme(editor) : undefined,
936
+ enabledButtons.includes('link') ? registerLinkCommands(editor) : undefined,
937
+ registerListCommands(editor, enabledButtons),
938
+ registerUpdateEvent(editor, enabledTransformers),
939
+ registerTabIndentation(editor),
884
940
  );
885
941
 
886
942
  return {
@@ -890,7 +946,7 @@ export const initEditor = ({
890
946
  * Remove all registered Lexical listeners.
891
947
  */
892
948
  dispose: () => {
893
- unregisters.forEach((unregister) => unregister());
949
+ unregister();
894
950
  editor.dispose();
895
951
  },
896
952
  };
@@ -914,23 +970,54 @@ export const loadCodeHighlighter = async (lang) => {
914
970
  await Promise.all([loadCodeLanguage(normalizeCodeLanguage(lang)), loadCodeTheme(getCodeTheme())]);
915
971
  };
916
972
 
973
+ /**
974
+ * Number of the latest Markdown import started for each editor.
975
+ * @type {WeakMap<LexicalEditor, number>}
976
+ */
977
+ const latestImports = new WeakMap();
978
+
917
979
  /**
918
980
  * Convert Markdown to Lexical nodes.
919
981
  * @param {LexicalEditor} editor Editor instance.
920
982
  * @param {string} value Current Markdown value.
921
983
  * @param {Transformer[]} enabledTransformers List of enabled Markdown transformers.
922
- * @returns {Promise<string>} The value as the editor exports it once imported, which can differ in
923
- * style from the given value, e.g. `_text_` for `*text*`.
984
+ * @returns {Promise<string | undefined>} The value as the editor exports it once imported, which
985
+ * can differ in style from the given value, e.g. `_text_` for `*text*`. `undefined` if another
986
+ * import has been started for the same editor in the meantime, in which case this value is skipped.
924
987
  * @throws {Error} Failed to convert the value to Lexical nodes.
925
988
  */
926
989
  export const convertMarkdownToLexical = async (editor, value, enabledTransformers) => {
927
- // Preload the highlighter for every language used in the document, so code blocks are highlighted
928
- // as soon as they appear rather than a moment later
929
- await Promise.all(
930
- [...value.matchAll(/^```(?<lang>.+?)\n/gm)].map(async ({ groups: { lang = 'plain' } = {} }) =>
931
- loadCodeHighlighter(lang),
932
- ),
933
- );
990
+ const importId = (latestImports.get(editor) ?? 0) + 1;
991
+
992
+ latestImports.set(editor, importId);
993
+
994
+ /**
995
+ * Check if a newer import has been started while waiting for the highlighter. Imports can finish
996
+ * out of order, as only those with code blocks have to wait, and an older value must not
997
+ * overwrite a newer one.
998
+ * @returns {boolean} Result.
999
+ */
1000
+ const isSuperseded = () => latestImports.get(editor) !== importId;
1001
+
1002
+ try {
1003
+ // Preload the highlighter for every language used in the document, so code blocks are
1004
+ // highlighted as soon as they appear rather than a moment later
1005
+ await Promise.all(
1006
+ [...value.matchAll(/^```(?<lang>.+?)\n/gm)].map(async ({ groups: { lang = 'plain' } = {} }) =>
1007
+ loadCodeHighlighter(lang),
1008
+ ),
1009
+ );
1010
+ } catch (ex) {
1011
+ if (isSuperseded()) {
1012
+ return undefined;
1013
+ }
1014
+
1015
+ throw ex;
1016
+ }
1017
+
1018
+ if (isSuperseded()) {
1019
+ return undefined;
1020
+ }
934
1021
 
935
1022
  // Split multiline formatting into separate lines to prevent Markdown parsing issues
936
1023
  value = splitMultilineFormatting(value);