@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.
Files changed (39) hide show
  1. package/dist/components/dialog/dialog.svelte +7 -4
  2. package/dist/components/text-editor/constants.d.ts +5 -0
  3. package/dist/components/text-editor/constants.js +6 -0
  4. package/dist/components/text-editor/core.d.ts +6 -2
  5. package/dist/components/text-editor/core.js +316 -46
  6. package/dist/components/text-editor/lexical-root.svelte +39 -3
  7. package/dist/components/text-editor/raw-markdown.d.ts +90 -0
  8. package/dist/components/text-editor/raw-markdown.js +471 -0
  9. package/dist/components/text-editor/shiki/facade.d.ts +4 -0
  10. package/dist/components/text-editor/shiki/facade.js +62 -0
  11. package/dist/components/text-editor/shiki/theme.d.ts +1 -0
  12. package/dist/components/text-editor/shiki/theme.js +31 -27
  13. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  14. package/dist/components/text-editor/shiki/version.js +1 -1
  15. package/dist/components/text-editor/store.svelte.js +39 -1
  16. package/dist/components/text-editor/text-editor.svelte +137 -1
  17. package/dist/components/text-editor/text-editor.svelte.d.ts +19 -3
  18. package/dist/components/text-editor/toolbar/component-editor.svelte +96 -0
  19. package/dist/components/text-editor/toolbar/component-editor.svelte.d.ts +35 -0
  20. package/dist/components/text-editor/toolbar/format-text-button.svelte +69 -9
  21. package/dist/components/text-editor/toolbar/insert-component-dialog.svelte +92 -0
  22. package/dist/components/text-editor/toolbar/insert-component-dialog.svelte.d.ts +30 -0
  23. package/dist/components/text-editor/toolbar/insert-item-button.svelte +13 -2
  24. package/dist/components/text-editor/toolbar/insert-link-button.svelte +89 -10
  25. package/dist/components/text-editor/toolbar/insert-menu-button.svelte +21 -2
  26. package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +4 -4
  27. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +13 -0
  28. package/dist/components/text-field/text-area.svelte +119 -4
  29. package/dist/components/text-field/text-area.svelte.d.ts +17 -0
  30. package/dist/components/util/font-links.svelte +1 -1
  31. package/dist/components/util/fonts.js +1 -1
  32. package/dist/self-hosted/fonts/material-symbols-outlined-latin-wght-normal.woff2 +0 -0
  33. package/dist/services/group.svelte.d.ts +1 -1
  34. package/dist/services/group.svelte.js +14 -6
  35. package/dist/services/tree.svelte.d.ts +6 -1
  36. package/dist/services/tree.svelte.js +23 -7
  37. package/dist/typedefs.d.ts +49 -1
  38. package/dist/typedefs.js +22 -1
  39. package/package.json +2 -2
@@ -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';
@@ -34,23 +34,44 @@ export const getCodeTheme = () => {
34
34
  };
35
35
 
36
36
  /**
37
- * Keep code blocks in sync with the app’s appearance.
38
- *
39
- * Unlike Prism, Shiki bakes token colours into inline styles, so a theme change cannot be handled
40
- * in CSS alone: every code node has to be re-tokenized.
41
- * @param {LexicalEditor} editor Editor instance.
37
+ * Call the given function whenever the app’s appearance, and therefore the matching syntax
38
+ * highlighting theme, may have changed.
39
+ * @param {() => void} callback Function to call.
42
40
  * @returns {() => void} Cleanup function.
43
41
  */
44
- export const observeCodeTheme = (editor) => {
42
+ export const onCodeThemeChange = (callback) => {
45
43
  /* v8 ignore next 3 */
46
44
  if (typeof document === 'undefined') {
47
45
  return () => undefined;
48
46
  }
49
47
 
50
- /**
51
- * Apply the current theme to every code block in the editor.
52
- */
53
- const update = () => {
48
+ const observer = new MutationObserver(callback);
49
+
50
+ observer.observe(document.documentElement, {
51
+ attributes: true,
52
+ attributeFilter: ['data-theme'],
53
+ });
54
+
55
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
56
+
57
+ media.addEventListener('change', callback);
58
+
59
+ return () => {
60
+ observer.disconnect();
61
+ media.removeEventListener('change', callback);
62
+ };
63
+ };
64
+
65
+ /**
66
+ * Keep code blocks in sync with the app’s appearance.
67
+ *
68
+ * Unlike Prism, Shiki bakes token colours into inline styles, so a theme change cannot be handled
69
+ * in CSS alone: every code node has to be re-tokenized.
70
+ * @param {LexicalEditor} editor Editor instance.
71
+ * @returns {() => void} Cleanup function.
72
+ */
73
+ export const observeCodeTheme = (editor) =>
74
+ onCodeThemeChange(() => {
54
75
  const theme = getCodeTheme();
55
76
 
56
77
  editor.update(
@@ -66,21 +87,4 @@ export const observeCodeTheme = (editor) => {
66
87
  },
67
88
  { tag: HISTORY_MERGE_TAG },
68
89
  );
69
- };
70
-
71
- const observer = new MutationObserver(update);
72
-
73
- observer.observe(document.documentElement, {
74
- attributes: true,
75
- attributeFilter: ['data-theme'],
76
90
  });
77
-
78
- const media = window.matchMedia('(prefers-color-scheme: dark)');
79
-
80
- media.addEventListener('change', update);
81
-
82
- return () => {
83
- observer.disconnect();
84
- media.removeEventListener('change', update);
85
- };
86
- };
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
3
3
  */
4
- export const UI_VERSION: "0.77.3";
4
+ export const UI_VERSION: "0.78.0";
@@ -3,4 +3,4 @@
3
3
  /**
4
4
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
5
5
  */
6
- export const UI_VERSION = '0.77.3';
6
+ export const UI_VERSION = '0.78.0';