@sveltia/ui 0.79.2 → 0.79.4

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.
@@ -0,0 +1,3 @@
1
+ export function getBackgroundUpdateTags(editor: LexicalEditor, tags?: UpdateTag[]): UpdateTag[];
2
+ import type { LexicalEditor } from 'lexical';
3
+ import type { UpdateTag } from 'lexical';
@@ -0,0 +1,18 @@
1
+ import { SKIP_DOM_SELECTION_TAG } from 'lexical';
2
+
3
+ /**
4
+ * @import { LexicalEditor, UpdateTag } from 'lexical';
5
+ */
6
+
7
+ /**
8
+ * Get the tags of an editor update the user didn’t make, such as one that highlights the code once
9
+ * a grammar has loaded. While the focus is elsewhere, the update leaves the DOM selection alone,
10
+ * which would otherwise move the focus back to the editor, taking over what the user types next.
11
+ * @param {LexicalEditor} editor Editor instance.
12
+ * @param {UpdateTag[]} [tags] Other tags of the update.
13
+ * @returns {UpdateTag[]} Tags.
14
+ */
15
+ export const getBackgroundUpdateTags = (editor, tags = []) =>
16
+ editor.getRootElement()?.contains(document.activeElement)
17
+ ? tags
18
+ : [...tags, SKIP_DOM_SELECTION_TAG];
@@ -20,6 +20,9 @@
20
20
  * @typedef {object} Props
21
21
  * @property {string} [code] Input value.
22
22
  * @property {string} [lang] Selected language.
23
+ * @property {boolean} [pending] Whether the user has changed the code, and the editor has yet to
24
+ * update {@link code}, which it does a moment later. Bind it to wait for the change before
25
+ * reading the code, for example to save it. Read-only.
23
26
  * @property {boolean} [showLanguageSwitcher] Whether to show the language selector.
24
27
  * @property {boolean} [flex] Make the text input container flexible.
25
28
  * @property {string} [class] The `class` attribute on the wrapper element.
@@ -47,6 +50,7 @@
47
50
  /* eslint-disable prefer-const */
48
51
  code = $bindable(''),
49
52
  lang = $bindable('plain'),
53
+ pending = $bindable(false),
50
54
  showLanguageSwitcher = false,
51
55
  flex = false,
52
56
  hidden = false,
@@ -86,6 +90,16 @@
86
90
 
87
91
  setContext('editorStore', editorStore);
88
92
 
