@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.
- package/dist/components/dialog/dialog.svelte +7 -4
- package/dist/components/text-editor/constants.d.ts +5 -0
- package/dist/components/text-editor/constants.js +6 -0
- package/dist/components/text-editor/core.d.ts +6 -2
- package/dist/components/text-editor/core.js +316 -46
- package/dist/components/text-editor/lexical-root.svelte +62 -2
- package/dist/components/text-editor/lexical-root.svelte.d.ts +10 -0
- package/dist/components/text-editor/raw-markdown.d.ts +90 -0
- package/dist/components/text-editor/raw-markdown.js +471 -0
- package/dist/components/text-editor/shiki/facade.d.ts +4 -0
- package/dist/components/text-editor/shiki/facade.js +62 -0
- package/dist/components/text-editor/shiki/theme.d.ts +1 -0
- package/dist/components/text-editor/shiki/theme.js +31 -27
- package/dist/components/text-editor/shiki/version.d.ts +1 -1
- package/dist/components/text-editor/shiki/version.js +1 -1
- package/dist/components/text-editor/store.svelte.js +39 -1
- package/dist/components/text-editor/text-editor.svelte +140 -2
- package/dist/components/text-editor/text-editor.svelte.d.ts +23 -5
- package/dist/components/text-editor/toolbar/code-language-switcher.svelte +14 -9
- package/dist/components/text-editor/toolbar/component-editor.svelte +96 -0
- package/dist/components/text-editor/toolbar/component-editor.svelte.d.ts +35 -0
- package/dist/components/text-editor/toolbar/format-text-button.svelte +69 -9
- package/dist/components/text-editor/toolbar/insert-component-dialog.svelte +92 -0
- package/dist/components/text-editor/toolbar/insert-component-dialog.svelte.d.ts +30 -0
- package/dist/components/text-editor/toolbar/insert-item-button.svelte +13 -2
- package/dist/components/text-editor/toolbar/insert-link-button.svelte +89 -10
- package/dist/components/text-editor/toolbar/insert-menu-button.svelte +21 -2
- package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +4 -4
- package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +13 -0
- package/dist/components/text-field/text-area.svelte +119 -4
- package/dist/components/text-field/text-area.svelte.d.ts +17 -0
- package/dist/components/util/font-links.svelte +1 -1
- package/dist/components/util/fonts.js +1 -1
- package/dist/self-hosted/fonts/material-symbols-outlined-latin-wght-normal.woff2 +0 -0
- package/dist/services/group.svelte.d.ts +1 -1
- package/dist/services/group.svelte.js +14 -6
- package/dist/services/tree.svelte.d.ts +6 -1
- package/dist/services/tree.svelte.js +23 -7
- package/dist/typedefs.d.ts +49 -1
- package/dist/typedefs.js +22 -1
- package/package.json +2 -2
|
@@ -34,23 +34,44 @@ export const getCodeTheme = () => {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
};
|
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
|
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={
|
|
70
|
+
value={selectedKey}
|
|
61
71
|
onChange={async ({ detail: { value: lang } }) => {
|
|
62
|
-
if (!editorStore.editor ||
|
|
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
|
|
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={
|
|
37
|
-
disabled={!editorStore.useRichText}
|
|
102
|
+
aria-controls={editorStore.controlId}
|
|
38
103
|
pressed={selectionTypeMatches}
|
|
39
|
-
onclick={
|
|
40
|
-
|
|
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()}
|