@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
|
@@ -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';
|
|
@@ -0,0 +1,471 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* @import { TextEditorBlockType, TextEditorFormatType } from '../../typedefs';
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Current value and selection of the `<textarea>`.
|
|
14
|
+
* @typedef {object} RawTextState
|
|
15
|
+
* @property {string} value Value.
|
|
16
|
+
* @property {number} start Selection start.
|
|
17
|
+
* @property {number} end Selection end.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Edit to apply to the `<textarea>`: replace the text between `start` and `end` with `text`, then
|
|
22
|
+
* select the text between `selectionStart` and `selectionEnd`, which are offsets in the new value.
|
|
23
|
+
* @typedef {object} RawTextEdit
|
|
24
|
+
* @property {number} start Start of the range to replace.
|
|
25
|
+
* @property {number} end End of the range to replace.
|
|
26
|
+
* @property {string} text Replacement text.
|
|
27
|
+
* @property {number} selectionStart New selection start.
|
|
28
|
+
* @property {number} selectionEnd New selection end.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Markdown markers for the inline formats.
|
|
33
|
+
* @type {Record<TextEditorFormatType, string>}
|
|
34
|
+
*/
|
|
35
|
+
export const INLINE_MARKERS = {
|
|
36
|
+
bold: '**',
|
|
37
|
+
italic: '_',
|
|
38
|
+
strikethrough: '~~',
|
|
39
|
+
code: '`',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Prefix of a heading, list item or blockquote line.
|
|
44
|
+
*/
|
|
45
|
+
const BLOCK_PREFIX_REGEX = /^(?:#{1,6}[ \t]+|>[ \t]?|[-*+][ \t]+|\d+[.)][ \t]+)/;
|
|
46
|
+
const CODE_FENCE_REGEX = /^[ \t]*(?:```|~~~)/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get the range of the full lines covered by the selection.
|
|
50
|
+
* @param {RawTextState} state Current state.
|
|
51
|
+
* @returns {{ lineStart: number, lineEnd: number }} Start of the first line and end of the last
|
|
52
|
+
* line, excluding the line break.
|
|
53
|
+
*/
|
|
54
|
+
const getLineRange = ({ value, start, end }) => {
|
|
55
|
+
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
|
56
|
+
// Ignore a selection that ends at the very start of a line, like a triple-clicked line
|
|
57
|
+
const lastIndex = end > start && value[end - 1] === '\n' ? end - 1 : end;
|
|
58
|
+
const lineEndIndex = value.indexOf('\n', lastIndex);
|
|
59
|
+
|
|
60
|
+
return { lineStart, lineEnd: lineEndIndex === -1 ? value.length : lineEndIndex };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Find the fenced code block the given line is in.
|
|
65
|
+
* @param {string[]} lines All the lines.
|
|
66
|
+
* @param {number} lineIndex Index of the line.
|
|
67
|
+
* @returns {{ open: number, close: number } | undefined} Indexes of the opening and closing fence
|
|
68
|
+
* lines, or `undefined` if the line is not in a code block. An unclosed block ends at the last
|
|
69
|
+
* line.
|
|
70
|
+
*/
|
|
71
|
+
const findCodeBlock = (lines, lineIndex) => {
|
|
72
|
+
/** @type {number | undefined} */
|
|
73
|
+
let open;
|
|
74
|
+
|
|
75
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
76
|
+
if (CODE_FENCE_REGEX.test(lines[index])) {
|
|
77
|
+
if (open === undefined) {
|
|
78
|
+
open = index;
|
|
79
|
+
} else {
|
|
80
|
+
if (lineIndex >= open && lineIndex <= index) {
|
|
81
|
+
return { open, close: index };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
open = undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (open === undefined && index >= lineIndex) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return open !== undefined && lineIndex >= open ? { open, close: lines.length } : undefined;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get the type of the block where the selection starts.
|
|
98
|
+
* @param {RawTextState} state Current state.
|
|
99
|
+
* @returns {TextEditorBlockType} Block type.
|
|
100
|
+
*/
|
|
101
|
+
export const getBlockType = ({ value, start }) => {
|
|
102
|
+
const lines = value.split('\n');
|
|
103
|
+
const lineIndex = value.slice(0, start).split('\n').length - 1;
|
|
104
|
+
const line = lines[lineIndex];
|
|
105
|
+
|
|
106
|
+
if (findCodeBlock(lines, lineIndex)) {
|
|
107
|
+
return 'code-block';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const [, hashes] = line.match(/^(#{1,6})[ \t]/) ?? [];
|
|
111
|
+
|
|
112
|
+
if (hashes) {
|
|
113
|
+
return /** @type {TextEditorBlockType} */ (`heading-${hashes.length}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (/^[-*+][ \t]/.test(line)) {
|
|
117
|
+
return 'bulleted-list';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (/^\d+[.)][ \t]/.test(line)) {
|
|
121
|
+
return 'numbered-list';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (line.startsWith('>')) {
|
|
125
|
+
return 'blockquote';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return 'paragraph';
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Wrap the selection with the Markdown markers of the given inline format, or unwrap it if it’s
|
|
133
|
+
* already formatted. Without a selection, the markers are inserted with the caret in between.
|
|
134
|
+
* @param {RawTextState} state Current state.
|
|
135
|
+
* @param {TextEditorFormatType} type Format type.
|
|
136
|
+
* @returns {RawTextEdit} Edit.
|
|
137
|
+
*/
|
|
138
|
+
export const toggleInlineFormat = ({ value, start, end }, type) => {
|
|
139
|
+
const marker = INLINE_MARKERS[type];
|
|
140
|
+
const { length } = marker;
|
|
141
|
+
const selected = value.slice(start, end);
|
|
142
|
+
|
|
143
|
+
// The markers are selected along with the text
|
|
144
|
+
if (selected.length >= length * 2 && selected.startsWith(marker) && selected.endsWith(marker)) {
|
|
145
|
+
const text = selected.slice(length, -length);
|
|
146
|
+
|
|
147
|
+
return { start, end, text, selectionStart: start, selectionEnd: start + text.length };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The markers surround the selection
|
|
151
|
+
if (value.slice(start - length, start) === marker && value.slice(end, end + length) === marker) {
|
|
152
|
+
return {
|
|
153
|
+
start: start - length,
|
|
154
|
+
end: end + length,
|
|
155
|
+
text: selected,
|
|
156
|
+
selectionStart: start - length,
|
|
157
|
+
selectionEnd: end - length,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Keep the surrounding whitespace out of the markers, because `** bold **` is not bold
|
|
162
|
+
const [, leading, content, trailing] = /** @type {RegExpMatchArray} */ (
|
|
163
|
+
selected.match(/^(\s*)([\s\S]*?)(\s*)$/)
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Inline formatting cannot span multiple paragraphs, so format each line separately
|
|
167
|
+
const text = `${leading}${content
|
|
168
|
+
.split('\n')
|
|
169
|
+
.map((line) => (line.trim() ? `${marker}${line}${marker}` : line))
|
|
170
|
+
.join('\n')}${trailing}`;
|
|
171
|
+
|
|
172
|
+
if (!content) {
|
|
173
|
+
const caret = start + leading.length + length;
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
start,
|
|
177
|
+
end,
|
|
178
|
+
text: `${leading}${marker}${marker}${trailing}`,
|
|
179
|
+
selectionStart: caret,
|
|
180
|
+
selectionEnd: caret,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
start,
|
|
186
|
+
end,
|
|
187
|
+
text,
|
|
188
|
+
selectionStart: start + leading.length + length,
|
|
189
|
+
selectionEnd: start + text.length - trailing.length - length,
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Change the type of the blocks covered by the selection.
|
|
195
|
+
* @param {RawTextState} state Current state.
|
|
196
|
+
* @param {TextEditorBlockType} type New block type.
|
|
197
|
+
* @returns {RawTextEdit} Edit.
|
|
198
|
+
*/
|
|
199
|
+
export const setBlockType = (state, type) => {
|
|
200
|
+
const { value, start, end } = state;
|
|
201
|
+
const lines = value.split('\n');
|
|
202
|
+
const firstIndex = value.slice(0, start).split('\n').length - 1;
|
|
203
|
+
const codeBlock = findCodeBlock(lines, firstIndex);
|
|
204
|
+
|
|
205
|
+
// Leave a code block by removing its fences, then change the type of the selected lines
|
|
206
|
+
if (codeBlock) {
|
|
207
|
+
const { open, close } = codeBlock;
|
|
208
|
+
const blockStart = lines.slice(0, open).join('\n').length + (open ? 1 : 0);
|
|
209
|
+
const blockEnd = blockStart + lines.slice(open, close + 1).join('\n').length;
|
|
210
|
+
const content = lines.slice(open + 1, close).join('\n');
|
|
211
|
+
const contentStart = blockStart + lines[open].length + 1;
|
|
212
|
+
/**
|
|
213
|
+
* Map a position in the whole value to one in the content.
|
|
214
|
+
* @param {number} position Position.
|
|
215
|
+
* @returns {number} Position in the content.
|
|
216
|
+
*/
|
|
217
|
+
const map = (position) => Math.min(Math.max(position - contentStart, 0), content.length);
|
|
218
|
+
const edit = setBlockType({ value: content, start: map(start), end: map(end) }, type);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
start: blockStart,
|
|
222
|
+
end: blockEnd,
|
|
223
|
+
text: `${content.slice(0, edit.start)}${edit.text}${content.slice(edit.end)}`,
|
|
224
|
+
selectionStart: blockStart + edit.selectionStart,
|
|
225
|
+
selectionEnd: blockStart + edit.selectionEnd,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const { lineStart, lineEnd } = getLineRange(state);
|
|
230
|
+
const oldLines = value.slice(lineStart, lineEnd).split('\n');
|
|
231
|
+
|
|
232
|
+
if (type === 'code-block') {
|
|
233
|
+
const text = `\`\`\`\n${oldLines.join('\n')}\n\`\`\``;
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
start: lineStart,
|
|
237
|
+
end: lineEnd,
|
|
238
|
+
text,
|
|
239
|
+
selectionStart: start + 4,
|
|
240
|
+
selectionEnd: end + 4,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const [, level] = type.match(/^heading-(\d)$/) ?? [];
|
|
245
|
+
let number = 0;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get the prefix for the given line.
|
|
249
|
+
* @param {string} line Line content without the prefix.
|
|
250
|
+
* @returns {string} Prefix.
|
|
251
|
+
*/
|
|
252
|
+
const getPrefix = (line) => {
|
|
253
|
+
if (level) {
|
|
254
|
+
return line.trim() ? `${'#'.repeat(Number(level))} ` : '';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (type === 'bulleted-list') {
|
|
258
|
+
return line.trim() ? '- ' : '';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (type === 'numbered-list') {
|
|
262
|
+
if (!line.trim()) {
|
|
263
|
+
return '';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
number += 1;
|
|
267
|
+
|
|
268
|
+
return `${number}. `;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (type === 'blockquote') {
|
|
272
|
+
return line.trim() ? '> ' : '>';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return '';
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
let offset = 0;
|
|
279
|
+
let selectionStart = start;
|
|
280
|
+
let selectionEnd = end;
|
|
281
|
+
|
|
282
|
+
const newLines = oldLines.map((line, index) => {
|
|
283
|
+
const [oldPrefix = ''] = line.match(BLOCK_PREFIX_REGEX) ?? [];
|
|
284
|
+
const content = line.slice(oldPrefix.length);
|
|
285
|
+
const newPrefix = getPrefix(content);
|
|
286
|
+
const oldLineStart = lineStart + oldLines.slice(0, index).join('\n').length + (index ? 1 : 0);
|
|
287
|
+
const oldContentStart = oldLineStart + oldPrefix.length;
|
|
288
|
+
const oldLineEnd = oldLineStart + line.length;
|
|
289
|
+
const newContentStart = oldContentStart + offset + newPrefix.length - oldPrefix.length;
|
|
290
|
+
/**
|
|
291
|
+
* Map a position on the old line to the new line, keeping it after the prefix.
|
|
292
|
+
* @param {number} position Position.
|
|
293
|
+
* @returns {number} New position.
|
|
294
|
+
*/
|
|
295
|
+
const map = (position) => newContentStart + Math.max(position - oldContentStart, 0);
|
|
296
|
+
|
|
297
|
+
if (start >= oldLineStart && start <= oldLineEnd) {
|
|
298
|
+
selectionStart = map(start);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (end >= oldLineStart && end <= oldLineEnd) {
|
|
302
|
+
selectionEnd = map(end);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
offset += newPrefix.length - oldPrefix.length;
|
|
306
|
+
|
|
307
|
+
return `${newPrefix}${content}`;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// The selection may end on the next line, which is left as is, but still shifted
|
|
311
|
+
if (end > lineEnd) {
|
|
312
|
+
selectionEnd = end + offset;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
start: lineStart,
|
|
317
|
+
end: lineEnd,
|
|
318
|
+
text: newLines.join('\n'),
|
|
319
|
+
selectionStart,
|
|
320
|
+
selectionEnd: Math.max(selectionStart, selectionEnd),
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Replace the selection with a Markdown link.
|
|
326
|
+
* @param {RawTextState} state Current state.
|
|
327
|
+
* @param {object} link Link.
|
|
328
|
+
* @param {string} link.url URL.
|
|
329
|
+
* @param {string} [link.text] Link text. Defaults to the selected text, then the URL.
|
|
330
|
+
* @returns {RawTextEdit} Edit.
|
|
331
|
+
*/
|
|
332
|
+
export const insertLink = ({ value, start, end }, { url, text }) => {
|
|
333
|
+
// Keep the whitespace around the selected text out of the link, as a double-clicked word may come
|
|
334
|
+
// with a trailing space
|
|
335
|
+
const [, leading, selected, trailing] = /** @type {RegExpMatchArray} */ (
|
|
336
|
+
value.slice(start, end).match(/^(\s*)([\s\S]*?)(\s*)$/)
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const label = (text ?? selected).trim() || url;
|
|
340
|
+
const escapedLabel = label.replace(/([[\]\\])/g, '\\$1');
|
|
341
|
+
// A URL with whitespace or parentheses has to be enclosed in angle brackets
|
|
342
|
+
const destination = /[\s()<>]/.test(url) ? `<${url.replace(/[<>]/g, encodeURIComponent)}>` : url;
|
|
343
|
+
const markdown = `[${escapedLabel}](${destination})`;
|
|
344
|
+
const caret = start + leading.length + markdown.length;
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
start,
|
|
348
|
+
end,
|
|
349
|
+
text: `${leading}${markdown}${trailing}`,
|
|
350
|
+
selectionStart: caret,
|
|
351
|
+
selectionEnd: caret,
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Insert the given Markdown at the selection. A block is separated from the surrounding text with
|
|
357
|
+
* blank lines, while inline content is inserted as is.
|
|
358
|
+
* @param {RawTextState} state Current state.
|
|
359
|
+
* @param {string} markdown Markdown to insert.
|
|
360
|
+
* @param {object} [options] Options.
|
|
361
|
+
* @param {boolean} [options.block] Whether the Markdown is a block.
|
|
362
|
+
* @returns {RawTextEdit} Edit.
|
|
363
|
+
*/
|
|
364
|
+
export const insertMarkdown = ({ value, start, end }, markdown, { block = false } = {}) => {
|
|
365
|
+
if (!block) {
|
|
366
|
+
const caret = start + markdown.length;
|
|
367
|
+
|
|
368
|
+
return { start, end, text: markdown, selectionStart: caret, selectionEnd: caret };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const before = value.slice(0, start).replace(/[ \t]*$/, '');
|
|
372
|
+
const after = value.slice(end).replace(/^[ \t]*/, '');
|
|
373
|
+
const trimmedStart = before.length;
|
|
374
|
+
const trimmedEnd = value.length - after.length;
|
|
375
|
+
|
|
376
|
+
const leading = !before
|
|
377
|
+
? ''
|
|
378
|
+
: before.endsWith('\n\n')
|
|
379
|
+
? ''
|
|
380
|
+
: before.endsWith('\n')
|
|
381
|
+
? '\n'
|
|
382
|
+
: '\n\n';
|
|
383
|
+
|
|
384
|
+
const trailing = after.startsWith('\n\n') ? '' : after.startsWith('\n') ? '\n' : '\n\n';
|
|
385
|
+
const text = `${leading}${markdown}${trailing}`;
|
|
386
|
+
// Place the caret after the block
|
|
387
|
+
const caret = trimmedStart + text.length;
|
|
388
|
+
|
|
389
|
+
return { start: trimmedStart, end: trimmedEnd, text, selectionStart: caret, selectionEnd: caret };
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Get the current value and selection of the given `<textarea>`.
|
|
394
|
+
* @param {HTMLTextAreaElement} textArea `<textarea>` element.
|
|
395
|
+
* @returns {RawTextState} State.
|
|
396
|
+
*/
|
|
397
|
+
export const getRawTextState = ({ value, selectionStart, selectionEnd }) => ({
|
|
398
|
+
value,
|
|
399
|
+
start: selectionStart,
|
|
400
|
+
end: selectionEnd,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Move the focus to the given `<textarea>`. A toolbar menu is a modal, which leaves the rest of the
|
|
405
|
+
* page inert until it’s closed, so the focus may not move right away when an item is selected;
|
|
406
|
+
* retry until it does, or give up after a second.
|
|
407
|
+
* @param {HTMLTextAreaElement} textArea `<textarea>` element.
|
|
408
|
+
* @returns {Promise<boolean>} Whether the `<textarea>` has the focus.
|
|
409
|
+
*/
|
|
410
|
+
export const focusTextArea = async (textArea) => {
|
|
411
|
+
const deadline = Date.now() + 1000;
|
|
412
|
+
|
|
413
|
+
for (;;) {
|
|
414
|
+
textArea.focus();
|
|
415
|
+
|
|
416
|
+
if (document.activeElement === textArea || Date.now() > deadline) {
|
|
417
|
+
return document.activeElement === textArea;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// eslint-disable-next-line no-await-in-loop
|
|
421
|
+
await new Promise((resolve) => {
|
|
422
|
+
window.setTimeout(resolve, 20);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Whether the given `<textarea>` can be edited by the user, and therefore by the toolbar.
|
|
429
|
+
* @param {HTMLTextAreaElement} textArea `<textarea>` element.
|
|
430
|
+
* @returns {boolean} Result.
|
|
431
|
+
*/
|
|
432
|
+
export const isRawTextEditable = ({ readOnly, disabled }) => !readOnly && !disabled;
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Apply the given edit to the `<textarea>`, and focus it. Nothing happens if it’s read-only or
|
|
436
|
+
* disabled, because the fallback below would edit it anyway.
|
|
437
|
+
* @param {HTMLTextAreaElement} textArea `<textarea>` element.
|
|
438
|
+
* @param {RawTextEdit} edit Edit.
|
|
439
|
+
* @returns {Promise<void>} Nothing.
|
|
440
|
+
*/
|
|
441
|
+
export const applyRawTextEdit = async (
|
|
442
|
+
textArea,
|
|
443
|
+
{ start, end, text, selectionStart, selectionEnd },
|
|
444
|
+
) => {
|
|
445
|
+
if (!isRawTextEditable(textArea)) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// The edit only goes into the undo history while the `<textarea>` has the focus
|
|
450
|
+
const focused = await focusTextArea(textArea);
|
|
451
|
+
|
|
452
|
+
if (textArea.value.slice(start, end) !== text) {
|
|
453
|
+
textArea.setSelectionRange(start, end);
|
|
454
|
+
|
|
455
|
+
// `execCommand()` is deprecated, but it’s still the only way to keep the edit in the native
|
|
456
|
+
// undo history. It fires an `input` event, so a bound value is updated as well
|
|
457
|
+
const done =
|
|
458
|
+
focused &&
|
|
459
|
+
typeof document.execCommand === 'function' &&
|
|
460
|
+
(text
|
|
461
|
+
? document.execCommand('insertText', false, text)
|
|
462
|
+
: document.execCommand('delete', false));
|
|
463
|
+
|
|
464
|
+
if (!done || textArea.value.slice(start, start + text.length) !== text) {
|
|
465
|
+
textArea.setRangeText(text, start, end);
|
|
466
|
+
textArea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
textArea.setSelectionRange(selectionStart, selectionEnd);
|
|
471
|
+
};
|
|
@@ -11,7 +11,11 @@ export function getHighlightNodes(codeNode: CodeNode, language: string): Lexical
|
|
|
11
11
|
export function highlightCodeToHTML(code: string, language: string, { theme }?: {
|
|
12
12
|
theme?: string | undefined;
|
|
13
13
|
}): string | undefined;
|
|
14
|
+
export function highlightCodeToTokens(code: string, language: string, { theme }?: {
|
|
15
|
+
theme?: string | undefined;
|
|
16
|
+
}): HighlightedToken[][] | undefined;
|
|
14
17
|
import type { LexicalEditor } from 'lexical';
|
|
15
18
|
import type { NodeKey } from 'lexical';
|
|
16
19
|
import type { CodeNode } from '@lexical/code-core';
|
|
17
20
|
import type { LexicalNode } from 'lexical';
|
|
21
|
+
import type { HighlightedToken } from '../../../typedefs';
|
|
@@ -29,6 +29,7 @@ import { getCodeTheme } from './theme.js';
|
|
|
29
29
|
/**
|
|
30
30
|
* @import { CodeNode } from '@lexical/code-core';
|
|
31
31
|
* @import { LexicalEditor, LexicalNode, NodeKey } from 'lexical';
|
|
32
|
+
* @import { HighlightedToken } from '../../../typedefs';
|
|
32
33
|
*/
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -450,3 +451,64 @@ export const highlightCodeToHTML = (code, language, { theme } = {}) => {
|
|
|
450
451
|
|
|
451
452
|
return highlighter.codeToHtml(code, { lang: id, theme: resolvedTheme });
|
|
452
453
|
};
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Highlight a snippet of code as plain tokens.
|
|
457
|
+
*
|
|
458
|
+
* Meant for painting the highlighting behind a `<textarea>`, where the highlighted text has to line
|
|
459
|
+
* up with the editable text glyph by glyph. Only the styles that don’t change the text width are
|
|
460
|
+
* kept: colours, bold, which is rendered as a stroke rather than with a bolder font, underline and
|
|
461
|
+
* strikethrough. Italic is dropped. Tokens in the theme’s default foreground colour have no colour,
|
|
462
|
+
* so they can follow the app’s theme instead. Like {@link highlightCodeToHTML}, this is synchronous
|
|
463
|
+
* and returns `undefined` when the engine, grammar or theme is not loaded yet.
|
|
464
|
+
* @param {string} code Code to highlight.
|
|
465
|
+
* @param {string} language Language identifier or alias, like `md`.
|
|
466
|
+
* @param {object} [options] Options.
|
|
467
|
+
* @param {string} [options.theme] Shiki theme ID. Defaults to the one matching the app’s
|
|
468
|
+
* appearance.
|
|
469
|
+
* @returns {HighlightedToken[][] | undefined} Tokens, one array per line, or `undefined` when the
|
|
470
|
+
* code cannot be highlighted yet.
|
|
471
|
+
*/
|
|
472
|
+
export const highlightCodeToTokens = (code, language, { theme } = {}) => {
|
|
473
|
+
const id = normalizeCodeLanguage(language);
|
|
474
|
+
|
|
475
|
+
if (!highlighter || isPlainLanguage(id) || !isCodeLanguageLoaded(id)) {
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const resolvedTheme = theme ?? getCodeTheme();
|
|
480
|
+
|
|
481
|
+
if (!isCodeThemeLoaded(resolvedTheme)) {
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const { tokens, fg = '' } = highlighter.codeToTokens(code, { lang: id, theme: resolvedTheme });
|
|
486
|
+
|
|
487
|
+
return tokens.map((/** @type {any[]} */ line) =>
|
|
488
|
+
line.map(({ content, color, fontStyle = 0 }) => {
|
|
489
|
+
/** @type {HighlightedToken} */
|
|
490
|
+
const token = { content };
|
|
491
|
+
|
|
492
|
+
if (color && color.toLowerCase() !== fg.toLowerCase()) {
|
|
493
|
+
token.color = color;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/* eslint-disable no-bitwise */
|
|
497
|
+
// Shiki’s `FontStyle` flags
|
|
498
|
+
if (fontStyle & 2) {
|
|
499
|
+
token.bold = true;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (fontStyle & 4) {
|
|
503
|
+
token.underline = true;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (fontStyle & 8) {
|
|
507
|
+
token.strikethrough = true;
|
|
508
|
+
}
|
|
509
|
+
/* eslint-enable no-bitwise */
|
|
510
|
+
|
|
511
|
+
return token;
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
};
|
|
@@ -8,5 +8,6 @@
|
|
|
8
8
|
export const CODE_THEME_LIGHT: "github-light";
|
|
9
9
|
export const CODE_THEME_DARK: "github-dark";
|
|
10
10
|
export function getCodeTheme(): string;
|
|
11
|
+
export function onCodeThemeChange(callback: () => void): () => void;
|
|
11
12
|
export function observeCodeTheme(editor: LexicalEditor): () => void;
|
|
12
13
|
import type { LexicalEditor } from 'lexical';
|