93
+ $effect(() => {
94
+ pending = editorStore.pending;
95
+ });
96
+
97
+ // An editor removed right after a change never converts it, so don’t leave a bound `pending` set
98
+ // for good, which would hold up anything waiting for the change
99
+ $effect(() => () => {
100
+ pending = false;
101
+ });
102
+
89
103
  $effect(() => {
90
104
  // The root initializes the editor before these effects first run, and stays initialized
91
105
  /* v8 ignore next */
@@ -13,6 +13,12 @@ declare const CodeEditor: import("svelte").Component<{
13
13
  * Selected language.
14
14
  */
15
15
  lang?: string | undefined;
16
+ /**
17
+ * Whether the user has changed the code, and the editor has yet to
18
+ * update {@link code}, which it does a moment later. Bind it to wait for the change before
19
+ * reading the code, for example to save it. Read-only.
20
+ */
21
+ pending?: boolean | undefined;
16
22
  /**
17
23
  * Whether to show the language selector.
18
24
  */
@@ -67,7 +73,7 @@ declare const CodeEditor: import("svelte").Component<{
67
73
  * Primary slot content.
68
74
  */
69
75
  children?: Snippet<[]> | undefined;
70
- } & Record<string, any>, {}, "code" | "lang">;
76
+ } & Record<string, any>, {}, "code" | "lang" | "pending">;
71
77
  type Props = {
72
78
  /**
73
79
  * Input value.
@@ -77,6 +83,12 @@ type Props = {
77
83
  * Selected language.
78
84
  */
79
85
  lang?: string | undefined;
86
+ /**
87
+ * Whether the user has changed the code, and the editor has yet to
88
+ * update {@link code}, which it does a moment later. Bind it to wait for the change before
89
+ * reading the code, for example to save it. Read-only.
90
+ */
91
+ pending?: boolean | undefined;
80
92
  /**
81
93
  * Whether to show the language selector.
82
94
  */
@@ -50,6 +50,7 @@ import {
50
50
  $getNearestNodeFromDOMNode as getNearestNodeFromDOMNode,
51
51
  $getRoot as getRoot,
52
52
  $getSelection as getSelection,
53
+ HISTORY_MERGE_TAG,
53
54
  INDENT_CONTENT_COMMAND,
54
55
  INSERT_PARAGRAPH_COMMAND,
55
56
  KEY_ENTER_COMMAND,
@@ -1033,6 +1034,10 @@ export const convertMarkdownToLexical = async (editor, value, enabledTransformer
1033
1034
  // Pad blank blockquote lines so they are not imported as literal `>` text
1034
1035
  value = padBlankBlockquoteLines(value);
1035
1036
 
1037
+ // Lexical commits an empty state when the root element is attached, and the history keeps it as
1038
+ // the current entry. An empty state can’t be restored, so undoing to it throws an error. Merge
1039
+ // the first import into that entry instead of pushing it onto the undo stack
1040
+ const isEmpty = editor.getEditorState().isEmpty();
1036
1041
  /** @type {unknown} */
1037
1042
  let error;
1038
1043
 
@@ -1047,7 +1052,7 @@ export const convertMarkdownToLexical = async (editor, value, enabledTransformer
1047
1052
  // Tell the import from a change made by the user, and commit it right away, so the node
1048
1053
  // transforms, e.g. the one giving a code block its default language, have run by the time the
1049
1054
  // content is exported below
1050
- { tag: IMPORT_UPDATE_TAG, discrete: true },
1055
+ { tag: isEmpty ? [IMPORT_UPDATE_TAG, HISTORY_MERGE_TAG] : IMPORT_UPDATE_TAG, discrete: true },
1051
1056
  );
1052
1057
 
1053
1058
  if (error) {
@@ -21,6 +21,7 @@ import {
21
21
  HISTORY_MERGE_TAG,
22
22
  tokenizeRawText,
23
23
  } from 'lexical';
24
+ import { getBackgroundUpdateTags } from '../background-update.js';
24
25
  import { cachePayload, getCachedPayload } from './cache.js';
25
26
  import { LANGUAGES, THEMES } from './generated.js';
26
27
  import { getCodeHighlighterLoaders } from './loader.js';
@@ -123,7 +124,7 @@ const refreshCodeNode = (editor, codeNodeKey) => {
123
124
 
124
125
  codeNode.markDirty();
125
126
  },
126
- { tag: HISTORY_MERGE_TAG },
127
+ { tag: getBackgroundUpdateTags(editor, [HISTORY_MERGE_TAG]) },
127
128
  );
128
129
  };
129
130
 
@@ -33,6 +33,7 @@ import {
33
33
  HISTORY_MERGE_TAG,
34
34
  TextNode,
35
35
  } from 'lexical';
36
+ import { getBackgroundUpdateTags } from '../background-update.js';
36
37
  import {
37
38
  getHighlightNodes,
38
39
  isCodeLanguageLoaded,
@@ -368,7 +369,7 @@ const codeNodeTransform = (editor, tokenizer, transformState, node) => {
368
369
  staleNode.markDirty();
369
370
  }
370
371
  },
371
- { tag: HISTORY_MERGE_TAG },
372
+ { tag: getBackgroundUpdateTags(editor, [HISTORY_MERGE_TAG]) },
372
373
  );
373
374
  });
374
375
  }
@@ -1,5 +1,6 @@
1
1
  import { $isCodeNode as isCodeNode } from '@lexical/code-core';
2
2
  import { $getRoot as getRoot, HISTORY_MERGE_TAG } from 'lexical';
3
+ import { getBackgroundUpdateTags } from '../background-update.js';
3
4
 
4
5
  /**
5
6
  * @import { LexicalEditor } from 'lexical';
@@ -85,6 +86,6 @@ export const observeCodeTheme = (editor) =>
85
86
  }
86
87
  });
87
88
  },
88
- { tag: HISTORY_MERGE_TAG },
89
+ { tag: getBackgroundUpdateTags(editor, [HISTORY_MERGE_TAG]) },
89
90
  );
90
91
  });
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
3
3
  */
