@sveltia/ui 0.78.3 → 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 +376 -273
  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 +38 -34
@@ -1,4 +1,5 @@
1
1
  import {
2
+ CodeExtension,
2
3
  CodeHighlightNode,
3
4
  CodeNode,
4
5
  $createCodeNode as createCodeNode,
@@ -6,7 +7,7 @@ import {
6
7
  $isCodeNode as isCodeNode,
7
8
  } from '@lexical/code-core';
8
9
  import { registerDragonSupport } from '@lexical/dragon';
9
- import { HorizontalRuleNode } from '@lexical/extension';
10
+ import { buildEditorFromExtensions, HorizontalRuleNode } from '@lexical/extension';
10
11
  import { createEmptyHistoryState, registerHistory } from '@lexical/history';
11
12
  import {
12
13
  $isLinkNode as isLinkNode,
@@ -41,6 +42,7 @@ import {
41
42
  COMMAND_PRIORITY_LOW,
42
43
  COMMAND_PRIORITY_NORMAL,
43
44
  createEditor,
45
+ defineExtension,
44
46
  DELETE_CHARACTER_COMMAND,
45
47
  $createParagraphNode as createParagraphNode,
46
48
  $createTextNode as createTextNode,
@@ -50,6 +52,7 @@ import {
50
52
  $getSelection as getSelection,
51
53
  INDENT_CONTENT_COMMAND,
52
54
  INSERT_PARAGRAPH_COMMAND,
55
+ KEY_ENTER_COMMAND,
53
56
  $insertNodes as insertNodes,
54
57
  $isDecoratorNode as isDecoratorNode,
55
58
  $isElementNode as isElementNode,
@@ -87,7 +90,8 @@ import { HR } from './transformers/hr.js';
87
90
  import { TABLE } from './transformers/table.js';
88
91
 
89
92
  /**
90
- * @import { CreateEditorArgs, LexicalEditor } from 'lexical';
93
+ * @import { LexicalEditor, LexicalNode } from 'lexical';
94
+ * @import { ListType } from '@lexical/list';
91
95
  * @import { Transformer } from '@lexical/markdown';
92
96
  * @import {
93
97
  * TextEditorBlockType,
@@ -96,7 +100,7 @@ import { TABLE } from './transformers/table.js';
96
100
  * TextEditorInlineType,
97
101
  * TextEditorNodeType,
98
102
  * TextEditorSelectionState,
99
- * } from '../../typedefs';
103
+ * } from '../../typedefs.js';
100
104
  */
101
105
 
102
106
  /**
@@ -160,7 +164,7 @@ export const isStaticDecoratorContent = (target) => {
160
164
  * block, or a new paragraph if the adjacent nodes are decorators, where nothing can be typed.
161
165
  * @returns {boolean} Whether the selection has been moved.
162
166
  */
163
- export const $moveCaretIntoBlock = () => {
167
+ const $moveCaretIntoBlock = () => {
164
168
  const selection = getSelection();
165
169
 
166
170
  if (
@@ -203,17 +207,29 @@ export const $moveCaretIntoBlock = () => {
203
207
  };
204
208
 
205
209
  /**
206
- * Move the focus to the editor, and the caret to right after the given decorator node, like an
207
- * 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.
208
212
  * @param {LexicalEditor} editor Editor instance.
209
- * @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.
210
216
  */
211
- const placeCaretAfterDecorator = (editor, decorator) => {
217
+ const updateNodeWithFocus = (editor, element, callback) => {
212
218
  editor.getRootElement()?.focus({ preventScroll: true });
213
219
 
214
220
  editor.update(() => {
215
- const node = getNearestNodeFromDOMNode(decorator);
221
+ callback(getNearestNodeFromDOMNode(element));
222
+ });
223
+ };
216
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) => {
217
233
  // The element has been found by its `data-lexical-decorator` attribute
218
234
  /* v8 ignore next 3 */
219
235
  if (!isDecoratorNode(node)) {
@@ -231,11 +247,7 @@ const placeCaretAfterDecorator = (editor, decorator) => {
231
247
  * @param {Element} block Element of the block node.
232
248
  */
233
249
  const placeCaretAtBlockEnd = (editor, block) => {
234
- editor.getRootElement()?.focus({ preventScroll: true });
235
-
236
- editor.update(() => {
237
- const node = getNearestNodeFromDOMNode(block);
238
-
250
+ updateNodeWithFocus(editor, block, (node) => {
239
251
  /* v8 ignore next 3 */
240
252
  if (!isElementNode(node)) {
241
253
  return;
@@ -352,6 +364,14 @@ export const isSafeLinkURL = (url) => {
352
364
  return !scheme || SAFE_LINK_URL_SCHEMES.includes(scheme.toLowerCase());
353
365
  };
354
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
+
355
375
  /**
356
376
  * Get the current selection’s block node key as well as block and inline level types.
357
377
  * @internal
@@ -375,7 +395,7 @@ export const getSelectionTypes = () => {
375
395
  const inlineTypes = TEXT_FORMAT_BUTTON_TYPES.filter((type) => selection.hasFormat(type));
376
396
 
377
397
  if (anchor.getType() !== 'root') {
378
- parent = anchor instanceof ElementNode ? anchor : getNearestNodeOfType(anchor, ElementNode);
398
+ parent = getNearestElementNode(anchor);
379
399
 
380
400
  if (isLinkNode(parent)) {
381
401
  inlineTypes.push('link');
@@ -529,251 +549,208 @@ export const getComponentMarkdown = ({ node, createNode, transformer, createMark
529
549
  };
530
550
 
531
551
  /**
532
- * Initialize the Lexical editor.
533
- * @param {TextEditorConfig} config Editor configuration.
534
- * @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.
535
556
  */
536
- export const initEditor = ({
537
- enabledButtons = [],
538
- components = [],
539
- useMarkdownShortcuts,
540
- isCodeEditor = false,
541
- defaultLanguage = 'plain',
542
- }) => {
543
- /** @type {CreateEditorArgs} */
544
- const editorConfig = {
545
- namespace: 'editor',
546
- nodes: [
547
- ...components.map(({ node }) => node),
548
- ...new Set(
549
- Object.entries(NODE_MAP)
550
- .filter(([button]) => enabledButtons.includes(/** @type {TextEditorNodeType} */ (button)))
551
- .flatMap(([, nodes]) => nodes),
552
- ),
553
- ...(isCodeEditor
554
- ? [CodeNode, CodeHighlightNode]
555
- : // We haven’t implemented buttons for horizontal rules and tables yet, but we still want
556
- // to support them in Markdown, so always include them in the node list
557
- [HorizontalRuleNode, TableNode, TableCellNode, TableRowNode]),
558
- ],
559
- theme: EDITOR_THEME,
560
- };
561
-
562
- /** @type {Transformer[]} */
563
- const enabledTransformers = [
564
- ...components.map(({ transformer }) => transformer),
565
- ...new Set(
566
- Object.entries(TRANSFORMER_MAP)
567
- .filter(([button]) => enabledButtons.includes(/** @type {TextEditorNodeType} */ (button)))
568
- .flatMap(([, transformers]) => transformers),
569
- ),
570
- ...(isCodeEditor
571
- ? [CODE]
572
- : // See the comment above for why we always include horizontal rules and tables
573
- [HR, TABLE]),
574
- ];
575
-
576
- const editor = createEditor(editorConfig);
577
- /** @type {Array<() => void>} */
578
- const unregisters = [];
557
+ const mergeUnregisters = (...unregisters) => {
558
+ const handlers = unregisters.filter((unregister) => typeof unregister === 'function');
579
559
 
580
- /**
581
- * Add a cleanup handler if it is defined.
582
- * @param {(() => void) | undefined | null} unregister Cleanup handler.
583
- */
584
- const addUnregister = (unregister) => {
585
- /* v8 ignore next */
586
- if (typeof unregister === 'function') {
587
- unregisters.push(unregister);
588
- }
560
+ return () => {
561
+ handlers.forEach((unregister) => unregister());
589
562
  };
563
+ };
590
564
 
591
- addUnregister(registerRichText(editor));
592
- addUnregister(registerDragonSupport(editor));
593
- // Cap the undo stack, as each entry holds a snapshot of the whole document
594
- addUnregister(
595
- registerHistory(
596
- editor,
597
- createEmptyHistoryState(),
598
- 1000,
599
- undefined,
600
- undefined,
601
- HISTORY_MAX_DEPTH,
602
- ),
603
- );
604
-
605
- 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(
606
574
  // Pressing Backspace at the very beginning of a code block converts it to a paragraph by
607
575
  // default (`CodeNode.collapseAtStart()`), which makes no sense when the editor only holds a
608
576
  // single code block, so ignore it
609
- addUnregister(
610
- editor.registerCommand(
611
- DELETE_CHARACTER_COMMAND,
612
- (isBackward) => {
613
- const selection = getSelection();
614
-
615
- if (!isBackward || !isRangeSelection(selection) || !selection.isCollapsed()) {
616
- return false;
617
- }
618
-
619
- const { offset } = selection.anchor;
620
- const node = selection.anchor.getNode();
621
- const codeNode = isCodeNode(node) ? node : getNearestNodeOfType(node, CodeNode);
622
-
623
- return (
624
- !!codeNode &&
625
- offset === 0 &&
626
- (node.is(codeNode) || !!codeNode.getFirstDescendant()?.is(node))
627
- );
628
- },
629
- COMMAND_PRIORITY_LOW,
630
- ),
631
- );
632
-
633
- // Make sure the editor always has a single code block. This runs within the same update that
634
- // made the change, so the user never gets a chance to type in anything else
635
- addUnregister(
636
- editor.registerNodeTransform(RootNode, (root) => {
637
- const children = root.getChildren();
577
+ editor.registerCommand(
578
+ DELETE_CHARACTER_COMMAND,
579
+ (isBackward) => {
580
+ const selection = getSelection();
638
581
 
639
- if (children.length === 1 && isCodeNode(children[0])) {
640
- return;
582
+ if (!isBackward || !isRangeSelection(selection) || !selection.isCollapsed()) {
583
+ return false;
641
584
  }
642
585
 
643
- const [firstChild] = children;
586
+ const { offset } = selection.anchor;
587
+ const node = selection.anchor.getNode();
588
+ const codeNode = isCodeNode(node) ? node : getNearestNodeOfType(node, CodeNode);
644
589
 
645
- if (children.length === 1 && isElementNode(firstChild)) {
646
- 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
+ }
647
610
 
648
- // Keep the content and the selection
649
- firstChild.replace(node, true);
650
- } else if (children.length === 0) {
651
- const node = createCodeNode(defaultLanguage);
611
+ event?.preventDefault();
652
612
 
653
- root.append(node);
654
- node.selectStart();
655
- }
656
- }),
657
- );
658
- }
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();
659
621
 
660
- if (useMarkdownShortcuts) {
661
- addUnregister(registerMarkdownShortcuts(editor, enabledTransformers));
662
- }
622
+ if (children.length === 1 && isCodeNode(children[0])) {
623
+ return;
624
+ }
663
625
 
664
- if (enabledButtons.includes('code-block') || isCodeEditor) {
665
- addUnregister(
666
- registerCodeHighlighting(editor, {
667
- ...shikiTokenizer,
668
- defaultLanguage,
669
- defaultTheme: getCodeTheme(),
670
- }),
671
- );
626
+ const [firstChild] = children;
672
627
 
673
- addUnregister(observeCodeTheme(editor));
674
- }
628
+ if (children.length === 1 && isElementNode(firstChild)) {
629
+ const node = createCodeNode(defaultLanguage);
675
630
 
676
- // https://github.com/facebook/lexical/blob/main/packages/lexical-link/src/LexicalLinkExtension.ts
677
- if (enabledButtons.includes('link')) {
678
- addUnregister(
679
- editor.registerCommand(
680
- TOGGLE_LINK_COMMAND,
681
- (payload) => {
682
- // Ignore an unsafe URL rather than linking to it
683
- if (typeof payload === 'string' && !isSafeLinkURL(payload)) {
684
- return true;
685
- }
631
+ // Keep the content and the selection
632
+ firstChild.replace(node, true);
633
+ } else if (children.length === 0) {
634
+ const node = createCodeNode(defaultLanguage);
686
635
 
687
- toggleLink(typeof payload === 'string' ? payload : null);
636
+ root.append(node);
637
+ node.selectStart();
638
+ }
639
+ }),
640
+ );
688
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)) {
689
655
  return true;
690
- },
691
- COMMAND_PRIORITY_NORMAL,
692
- ),
693
- );
656
+ }
694
657
 
695
- addUnregister(
696
- editor.registerCommand(
697
- PASTE_COMMAND,
698
- (event) => {
699
- const selection = getSelection();
658
+ toggleLink(typeof payload === 'string' ? payload : null);
700
659
 
701
- if (
702
- !isRangeSelection(selection) ||
703
- !objectKlassEquals(event, ClipboardEvent) ||
704
- !event.clipboardData ||
705
- /** @type {HTMLElement} */ (event.target).matches('input, textarea')
706
- ) {
707
- return false;
708
- }
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
+ }
709
677
 
710
- const clipboardText = event.clipboardData.getData('text').trim();
678
+ const clipboardText = event.clipboardData.getData('text').trim();
711
679
 
712
- // Paste an unsafe URL as plain text rather than as a link
713
- if (!isURL(clipboardText) || !isSafeLinkURL(clipboardText)) {
714
- return false;
715
- }
680
+ // Paste an unsafe URL as plain text rather than as a link
681
+ if (!isURL(clipboardText) || !isSafeLinkURL(clipboardText)) {
682
+ return false;
683
+ }
716
684
 
717
- if (selection.isCollapsed()) {
718
- insertNodes([createTextNode(clipboardText)]);
719
- }
685
+ if (selection.isCollapsed()) {
686
+ insertNodes([createTextNode(clipboardText)]);
687
+ }
720
688
 
721
- if (
722
- !selection
723
- .getNodes()
724
- .some((node) => isElementNode(node) || (isTextNode(node) && !node.isSimpleText()))
725
- ) {
726
- editor.dispatchCommand(TOGGLE_LINK_COMMAND, clipboardText);
727
- event.preventDefault();
728
- return true;
729
- }
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
+ }
730
698
 
731
- return false;
732
- },
733
- COMMAND_PRIORITY_LOW,
734
- ),
735
- );
736
- }
699
+ return false;
700
+ },
701
+ COMMAND_PRIORITY_LOW,
702
+ ),
703
+ );
737
704
 
738
- if (enabledButtons.includes('bulleted-list')) {
739
- addUnregister(
740
- editor.registerCommand(
741
- INSERT_UNORDERED_LIST_COMMAND,
742
- () => {
743
- 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
+ ];
744
713
 
745
- return true;
746
- },
747
- COMMAND_PRIORITY_NORMAL,
748
- ),
749
- );
750
- }
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));
751
722
 
752
- if (enabledButtons.includes('numbered-list')) {
753
- addUnregister(
723
+ return mergeUnregisters(
724
+ ...enabledLists.map(([, command, listType]) =>
754
725
  editor.registerCommand(
755
- INSERT_ORDERED_LIST_COMMAND,
726
+ command,
756
727
  () => {
757
- insertList('number');
728
+ insertList(listType);
758
729
 
759
730
  return true;
760
731
  },
761
732
  COMMAND_PRIORITY_NORMAL,
762
733
  ),
763
- );
764
- }
765
-
766
- if (enabledButtons.includes('bulleted-list') || enabledButtons.includes('numbered-list')) {
734
+ ),
767
735
  // https://github.com/facebook/lexical/blob/main/packages/lexical-react/src/shared/useList.ts
768
- addUnregister(
769
- editor.registerCommand(
770
- INSERT_PARAGRAPH_COMMAND,
771
- () => handleListInsertParagraph(),
772
- COMMAND_PRIORITY_NORMAL,
773
- ),
774
- );
775
- }
736
+ enabledLists.length
737
+ ? editor.registerCommand(
738
+ INSERT_PARAGRAPH_COMMAND,
739
+ () => handleListInsertParagraph(),
740
+ COMMAND_PRIORITY_NORMAL,
741
+ )
742
+ : undefined,
743
+ );
744
+ };
776
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) => {
777
754
  /** Incremented on every update, so only the latest one in a burst triggers the Update event. */
778
755
  let updateCount = 0;
779
756
  /** Whether the content has changed since the Update event was last triggered. */
@@ -782,11 +759,10 @@ export const initEditor = ({
782
759
  let lastValue = undefined;
783
760
  let disposed = false;
784
761
 
785
- addUnregister(() => {
786
- disposed = true;
787
- });
788
-
789
- addUnregister(
762
+ return mergeUnregisters(
763
+ () => {
764
+ disposed = true;
765
+ },
790
766
  editor.registerUpdateListener(({ dirtyElements, dirtyLeaves } = /** @type {any} */ ({})) => {
791
767
  // An update that only moves the selection leaves no dirty nodes. Remember whether any update
792
768
  // in the burst changed the content, so the document is only converted again when needed.
@@ -820,52 +796,147 @@ export const initEditor = ({
820
796
  })();
821
797
  }),
822
798
  );
799
+ };
823
800
 
824
- // `editor.registerCommand(KEY_TAB_COMMAND, listener, priority)` doesn’t work for some reason, so
825
- // use another method
826
- addUnregister(
827
- editor.registerRootListener((root) => {
828
- if (!root) {
829
- return undefined;
830
- }
831
-
832
- /**
833
- * Handle Tab indentation shortcuts.
834
- * @param {KeyboardEvent} event Keydown event.
835
- */
836
- const onKeydown = (event) => {
837
- editor.update(() => {
838
- if (event.key === 'Tab') {
839
- 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
+ }
840
813
 
841
- if (!isRangeSelection(selection)) {
842
- return;
843
- }
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();
844
822
 
845
- const anchor = selection.anchor.getNode();
823
+ if (!isRangeSelection(selection)) {
824
+ return;
825
+ }
846
826
 
847
- const parent =
848
- anchor instanceof ElementNode ? anchor : getNearestNodeOfType(anchor, ElementNode);
827
+ const parent = getNearestElementNode(selection.anchor.getNode());
849
828
 
850
- if (isListItemNode(parent) && parent.canIndent()) {
851
- if (!event.shiftKey) {
852
- event.preventDefault();
853
- editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined);
854
- } else if (parent.getIndent() > 0) {
855
- event.preventDefault();
856
- editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
857
- }
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);
858
836
  }
859
837
  }
860
- });
861
- };
838
+ }
839
+ });
840
+ };
862
841
 
863
- root.addEventListener('keydown', onKeydown);
842
+ root.addEventListener('keydown', onKeydown);
864
843
 
865
- return () => {
866
- root.removeEventListener('keydown', onKeydown);
867
- };
868
- }),
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),
869
940
  );
