@sveltia/ui 0.77.2 → 0.78.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 (41) hide show
  1. package/dist/components/dialog/dialog.svelte +7 -4
  2. package/dist/components/text-editor/constants.d.ts +5 -0
  3. package/dist/components/text-editor/constants.js +6 -0
  4. package/dist/components/text-editor/core.d.ts +6 -2
  5. package/dist/components/text-editor/core.js +316 -46
  6. package/dist/components/text-editor/lexical-root.svelte +62 -2
  7. package/dist/components/text-editor/lexical-root.svelte.d.ts +10 -0
  8. package/dist/components/text-editor/raw-markdown.d.ts +90 -0
  9. package/dist/components/text-editor/raw-markdown.js +471 -0
  10. package/dist/components/text-editor/shiki/facade.d.ts +4 -0
  11. package/dist/components/text-editor/shiki/facade.js +62 -0
  12. package/dist/components/text-editor/shiki/theme.d.ts +1 -0
  13. package/dist/components/text-editor/shiki/theme.js +31 -27
  14. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  15. package/dist/components/text-editor/shiki/version.js +1 -1
  16. package/dist/components/text-editor/store.svelte.js +39 -1
  17. package/dist/components/text-editor/text-editor.svelte +140 -2
  18. package/dist/components/text-editor/text-editor.svelte.d.ts +23 -5
  19. package/dist/components/text-editor/toolbar/code-language-switcher.svelte +14 -9
  20. package/dist/components/text-editor/toolbar/component-editor.svelte +96 -0
  21. package/dist/components/text-editor/toolbar/component-editor.svelte.d.ts +35 -0
  22. package/dist/components/text-editor/toolbar/format-text-button.svelte +69 -9
  23. package/dist/components/text-editor/toolbar/insert-component-dialog.svelte +92 -0
  24. package/dist/components/text-editor/toolbar/insert-component-dialog.svelte.d.ts +30 -0
  25. package/dist/components/text-editor/toolbar/insert-item-button.svelte +13 -2
  26. package/dist/components/text-editor/toolbar/insert-link-button.svelte +89 -10
  27. package/dist/components/text-editor/toolbar/insert-menu-button.svelte +21 -2
  28. package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +4 -4
  29. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +13 -0
  30. package/dist/components/text-field/text-area.svelte +119 -4
  31. package/dist/components/text-field/text-area.svelte.d.ts +17 -0
  32. package/dist/components/util/font-links.svelte +1 -1
  33. package/dist/components/util/fonts.js +1 -1
  34. package/dist/self-hosted/fonts/material-symbols-outlined-latin-wght-normal.woff2 +0 -0
  35. package/dist/services/group.svelte.d.ts +1 -1
  36. package/dist/services/group.svelte.js +14 -6
  37. package/dist/services/tree.svelte.d.ts +6 -1
  38. package/dist/services/tree.svelte.js +23 -7
  39. package/dist/typedefs.d.ts +49 -1
  40. package/dist/typedefs.js +22 -1
  41. package/package.json +2 -2
@@ -79,7 +79,11 @@
79
79
  (async () => {
80
80
  await sleep(50);
81
81
 
82
- if (cancelled) {
82
+ // Leave the focus alone if it has already moved into the content in the meantime, e.g. the
83
+ // user has clicked an input field or started typing in it, or the content has taken the
84
+ // focus on its own. Moving it now would pull it away, and selecting the input value would
85
+ // cause the next keystrokes to replace it.
86
+ if (cancelled || content?.contains(document.activeElement)) {
83
87
  return;
84
88
  }
85
89
 
@@ -95,10 +99,9 @@
95
99
  if (target instanceof HTMLInputElement) {
96
100
  target.select();
97
101
  }
98
- } else if (!focusInput || !content?.contains(document.activeElement)) {
102
+ } else {
99
103
  // Fall back to the `<dialog>` element itself, so the focus is never left outside the
100
- // modal, e.g. when the dialog has no input field or primary button. Content that has
101
- // already taken the focus is left alone.
104
+ // modal, e.g. when the dialog has no input field or primary button
102
105
  modal?.focus();
103
106
  }
104
107
  })();
