@sveltia/ui 0.77.3 → 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 +39 -3
- 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 +137 -1
- package/dist/components/text-editor/text-editor.svelte.d.ts +19 -3
- 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
|
@@ -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,19 +9,29 @@
|
|
|
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
36
|
* @property {'ltr' | 'rtl' | 'auto'} [dir] The `dir` attribute on the editable text box, both
|
|
27
37
|
* the rich text editor and the `<textarea>` element of the plain text mode.
|
|
@@ -56,6 +66,7 @@
|
|
|
56
66
|
let {
|
|
57
67
|
/* eslint-disable prefer-const */
|
|
58
68
|
value = $bindable(''),
|
|
69
|
+
pending = $bindable(false),
|
|
59
70
|
flex = false,
|
|
60
71
|
dir = undefined,
|
|
61
72
|
modes = ['rich-text', 'plain-text'],
|
|
@@ -103,6 +114,128 @@
|
|
|
103
114
|
|
|
104
115
|
setContext('editorStore', editorStore);
|
|
105
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
|
+
|
|
106
239
|
$effect(() => {
|
|
107
240
|
// The root initializes the editor before these effects first run, and stays initialized
|
|
108
241
|
/* v8 ignore next */
|
|
@@ -147,9 +280,12 @@
|
|
|
147
280
|
/>
|
|
148
281
|
<TextArea
|
|
149
282
|
{...labelAttrs}
|
|
283
|
+
bind:element={editorStore.textArea}
|
|
284
|
+
id="{editorStore.editorId}-plain-text"
|
|
150
285
|
autoResize={true}
|
|
151
286
|
bind:value={editorStore.inputValue}
|
|
152
287
|
{useEmojiAutocomplete}
|
|
288
|
+
highlight={highlightMarkdown}
|
|
153
289
|
{flex}
|
|
154
290
|
{dir}
|
|
155
291
|
hidden={editorStore.useRichText || hidden}
|
|
@@ -6,9 +6,17 @@ 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
|
*/
|
|
@@ -86,12 +94,20 @@ declare const TextEditor: import("svelte").Component<{
|
|
|
86
94
|
* Primary slot content.
|
|
87
95
|
*/
|
|
88
96
|
children?: Snippet<[]> | undefined;
|
|
89
|
-
} & Record<string, any>, {}, "value">;
|
|
97
|
+
} & Record<string, any>, {}, "value" | "pending">;
|
|
90
98
|
type Props = {
|
|
91
99
|
/**
|
|
92
|
-
* 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*`.
|
|
93
103
|
*/
|
|
94
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;
|
|
95
111
|
/**
|
|
96
112
|
* Make the text input container flexible.
|
|
97
113
|
*/
|
|
@@ -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()}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
A dialog to insert an editor component in the plain text mode. The component is edited with its
|
|
4
|
+
own field UI in a minimal rich text editor, then inserted into the `<textarea>` as Markdown.
|
|
5
|
+
-->
|
|
6
|
+
<script>
|
|
7
|
+
import { _ } from '@sveltia/i18n';
|
|
8
|
+
import { getContext } from 'svelte';
|
|
9
|
+
import Dialog from '../../dialog/dialog.svelte';
|
|
10
|
+
import { applyRawTextEdit, getRawTextState, insertMarkdown } from '../raw-markdown.js';
|
|
11
|
+
import ComponentEditor from './component-editor.svelte';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @import { TextEditorComponent, TextEditorStore } from '../../../typedefs';
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} Props
|
|
19
|
+
* @property {TextEditorComponent | undefined} component Editor component to insert.
|
|
20
|
+
* @property {boolean} [open] Whether to open the dialog.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** @type {Props} */
|
|
24
|
+
let {
|
|
25
|
+
/* eslint-disable prefer-const */
|
|
26
|
+
component,
|
|
27
|
+
open = $bindable(false),
|
|
28
|
+
/* eslint-enable prefer-const */
|
|
29
|
+
} = $props();
|
|
30
|
+
|
|
31
|
+
/** @type {TextEditorStore} */
|
|
32
|
+
const editorStore = getContext('editorStore');
|
|
33
|
+
|
|
34
|
+
let markdown = $state('');
|
|
35
|
+
/** @type {ComponentEditor | undefined} */
|
|
36
|
+
let componentEditor = $state();
|
|
37
|
+
/**
|
|
38
|
+
* Markdown taken from the editor as the dialog starts closing, while it’s still there.
|
|
39
|
+
* @type {string}
|
|
40
|
+
*/
|
|
41
|
+
let latestMarkdown = '';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Insert the component’s Markdown into the `<textarea>`, or just bring the focus back there.
|
|
45
|
+
* @param {CustomEvent} event `close` event.
|
|
46
|
+
*/
|
|
47
|
+
const onClose = (event) => {
|
|
48
|
+
const { textArea } = editorStore;
|
|
49
|
+
|
|
50
|
+
// The dialog can only be opened from a button, which needs the `<textarea>`
|
|
51
|
+
/* v8 ignore next 3 */
|
|
52
|
+
if (!textArea) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (event.detail.returnValue !== 'cancel' && latestMarkdown) {
|
|
57
|
+
// A component matched by a single-line pattern is inline; anything else is a block
|
|
58
|
+
const block = component?.transformer?.type !== 'text-match';
|
|
59
|
+
|
|
60
|
+
applyRawTextEdit(
|
|
61
|
+
textArea,
|
|
62
|
+
insertMarkdown(getRawTextState(textArea), latestMarkdown, { block }),
|
|
63
|
+
);
|
|
64
|
+
} else {
|
|
65
|
+
textArea.focus();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
markdown = '';
|
|
69
|
+
latestMarkdown = '';
|
|
70
|
+
};
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<Dialog
|
|
74
|
+
title={component?.label ?? ''}
|
|
75
|
+
size="large"
|
|
76
|
+
bind:open
|
|
77
|
+
okLabel={_('_sui.insert')}
|
|
78
|
+
okDisabled={!markdown}
|
|
79
|
+
restoreFocus={false}
|
|
80
|
+
onClosing={() => {
|
|
81
|
+
// Get the latest Markdown, as the bound value is updated with a delay
|
|
82
|
+
latestMarkdown = componentEditor?.getMarkdown() ?? markdown;
|
|
83
|
+
}}
|
|
84
|
+
onClose={(event) => {
|
|
85
|
+
onClose(event);
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
<!-- The dialog unmounts its content once closed, so each opening gets a fresh editor -->
|
|
89
|
+
{#if component}
|
|
90
|
+
<ComponentEditor bind:this={componentEditor} {component} bind:markdown />
|
|
91
|
+
{/if}
|
|
92
|
+
</Dialog>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export default InsertComponentDialog;
|
|
2
|
+
type InsertComponentDialog = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<Props>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* A dialog to insert an editor component in the plain text mode. The component is edited with its
|
|
8
|
+
* own field UI in a minimal rich text editor, then inserted into the `<textarea>` as Markdown.
|
|
9
|
+
*/
|
|
10
|
+
declare const InsertComponentDialog: import("svelte").Component<{
|
|
11
|
+
/**
|
|
12
|
+
* Editor component to insert.
|
|
13
|
+
*/
|
|
14
|
+
component: TextEditorComponent | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Whether to open the dialog.
|
|
17
|
+
*/
|
|
18
|
+
open?: boolean | undefined;
|
|
19
|
+
}, {}, "open">;
|
|
20
|
+
type Props = {
|
|
21
|
+
/**
|
|
22
|
+
* Editor component to insert.
|
|
23
|
+
*/
|
|
24
|
+
component: TextEditorComponent | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Whether to open the dialog.
|
|
27
|
+
*/
|
|
28
|
+
open?: boolean | undefined;
|
|
29
|
+
};
|
|
30
|
+
import type { TextEditorComponent } from '../../../typedefs';
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { getContext } from 'svelte';
|
|
7
7
|
import Button from '../../button/button.svelte';
|
|
8
8
|
import Icon from '../../icon/icon.svelte';
|
|
9
|
+
import InsertComponentDialog from './insert-component-dialog.svelte';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* @import { TextEditorComponent, TextEditorStore } from '../../../typedefs';
|
|
@@ -27,6 +28,8 @@
|
|
|
27
28
|
const editorStore = getContext('editorStore');
|
|
28
29
|
|
|
29
30
|
const { label, icon, createNode } = $derived(component);
|
|
31
|
+
|
|
32
|
+
let openDialog = $state(false);
|
|
30
33
|
</script>
|
|
31
34
|
|
|
32
35
|
<Button
|
|
@@ -34,9 +37,15 @@
|
|
|
34
37
|
label={icon ? undefined : label}
|
|
35
38
|
title={label}
|
|
36
39
|
aria-label={label}
|
|
37
|
-
aria-controls={
|
|
38
|
-
disabled={!editorStore.useRichText}
|
|
40
|
+
aria-controls={editorStore.controlId}
|
|
39
41
|
onclick={() => {
|
|
42
|
+
// The plain text mode needs a dialog to fill in the component’s fields
|
|
43
|
+
if (!editorStore.useRichText) {
|
|
44
|
+
openDialog = true;
|
|
45
|
+
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
editorStore.editor?.update(() => {
|
|
41
50
|
// Add an additional paragraph for easier editing
|
|
42
51
|
insertNodes([createNode(), createParagraphNode()]);
|
|
@@ -49,3 +58,5 @@
|
|
|
49
58
|
{/if}
|
|
50
59
|
{/snippet}
|
|
51
60
|
</Button>
|
|
61
|
+
|
|
62
|
+
<InsertComponentDialog {component} bind:open={openDialog} />
|