4
- export const UI_VERSION: "0.79.2";
4
+ export const UI_VERSION: "0.79.4";
@@ -3,4 +3,4 @@
3
3
  /**
4
4
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
5
5
  */
6
- export const UI_VERSION = '0.79.2';
6
+ export const UI_VERSION = '0.79.4';
@@ -45,6 +45,11 @@ export const createEditorStore = () => {
45
45
  let showConverterError = $state(false);
46
46
  /** @type {boolean} */
47
47
  let pending = $state(false);
48
+ /**
49
+ * Number of editor operations started by the user that have yet to update the editor.
50
+ * @type {number}
51
+ */
52
+ let runningOperations = $state(0);
48
53
  /**
49
54
  * Value last imported to the Lexical editor, and the Markdown the editor exports for it.
50
55
  * @type {{ source: string, exported: string } | undefined}
@@ -195,7 +200,7 @@ export const createEditorStore = () => {
195
200
  showConverterError = newValue;
196
201
  },
197
202
  get pending() {
198
- return pending;
203
+ return pending || runningOperations > 0;
199
204
  },
200
205
  set pending(newValue) {
201
206
  pending = newValue;
@@ -205,6 +210,27 @@ export const createEditorStore = () => {
205
210
  },
206
211
  editorId,
207
212
  convertMarkdown,
213
+ /**
214
+ * Mark an operation the user started as running, which makes the content count as
215
+ * {@link TextEditorStore.pending} until it’s done. Use it for an operation that only reaches
216
+ * the editor after an `await`, such as a language change waiting for the highlighter to load:
217
+ * the flag the editor sets for itself is cleared by every update it makes in the meantime,
218
+ * including the one the focus move fires, so it can’t cover the wait.
219
+ * @returns {() => void} Function to call once the operation has updated the editor. Calling it
220
+ * more than once has no further effect.
221
+ */
222
+ startOperation: () => {
223
+ runningOperations += 1;
224
+
225
+ let done = false;
226
+
227
+ return () => {
228
+ if (!done) {
229
+ done = true;
230
+ runningOperations -= 1;
231
+ }
232
+ };
233
+ },
208
234
  /**
209
235
  * Get the value last imported if the given value, exported by the editor, is only that value
210
236
  * written in the editor’s own Markdown style.
@@ -5,6 +5,7 @@
5
5
  import { getContext } from 'svelte';
6
6
  import Option from '../../listbox/option.svelte';
7
7
  import Select from '../../select/select.svelte';
8
+ import { getBackgroundUpdateTags } from '../background-update.js';
8
9
  import { focusEditor, loadCodeHighlighter } from '../core.js';
9
10
  import { LANGUAGES } from '../shiki/generated.js';
10
11
 
@@ -65,6 +66,46 @@
65
66
  return isCodeNode(node) ? node : null;
66
67
  };
67
68
 
69
+ /**
70
+ * Give the code block the given language, once the highlighter for it has loaded.
71
+ * @param {string} lang Language ID.
72
+ */
73
+ const changeLanguage = async (lang) => {
74
+ const { editor } = editorStore;
75
+
76
+ if (!editor || selectedKey === lang) {
77
+ return;
78
+ }
79
+
80
+ // The change only reaches the editor, and anything bound to it, once the highlighter has
81
+ // loaded, so report the content as pending until then: the flag the editor sets for itself is
82
+ // cleared by the update the focus move below fires
83
+ const endOperation = editorStore.startOperation();
84
+
85
+ try {
86
+ await focusEditor(editor);
87
+ await loadCodeHighlighter(lang);
88
+
89
+ // The user may have moved on to another field while the highlighter was loading
90
+ editor.update(
91
+ () => {
92
+ // https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx#L713
93
+ const node = getCodeNode();
94
+
95
+ if (node) {
96
+ node.setLanguage(lang);
97
+ selectedLanguage = lang;
98
+ }
99
+ },
100
+ { tag: getBackgroundUpdateTags(editor) },
101
+ );
102
+ } finally {
103
+ // The editor has flagged the export that follows as pending by now, so the hand-off leaves
104
+ // no gap
105
+ endOperation();
106
+ }
107
+ };
108
+
68
109
  $effect(() => {
69
110
  void editorStore.selection.blockNodeKey;
70
111
 
@@ -85,23 +126,8 @@
85
126
  {disabled}
86
127
  ariaLabel={_('_sui.text_editor.language')}
87
128
  value={selectedKey}
88
- onChange={async ({ detail: { value: lang } }) => {
89
- if (!editorStore.editor || selectedKey === lang) {
90
- return;
91
- }
92
-
93
- await focusEditor(editorStore.editor);
94
- await loadCodeHighlighter(lang);
95
-
96
- editorStore.editor.update(() => {
97
- // https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx#L713
98
- const node = getCodeNode();
99
-
100
- if (node) {
101
- node.setLanguage(lang);
102
- selectedLanguage = lang;
103
- }
104
- });
129
+ onChange={({ detail: { value: lang } }) => {
130
+ changeLanguage(lang);
105
131
  }}
106
132
  >
107
133
  <Option label={_('_sui.text_editor.plain_text')} value="plain" dir="ltr" />
@@ -1045,7 +1045,8 @@ export type TextEditorStore = {
1045
1045
  showConverterError: boolean;
1046
1046
  /**
1047
1047
  * Whether the user has changed the rich text content, and the editor
1048
- * has yet to convert it to Markdown and update {@link TextEditorStore.inputValue}.
1048
+ * has yet to convert it to Markdown and update {@link TextEditorStore.inputValue}. It’s also set
1049
+ * while an operation started with {@link TextEditorStore.startOperation} is running.
1049
1050
  */
1050
1051
  pending: boolean;
1051
1052
  /**
@@ -1057,6 +1058,12 @@ export type TextEditorStore = {
1057
1058
  * Function to trigger the Lexical converter.
1058
1059
  */
1059
1060
  convertMarkdown: () => Promise<void>;
1061
+ /**
1062
+ * Function marking an operation the user started, which
1063
+ * only updates the editor after an `await`, as running. It returns a function to call once the
1064
+ * operation is done. The content counts as {@link TextEditorStore.pending} meanwhile.
1065
+ */
1066
+ startOperation: () => () => void;
1060
1067
  /**
1061
1068
  * Function to get the
1062
1069
  * {@link TextEditorStore.inputValue} last imported if the given value, exported by the editor, is
package/dist/typedefs.js CHANGED
@@ -447,10 +447,14 @@
447
447
  * Lexical nodes.
448
448
  * @property {boolean} showConverterError Whether to show a converter error in the UI.
449
449
  * @property {boolean} pending Whether the user has changed the rich text content, and the editor
450
- * has yet to convert it to Markdown and update {@link TextEditorStore.inputValue}.
450
+ * has yet to convert it to Markdown and update {@link TextEditorStore.inputValue}. It’s also set
451
+ * while an operation started with {@link TextEditorStore.startOperation} is running.
451
452
  * @property {boolean} importing Whether the latest {@link TextEditorStore.inputValue} is still
452
453
  * being imported to the Lexical editor.
453
454
  * @property {() => Promise<void>} convertMarkdown Function to trigger the Lexical converter.
455
+ * @property {() => () => void} startOperation Function marking an operation the user started, which
456
+ * only updates the editor after an `await`, as running. It returns a function to call once the
457
+ * operation is done. The content counts as {@link TextEditorStore.pending} meanwhile.
454
458
  * @property {(value: string) => string | undefined} getImportedValue Function to get the
455
459
  * {@link TextEditorStore.inputValue} last imported if the given value, exported by the editor, is
456
460
  * only that value written in the editor’s own Markdown style, e.g. `_text_` for `*text*`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltia/ui",
3
- "version": "0.79.2",
3
+ "version": "0.79.4",
4
4
  "description": "A collection of Svelte components and utilities for building user interfaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -85,9 +85,9 @@
85
85
  "eslint-plugin-svelte": "^3.23.0",
86
86
  "globals": "^17.13.0",
87
87
  "happy-dom": "^20.14.5",
88
- "oxlint": "^1.86.0",
88
+ "oxlint": "^1.87.0",
89
89
  "playwright": "^1.63.0",
90
- "postcss": "^8.5.28",
90
+ "postcss": "^8.5.29",
91
91
  "postcss-html": "^2.0.0",
92
92
  "prettier": "^3.9.9",
93
93
  "prettier-plugin-svelte": "^4.1.1",