@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
@@ -34,23 +34,44 @@ export const getCodeTheme = () => {
34
34
  };
35
35
 
36
36
  /**
37
- * Keep code blocks in sync with the app’s appearance.
38
- *
39
- * Unlike Prism, Shiki bakes token colours into inline styles, so a theme change cannot be handled
40
- * in CSS alone: every code node has to be re-tokenized.
41
- * @param {LexicalEditor} editor Editor instance.
37
+ * Call the given function whenever the app’s appearance, and therefore the matching syntax
38
+ * highlighting theme, may have changed.
39
+ * @param {() => void} callback Function to call.
42
40
  * @returns {() => void} Cleanup function.
43
41
  */
44
- export const observeCodeTheme = (editor) => {
42
+ export const onCodeThemeChange = (callback) => {
45
43
  /* v8 ignore next 3 */
46
44
  if (typeof document === 'undefined') {
47
45
  return () => undefined;
48
46
  }
49
47
 
50
- /**
51
- * Apply the current theme to every code block in the editor.
52
- */
53
- const update = () => {
48
+ const observer = new MutationObserver(callback);
49
+
50
+ observer.observe(document.documentElement, {
51
+ attributes: true,
52
+ attributeFilter: ['data-theme'],
53
+ });
54
+
55
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
56
+
57
+ media.addEventListener('change', callback);
58
+
59
+ return () => {
60
+ observer.disconnect();
61
+ media.removeEventListener('change', callback);
62
+ };
63
+ };
64
+
65
+ /**
66
+ * Keep code blocks in sync with the app’s appearance.
67
+ *
68
+ * Unlike Prism, Shiki bakes token colours into inline styles, so a theme change cannot be handled
69
+ * in CSS alone: every code node has to be re-tokenized.
70
+ * @param {LexicalEditor} editor Editor instance.
71
+ * @returns {() => void} Cleanup function.
72
+ */
73
+ export const observeCodeTheme = (editor) =>
74
+ onCodeThemeChange(() => {
54
75
  const theme = getCodeTheme();
55
76
 
56
77
  editor.update(
@@ -66,21 +87,4 @@ export const observeCodeTheme = (editor) => {
66
87
  },
67
88
  { tag: HISTORY_MERGE_TAG },
68
89
  );
69
- };
70
-
71
- const observer = new MutationObserver(update);
72
-
73
- observer.observe(document.documentElement, {
74
- attributes: true,
75
- attributeFilter: ['data-theme'],
76
90
  });
77
-
78
- const media = window.matchMedia('(prefers-color-scheme: dark)');
79
-
80
- media.addEventListener('change', update);
81
-
82
- return () => {
83
- observer.disconnect();
84
- media.removeEventListener('change', update);
85
- };
86
- };
@@ -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.77.2";
4
+ export const UI_VERSION: "0.78.0";
@@ -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.77.2';
6
+ export const UI_VERSION = '0.78.0';
@@ -37,10 +37,19 @@ export const createEditorStore = () => {
37
37
  let selection = $state({ blockNodeKey: null, blockType: 'paragraph', inlineTypes: [] });
38
38
  /** @type {boolean} */
39
39
  let useRichText = $state(true);
40
+ /** @type {HTMLTextAreaElement | undefined} */
41
+ let textArea = $state();
40
42
  /** @type {boolean} */
41
43
  let hasConverterError = $state(false);
42
44
  /** @type {boolean} */
43
45
  let showConverterError = $state(false);
46
+ /** @type {boolean} */
47
+ let pending = $state(false);
48
+ /**
49
+ * Value last imported to the Lexical editor, and the Markdown the editor exports for it.
50
+ * @type {{ source: string, exported: string } | undefined}
51
+ */
52
+ let lastImport = undefined;
44
53
 
45
54
  /**
46
55
  * Flag a conversion error, which takes the editor out of the rich text mode, and shows the error
@@ -71,7 +80,13 @@ export const createEditorStore = () => {
71
80
  // We should avoid an empty editor; there should be at least one `<p>`, so give it an empty
72
81
  // string if the `value` is `undefined`
73
82
  // @see https://github.com/facebook/lexical/issues/2308
74
- await convertMarkdownToLexical(editor, inputValue || '', enabledTransformers);
83
+ const exported = await convertMarkdownToLexical(
84
+ editor,
85
+ inputValue || '',
86
+ enabledTransformers,
87
+ );
88
+
89
+ lastImport = { source: originalValue, exported };
75
90
  } catch (ex) {
76
91
  // Go through the setter, so the editor actually falls back to the plain text mode
77
92
  setConverterError(true);
@@ -128,6 +143,15 @@ export const createEditorStore = () => {
128
143
  set selection(newValue) {
129
144
  selection = newValue;
130
145
  },
146
+ get controlId() {
147
+ return useRichText ? `${editorId}-lexical-root` : `${editorId}-plain-text`;
148
+ },
149
+ get textArea() {
150
+ return textArea;
151
+ },
152
+ set textArea(newValue) {
153
+ textArea = newValue;
154
+ },
131
155
  get useRichText() {
132
156
  return useRichText;
133
157
  },
@@ -146,8 +170,22 @@ export const createEditorStore = () => {
146
170
  set showConverterError(newValue) {
147
171
  showConverterError = newValue;
148
172
  },
173
+ get pending() {
174
+ return pending;
175
+ },
176
+ set pending(newValue) {
177
+ pending = newValue;
178
+ },
149
179
  editorId,
150
180
  convertMarkdown,
181
+ /**
182
+ * Check if the given value, exported by the editor, is only the value last imported, written in
183
+ * the editor’s own Markdown style.
184
+ * @param {string} value Exported value.
185
+ * @returns {boolean} Result.
186
+ */
187
+ isRestyledImport: (value) =>
188
+ !!lastImport && lastImport.source === inputValue && lastImport.exported === value,
151
189
  /* eslint-enable jsdoc/require-jsdoc */
152
190
  };
153
191
  };
@@ -9,21 +9,32 @@
9
9
  import TextArea from '../text-field/text-area.svelte';
10
10
  import Toast from '../toast/toast.svelte';
11
11
  import { BLOCK_BUTTON_TYPES, INLINE_BUTTON_TYPES } from './constants.js';
12
+ import { loadCodeHighlighter } from './core.js';
12
13
  import EmojiAutocomplete from './emoji-autocomplete.svelte';
13
14
  import LexicalRoot from './lexical-root.svelte';
15
+ import { getBlockType, getRawTextState } from './raw-markdown.js';
16
+ import { highlightCodeToTokens } from './shiki/facade.js';
17
+ import { getCodeTheme, onCodeThemeChange } from './shiki/theme.js';
14
18
  import { createEditorStore } from './store.svelte.js';
15
19
  import TextEditorToolbar from './toolbar/text-editor-toolbar.svelte';
16
20
 
17
21
  /**
18
22
  * @import { Snippet } from 'svelte';
19
23
  * @import { TextEditorComponent, TextEditorMode, TextEditorNodeType } from '../../typedefs';
24
+ * @import { HighlightedToken } from '../../typedefs';
20
25
  */
21
26
 
22
27
  /**
23
28
  * @typedef {object} Props
24
- * @property {string} [value] Input value.
29
+ * @property {string} [value] Input value. A value set from outside is kept as is until the user
30
+ * changes the content, although the rich text editor writes Markdown in its own style, such as
31
+ * `_text_` for `*text*`.
32
+ * @property {boolean} [pending] Whether the user has changed the rich text content, and the
33
+ * editor has yet to update {@link value}, which it does a moment later. Bind it to wait for the
34
+ * change before reading the value, for example to save it. Read-only.
25
35
  * @property {boolean} [flex] Make the text input container flexible.
26
- * @property {'ltr' | 'rtl' | 'auto'} [dir] The `dir` attribute on the `<textarea>` element.
36
+ * @property {'ltr' | 'rtl' | 'auto'} [dir] The `dir` attribute on the editable text box, both
37
+ * the rich text editor and the `<textarea>` element of the plain text mode.
27
38
  * @property {TextEditorMode[]} [modes] Enabled modes.
28
39
  * @property {TextEditorNodeType[]} [buttons] Enabled buttons.
29
40
  * @property {TextEditorComponent[]} [components] Editor components.
@@ -55,6 +66,7 @@
55
66
  let {
56
67
  /* eslint-disable prefer-const */
57
68
  value = $bindable(''),
69
+ pending = $bindable(false),
58
70
  flex = false,
59
71
  dir = undefined,
60
72
  modes = ['rich-text', 'plain-text'],
@@ -102,6 +114,128 @@
102
114
 
103
115
  setContext('editorStore', editorStore);
104
116
 
117
+ /**
118
+ * Maximum length of the Markdown source to highlight in the plain text mode. The whole source is
119
+ * tokenized on every keystroke, so highlighting a longer one would make typing sluggish.
120
+ */
121
+ const MAX_HIGHLIGHT_LENGTH = 20_000;
122
+
123
+ /** Syntax highlighting theme matching the app’s appearance. */
124
+ let codeTheme = $state(getCodeTheme());
125
+ /** Incremented whenever the highlighter has loaded something, to highlight the text again. */
126
+ let highlighterLoadCount = $state(0);
127
+
128
+ const usePlainText = $derived(!editorStore.useRichText && !hidden);
129
+ /**
130
+ * Comma-separated languages used in the fenced code blocks, which are highlighted as well. It’s a
131
+ * string rather than an array, so the highlighter isn’t reloaded on every keystroke.
132
+ */
133
+ const codeLanguages = $derived.by(() => {
134
+ if (!usePlainText || editorStore.inputValue.length > MAX_HIGHLIGHT_LENGTH) {
135
+ return '';
136
+ }
137
+
138
+ return [
139
+ ...new Set(
140
+ [...editorStore.inputValue.matchAll(/^[ \t]*(?:```|~~~)[ \t]*(?<lang>[\w+#-]+)/gm)].map(
141
+ ({ groups }) => groups?.lang,
142
+ ),
143
+ ),
144
+ ].join(',');
145
+ });
146
+
147
+ /**
148
+ * Highlight the Markdown source in the plain text mode.
149
+ * @param {string} source Markdown source.
150
+ * @returns {HighlightedToken[][] | undefined} Tokens, or `undefined` while the highlighter is
151
+ * still loading.
152
+ */
153
+ const highlightMarkdown = (source) => {
154
+ void highlighterLoadCount;
155
+
156
+ // Don’t waste time on tokenizing the source while the rich text mode is shown
157
+ if (!usePlainText || source.length > MAX_HIGHLIGHT_LENGTH) {
158
+ return undefined;
159
+ }
160
+
161
+ return highlightCodeToTokens(source, 'markdown', { theme: codeTheme });
162
+ };
163
+
164
+ $effect(() =>
165
+ onCodeThemeChange(async () => {
166
+ const theme = getCodeTheme();
167
+
168
+ // Keep the current highlighting until the new theme is ready, so the text doesn’t flash
169
+ if (usePlainText) {
170
+ await loadCodeHighlighter('markdown');
171
+ }
172
+
173
+ // Ignore an outdated change, in case the appearance has changed again while loading
174
+ if (theme === getCodeTheme()) {
175
+ codeTheme = theme;
176
+ }
177
+ }),
178
+ );
179
+
180
+ $effect(() => {
181
+ // Load the highlighter only when the plain text mode is actually shown
182
+ if (!usePlainText) {
183
+ return;
184
+ }
185
+
186
+ const languages = codeLanguages ? codeLanguages.split(',') : [];
187
+
188
+ void codeTheme;
189
+
190
+ untrack(() => {
191
+ // The Markdown grammar is loaded first, because the engine has to be in place before
192
+ // anything else can be loaded
193
+ loadCodeHighlighter('markdown').then(async () => {
194
+ highlighterLoadCount += 1;
195
+ await Promise.all(languages.map((lang) => loadCodeHighlighter(lang)));
196
+ highlighterLoadCount += 1;
197
+ });
198
+ });
199
+ });
200
+
201
+ $effect(() => {
202
+ // Keep the block type of the selection up to date in the plain text mode, so the toolbar can
203
+ // reflect it like in the rich text mode
204
+ const { textArea, useRichText } = editorStore;
205
+
206
+ if (!textArea || useRichText) {
207
+ return undefined;
208
+ }
209
+
210
+ /**
211
+ * Update the selection state. Until the `<textarea>` gets the focus, its caret is at the end of
212
+ * the value rather than where the user would expect, so the block type is not detected yet.
213
+ */
214
+ const update = () => {
215
+ editorStore.selection = {
216
+ blockNodeKey: null,
217
+ blockType:
218
+ document.activeElement === textArea
219
+ ? getBlockType(getRawTextState(textArea))
220
+ : 'paragraph',
221
+ inlineTypes: [],
222
+ };
223
+ };
224
+
225
+ const events = ['selectionchange', 'select', 'input', 'keyup', 'mouseup', 'focus'];
226
+
227
+ untrack(update);
228
+ events.forEach((type) => textArea.addEventListener(type, update));
229
+
230
+ return () => {
231
+ events.forEach((type) => textArea.removeEventListener(type, update));
232
+ };
233
+ });
234
+
235
+ $effect(() => {
236
+ pending = editorStore.pending;
237
+ });
238
+
105
239
  $effect(() => {
106
240
  // The root initializes the editor before these effects first run, and stays initialized
107
241
  /* v8 ignore next */
@@ -137,6 +271,7 @@
137
271
  <TextEditorToolbar {disabled} {readonly} />
138
272
  <LexicalRoot
139
273
  {...labelAttrs}
274
+ {dir}
140
275
  hidden={!editorStore.useRichText || hidden}
141
276
  {disabled}
142
277
  {readonly}
@@ -145,9 +280,12 @@
145
280
  />
146
281
  <TextArea
147
282
  {...labelAttrs}
283
+ bind:element={editorStore.textArea}
284
+ id="{editorStore.editorId}-plain-text"
148
285
  autoResize={true}
149
286
  bind:value={editorStore.inputValue}
150
287
  {useEmojiAutocomplete}
288
+ highlight={highlightMarkdown}
151
289
  {flex}
152
290
  {dir}
153
291
  hidden={editorStore.useRichText || hidden}
@@ -6,15 +6,24 @@ type TextEditor = {
6
6
  /** A rich text editor based on Lexical. */
7
7
  declare const TextEditor: import("svelte").Component<{
8
8
  /**
9
- * Input value.
9
+ * Input value. A value set from outside is kept as is until the user
10
+ * changes the content, although the rich text editor writes Markdown in its own style, such as
11
+ * `_text_` for `*text*`.
10
12
  */
11
13
  value?: string | undefined;
14
+ /**
15
+ * Whether the user has changed the rich text content, and the
16
+ * editor has yet to update {@link value}, which it does a moment later. Bind it to wait for the
17
+ * change before reading the value, for example to save it. Read-only.
18
+ */
19
+ pending?: boolean | undefined;
12
20
  /**
13
21
  * Make the text input container flexible.
14
22
  */
15
23
  flex?: boolean | undefined;
16
24
  /**
17
- * The `dir` attribute on the `<textarea>` element.
25
+ * The `dir` attribute on the editable text box, both
26
+ * the rich text editor and the `<textarea>` element of the plain text mode.
18
27
  */
19
28
  dir?: "ltr" | "rtl" | "auto" | undefined;
20
29
  /**
@@ -85,18 +94,27 @@ declare const TextEditor: import("svelte").Component<{
85
94
  * Primary slot content.
86
95
  */
87
96
  children?: Snippet<[]> | undefined;
88
- } & Record<string, any>, {}, "value">;
97
+ } & Record<string, any>, {}, "value" | "pending">;
89
98
  type Props = {
90
99
  /**
91
- * Input value.
100
+ * Input value. A value set from outside is kept as is until the user
101
+ * changes the content, although the rich text editor writes Markdown in its own style, such as
102
+ * `_text_` for `*text*`.
92
103
  */
93
104
  value?: string | undefined;
105
+ /**
106
+ * Whether the user has changed the rich text content, and the
107
+ * editor has yet to update {@link value}, which it does a moment later. Bind it to wait for the
108
+ * change before reading the value, for example to save it. Read-only.
109
+ */
110
+ pending?: boolean | undefined;
94
111
  /**
95
112
  * Make the text input container flexible.
96
113
  */
97
114
  flex?: boolean | undefined;
98
115
  /**
99
- * The `dir` attribute on the `<textarea>` element.
116
+ * The `dir` attribute on the editable text box, both
117
+ * the rich text editor and the `<textarea>` element of the plain text mode.
100
118
  */
101
119
  dir?: "ltr" | "rtl" | "auto" | undefined;
102
120
  /**
@@ -36,6 +36,16 @@
36
36
 
37
37
  let selectedLanguage = $state('plain');
38
38
 
39
+ // A code block can carry an alias, e.g. `js`, while the options are keyed by the canonical ID,
40
+ // e.g. `javascript`, so resolve the alias for the selector to find its option. A language that
41
+ // isn’t listed, e.g. `txt`, is shown as plain text; otherwise the selector would keep the label
42
+ // of the previous block, as it only updates the label when an option matches
43
+ const selectedKey = $derived(
44
+ codeLanguages.find(
45
+ ({ key, aliases }) => key === selectedLanguage || aliases.includes(selectedLanguage),
46
+ )?.key ?? 'plain',
47
+ );
48
+
39
49
  $effect(() => {
40
50
  void editorStore.selection.blockNodeKey;
41
51
 
@@ -57,9 +67,9 @@
57
67
  <Select
58
68
  {disabled}
59
69
  ariaLabel={_('_sui.text_editor.language')}
60
- value={selectedLanguage}
70
+ value={selectedKey}
61
71
  onChange={async ({ detail: { value: lang } }) => {
62
- if (!editorStore.editor || selectedLanguage === lang) {
72
+ if (!editorStore.editor || selectedKey === lang) {
63
73
  return;
64
74
  }
65
75
 
@@ -87,12 +97,7 @@
87
97
  }}
88
98
  >
89
99
  <Option label={_('_sui.text_editor.plain_text')} value="plain" dir="ltr" />
90
- {#each codeLanguages as { key, label, aliases } (key)}
91
- <Option
92
- {label}
93
- value={key}
94
- selected={key === selectedLanguage || aliases.includes(selectedLanguage)}
95
- dir="ltr"
96
- />
100
+ {#each codeLanguages as { key, label } (key)}
101
+ <Option {label} value={key} selected={key === selectedKey} dir="ltr" />
97
102
  {/each}
98
103
  </Select>
@@ -0,0 +1,96 @@
1
+ <!--
2
+ @component
3
+ A minimal rich text editor holding a single editor component, so it can be edited with its own
4
+ field UI outside the main editor, and exported to Markdown. Used to insert a component in the
5
+ plain text mode.
6
+ -->
7
+ <script>
8
+ import {
9
+ $createParagraphNode as createParagraphNode,
10
+ $getRoot as getRoot,
11
+ $insertNodes as insertNodes,
12
+ } from 'lexical';
13
+ import { getContext, setContext } from 'svelte';
14
+ import { onEditorUpdate } from '../core.js';
15
+ import LexicalRoot from '../lexical-root.svelte';
16
+ import { createEditorStore } from '../store.svelte.js';
17
+
18
+ /**
19
+ * @import { TextEditorComponent, TextEditorStore } from '../../../typedefs';
20
+ */
21
+
22
+ /**
23
+ * @typedef {object} Props
24
+ * @property {TextEditorComponent} component Editor component to edit.
25
+ * @property {string} [markdown] Markdown output of the component.
26
+ */
27
+
28
+ /** @type {Props} */
29
+ let {
30
+ /* eslint-disable prefer-const */
31
+ component,
32
+ markdown = $bindable(''),
33
+ /* eslint-enable prefer-const */
34
+ } = $props();
35
+
36
+ /** @type {TextEditorStore} */
37
+ const parentStore = getContext('editorStore');
38
+ const editorStore = createEditorStore();
39
+
40
+ // svelte-ignore state_referenced_locally
41
+ editorStore.config = {
42
+ ...parentStore.config,
43
+ modes: ['rich-text'],
44
+ components: [component],
45
+ useMarkdownShortcuts: false,
46
+ useEmojiAutocomplete: false,
47
+ };
48
+
49
+ setContext('editorStore', editorStore);
50
+
51
+ let inserted = false;
52
+
53
+ $effect(() => {
54
+ const { editor, initialized } = editorStore;
55
+
56
+ if (!editor || !initialized || inserted) {
57
+ return;
58
+ }
59
+
60
+ inserted = true;
61
+
62
+ editor.update(() => {
63
+ const paragraph = createParagraphNode();
64
+
65
+ getRoot().clear().append(paragraph);
66
+ paragraph.select();
67
+ insertNodes([component.createNode()]);
68
+ });
69
+ });
70
+
71
+ $effect(() => {
72
+ markdown = editorStore.inputValue.trim();
73
+ });
74
+
75
+ /**
76
+ * Get the component’s Markdown right now. The bound `markdown` is only updated after a short
77
+ * delay, so it may not reflect a field that has just been changed.
78
+ * @returns {string} Markdown.
79
+ */
80
+ export const getMarkdown = () => {
81
+ const { editor, enabledTransformers } = editorStore;
82
+
83
+ // The dialog can only be submitted once the editor is there
84
+ /* v8 ignore next 3 */
85
+ if (!editor) {
86
+ return markdown;
87
+ }
88
+
89
+ return editor
90
+ .getEditorState()
91
+ .read(() => onEditorUpdate(editor, enabledTransformers))
92
+ .trim();
93
+ };
94
+ </script>
95
+
96
+ <LexicalRoot aria-label={component.label} />
@@ -0,0 +1,35 @@
1
+ export default ComponentEditor;
2
+ type ComponentEditor = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<Props>): void;
5
+ } & {
6
+ getMarkdown: () => string;
7
+ };
8
+ /**
9
+ * A minimal rich text editor holding a single editor component, so it can be edited with its own
10
+ * field UI outside the main editor, and exported to Markdown. Used to insert a component in the
11
+ * plain text mode.
12
+ */
13
+ declare const ComponentEditor: import("svelte").Component<{
14
+ /**
15
+ * Editor component to edit.
16
+ */
17
+ component: TextEditorComponent;
18
+ /**
19
+ * Markdown output of the component.
20
+ */
21
+ markdown?: string | undefined;
22
+ }, {
23
+ getMarkdown: () => string;
24
+ }, "markdown">;
25
+ type Props = {
26
+ /**
27
+ * Editor component to edit.
28
+ */
29
+ component: TextEditorComponent;
30
+ /**
31
+ * Markdown output of the component.
32
+ */
33
+ markdown?: string | undefined;
34
+ };
35
+ import type { TextEditorComponent } from '../../../typedefs';
@@ -1,11 +1,18 @@
1
1
  <script>
2
2
  import { _ } from '@sveltia/i18n';
3
+ import { isMac, matchesShortcuts } from '@sveltia/utils/events';
3
4
  import { FORMAT_TEXT_COMMAND } from 'lexical';
4
5
  import { getContext } from 'svelte';
5
6
  import Button from '../../button/button.svelte';
6
7
  import Icon from '../../icon/icon.svelte';
7
8
  import { AVAILABLE_BUTTONS } from '../constants.js';
8
9
  import { focusEditor } from '../core.js';
10
+ import {
11
+ applyRawTextEdit,
12
+ getRawTextState,
13
+ isRawTextEditable,
14
+ toggleInlineFormat,
15
+ } from '../raw-markdown.js';
9
16
 
10
17
  /**
11
18
  * @import { TextEditorFormatType, TextEditorStore } from '../../../typedefs';
@@ -28,21 +35,74 @@
28
35
  /** @type {TextEditorStore} */
29
36
  const editorStore = getContext('editorStore');
30
37
  const selectionTypeMatches = $derived(editorStore.selection.inlineTypes.includes(type));
38
+
39
+ /**
40
+ * Keyboard shortcut keys for the plain text mode. The rich text editor handles them on its own.
41
+ * @type {Record<string, string>}
42
+ */
43
+ const SHORTCUT_KEYS = { bold: 'B', italic: 'I' };
44
+
45
+ /**
46
+ * Format the selection.
47
+ */
48
+ const format = async () => {
49
+ const { editor, textArea, useRichText } = editorStore;
50
+
51
+ if (!useRichText) {
52
+ // The button is only enabled while the `<textarea>` is there
53
+ /* v8 ignore else */
54
+ if (textArea) {
55
+ applyRawTextEdit(textArea, toggleInlineFormat(getRawTextState(textArea), type));
56
+ }
57
+
58
+ return;
59
+ }
60
+
61
+ // The button is only enabled while the editor is there
62
+ /* v8 ignore else */
63
+ if (editor) {
64
+ await focusEditor(editor);
65
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, type);
66
+ }
67
+ };
68
+
69
+ $effect(() => {
70
+ const { textArea } = editorStore;
71
+ const key = SHORTCUT_KEYS[type];
72
+
73
+ if (!textArea || !key) {
74
+ return undefined;
75
+ }
76
+
77
+ /**
78
+ * Handle the keyboard shortcut in the plain text mode.
79
+ * @param {KeyboardEvent} event `keydown` event.
80
+ */
81
+ const onKeyDown = (event) => {
82
+ if (
83
+ isRawTextEditable(textArea) &&
84
+ matchesShortcuts(event, isMac() ? `Meta+${key}` : `Ctrl+${key}`)
85
+ ) {
86
+ event.preventDefault();
87
+ format();
88
+ }
89
+ };
90
+
91
+ textArea.addEventListener('keydown', onKeyDown);
92
+
93
+ return () => {
94
+ textArea.removeEventListener('keydown', onKeyDown);
95
+ };
96
+ });
31
97
  </script>
32
98
 
33
99
  <Button
34
100
  iconic
35
101
  aria-label={_(`_sui.text_editor.${AVAILABLE_BUTTONS[type].labelKey}`)}
36
- aria-controls={`${editorStore.editorId}-lexical-root`}
37
- disabled={!editorStore.useRichText}
102
+ aria-controls={editorStore.controlId}
38
103
  pressed={selectionTypeMatches}
39
- onclick={async () => {
40
- // The button is only enabled while the editor is there
41
- /* v8 ignore else */
42
- if (editorStore.editor) {
43
- await focusEditor(editorStore.editor);
44
- editorStore.editor.dispatchCommand(FORMAT_TEXT_COMMAND, type);
45
- }
104
+ onclick={() => {
105
+ format();
46
106
  }}
47
107
  >
48
108
  {#snippet startIcon()}