@sveltia/ui 0.62.0 → 0.63.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 (33) hide show
  1. package/dist/components/button/button.svelte +6 -1
  2. package/dist/components/resizable-pane/resizable-handle.svelte +12 -3
  3. package/dist/components/select/select-tags.svelte +8 -1
  4. package/dist/components/text-editor/constants.d.ts +0 -1
  5. package/dist/components/text-editor/constants.js +1 -36
  6. package/dist/components/text-editor/core.js +24 -31
  7. package/dist/components/text-editor/lexical-root.svelte +0 -68
  8. package/dist/components/text-editor/shiki/cache.d.ts +3 -0
  9. package/dist/components/text-editor/shiki/cache.js +121 -0
  10. package/dist/components/text-editor/shiki/engine-entry.d.ts +2 -0
  11. package/dist/components/text-editor/shiki/engine-entry.js +20 -0
  12. package/dist/components/text-editor/shiki/facade.d.ts +16 -0
  13. package/dist/components/text-editor/shiki/facade.js +452 -0
  14. package/dist/components/text-editor/shiki/generated.d.ts +28 -0
  15. package/dist/components/text-editor/shiki/generated.js +25 -0
  16. package/dist/components/text-editor/shiki/highlighter.d.ts +11 -0
  17. package/dist/components/text-editor/shiki/highlighter.js +477 -0
  18. package/dist/components/text-editor/shiki/loader.d.ts +6 -0
  19. package/dist/components/text-editor/shiki/loader.js +102 -0
  20. package/dist/components/text-editor/shiki/theme.d.ts +12 -0
  21. package/dist/components/text-editor/shiki/theme.js +86 -0
  22. package/dist/components/text-editor/toolbar/code-language-switcher.svelte +24 -34
  23. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +1 -5
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +4 -0
  26. package/dist/services/group.svelte.d.ts +2 -1
  27. package/dist/services/group.svelte.js +183 -50
  28. package/dist/services/tree.svelte.d.ts +8 -6
  29. package/dist/services/tree.svelte.js +104 -45
  30. package/dist/shiki-engine.js +152 -0
  31. package/dist/typedefs.d.ts +52 -0
  32. package/dist/typedefs.js +29 -0
  33. package/package.json +13 -11
@@ -49,6 +49,11 @@
49
49
  } = $props();
50
50
  </script>
51
51
 
52
+ <!--
53
+ The key shortcut handler is attached only when there are shortcuts to bind. A falsy value counts
54
+ as no attachment at all, which saves an effect per button — and both `<Option>` and `<MenuItem>`
55
+ wrap one, so a long list would otherwise pay for an effect per item that has nothing to do.
56
+ -->
52
57
  <button
53
58
  bind:this={element}
54
59
  {...restProps}
@@ -70,7 +75,7 @@
70
75
  data-name={name}
71
76
  data-label={label}
72
77
  data-value={value}
73
- {@attach activateKeyShortcuts(keyShortcuts)}
78
+ {@attach keyShortcuts && activateKeyShortcuts(keyShortcuts)}
74
79
  >
75
80
  {@render startIcon?.()}
