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