@@ -56,6 +56,11 @@ export const TRANSFORMER_MAP: Record<Exclude<TextEditorNodeType, "paragraph">, T
56
56
  * used. This is to ensure that the Markdown output is more readable and consistent.
57
57
  */
58
58
  export const DISABLED_MARKDOWN_TAGS: string[];
59
+ /**
60
+ * Tag of the Lexical update that imports a value set from outside, so the editor can tell it from a
61
+ * change made by the user.
62
+ */
63
+ export const IMPORT_UPDATE_TAG: "sui-import";
59
64
  import type { EditorThemeClasses } from 'lexical';
60
65
  import type { TextEditorFormatType } from '../../typedefs';
61
66
  import type { TextEditorInlineType } from '../../typedefs';
@@ -231,3 +231,9 @@ export const TRANSFORMER_MAP = {
231
231
  * used. This is to ensure that the Markdown output is more readable and consistent.
232
232
  */
233
233
  export const DISABLED_MARKDOWN_TAGS = ['*', '__', '***', '___'];
234
+
235
+ /**
236
+ * Tag of the Lexical update that imports a value set from outside, so the editor can tell it from a
237
+ * change made by the user.
238
+ */
239
+ export const IMPORT_UPDATE_TAG = 'sui-import';
@@ -1,9 +1,13 @@
1
+ export function isStaticDecoratorContent(target: EventTarget | null): boolean;
2
+ export function $moveCaretIntoBlock(): boolean;
3
+ export function handleEditorMouseDown(editor: LexicalEditor, event: MouseEvent): boolean;
1
4
  export function isSafeLinkURL(url: string): boolean;
2
5
  export function getSelectionTypes(): TextEditorSelectionState;
6
+ export function exportMarkdown(enabledTransformers: Transformer[]): string;
3
7
  export function onEditorUpdate(editor: LexicalEditor, enabledTransformers: Transformer[], cachedValue?: string): string;
4
8
  export function initEditor({ enabledButtons, components, useMarkdownShortcuts, isCodeEditor, defaultLanguage, }: TextEditorConfig): InitEditorResult;
5
9
  export function loadCodeHighlighter(lang: string): Promise<void>;
6
- export function convertMarkdownToLexical(editor: LexicalEditor, value: string, enabledTransformers: Transformer[]): Promise<void>;
10
+ export function convertMarkdownToLexical(editor: LexicalEditor, value: string, enabledTransformers: Transformer[]): Promise<string>;
7
11
  export function focusEditor(editor: LexicalEditor): Promise<void>;
8
12
  export type InitEditorResult = {
9
13
  /**
@@ -19,7 +23,7 @@ export type InitEditorResult = {
19
23
  */
20
24
  dispose: () => void;
21
25
  };
22
- import type { TextEditorSelectionState } from '../../typedefs';
23
26
  import type { LexicalEditor } from 'lexical';
27
+ import type { TextEditorSelectionState } from '../../typedefs';
24
28
  import type { Transformer } from '@lexical/markdown';
25
29
  import type { TextEditorConfig } from '../../typedefs';
@@ -41,23 +41,29 @@ import {
41
41
  COMMAND_PRIORITY_LOW,
42
42
  COMMAND_PRIORITY_NORMAL,
43
43
  createEditor,
44
+ DELETE_CHARACTER_COMMAND,
45
+ $createParagraphNode as createParagraphNode,
44
46
  $createTextNode as createTextNode,
45
47
  ElementNode,
48
+ $getNearestNodeFromDOMNode as getNearestNodeFromDOMNode,
46
49
  $getRoot as getRoot,
47
50
  $getSelection as getSelection,
48
51
  INDENT_CONTENT_COMMAND,
49
52
  INSERT_PARAGRAPH_COMMAND,
50
53
  $insertNodes as insertNodes,
54
+ $isDecoratorNode as isDecoratorNode,
51
55
  $isElementNode as isElementNode,
52
56
  $isRangeSelection as isRangeSelection,
53
57
  $isTextNode as isTextNode,
54
58
  OUTDENT_CONTENT_COMMAND,
55
59
  PASTE_COMMAND,
60
+ RootNode,
56
61
  } from 'lexical';
57
62
  import {
58
63
  BLOCK_BUTTON_TYPES,
59
64
  DISABLED_MARKDOWN_TAGS,
60
65
  EDITOR_THEME,
66
+ IMPORT_UPDATE_TAG,
61
67
  NODE_MAP,
62
68
  TEXT_FORMAT_BUTTON_TYPES,
63
69
  TRANSFORMER_MAP,
@@ -103,6 +109,218 @@ import { TABLE } from './transformers/table.js';
103
109
  * Maximum number of entries in the undo stack.
104
110
  */
105
111
  const HISTORY_MAX_DEPTH = 200;
112
+
113
+ /**
114
+ * Selector for the elements in a decorator node, like an editor component, that handle a click on
115
+ * their own.
116
+ */
117
+ const DECORATOR_INTERACTIVE_SELECTOR = [
118
+ 'input',
119
+ 'textarea',
120
+ 'select',
121
+ 'button',
122
+ 'a[href]',
123
+ // A label moves the focus to its control on click
124
+ 'label',
125
+ 'summary',
126
+ 'video[controls]',
127
+ 'audio[controls]',
128
+ '[tabindex]',
129
+ '[draggable="true"]',
130
+ '[contenteditable]:not([contenteditable="false"])',
131
+ ].join(', ');
132
+
133
+ /**
134
+ * Whether a click on the given element would put the caret on the static content of a decorator
135
+ * node, like the label or padding of an editor component. The editor cannot map such a position to
136
+ * a node, so the caret appears there but typing does nothing.
137
+ * @param {EventTarget | null} target Event target.
138
+ * @returns {boolean} Result.
139
+ */
140
+ export const isStaticDecoratorContent = (target) => {
141
+ if (!(target instanceof Element)) {
142
+ return false;
143
+ }
144
+
145
+ const decorator = target.closest('[data-lexical-decorator]');
146
+
147
+ if (!decorator) {
148
+ return false;
149
+ }
150
+
151
+ const interactive = target.closest(DECORATOR_INTERACTIVE_SELECTOR);
152
+
153
+ // An interactive element outside the decorator is the editor root or one of its ancestors
154
+ return !interactive || !decorator.contains(interactive);
155
+ };
156
+
157
+ /**
158
+ * Move a caret placed directly on the root node into a block: the adjacent paragraph or other
159
+ * block, or a new paragraph if the adjacent nodes are decorators, where nothing can be typed.
160
+ * @returns {boolean} Whether the selection has been moved.
161
+ */
162
+ export const $moveCaretIntoBlock = () => {
163
+ const selection = getSelection();
164
+
165
+ if (
166
+ !isRangeSelection(selection) ||
167
+ !selection.isCollapsed() ||
168
+ selection.anchor.type !== 'element'
169
+ ) {
170
+ return false;
171
+ }
172
+
173
+ const root = getRoot();
174
+
175
+ if (!selection.anchor.getNode().is(root)) {
176
+ return false;
177
+ }
178
+
179
+ const { offset } = selection.anchor;
180
+ const before = root.getChildAtIndex(offset - 1);
181
+ const after = root.getChildAtIndex(offset);
182
+
183
+ if (isElementNode(after)) {
184
+ after.selectStart();
185
+ } else if (isElementNode(before)) {
186
+ before.selectEnd();
187
+ } else {
188
+ const paragraph = createParagraphNode();
189
+
190
+ if (before) {
191
+ before.insertAfter(paragraph);
192
+ } else if (after) {
193
+ after.insertBefore(paragraph);
194
+ } else {
195
+ root.append(paragraph);
196
+ }
197
+
198
+ paragraph.select();
199
+ }
200
+
201
+ return true;
202
+ };
203
+
204
+ /**
205
+ * Move the focus to the editor, and the caret to right after the given decorator node, like an
206
+ * editor component: the start of the next block, or a new paragraph if there’s none to type in.
207
+ * @param {LexicalEditor} editor Editor instance.
208
+ * @param {Element} decorator Element of the decorator node.
209
+ */
210
+ const placeCaretAfterDecorator = (editor, decorator) => {
211
+ editor.getRootElement()?.focus({ preventScroll: true });
212
+
213
+ editor.update(() => {
214
+ const node = getNearestNodeFromDOMNode(decorator);
215
+
216
+ // The element has been found by its `data-lexical-decorator` attribute
217
+ /* v8 ignore next 3 */
218
+ if (!isDecoratorNode(node)) {
219
+ return;
220
+ }
221
+
222
+ node.selectNext();
223
+ $moveCaretIntoBlock();
224
+ });
225
+ };
226
+
227
+ /**
228
+ * Move the focus to the editor, and the caret to the end of the given block.
229
+ * @param {LexicalEditor} editor Editor instance.
230
+ * @param {Element} block Element of the block node.
231
+ */
232
+ const placeCaretAtBlockEnd = (editor, block) => {
233
+ editor.getRootElement()?.focus({ preventScroll: true });
234
+
235
+ editor.update(() => {
236
+ const node = getNearestNodeFromDOMNode(block);
237
+
238
+ /* v8 ignore next 3 */
239
+ if (!isElementNode(node)) {
240
+ return;
241
+ }
242
+
243
+ node.selectEnd();
244
+ });
245
+ };
246
+
247
+ /**
248
+ * Handle a `mousedown` event on the editor, placing the caret where the user expects, rather than
249
+ * leaving it to the browser, when the pointer is not on any editable text. Browsers disagree on
250
+ * where the caret goes in these cases, and some put it within a decorator node, like an editor
251
+ * component, where the editor cannot map it to a node, so typing does nothing:
252
+ *
253
+ * - The static content of a decorator, like its label or padding, places the caret after it.
254
+ * - The empty area of the editor, outside any block, places the caret after the decorator above
255
+ * the pointer, or at the end of the block above the pointer.
256
+ * @param {LexicalEditor} editor Editor instance.
257
+ * @param {MouseEvent} event `mousedown` event.
258
+ * @returns {boolean} Whether the event has been handled.
259
+ */
260
+ export const handleEditorMouseDown = (editor, event) => {
261
+ const { target, button, shiftKey, clientY } = event;
262
+ const root = editor.getRootElement();
263
+
264
+ // Leave the primary button with Shift, which extends the selection, and other buttons alone.
265
+ // Leave a read-only editor alone as well, as a paragraph may be added, and an event already
266
+ // handled by a nested editor, like one in an editor component
267
+ if (
268
+ !root ||
269
+ !editor.isEditable() ||
270
+ event.defaultPrevented ||
271
+ button !== 0 ||
272
+ shiftKey ||
273
+ !(target instanceof Element)
274
+ ) {
275
+ return false;
276
+ }
277
+
278
+ if (isStaticDecoratorContent(target)) {
279
+ const decorator = /** @type {Element} */ (target.closest('[data-lexical-decorator]'));
280
+
281
+ // A decorator of a nested editor, like one in an editor component, is handled by that editor
282
+ if (decorator.parentElement?.closest('[data-lexical-editor]') !== root) {
283
+ return false;
284
+ }
285
+
286
+ event.preventDefault();
287
+ placeCaretAfterDecorator(editor, decorator);
288
+
289
+ return true;
290
+ }
291
+
292
+ if (target !== root) {
293
+ return false;
294
+ }
295
+
296
+ // Find the last block starting above the pointer. Lexical adds a hidden element to the root
297
+ // after a decorator, which has no size
298
+ const block = [...root.children]
299
+ .filter((element) => !element.hasAttribute('data-lexical-decorator-boundary'))
300
+ .findLast((element) => element.getBoundingClientRect().top <= clientY);
301
+
302
+ if (!block) {
303
+ return false;
304
+ }
305
+
306
+ if (block.hasAttribute('data-lexical-decorator')) {
307
+ event.preventDefault();
308
+ placeCaretAfterDecorator(editor, block);
309
+
310
+ return true;
311
+ }
312
+
313
+ // Beside a block, the browser can find the nearest text on its own
314
+ if (clientY <= block.getBoundingClientRect().bottom) {
315
+ return false;
316
+ }
317
+
318
+ event.preventDefault();
319
+ placeCaretAtBlockEnd(editor, block);
320
+
321
+ return true;
322
+ };
323
+
106
324
  /**
107
325
  * URL schemes allowed in a link, the same as the ones Lexical renders as is.
108
326
  */
@@ -209,6 +427,34 @@ export const getSelectionTypes = () => {
209
427
  };
210
428
  };
211
429
 
430
+ /**
431
+ * Convert the editor content to Markdown. Call this within an editor update or read.
432
+ * @internal
433
+ * @param {Transformer[]} enabledTransformers Enabled Markdown transformers.
434
+ * @returns {string} Markdown value.
435
+ */
436
+ export const exportMarkdown = (enabledTransformers) => {
437
+ const transformers = enabledTransformers.filter(
438
+ (/** @type {any} */ { tag }) => !DISABLED_MARKDOWN_TAGS.includes(tag),
439
+ );
440
+
441
+ return trimBlankBlockquoteLines(
442
+ convertToMarkdownString(transformers)
443
+ // Remove unnecessary backslash for underscore and backslash characters
444
+ // @see https://github.com/sveltia/sveltia-cms/issues/430
445
+ // @see https://github.com/sveltia/sveltia-cms/issues/512
446
+ .replace(/\\([_\\])/g, '$1')
447
+ // Replace encoded spaces with regular spaces. The HTML entity can appear with a
448
+ // combination of bold and italic text
449
+ // @see https://github.com/sveltia/sveltia-cms/issues/511
450
+ // @see https://github.com/sveltia/sveltia-cms/issues/534
451
+ .replace(/&#32;/g, ' ')
452
+ // Remove the line breaks left by empty paragraphs at the end, which have no content. One
453
+ // may be added just by clicking below a block decorator; see `$moveCaretIntoBlock()`
454
+ .replace(/\n+$/, ''),
455
+ );
456
+ };
457
+
212
458
  /**
213
459
  * Listen to changes made on the editor and trigger the Update event.
214
460
  * @internal
@@ -219,24 +465,7 @@ export const getSelectionTypes = () => {
219
465
  * @returns {string} Markdown value.
220
466
  */
221
467
  export const onEditorUpdate = (editor, enabledTransformers, cachedValue) => {
222
- const transformers = enabledTransformers.filter(
223
- (/** @type {any} */ { tag }) => !DISABLED_MARKDOWN_TAGS.includes(tag),
224
- );
225
-
226
- const value =
227
- cachedValue ??
228
- trimBlankBlockquoteLines(
229
- convertToMarkdownString(transformers)
230
- // Remove unnecessary backslash for underscore and backslash characters
231
- // @see https://github.com/sveltia/sveltia-cms/issues/430
232
- // @see https://github.com/sveltia/sveltia-cms/issues/512
233
- .replace(/\\([_\\])/g, '$1')
234
- // Replace encoded spaces with regular spaces. The HTML entity can appear with a
235
- // combination of bold and italic text
236
- // @see https://github.com/sveltia/sveltia-cms/issues/511
237
- // @see https://github.com/sveltia/sveltia-cms/issues/534
238
- .replace(/&#32;/g, ' '),
239
- );
468
+ const value = cachedValue ?? exportMarkdown(enabledTransformers);
240
469
 
241
470
  editor.getRootElement()?.dispatchEvent(
242
471
  new CustomEvent('Update', {
@@ -324,6 +553,61 @@ export const initEditor = ({
324
553
  ),
325
554
  );
326
555
 
556
+ if (isCodeEditor) {
557
+ // Pressing Backspace at the very beginning of a code block converts it to a paragraph by
558
+ // default (`CodeNode.collapseAtStart()`), which makes no sense when the editor only holds a
559
+ // single code block, so ignore it
560
+ addUnregister(
561
+ editor.registerCommand(
562
+ DELETE_CHARACTER_COMMAND,
563
+ (isBackward) => {
564
+ const selection = getSelection();
565
+
566
+ if (!isBackward || !isRangeSelection(selection) || !selection.isCollapsed()) {
567
+ return false;
568
+ }
569
+
570
+ const { offset } = selection.anchor;
571
+ const node = selection.anchor.getNode();
572
+ const codeNode = isCodeNode(node) ? node : getNearestNodeOfType(node, CodeNode);
573
+
574
+ return (
575
+ !!codeNode &&
576
+ offset === 0 &&
577
+ (node.is(codeNode) || !!codeNode.getFirstDescendant()?.is(node))
578
+ );
579
+ },
580
+ COMMAND_PRIORITY_LOW,
581
+ ),
582
+ );
583
+
584
+ // Make sure the editor always has a single code block. This runs within the same update that
585
+ // made the change, so the user never gets a chance to type in anything else
586
+ addUnregister(
587
+ editor.registerNodeTransform(RootNode, (root) => {
588
+ const children = root.getChildren();
589
+
590
+ if (children.length === 1 && isCodeNode(children[0])) {
591
+ return;
592
+ }
593
+
594
+ const [firstChild] = children;
595
+
596
+ if (children.length === 1 && isElementNode(firstChild)) {
597
+ const node = createCodeNode(defaultLanguage);
598
+
599
+ // Keep the content and the selection
600
+ firstChild.replace(node, true);
601
+ } else if (children.length === 0) {
602
+ const node = createCodeNode(defaultLanguage);
603
+
604
+ root.append(node);
605
+ node.selectStart();
606
+ }
607
+ }),
608
+ );
609
+ }
610
+
327
611
  if (useMarkdownShortcuts) {
328
612
  addUnregister(registerMarkdownShortcuts(editor, enabledTransformers));
329
613
  }
@@ -477,25 +761,6 @@ export const initEditor = ({
477
761
  }
478
762
 
479
763
  editor.update(() => {
480
- // Prevent CodeNode from being removed
481
- /* v8 ignore next */
482
- if (isCodeEditor) {
483
- const root = getRoot();
484
- const children = root.getChildren();
485
-
486
- // c8 ignore next 3
487
- if (children.length === 1 && !isCodeNode(children[0])) {
488
- children[0].remove();
489
- }
490
-
491
- if (children.length === 0) {
492
- const node = createCodeNode();
493
-
494
- node.setLanguage(defaultLanguage);
495
- root.append(node);
496
- }
497
- }
498
-
499
764
  lastValue = onEditorUpdate(
500
765
  editor,
501
766
  enabledTransformers,
@@ -589,7 +854,8 @@ export const loadCodeHighlighter = async (lang) => {
589
854
  * @param {LexicalEditor} editor Editor instance.
590
855
  * @param {string} value Current Markdown value.
591
856
  * @param {Transformer[]} enabledTransformers List of enabled Markdown transformers.
592
- * @returns {Promise<void>} Nothing.
857
+ * @returns {Promise<string>} The value as the editor exports it once imported, which can differ in
858
+ * style from the given value, e.g. `_text_` for `*text*`.
593
859
  * @throws {Error} Failed to convert the value to Lexical nodes.
594
860
  */
595
861
  export const convertMarkdownToLexical = async (editor, value, enabledTransformers) => {
@@ -611,14 +877,18 @@ export const convertMarkdownToLexical = async (editor, value, enabledTransformer
611
877
  value = padBlankBlockquoteLines(value);
612
878
 
613
879
  return new Promise((resolve, reject) => {
614
- editor.update(() => {
615
- try {
616
- convertFromMarkdownString(value, enabledTransformers);
617
- resolve(undefined);
618
- } catch (ex) {
619
- reject(new Error('Failed to convert Markdown', { cause: ex }));
620
- }
621
- });
880
+ editor.update(
881
+ () => {
882
+ try {
883
+ convertFromMarkdownString(value, enabledTransformers);
884
+ resolve(exportMarkdown(enabledTransformers));
885
+ } catch (ex) {
886
+ reject(new Error('Failed to convert Markdown', { cause: ex }));
887
+ }
888
+ },
889
+ // Tell the import from a change made by the user
890
+ { tag: IMPORT_UPDATE_TAG },
891
+ );
622
892
  });
623
893
  };
624
894
 
@@ -1,6 +1,8 @@
1
1
  <script>
2
+ import { $getRoot as getRoot, HISTORY_MERGE_TAG } from 'lexical';
2
3
  import { getContext, onMount } from 'svelte';
3
- import { initEditor } from './core.js';
4
+ import { IMPORT_UPDATE_TAG } from './constants.js';
5
+ import { handleEditorMouseDown, initEditor } from './core.js';
4
6
 
5
7
  /**
6
8
  * @import { Snippet } from 'svelte';
@@ -10,6 +12,8 @@
10
12
  /**
11
13
  * @typedef {object} Props
12
14
  * @property {string} [class] The `class` attribute on the wrapper element.
15
+ * @property {'ltr' | 'rtl' | 'auto'} [dir] Direction of the text. With `auto` or no value, each
16
+ * paragraph takes the direction of its own text.
13
17
  * @property {boolean} [hidden] Whether to hide the widget.
14
18
  * @property {boolean} [disabled] Whether to disable the widget. An alias of the `aria-disabled`
15
19
  * attribute.
@@ -28,6 +32,7 @@
28
32
  let {
29
33
  /* eslint-disable prefer-const */
30
34
  class: className,
35
+ dir = undefined,
31
36
  hidden = false,
32
37
  disabled = false,
33
38
  readonly = false,
@@ -53,6 +58,26 @@
53
58
  editorStore.editor?.setEditable(editable);
54
59
  });
55
60
 
61
+ $effect(() => {
62
+ // Lexical owns the root element’s `dir` attribute, so set the direction on the root node, which
63
+ // the paragraphs then follow. Without one, each paragraph gets `dir="auto"`
64
+ const { editor } = editorStore;
65
+ const direction = dir === 'ltr' || dir === 'rtl' ? dir : null;
66
+
67
+ // Check first, as even an update that changes nothing makes the editor convert its content
68
+ if (!editor || editor.getEditorState().read(() => getRoot().getDirection()) === direction) {
69
+ return;
70
+ }
71
+
72
+ editor.update(
73
+ () => {
74
+ getRoot().setDirection(direction);
75
+ },
76
+ // Keep the change out of the undo history
77
+ { tag: 'history-merge' },
78
+ );
79
+ });
80
+
56
81
  /**
57
82
  * Update {@link value} and other state variables whenever the editor content is updated.
58
83
  * @param {Event} event `Update` custom event.
@@ -60,13 +85,19 @@
60
85
  const onUpdate = (event) => {
61
86
  const { hasConverterError, useRichText, inputValue } = editorStore;
62
87
 
88
+ // The content has been converted, if it has changed at all
89
+ editorStore.pending = false;
90
+
63
91
  if (hasConverterError || !useRichText) {
64
92
  return;
65
93
  }
66
94
 
67
95
  const { value: newValue, selection } = /** @type {CustomEvent} */ (event).detail;
68
96
 
69
- if (inputValue !== newValue) {
97
+ // Keep the value as given when the editor has only rewritten it in its own Markdown style, e.g.
98
+ // `_text_` for `*text*`: that’s not a change, and the value would otherwise change as soon as
99
+ // it’s set
100
+ if (inputValue !== newValue && !editorStore.isRestyledImport(newValue)) {
70
101
  // Temporarily disable rich text to prevent unnecessary Markdown conversion that resets
71
102
  // Lexical nodes and selection, then restore the state
72
103
  editorStore.useRichText = false;
@@ -88,6 +119,17 @@
88
119
  }
89
120
  };
90
121
 
122
+ /**
123
+ * Listen to `mousedown` events on the editor to place the caret where the user expects when the
124
+ * pointer is not on any editable text, like on an editor component.
125
+ * @param {MouseEvent} event `mousedown` event.
126
+ */
127
+ const onMouseDown = (event) => {
128
+ if (editorStore.editor) {
129
+ handleEditorMouseDown(editorStore.editor, event);
130
+ }
131
+ };
132
+
91
133
  onMount(() => {
92
134
  const { editor, enabledTransformers, dispose } = initEditor(editorStore.config);
93
135
 
@@ -96,10 +138,28 @@
96
138
 
97
139
  lexicalRoot?.addEventListener('Update', onUpdate);
98
140
  lexicalRoot?.addEventListener('click', onClick);
141
+ lexicalRoot?.addEventListener('mousedown', onMouseDown);
142
+
143
+ // A change made by the user is converted to Markdown a moment later, so report it as pending
144
+ // until then. An import, or an update that only changes how the content looks, e.g. the code
145
+ // highlighting, isn’t a change
146
+ const unregisterUpdateListener = editor.registerUpdateListener(
147
+ ({ dirtyElements, dirtyLeaves, tags }) => {
148
+ if (
149
+ (dirtyElements.size || dirtyLeaves.size) &&
150
+ !tags.has(IMPORT_UPDATE_TAG) &&
151
+ !tags.has(HISTORY_MERGE_TAG)
152
+ ) {
153
+ editorStore.pending = true;
154
+ }
155
+ },
156
+ );
99
157
 
100
158
  return () => {
159
+ unregisterUpdateListener();
101
160
  lexicalRoot?.removeEventListener('Update', onUpdate);
102
161
  lexicalRoot?.removeEventListener('click', onClick);
162
+ lexicalRoot?.removeEventListener('mousedown', onMouseDown);
103
163
  dispose();
104
164
  editor.setRootElement(null);
105
165
  editorStore.initialized = false;
@@ -8,6 +8,11 @@ declare const LexicalRoot: import("svelte").Component<{
8
8
  * The `class` attribute on the wrapper element.
9
9
  */
10
10
  class?: string | undefined;
11
+ /**
12
+ * Direction of the text. With `auto` or no value, each
13
+ * paragraph takes the direction of its own text.
14
+ */
15
+ dir?: "ltr" | "rtl" | "auto" | undefined;
11
16
  /**
12
17
  * Whether to hide the widget.
13
18
  */
@@ -42,6 +47,11 @@ type Props = {
42
47
  * The `class` attribute on the wrapper element.
43
48
  */
44
49
  class?: string | undefined;
50
+ /**
51
+ * Direction of the text. With `auto` or no value, each
52
+ * paragraph takes the direction of its own text.
53
+ */
54
+ dir?: "ltr" | "rtl" | "auto" | undefined;
45
55
  /**
46
56
  * Whether to hide the widget.
47
57
  */