76
81
  {#if variant === 'link'}
@@ -91,10 +91,19 @@
91
91
  let keyResizing = $state(false);
92
92
 
93
93
  /**
94
- * Get the pane group container element's size in pixels for px→% conversion.
94
+ * The pane group container's size in pixels, used for px→% conversion. Measured once when a drag
95
+ * begins rather than on every pointer move: reading `clientWidth` forces the browser to lay the
96
+ * page out, which is the last thing wanted in the middle of a drag, and the container cannot
97
+ * change size while the pointer holding it is down.
98
+ * @type {number}
99
+ */
100
+ let containerSize = 0;
101
+
102
+ /**
103
+ * Measure the pane group container element's size in pixels.
95
104
  * @returns {number} Container size in pixels.
96
105
  */
97
- const getContainerSize = () => {
106
+ const measureContainerSize = () => {
98
107
  const container = element?.closest('.resizable-pane-group');
99
108
 
100
109
  if (!container) return 0;
@@ -116,7 +125,6 @@
116
125
 
117
126
  const screenPos = isHorizontal ? screenX : screenY;
118
127
  const pixelDelta = screenPos - startScreenPos;
119
- const containerSize = getContainerSize();
120
128
 
121
129
  if (!containerSize) return;
122
130
 
@@ -167,6 +175,7 @@
167
175
  dragging = true;
168
176
  startScreenPos = isHorizontal ? screenX : screenY;
169
177
  targetPointerId = pointerId;
178
+ containerSize = measureContainerSize();
170
179
  element?.setPointerCapture(pointerId);
171
180
 
172
181
  onResizeStart?.();
@@ -59,6 +59,13 @@
59
59
 
60
60
  /** @type {Map<any, { label: string, value: any, searchValue?: string }>} */
61
61
  const optionMap = $derived(new Map(options.map((o) => [o.value, o])));
62
+ /**
63
+ * The selected values as a set, so the option list below can test membership in constant time.
64
+ * Calling `values.includes()` once per option instead makes rendering the list quadratic in the
65
+ * number of options and selected tags.
66
+ * @type {Set<any>}
67
+ */
68
+ const selectedValues = $derived(new Set(values));
62
69
  const prevKey = $derived(isRTL() ? 'ArrowRight' : 'ArrowLeft');
63
70
  const nextKey = $derived(isRTL() ? 'ArrowLeft' : 'ArrowRight');
64
71
 
@@ -248,7 +255,7 @@
248
255
  }}
249
256
  >
250
257
  {#each options as { label, value, searchValue } (value)}
251
- {#if !values.includes(value)}
258
+ {#if !selectedValues.has(value)}
252
259
  <Option {label} {value} {searchValue} />
253
260
  {/if}
254
261
  {/each}
@@ -8,7 +8,6 @@
8
8
  * TextEditorNodeType,
9
9
  * } from '../../typedefs';
10
10
  */
11
- export const PRISM_BASE_URL: "https://unpkg.com/prismjs@1.30.0";
12
11
  /**
13
12
  * @type {EditorThemeClasses}
14
13
  */
@@ -1,4 +1,4 @@
1
- import { CodeHighlightNode, CodeNode } from '@lexical/code';
1
+ import { CodeHighlightNode, CodeNode } from '@lexical/code-core';
2
2
  import { LinkNode } from '@lexical/link';
3
3
  import { ListItemNode, ListNode } from '@lexical/list';
4
4
  import {
@@ -30,8 +30,6 @@ import { HeadingNode, QuoteNode } from '@lexical/rich-text';
30
30
  * } from '../../typedefs';
31
31
  */
32
32
 
33
- export const PRISM_BASE_URL = `https://unpkg.com/prismjs@1.30.0`;
34
-
35
33
  /**
36
34
  * @type {EditorThemeClasses}
37
35
  */
@@ -50,39 +48,6 @@ export const EDITOR_THEME = {
50
48
  },
51
49
  },
52
50
  code: 'code-block',
53
- // https://github.com/facebook/lexical/blob/main/packages/lexical-website/docs/getting-started/theming.md
54
- codeHighlight: {
55
- atrule: 'token atrule',
56
- attr: 'token attr',
57
- boolean: 'token boolean',
58
- builtin: 'token builtin',
59
- cdata: 'token cdata',
60
- char: 'token char',
61
- class: 'token class',
62
- 'class-name': 'token class-name',
63
- comment: 'token comment',
64
- constant: 'token constant',
65
- deleted: 'token deleted',
66
- doctype: 'token doctype',
67
- entity: 'token entity',
68
- function: 'token function',
69
- important: 'token important',
70
- inserted: 'token inserted',
71
- keyword: 'token keyword',
72
- namespace: 'token namespace',
73
- number: 'token number',
74
- operator: 'token operator',
75
- prolog: 'token prolog',
76
- property: 'token property',
77
- punctuation: 'token punctuation',
78
- regex: 'token regex',
79
- selector: 'token selector',
80
- string: 'token string',
81
- symbol: 'token symbol',
82
- tag: 'token tag',
83
- url: 'token url',
84
- variable: 'token variable',
85
- },
86
51
  };
87
52
 
88
53
  /**
@@ -1,15 +1,10 @@
1
- // Work around the “Prism is not defined” error in consumers
2
- // @see https://github.com/remix-run/remix/discussions/8182
3
- import 'prismjs';
4
-
5
1
  import {
6
2
  CodeHighlightNode,
7
3
  CodeNode,
8
4
  $createCodeNode as createCodeNode,
9
5
  $isCodeHighlightNode as isCodeHighlightNode,
10
6
  $isCodeNode as isCodeNode,
11
- } from '@lexical/code';
12
- import { PrismTokenizer, registerCodeHighlighting } from '@lexical/code-prism';
7
+ } from '@lexical/code-core';
13
8
  import { registerDragonSupport } from '@lexical/dragon';
14
9
  import { HorizontalRuleNode } from '@lexical/extension';
15
10
  import { createEmptyHistoryState, registerHistory } from '@lexical/history';
@@ -59,17 +54,24 @@ import {
59
54
  OUTDENT_CONTENT_COMMAND,
60
55
  PASTE_COMMAND,
61
56
  } from 'lexical';
62
- import prismComponents from 'prismjs/components';
63
57
  import {
64
58
  BLOCK_BUTTON_TYPES,
65
59
  DISABLED_MARKDOWN_TAGS,
66
60
  EDITOR_THEME,
67
61
  NODE_MAP,
68
- PRISM_BASE_URL,
69
62
  TEXT_FORMAT_BUTTON_TYPES,
70
63
  TRANSFORMER_MAP,
71
64
  } from './constants.js';
72
65
  import { increaseListIndentation, splitMultilineFormatting } from './markdown.js';
66
+ import {
67
+ isPlainLanguage,
68
+ loadCodeLanguage,
69
+ loadCodeTheme,
70
+ loadEngine,
71
+ normalizeCodeLanguage,
72
+ } from './shiki/facade.js';
73
+ import { registerCodeHighlighting, shikiTokenizer } from './shiki/highlighter.js';
74
+ import { getCodeTheme, observeCodeTheme } from './shiki/theme.js';
73
75
  import { HR } from './transformers/hr.js';
74
76
  import { TABLE } from './transformers/table.js';
75
77
 
@@ -267,13 +269,13 @@ export const initEditor = ({
267
269
  if (enabledButtons.includes('code-block') || isCodeEditor) {
268
270
  addUnregister(
269
271
  registerCodeHighlighting(editor, {
272
+ ...shikiTokenizer,
270
273
  defaultLanguage,
271
- // eslint-disable-next-line jsdoc/require-jsdoc
272
- tokenize: (code, lang = 'plain') =>
273
- window.Prism.tokenize(code, window.Prism.languages[lang] ?? window.Prism.languages.plain),
274
- $tokenize: PrismTokenizer.$tokenize,
274
+ defaultTheme: getCodeTheme(),
275
275
  }),
276
276
  );
277
+
278
+ addUnregister(observeCodeTheme(editor));
277
279
  }
278
280
 
279
281
  // https://github.com/facebook/lexical/blob/main/packages/lexical-link/src/LexicalLinkExtension.ts
@@ -466,31 +468,21 @@ export const initEditor = ({
466
468
  };
467
469
 
468
470
  /**
469
- * Load additional Prism syntax highlighter settings for the given programming language.
471
+ * Preload the syntax highlighter for the given programming language.
472
+ *
473
+ * Highlighting also works without this — the transform loads whatever it needs and re-highlights
474
+ * once it arrives — but preloading avoids a visible flash of unhighlighted code.
470
475
  * @param {string} lang Language name, like scss.
471
476
  */
472
477
  export const loadCodeHighlighter = async (lang) => {
473
- if (lang in window.Prism.languages) {
478
+ if (isPlainLanguage(lang)) {
474
479
  return;
475
480
  }
476
481
 
477
- const canonicalLang = Object.entries(prismComponents.languages).find(
478
- // @ts-ignore
479
- ([key, { alias }]) =>
480
- key === lang ||
481
- (Array.isArray(alias) ? alias.includes(lang) : /* v8 ignore next */ alias === lang),
482
- )?.[0];
482
+ // The grammar and theme loaders are no-ops until the engine is in place
483
+ await loadEngine();
483
484
 
484
- if (!canonicalLang) {
485
- return;
486
- }
487
-
488
- try {
489
- // eslint-disable-next-line jsdoc/no-bad-blocks
490
- await import(/* @vite-ignore */ `${PRISM_BASE_URL}/components/prism-${canonicalLang}.min.js`);
491
- } catch {
492
- //
493
- }
485
+ await Promise.all([loadCodeLanguage(normalizeCodeLanguage(lang)), loadCodeTheme(getCodeTheme())]);
494
486
  };
495
487
 
496
488
  /**
@@ -502,7 +494,8 @@ export const loadCodeHighlighter = async (lang) => {
502
494
  * @throws {Error} Failed to convert the value to Lexical nodes.
503
495
  */
504
496
  export const convertMarkdownToLexical = async (editor, value, enabledTransformers) => {
505
- // Load Prism language support on demand; the `loadLanguages` Prism utility method cannot be used
497
+ // Preload the highlighter for every language used in the document, so code blocks are highlighted
498
+ // as soon as they appear rather than a moment later
506
499
  await Promise.all(
507
500
  [...value.matchAll(/^```(?<lang>.+?)\n/gm)].map(async ({ groups: { lang = 'plain' } = {} }) =>
508
501
  loadCodeHighlighter(lang),
@@ -222,72 +222,4 @@
222
222
  background-color: var(--sui-control-border-color);
223
223
  line-height: 2px;
224
224
  content: "";
225
- }
226
-
227
- :root[data-theme=light] .lexical-root :global(.token:is(.comment, .prolog, .doctype, .cdata)) {
228
- color: slategray;
229
- }
230
- :root[data-theme=light] .lexical-root :global(.token.punctuation) {
231
- color: #999;
232
- }
233
- :root[data-theme=light] .lexical-root :global(.token.namespace) {
234
- opacity: 0.7;
235
- }
236
- :root[data-theme=light] .lexical-root :global(.token:is(.property, .tag, .boolean, .number, .constant, .symbol, .deleted)) {
237
- color: #905;
238
- }
239
- :root[data-theme=light] .lexical-root :global(.token:is(.selector, .attr-name, .string, .char, .builtin, .inserted)) {
240
- color: #690;
241
- }
242
- :root[data-theme=light] .lexical-root :global(.token:is(.operator, .entity, .url)), :root[data-theme=light] .lexical-root :global(.language-css) :global(.token.string), :root[data-theme=light] .lexical-root :global(.style) :global(.token.string) {
243
- color: #9a6e3a;
244
- }
245
- :root[data-theme=light] .lexical-root :global(.token:is(.atrule, .attr-value, .keyword)) {
246
- color: #07a;
247
- }
248
- :root[data-theme=light] .lexical-root :global(.token:is(.function, .class-name)) {
249
- color: #dd4a68;
250
- }
251
- :root[data-theme=light] .lexical-root :global(.token:is(.regex, .important, .variable)) {
252
- color: #e90;
253
- }
254
-
255
- :root[data-theme=dark] .lexical-root :global(.token:is(.comment, .block-comment, .prolog, .doctype, .cdata)) {
256
- color: #999;
257
- }
258
- :root[data-theme=dark] .lexical-root :global(.token.punctuation) {
259
- color: #ccc;
260
- }
261
- :root[data-theme=dark] .lexical-root :global(.token:is(.tag, .attr-name, .namespace, .deleted)) {
262
- color: #e2777a;
263
- }
264
- :root[data-theme=dark] .lexical-root :global(.token.function-name) {
265
- color: #6196cc;
266
- }
267
- :root[data-theme=dark] .lexical-root :global(.token:is(.boolean, .number, .function)) {
268
- color: #f08d49;
269
- }
270
- :root[data-theme=dark] .lexical-root :global(.token:is(.property, .class-name, .constant, .symbol)) {
271
- color: #f8c555;
272
- }
273
- :root[data-theme=dark] .lexical-root :global(.token:is(.selector, .important, .atrule, .keyword, .builtin)) {
274
- color: #cc99cd;
275
- }
276
- :root[data-theme=dark] .lexical-root :global(.token:is(.string, .char, .attr-value, .regex, .variable)) {
277
- color: #7ec699;
278
- }
279
- :root[data-theme=dark] .lexical-root :global(.token:is(.operator, .entity, .url)) {
280
- color: #67cdcc;
281
- }
282
- :root[data-theme=dark] .lexical-root :global(.token:is(.important, .bold)) {
283
- font-weight: bold;
284
- }
285
- :root[data-theme=dark] .lexical-root :global(.token.italic) {
286
- font-style: italic;
287
- }
288
- :root[data-theme=dark] .lexical-root :global(.token.entity) {
289
- cursor: help;
290
- }
291
- :root[data-theme=dark] .lexical-root :global(.token.inserted) {
292
- color: green;
293
225
  }</style>
@@ -0,0 +1,3 @@
1
+ export function setCodeHighlighterCacheEnabled(value: boolean): void;
2
+ export function getCachedPayload(kind: string, id: string): Promise<any>;
3
+ export function cachePayload(kind: string, id: string, payload: any): Promise<void>;
@@ -0,0 +1,121 @@
1
+ import { IndexedDB } from '@sveltia/utils/storage';
2
+ import { SHIKI_VERSION } from './generated.js';
3
+
4
+ /**
5
+ * Client-side cache for Shiki grammars and themes.
6
+ *
7
+ * Grammars and themes resolve to plain data, so they can be stored and replayed without going back
8
+ * to the network. That saves more than bytes: a grammar references its embedded languages by
9
+ * relative path, so loading Vue or Svelte fans out to several requests, and a cache hit collapses
10
+ * all of them into one read.
11
+ *
12
+ * The engine is deliberately not cached here. It is executable code, so replaying it from storage
13
+ * would mean importing a `blob:` URL, which needs `script-src blob:` in the page’s CSP. It also
14
+ * gains nothing, because it is fetched from a version-immutable URL that the browser’s HTTP cache
15
+ * already keeps.
16
+ */
17
+
18
+ const DATABASE_NAME = 'sveltia-ui';
19
+ const STORE_NAME = 'shiki';
20
+ /** @type {IndexedDB | undefined | null} `null` once the store is known to be unusable. */
21
+ let database;
22
+ /** @type {Promise<void> | undefined} */
23
+ let purgePromise;
24
+ let enabled = true;
25
+
26
+ /**
27
+ * Enable or disable the cache.
28
+ *
29
+ * Worth turning off when the grammars are bundled with the app rather than fetched, since reading
30
+ * them back from storage is then slower than the bundled import it replaces.
31
+ * @param {boolean} value Whether to use the cache.
32
+ */
33
+ export const setCodeHighlighterCacheEnabled = (value) => {
34
+ enabled = value;
35
+ };
36
+
37
+ /**
38
+ * Build the cache key for a payload.
39
+ * @param {string} kind Payload kind, either `lang` or `theme`.
40
+ * @param {string} id Language or theme ID.
41
+ * @returns {string} Key, scoped to the Shiki version the payload came from.
42
+ */
43
+ const getKey = (kind, id) => `${SHIKI_VERSION}/${kind}/${id}`;
44
+
45
+ /**
46
+ * Get the store, or `null` when IndexedDB is unavailable, as in a server-side render or with
47
+ * storage blocked.
48
+ * @returns {IndexedDB | null} Store.
49
+ */
50
+ const getDatabase = () => {
51
+ if (database === undefined) {
52
+ database = typeof indexedDB === 'undefined' ? null : new IndexedDB(DATABASE_NAME, STORE_NAME);
53
+ }
54
+
55
+ return database;
56
+ };
57
+
58
+ /**
59
+ * Drop anything cached for a different Shiki version, once per session. A version bump changes
60
+ * every key, so the old payloads would otherwise linger forever.
61
+ * @param {IndexedDB} db Store.
62
+ * @returns {Promise<void>} Nothing.
63
+ */
64
+ const purgeStaleVersions = async (db) => {
65
+ purgePromise ??= (async () => {
66
+ const stale = (await db.keys()).filter(
67
+ (key) => typeof key === 'string' && !key.startsWith(`${SHIKI_VERSION}/`),
68
+ );
69
+
70
+ if (stale.length) {
71
+ await db.deleteEntries(stale);
72
+ }
73
+ })();
74
+
75
+ return purgePromise;
76
+ };
77
+
78
+ /**
79
+ * Read a cached grammar or theme.
80
+ * @param {string} kind Payload kind, either `lang` or `theme`.
81
+ * @param {string} id Language or theme ID.
82
+ * @returns {Promise<any>} Cached payload, or `undefined` when not cached or unreadable.
83
+ */
84
+ export const getCachedPayload = async (kind, id) => {
85
+ const db = enabled ? getDatabase() : null;
86
+
87
+ if (!db) {
88
+ return undefined;
89
+ }
90
+
91
+ try {
92
+ await purgeStaleVersions(db);
93
+
94
+ return await db.get(getKey(kind, id));
95
+ } catch {
96
+ // A failed cache read must never stop a code block from highlighting
97
+ return undefined;
98
+ }
99
+ };
100
+
101
+ /**
102
+ * Store a grammar or theme.
103
+ * @param {string} kind Payload kind, either `lang` or `theme`.
104
+ * @param {string} id Language or theme ID.
105
+ * @param {any} payload Payload to store. Must be structured-cloneable, which grammars and themes
106
+ * are, being plain data.
107
+ * @returns {Promise<void>} Nothing.
108
+ */
109
+ export const cachePayload = async (kind, id, payload) => {
110
+ const db = enabled ? getDatabase() : null;
111
+
112
+ if (!db) {
113
+ return;
114
+ }
115
+
116
+ try {
117
+ await db.set(getKey(kind, id), payload);
118
+ } catch {
119
+ // Out of quota, storage blocked, or a payload that won’t clone: not worth failing over
120
+ }
121
+ };
@@ -0,0 +1,2 @@
1
+ export { createJavaScriptRegexEngine } from "shiki/engine/javascript";
2
+ export { createHighlighterCoreSync, getTokenStyleObject, isSpecialLang, isSpecialTheme, stringifyTokenStyle } from "shiki/core";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Entry point for the standalone Shiki engine chunk built by `scripts/build-shiki-engine.js`.
3
+ *
4
+ * This is the complete set of Shiki APIs the vendored highlighter needs. It is never imported
5
+ * directly by the library — only bundled into `dist/shiki-engine.js` and loaded at runtime — so
6
+ * Shiki stays a development dependency and never reaches a consumer’s bundle.
7
+ *
8
+ * Everything is imported through the `shiki` package rather than from `@shikijs/*` directly,
9
+ * because `shiki` pins those to exact versions. That keeps the engine bundled here and the
10
+ * grammars fetched at runtime on the same version, which `generated.js` records.
11
+ */
12
+
13
+ export {
14
+ createHighlighterCoreSync,
15
+ getTokenStyleObject,
16
+ isSpecialLang,
17
+ isSpecialTheme,
18
+ stringifyTokenStyle,
19
+ } from 'shiki/core';
20
+ export { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
@@ -0,0 +1,16 @@
1
+ export function isPlainLanguage(language: string | null | undefined): boolean;
2
+ export function isEngineLoaded(): boolean;
3
+ export function isEngineUnavailable(): boolean;
4
+ export function isCodeLanguageLoaded(language: string): boolean;
5
+ export function loadEngine(editor?: LexicalEditor | undefined, codeNodeKey?: string | undefined): Promise<void> | undefined;
6
+ export function normalizeCodeLanguage(language: string): string;
7
+ export function loadCodeLanguage(language: string, editor?: LexicalEditor | undefined, codeNodeKey?: string | undefined): Promise<void> | undefined;
8
+ export function isCodeThemeLoaded(theme: string): boolean;
9
+ export function loadCodeTheme(theme: string, editor?: LexicalEditor | undefined, codeNodeKey?: string | undefined): Promise<void> | undefined;
10
+ export function getHighlightNodes(codeNode: CodeNode, language: string): LexicalNode[];
11
+ export function highlightCodeToHTML(code: string, language: string, { theme }?: {
12
+ theme?: string | undefined;
13
+ } | undefined): string | undefined;
14
+ import type { LexicalEditor } from 'lexical';
15
+ import type { CodeNode } from '@lexical/code-core';
16
+ import type { LexicalNode } from 'lexical';