@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
|
@@ -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,6 +1,8 @@
|
|
|
1
1
|
<script>
|
|
2
|
+
import { $getRoot as getRoot, HISTORY_MERGE_TAG } from 'lexical';
|
|
2
3
|
import { getContext, onMount } from 'svelte';
|
|
3
|
-
import {
|
|
4
|
+
import { IMPORT_UPDATE_TAG } from './constants.js';
|
|
5
|
+
import { handleEditorMouseDown, initEditor } from './core.js';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* @import { Snippet } from 'svelte';
|
|
@@ -10,6 +12,8 @@
|
|
|
10
12
|
/**
|
|
11
13
|
* @typedef {object} Props
|
|
12
14
|
* @property {string} [class] The `class` attribute on the wrapper element.
|
|
15
|
+
* @property {'ltr' | 'rtl' | 'auto'} [dir] Direction of the text. With `auto` or no value, each
|
|
16
|
+
* paragraph takes the direction of its own text.
|
|
13
17
|
* @property {boolean} [hidden] Whether to hide the widget.
|
|
14
18
|
* @property {boolean} [disabled] Whether to disable the widget. An alias of the `aria-disabled`
|
|
15
19
|
* attribute.
|
|
@@ -28,6 +32,7 @@
|
|
|
28
32
|
let {
|
|
29
33
|
/* eslint-disable prefer-const */
|
|
30
34
|
class: className,
|
|
35
|
+
dir = undefined,
|
|
31
36
|
hidden = false,
|
|
32
37
|
disabled = false,
|
|
33
38
|
readonly = false,
|
|
@@ -53,6 +58,26 @@
|
|
|
53
58
|
editorStore.editor?.setEditable(editable);
|
|
54
59
|
});
|
|
55
60
|
|
|
61
|
+
$effect(() => {
|
|
62
|
+
// Lexical owns the root element’s `dir` attribute, so set the direction on the root node, which
|
|
63
|
+
// the paragraphs then follow. Without one, each paragraph gets `dir="auto"`
|
|
64
|
+
const { editor } = editorStore;
|
|
65
|
+
const direction = dir === 'ltr' || dir === 'rtl' ? dir : null;
|
|
66
|
+
|
|
67
|
+
// Check first, as even an update that changes nothing makes the editor convert its content
|
|
68
|
+
if (!editor || editor.getEditorState().read(() => getRoot().getDirection()) === direction) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
editor.update(
|
|
73
|
+
() => {
|
|
74
|
+
getRoot().setDirection(direction);
|
|
75
|
+
},
|
|
76
|
+
// Keep the change out of the undo history
|
|
77
|
+
{ tag: 'history-merge' },
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
56
81
|
/**
|
|
57
82
|
* Update {@link value} and other state variables whenever the editor content is updated.
|
|
58
83
|
* @param {Event} event `Update` custom event.
|
|
@@ -60,13 +85,19 @@
|
|
|
60
85
|
const onUpdate = (event) => {
|
|
61
86
|
const { hasConverterError, useRichText, inputValue } = editorStore;
|
|
62
87
|
|
|
88
|
+
// The content has been converted, if it has changed at all
|
|
89
|
+
editorStore.pending = false;
|
|
90
|
+
|
|
63
91
|
if (hasConverterError || !useRichText) {
|
|
64
92
|
return;
|
|
65
93
|
}
|
|
66
94
|
|
|
67
95
|
const { value: newValue, selection } = /** @type {CustomEvent} */ (event).detail;
|
|
68
96
|
|
|
69
|
-
|
|
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)) {
|
|
70
101
|
// Temporarily disable rich text to prevent unnecessary Markdown conversion that resets
|
|
71
102
|
// Lexical nodes and selection, then restore the state
|
|
72
103
|
editorStore.useRichText = false;
|
|
@@ -88,6 +119,17 @@
|
|
|
88
119
|
}
|
|
89
120
|
};
|
|
90
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
|
+
|
|
91
133
|
onMount(() => {
|
|
92
134
|
const { editor, enabledTransformers, dispose } = initEditor(editorStore.config);
|
|
93
135
|
|
|
@@ -96,10 +138,28 @@
|
|
|
96
138
|
|
|
97
139
|
lexicalRoot?.addEventListener('Update', onUpdate);
|
|
98
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
|
+
);
|
|
99
157
|
|
|
100
158
|
return () => {
|
|
159
|
+
unregisterUpdateListener();
|
|
101
160
|
lexicalRoot?.removeEventListener('Update', onUpdate);
|
|
102
161
|
lexicalRoot?.removeEventListener('click', onClick);
|
|
162
|
+
lexicalRoot?.removeEventListener('mousedown', onMouseDown);
|
|
103
163
|
dispose();
|
|
104
164
|
editor.setRootElement(null);
|
|
105
165
|
editorStore.initialized = false;
|
|
@@ -8,6 +8,11 @@ declare const LexicalRoot: import("svelte").Component<{
|
|
|
8
8
|
* The `class` attribute on the wrapper element.
|
|
9
9
|
*/
|
|
10
10
|
class?: string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Direction of the text. With `auto` or no value, each
|
|
13
|
+
* paragraph takes the direction of its own text.
|
|
14
|
+
*/
|
|
15
|
+
dir?: "ltr" | "rtl" | "auto" | undefined;
|
|
11
16
|
/**
|
|
12
17
|
* Whether to hide the widget.
|
|
13
18
|
*/
|
|
@@ -42,6 +47,11 @@ type Props = {
|
|
|
42
47
|
* The `class` attribute on the wrapper element.
|
|
43
48
|
*/
|
|
44
49
|
class?: string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Direction of the text. With `auto` or no value, each
|
|
52
|
+
* paragraph takes the direction of its own text.
|
|
53
|
+
*/
|
|
54
|
+
dir?: "ltr" | "rtl" | "auto" | undefined;
|
|
45
55
|
/**
|
|
46
56
|
* Whether to hide the widget.
|
|
47
57
|
*/
|