870
941
 
871
942
  return {
@@ -875,7 +946,8 @@ export const initEditor = ({
875
946
  * Remove all registered Lexical listeners.
876
947
  */
877
948
  dispose: () => {
878
- unregisters.forEach((unregister) => unregister());
949
+ unregister();
950
+ editor.dispose();
879
951
  },
880
952
  };
881
953
  };
@@ -898,23 +970,54 @@ export const loadCodeHighlighter = async (lang) => {
898
970
  await Promise.all([loadCodeLanguage(normalizeCodeLanguage(lang)), loadCodeTheme(getCodeTheme())]);
899
971
  };
900
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
+
901
979
  /**
902
980
  * Convert Markdown to Lexical nodes.
903
981
  * @param {LexicalEditor} editor Editor instance.
904
982
  * @param {string} value Current Markdown value.
905
983
  * @param {Transformer[]} enabledTransformers List of enabled Markdown transformers.
906
- * @returns {Promise<string>} The value as the editor exports it once imported, which can differ in
907
- * 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.
908
987
  * @throws {Error} Failed to convert the value to Lexical nodes.
909
988
  */
910
989
  export const convertMarkdownToLexical = async (editor, value, enabledTransformers) => {
911
- // Preload the highlighter for every language used in the document, so code blocks are highlighted
912
- // as soon as they appear rather than a moment later
913
- await Promise.all(
914
- [...value.matchAll(/^```(?<lang>.+?)\n/gm)].map(async ({ groups: { lang = 'plain' } = {} }) =>
915
- loadCodeHighlighter(lang),
916
- ),
917
- );
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
+ }
918
1021
 
919
1022
  // Split multiline formatting into separate lines to prevent Markdown parsing issues
920
1023
  value = splitMultilineFormatting(value);