@sveltia/ui 0.72.0 → 0.72.2

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 (90) hide show
  1. package/dist/components/alert/infobar.svelte +1 -1
  2. package/dist/components/button/button.svelte +1 -1
  3. package/dist/components/button/select-button.svelte +2 -1
  4. package/dist/components/calendar/calendar.d.ts +20 -0
  5. package/dist/components/calendar/calendar.js +81 -0
  6. package/dist/components/calendar/calendar.svelte +23 -45
  7. package/dist/components/checkbox/checkbox-group.svelte +1 -1
  8. package/dist/components/checkbox/checkbox.svelte +6 -1
  9. package/dist/components/dialog/dialog.svelte +1 -1
  10. package/dist/components/drawer/drawer.svelte +1 -1
  11. package/dist/components/emoji/emoji-suggestions.svelte +14 -40
  12. package/dist/components/emoji/position.d.ts +44 -0
  13. package/dist/components/emoji/position.js +60 -0
  14. package/dist/components/progressbar/progressbar.svelte +1 -1
  15. package/dist/components/radio/radio-group.svelte +1 -1
  16. package/dist/components/resizable-pane/resizable-handle.svelte +15 -47
  17. package/dist/components/resizable-pane/resizable-pane-group.svelte +37 -100
  18. package/dist/components/resizable-pane/resizable-pane.svelte +1 -1
  19. package/dist/components/resizable-pane/sizing.d.ts +48 -0
  20. package/dist/components/resizable-pane/sizing.js +190 -0
  21. package/dist/components/scroll/infinite-scroll.svelte +2 -0
  22. package/dist/components/select/combobox.svelte +3 -1
  23. package/dist/components/select/select-tags.d.ts +17 -0
  24. package/dist/components/select/select-tags.js +95 -0
  25. package/dist/components/select/select-tags.svelte +26 -28
  26. package/dist/components/slider/slider.d.ts +17 -0
  27. package/dist/components/slider/slider.js +96 -0
  28. package/dist/components/slider/slider.svelte +30 -56
  29. package/dist/components/tabs/tab-box.svelte +1 -1
  30. package/dist/components/tabs/tab-list.d.ts +6 -0
  31. package/dist/components/tabs/tab-list.js +19 -0
  32. package/dist/components/tabs/tab-list.svelte +2 -14
  33. package/dist/components/text-editor/code-editor.d.ts +5 -0
  34. package/dist/components/text-editor/code-editor.js +28 -0
  35. package/dist/components/text-editor/code-editor.svelte +7 -8
  36. package/dist/components/text-editor/emoji-autocomplete.svelte +9 -1
  37. package/dist/components/text-editor/lexical-root.svelte +3 -1
  38. package/dist/components/text-editor/shiki/facade.js +1 -1
  39. package/dist/components/text-editor/shiki/highlighter.js +40 -15
  40. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  41. package/dist/components/text-editor/shiki/version.js +1 -1
  42. package/dist/components/text-editor/store.svelte.js +17 -7
  43. package/dist/components/text-editor/text-editor.svelte +4 -0
  44. package/dist/components/text-editor/toolbar/code-editor-toolbar.svelte +1 -1
  45. package/dist/components/text-editor/toolbar/code-language-switcher.svelte +4 -1
  46. package/dist/components/text-editor/toolbar/format-text-button.svelte +3 -1
  47. package/dist/components/text-editor/toolbar/insert-item-button.svelte +1 -1
  48. package/dist/components/text-editor/toolbar/insert-link-button.svelte +10 -3
  49. package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +4 -5
  50. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +2 -0
  51. package/dist/components/text-field/emoji-autocomplete.svelte +2 -0
  52. package/dist/components/text-field/number-input.d.ts +15 -0
  53. package/dist/components/text-field/number-input.js +98 -0
  54. package/dist/components/text-field/number-input.svelte +27 -23
  55. package/dist/components/text-field/text-input.svelte +9 -3
  56. package/dist/components/toast/toast.svelte +7 -2
  57. package/dist/components/toolbar/toolbar.svelte +1 -1
  58. package/dist/components/tree/tree-item.svelte +3 -2
  59. package/dist/components/typography/truncated-text.svelte +2 -1
  60. package/dist/components/util/app-shell.d.ts +1 -0
  61. package/dist/components/util/app-shell.js +29 -0
  62. package/dist/components/util/app-shell.svelte +5 -18
  63. package/dist/components/util/modal.d.ts +5 -0
  64. package/dist/components/util/modal.js +22 -0
  65. package/dist/components/util/modal.svelte +9 -10
  66. package/dist/components/util/popup.svelte +11 -0
  67. package/dist/locales/ar.yaml +2 -2
  68. package/dist/locales/ca.yaml +2 -2
  69. package/dist/locales/cs.yaml +2 -2
  70. package/dist/locales/da.yaml +2 -2
  71. package/dist/locales/de.yaml +1 -1
  72. package/dist/locales/el.yaml +2 -2
  73. package/dist/locales/en-CA.yaml +1 -1
  74. package/dist/locales/en-GB.yaml +1 -1
  75. package/dist/locales/en-US.yaml +1 -1
  76. package/dist/locales/es-CO.yaml +2 -2
  77. package/dist/locales/fa.yaml +2 -2
  78. package/dist/locales/fi.yaml +1 -1
  79. package/dist/locales/fr.yaml +2 -2
  80. package/dist/locales/hr.yaml +2 -2
  81. package/dist/locales/it.yaml +2 -2
  82. package/dist/locales/nl.yaml +1 -1
  83. package/dist/locales/pl.yaml +2 -2
  84. package/dist/locales/pt-BR.yaml +2 -2
  85. package/dist/locales/pt-PT.yaml +2 -2
  86. package/dist/locales/sv.yaml +2 -2
  87. package/dist/locales/zh-CN.yaml +1 -1
  88. package/dist/locales/zh-TW.yaml +1 -1
  89. package/dist/styles/variables.scss +1 -0
  90. package/package.json +13 -9
@@ -0,0 +1,17 @@
1
+ export function getSliderSteps({ min, max, step, barWidth }: {
2
+ min: number;
3
+ max: number;
4
+ step: number;
5
+ barWidth: number;
6
+ }): {
7
+ valueList: number[];
8
+ positionList: number[];
9
+ };
10
+ export function toLogicalX(physicalX: number, barWidth: number, rtl: boolean): number;
11
+ export function findNearestStepIndex(positionList: number[], logicalX: number): number;
12
+ export function getSliderKeyDirection(key: string, rtl: boolean): 1 | -1 | 0;
13
+ export function wouldCrossThumbs({ valueIndex, targetPosition, sliderPositions }: {
14
+ valueIndex: number;
15
+ targetPosition: number;
16
+ sliderPositions: number[];
17
+ }): boolean;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Geometry and keyboard logic behind `<Slider>`, kept apart from the component so the value
3
+ * calculations can be tested without a rendered track.
4
+ */
5
+
6
+ /**
7
+ * Get the values a thumb can take and where each of them sits along the track.
8
+ * @param {object} options Options.
9
+ * @param {number} options.min Minimum value.
10
+ * @param {number} options.max Maximum value.
11
+ * @param {number} options.step Distance between two values.
12
+ * @param {number} options.barWidth Width of the track in pixels.
13
+ * @returns {{ valueList: number[], positionList: number[] }} Values from `min` to `max` in `step`
14
+ * increments, and the matching pixel offsets from the logical start of the track.
15
+ */
16
+ export const getSliderSteps = ({ min, max, step, barWidth }) => {
17
+ const stepCount = (max - min) / step + 1;
18
+ const stepWidth = barWidth / (stepCount - 1);
19
+ const emptyArray = Array.from({ length: stepCount });
20
+
21
+ return {
22
+ valueList: emptyArray.map((_, index) => index * step + min),
23
+ positionList: emptyArray.map((_, index) => index * stepWidth),
24
+ };
25
+ };
26
+
27
+ /**
28
+ * Convert a physical pointer position into a logical one along the track. The logical position
29
+ * always runs from the minimum value at `0` to the maximum at `barWidth`, so in a right-to-left
30
+ * layout, where the minimum sits on the right, the axis is mirrored. The result is clamped to the
31
+ * track, so a fast drag that overshoots the edge still resolves to the nearest valid position
32
+ * instead of dropping the update.
33
+ * @param {number} physicalX Position in pixels from the left edge of the track.
34
+ * @param {number} barWidth Width of the track in pixels.
35
+ * @param {boolean} rtl Whether the layout is right-to-left.
36
+ * @returns {number} Logical position in pixels.
37
+ */
38
+ export const toLogicalX = (physicalX, barWidth, rtl) =>
39
+ Math.min(barWidth, Math.max(0, rtl ? barWidth - physicalX : physicalX));
40
+
41
+ /**
42
+ * Find the step closest to the given position along the track.
43
+ * @param {number[]} positionList Pixel offset of each step, in ascending order.
44
+ * @param {number} logicalX Logical position in pixels.
45
+ * @returns {number} Index of the nearest step, or `-1` if there are no steps.
46
+ */
47
+ export const findNearestStepIndex = (positionList, logicalX) => {
48
+ const fromIndex = positionList.findLastIndex((s) => s <= logicalX);
49
+ const toIndex = positionList.findIndex((s) => logicalX <= s);
50
+
51
+ if (fromIndex === -1) {
52
+ return toIndex;
53
+ }
54
+
55
+ if (toIndex === -1) {
56
+ return fromIndex;
57
+ }
58
+
59
+ const fromDiff = Math.abs(positionList[fromIndex] - logicalX);
60
+ const toDiff = Math.abs(positionList[toIndex] - logicalX);
61
+
62
+ return fromDiff < toDiff ? fromIndex : toIndex;
63
+ };
64
+
65
+ /**
66
+ * Get the direction an arrow key moves the thumb in. The vertical keys don’t depend on the layout,
67
+ * while the horizontal ones follow the visual direction of the track, so in a right-to-left layout
68
+ * `ArrowLeft` increases the value.
69
+ * @param {string} key The key pressed.
70
+ * @param {boolean} rtl Whether the layout is right-to-left.
71
+ * @returns {1 | -1 | 0} `1` to increase, `-1` to decrease, `0` if the key doesn’t move the thumb.
72
+ */
73
+ export const getSliderKeyDirection = (key, rtl) => {
74
+ if (key === 'ArrowUp' || key === (rtl ? 'ArrowLeft' : 'ArrowRight')) {
75
+ return 1;
76
+ }
77
+
78
+ if (key === 'ArrowDown' || key === (rtl ? 'ArrowRight' : 'ArrowLeft')) {
79
+ return -1;
80
+ }
81
+
82
+ return 0;
83
+ };
84
+
85
+ /**
86
+ * Whether moving one thumb of a multi-thumb slider to the given position would make it cross, or
87
+ * land on, the other thumb.
88
+ * @param {object} options Options.
89
+ * @param {number} options.valueIndex Index of the thumb being moved, `0` or `1`.
90
+ * @param {number} options.targetPosition Position the thumb would move to, in pixels.
91
+ * @param {number[]} options.sliderPositions Current position of each thumb, in pixels.
92
+ * @returns {boolean} `true` if the move would cross the other thumb.
93
+ */
94
+ export const wouldCrossThumbs = ({ valueIndex, targetPosition, sliderPositions }) =>
95
+ (valueIndex === 0 && sliderPositions[1] <= targetPosition) ||
96
+ (valueIndex === 1 && sliderPositions[0] >= targetPosition);
@@ -10,6 +10,13 @@
10
10
  <script>
11
11
  import { isRTL } from '@sveltia/i18n';
12
12
  import { onMount } from 'svelte';
13
+ import {
14
+ findNearestStepIndex,
15
+ getSliderKeyDirection,
16
+ getSliderSteps,
17
+ toLogicalX,
18
+ wouldCrossThumbs,
19
+ } from './slider.js';
13
20
 
14
21
  /**
15
22
  * @import { Snippet } from 'svelte';
@@ -85,33 +92,16 @@
85
92
  * @param {number} physicalX Physical X position in pixels from the left edge.
86
93
  */
87
94
  const moveThumb = (physicalX) => {
88
- // Convert physical position to logical position (always LTR)
89
- // In RTL, left side (physicalX=0) maps to max value (logicalX=barWidth)
90
- // In LTR, left side (physicalX=0) maps to min value (logicalX=0)
91
- // Clamp to the track bounds so a fast drag that overshoots the edge still resolves to the
92
- // nearest valid position instead of dropping the update.
93
- const logicalX = Math.min(barWidth, Math.max(0, isRTL() ? barWidth - physicalX : physicalX));
94
- const fromIndex = positionList.findLastIndex((s) => s <= logicalX);
95
- const toIndex = positionList.findIndex((s) => logicalX <= s);
96
- /** @type {number} */
97
- let index;
98
-
99
- if (fromIndex === -1) {
100
- index = toIndex;
101
- } else if (toIndex === -1) {
102
- index = fromIndex;
103
- } else {
104
- const fromDiff = Math.abs(positionList[fromIndex] - logicalX);
105
- const toDiff = Math.abs(positionList[toIndex] - logicalX);
106
-
107
- index = fromDiff < toDiff ? fromIndex : toIndex;
108
- }
95
+ const index = findNearestStepIndex(positionList, toLogicalX(physicalX, barWidth, isRTL()));
109
96
 
110
97
  if (
111
98
  sliderPositions[targetValueIndex] === positionList[index] ||
112
99
  (multiThumb &&
113
- ((targetValueIndex === 0 && sliderPositions[1] <= positionList[index]) ||
114
- (targetValueIndex === 1 && sliderPositions[0] >= positionList[index])))
100
+ wouldCrossThumbs({
101
+ valueIndex: targetValueIndex,
102
+ targetPosition: positionList[index],
103
+ sliderPositions,
104
+ }))
115
105
  ) {
116
106
  return;
117
107
  }
@@ -138,35 +128,24 @@
138
128
  }
139
129
 
140
130
  const _value = multiThumb ? /** @type {[number, number]} */ (values)[valueIndex] : value;
141
- let index = -1;
142
- const _isRTL = isRTL();
143
- // In RTL, ArrowLeft increases value, ArrowRight decreases value
144
- const decreaseKeys = _isRTL ? ['ArrowDown', 'ArrowRight'] : ['ArrowDown', 'ArrowLeft'];
145
- const increaseKeys = _isRTL ? ['ArrowUp', 'ArrowLeft'] : ['ArrowUp', 'ArrowRight'];
146
-
147
- if (decreaseKeys.includes(key)) {
148
- if (_value > min) {
149
- index = valueList.indexOf(_value) - 1;
150
- }
131
+ const direction = getSliderKeyDirection(key, isRTL());
151
132
 
152
- event.preventDefault();
153
- event.stopPropagation();
133
+ if (!direction) {
134
+ return;
154
135
  }
155
136
 
156
- if (increaseKeys.includes(key)) {
157
- if (_value < max) {
158
- index = valueList.indexOf(_value) + 1;
159
- }
137
+ event.preventDefault();
138
+ event.stopPropagation();
160
139
 
161
- event.preventDefault();
162
- event.stopPropagation();
163
- }
140
+ const index =
141
+ (direction < 0 && _value > min) || (direction > 0 && _value < max)
142
+ ? valueList.indexOf(_value) + direction
143
+ : -1;
164
144
 
165
145
  if (index > -1) {
166
146
  if (
167
147
  multiThumb &&
168
- ((valueIndex === 0 && sliderPositions[1] <= positionList[index]) ||
169
- (valueIndex === 1 && sliderPositions[0] >= positionList[index]))
148
+ wouldCrossThumbs({ valueIndex, targetPosition: positionList[index], sliderPositions })
170
149
  ) {
171
150
  return;
172
151
  }
@@ -289,19 +268,14 @@
289
268
  * Initialize the variables.
290
269
  */
291
270
  const init = () => {
271
+ // Only called once mounted, when the track is bound
272
+ /* v8 ignore next */
292
273
  if (!base) {
293
274
  return;
294
275
  }
295
276
 
296
277
  barWidth = base.clientWidth;
297
-
298
- const stepCount = (max - min) / step + 1;
299
- const stepWidth = barWidth / (stepCount - 1);
300
- const emptyArray = Array.from({ length: stepCount });
301
-
302
- valueList = emptyArray.map((_, index) => index * step + min, 10);
303
- positionList = emptyArray.map((_, index) => index * stepWidth);
304
-
278
+ ({ valueList, positionList } = getSliderSteps({ min, max, step, barWidth }));
305
279
  onValueChange();
306
280
  };
307
281
 
@@ -345,8 +319,8 @@
345
319
  <div role="none" class="base-bar"></div>
346
320
  <div
347
321
  class="slider-bar"
348
- style:inset-inline-start="{multiThumb ? sliderPositions[0] : 0}px"
349
- style:width="{multiThumb ? sliderPositions[1] - sliderPositions[0] : sliderPositions[0]}px"
322
+ style:inset-inline-start={`${multiThumb ? sliderPositions[0] : 0}px`}
323
+ style:width={`${multiThumb ? sliderPositions[1] - sliderPositions[0] : sliderPositions[0]}px`}
350
324
  ></div>
351
325
  <div
352
326
  role="slider"
@@ -359,7 +333,7 @@
359
333
  aria-valuemin={min}
360
334
  aria-valuemax={max}
361
335
  aria-valuenow={multiThumb ? values?.[0] : value}
362
- style:inset-inline-start="{sliderPositions[0]}px"
336
+ style:inset-inline-start={`${sliderPositions[0]}px`}
363
337
  onpointerdown={(event) => onPointerDown(event, 0)}
364
338
  onkeydown={(event) => onKeyDown(event, 0)}
365
339
  ></div>
@@ -375,7 +349,7 @@
375
349
  aria-valuemin={min}
376
350
  aria-valuemax={max}
377
351
  aria-valuenow={values?.[1]}
378
- style:inset-inline-start="{sliderPositions[1]}px"
352
+ style:inset-inline-start={`${sliderPositions[1]}px`}
379
353
  onpointerdown={(event) => onPointerDown(event, 1)}
380
354
  onkeydown={(event) => onKeyDown(event, 1)}
381
355
  ></div>
@@ -29,7 +29,7 @@
29
29
  } = $props();
30
30
  </script>
31
31
 
32
- <div {...restProps} role="none" class="sui tab-box {orientation} {className}">
32
+ <div {...restProps} role="none" class={['sui', 'tab-box', orientation, className]}>
33
33
  {@render children?.()}
34
34
  </div>
35
35
 
@@ -0,0 +1,6 @@
1
+ export function getIndicatorStyle({ offsetTop, offsetLeft, offsetWidth, offsetHeight }: {
2
+ offsetTop: number;
3
+ offsetLeft: number;
4
+ offsetWidth: number;
5
+ offsetHeight: number;
6
+ }): string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Helpers behind `<TabList>`, kept apart from the component so they can be tested on their own.
3
+ */
4
+
5
+ /**
6
+ * Get the inline style placing the selection indicator over the given tab.
7
+ * @param {{ offsetTop: number, offsetLeft: number, offsetWidth: number, offsetHeight: number }}
8
+ * tab The selected tab’s offset box, relative to the tab list.
9
+ * @returns {string} CSS declarations for the indicator.
10
+ */
11
+ export const getIndicatorStyle = ({ offsetTop, offsetLeft, offsetWidth, offsetHeight }) =>
12
+ Object.entries({
13
+ top: offsetTop,
14
+ left: offsetLeft,
15
+ width: offsetWidth,
16
+ height: offsetHeight,
17
+ })
18
+ .map(([key, value]) => `${key}: ${value}px`)
19
+ .join('; ');
@@ -7,6 +7,7 @@
7
7
  <script>
8
8
  import { onMount } from 'svelte';
9
9
  import { activateGroup } from '../../services/group.svelte.js';
10
+ import { getIndicatorStyle } from './tab-list.js';
10
11
 
11
12
  /**
12
13
  * @import { Snippet } from 'svelte';
@@ -63,20 +64,7 @@
63
64
  wrapper?.querySelector('[role="tab"][aria-selected="true"]')
64
65
  );
65
66
 
66
- if (selected) {
67
- const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = selected;
68
-
69
- indicatorStyle = Object.entries({
70
- top: offsetTop,
71
- left: offsetLeft,
72
- width: offsetWidth,
73
- height: offsetHeight,
74
- })
75
- .map(([key, value]) => `${key}: ${value}px`)
76
- .join('; ');
77
- } else {
78
- indicatorStyle = undefined;
79
- }
67
+ indicatorStyle = selected ? getIndicatorStyle(selected) : undefined;
80
68
  });
81
69
  };
82
70
 
@@ -0,0 +1,5 @@
1
+ export function toCodeBlock(lang: string, code: string): string;
2
+ export function parseCodeBlock(markdown: string): {
3
+ lang: string;
4
+ code: string;
5
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Conversion between the code and language a `<CodeEditor>` exposes and the Markdown fenced code
3
+ * block the underlying editor works with.
4
+ */
5
+
6
+ const BACKTICKS = '```';
7
+
8
+ /**
9
+ * Wrap code in a Markdown fenced code block.
10
+ * @param {string} lang Language identifier, placed after the opening fence.
11
+ * @param {string} code Code. If empty, the block has no content line at all.
12
+ * @returns {string} Fenced code block.
13
+ */
14
+ export const toCodeBlock = (lang, code) =>
15
+ code ? `${BACKTICKS}${lang}\n${code}\n${BACKTICKS}` : `${BACKTICKS}${lang}\n${BACKTICKS}`;
16
+
17
+ /**
18
+ * Take a Markdown fenced code block apart.
19
+ * @param {string} markdown Fenced code block, as produced by {@link toCodeBlock}.
20
+ * @returns {{ lang: string, code: string }} Language identifier, `plain` if the fence has none,
21
+ * and the code, empty if the block has no content or `markdown` is not a code block at all.
22
+ */
23
+ export const parseCodeBlock = (markdown) => {
24
+ const { lang = 'plain', code = '' } =
25
+ markdown.match(/^```(?<lang>\w+?)?\n(?:(?<code>.*)\n)?```/s)?.groups ?? {};
26
+
27
+ return { lang, code };
28
+ };
@@ -7,6 +7,7 @@
7
7
  import { setContext, untrack } from 'svelte';
8
8
  import Alert from '../alert/alert.svelte';
9
9
  import Toast from '../toast/toast.svelte';
10
+ import { parseCodeBlock, toCodeBlock } from './code-editor.js';
10
11
  import LexicalRoot from './lexical-root.svelte';
11
12
  import { createEditorStore } from './store.svelte.js';
12
13
  import CodeEditorToolbar from './toolbar/code-editor-toolbar.svelte';
@@ -53,7 +54,6 @@
53
54
  /* eslint-enable prefer-const */
54
55
  } = $props();
55
56
 
56
- const backticks = '```';
57
57
  const editorStore = createEditorStore();
58
58
 
59
59
  editorStore.config = {
@@ -66,6 +66,8 @@
66
66
  setContext('editorStore', editorStore);
67
67
 
68
68
  $effect(() => {
69
+ // The root initializes the editor before these effects first run, and stays initialized
70
+ /* v8 ignore next */
69
71
  if (!editorStore.initialized) {
70
72
  return;
71
73
  }
@@ -74,15 +76,13 @@
74
76
  void lang;
75
77
 
76
78
  untrack(() => {
77
- const newValue = code
78
- ? `${backticks}${lang}\n${code}\n${backticks}`
79
- : `${backticks}${lang}\n${backticks}`;
80
-
81
- editorStore.inputValue = newValue;
79
+ editorStore.inputValue = toCodeBlock(lang, code);
82
80
  });
83
81
  });
84
82
 
85
83
  $effect(() => {
84
+ // The root initializes the editor before these effects first run, and stays initialized
85
+ /* v8 ignore next */
86
86
  if (!editorStore.initialized) {
87
87
  return;
88
88
  }
@@ -90,8 +90,7 @@
90
90
  void editorStore.inputValue;
91
91
 
92
92
  untrack(() => {
93
- const { lang: _lang = 'plain', code: _code = '' } =
94
- editorStore.inputValue.match(/^```(?<lang>\w+?)?\n(?:(?<code>.*)\n)?```/s)?.groups ?? {};
93
+ const { lang: _lang, code: _code } = parseCodeBlock(editorStore.inputValue);
95
94
 
96
95
  if (lang !== _lang) {
97
96
  lang = _lang;
@@ -84,6 +84,8 @@
84
84
  const getAnchorRect = ({ query }) => {
85
85
  const domSelection = window.getSelection();
86
86
 
87
+ // A shortcode has just been detected at the caret, so there is a selection
88
+ /* v8 ignore next */
87
89
  if (!domSelection?.rangeCount) {
88
90
  return undefined;
89
91
  }
@@ -115,7 +117,9 @@
115
117
  editor?.update(() => {
116
118
  const node = getNodeByKey(nodeKey);
117
119
 
118
- // Make sure the shortcode is still where it was when the suggestions appeared
120
+ // Make sure the shortcode is still where it was when the suggestions appeared. The list is
121
+ // closed as soon as the shortcode changes, so this only guards against a race.
122
+ /* v8 ignore next */
119
123
  if (!isTextNode(node) || node.getTextContent().slice(start, offset) !== `:${query}`) {
120
124
  return;
121
125
  }
@@ -136,6 +140,8 @@
136
140
  const registerCommands = () => {
137
141
  const { editor } = editorStore;
138
142
 
143
+ // Only called from the effect below, once the editor is there
144
+ /* v8 ignore next */
139
145
  if (!editor) {
140
146
  return [];
141
147
  }
@@ -196,6 +202,8 @@
196
202
  };
197
203
 
198
204
  $effect(() => {
205
+ // The root initializes the editor, and the list is bound, before this first runs
206
+ /* v8 ignore next */
199
207
  if (!editorStore.editor || !list) {
200
208
  return undefined;
201
209
  }
@@ -107,6 +107,8 @@
107
107
  });
108
108
 
109
109
  $effect(() => {
110
+ // Both are in place by the time the effect first runs; see `onMount()` above
111
+ /* v8 ignore else */
110
112
  if (editorStore.editor && lexicalRoot) {
111
113
  editorStore.editor.setRootElement(lexicalRoot);
112
114
  editorStore.initialized = true;
@@ -126,7 +128,7 @@
126
128
  aria-invalid={invalid}
127
129
  class="lexical-root"
128
130
  class:code={editorStore.config.isCodeEditor}
129
- id="{editorStore.editorId}-lexical-root"
131
+ id={`${editorStore.editorId}-lexical-root`}
130
132
  contenteditable={editable}
131
133
  {hidden}
132
134
  ></div>
@@ -6,7 +6,7 @@
6
6
  * imported at module scope, so a page that never shows a code block never pays for it. Second,
7
7
  * grammar and theme metadata comes from `generated.js` rather than `shiki/langs` and
8
8
  * `shiki/themes`, whose lazy `import()` thunks would drag the whole grammar set into a bundle.
9
- * @see https://github.com/facebook/lexical/tree/v0.49.0/packages/lexical-code-shiki
9
+ * @see https://github.com/facebook/lexical/tree/v0.50.0/packages/lexical-code-shiki
10
10
  * @see https://github.com/sveltia/sveltia-cms/issues/587
11
11
  */
12
12
 
@@ -4,7 +4,7 @@
4
4
  * Adapted from Lexical’s `CodeHighlighterShiki.ts` (MIT licensed). The transform logic is copied
5
5
  * as-is; only the highlighting back end differs, because upstream imports its Shiki facade directly
6
6
  * and offers no way to substitute one that loads grammars on demand.
7
- * @see https://github.com/facebook/lexical/blob/v0.49.0/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts
7
+ * @see https://github.com/facebook/lexical/blob/v0.50.0/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts
8
8
  * @see https://github.com/sveltia/sveltia-cms/issues/587
9
9
  *
10
10
  * The extension APIs (`CodeShikiExtension`, `CodeHighlighterShikiExtension`) are intentionally left
@@ -189,6 +189,8 @@ const getDiffRange = (prevNodes, nextNodes) => {
189
189
  const updateAndRetainSelection = (nodeKey, updateFn) => {
190
190
  const node = getNodeByKey(nodeKey);
191
191
 
192
+ // Unreachable in practice, since Lexical only runs transforms on attached nodes
193
+ /* v8 ignore next */
192
194
  if (!isCodeNode(node) || !node.isAttached()) {
193
195
  return;
194
196
  }
@@ -204,6 +206,18 @@ const updateAndRetainSelection = (nodeKey, updateFn) => {
204
206
  }
205
207
 
206
208
  const { anchor } = selection;
209
+ // The selection is restored by walking this code node’s children, so it can only be retained when
210
+ // it actually points inside this code node. When the selection lives elsewhere there is nothing
211
+ // to retain, and restoring would drag the caret into the code block instead of leaving it where
212
+ // the user put it.
213
+ const anchorNode = anchor.getNode();
214
+
215
+ if (anchorNode !== node && !node.isParentOf(anchorNode)) {
216
+ updateFn();
217
+
218
+ return;
219
+ }
220
+
207
221
  const anchorOffset = anchor.offset;
208
222
 
209
223
  const isNewLineAnchor =
@@ -214,8 +228,6 @@ const updateAndRetainSelection = (nodeKey, updateFn) => {
214
228
  // Calculate the previous text offset: all text nodes prior to the anchor, plus the anchor’s own
215
229
  // text offset
216
230
  if (!isNewLineAnchor) {
217
- const anchorNode = anchor.getNode();
218
-
219
231
  textOffset =
220
232
  anchorOffset +
221
233
  anchorNode
@@ -236,24 +248,37 @@ const updateAndRetainSelection = (nodeKey, updateFn) => {
236
248
  }
237
249
 
238
250
  // If it was a non-element anchor, walk through the child nodes looking for the position of the
239
- // original text offset
240
- node.getChildren().some((node_) => {
241
- const isText = isTextNode(node_);
251
+ // original text offset. A line break node consumes one unit of the offset but cannot host a text
252
+ // point, so when the offset lands on one, use an element point on the code node instead of
253
+ // letting the offset go negative and selecting the next text node at an out-of-range position.
254
+ // A code node holds nothing but text, tab and line break nodes, so there is no third case.
255
+ const children = node.getChildren();
242
256
 
243
- if (isText || isLineBreakNode(node_)) {
244
- const textContentSize = node_.getTextContentSize();
257
+ for (let index = 0; index < children.length; index += 1) {
258
+ const child = children[index];
245
259
 
246
- if (isText && textContentSize >= textOffset) {
247
- node_.select(textOffset, textOffset);
260
+ if (isTextNode(child)) {
261
+ const textContentSize = child.getTextContentSize();
248
262
 
249
- return true;
263
+ if (textContentSize >= textOffset) {
264
+ child.select(textOffset, textOffset);
265
+
266
+ return;
250
267
  }
251
268
 
252
269
  textOffset -= textContentSize;
270
+ } else /* v8 ignore else */ if (isLineBreakNode(child)) {
271
+ if (textOffset === 0) {
272
+ node.select(index, index);
273
+
274
+ return;
275
+ }
276
+
277
+ textOffset -= 1;
253
278
  }
279
+ }
254
280
 
255
- return false;
256
- });
281
+ node.select(children.length, children.length);
257
282
  };
258
283
 
259
284
  /**
@@ -366,6 +391,8 @@ const codeNodeTransform = (editor, tokenizer, transformState, node) => {
366
391
  updateAndRetainSelection(nodeKey, () => {
367
392
  const currentNode = getNodeByKey(nodeKey);
368
393
 
394
+ // Unreachable in practice, since the update runs synchronously on a node just checked above
395
+ /* v8 ignore next */
369
396
  if (!isCodeNode(currentNode) || !currentNode.isAttached()) {
370
397
  return false;
371
398
  }
@@ -426,8 +453,6 @@ export const registerCodeHighlighting = (editor, tokenizer = shikiTokenizer) =>
426
453
  const registrations = [];
427
454
 
428
455
  // Only register the mutation listener if not in headless mode
429
- /* v8 ignore next 18 */
430
- // @ts-ignore Internal field
431
456
  if (editor._headless !== true) {
432
457
  registrations.push(
433
458
  editor.registerMutationListener(
@@ -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.72.0";
4
+ export const UI_VERSION: "0.72.2";
@@ -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.72.0';
6
+ export const UI_VERSION = '0.72.2';
@@ -42,6 +42,20 @@ export const createEditorStore = () => {
42
42
  /** @type {boolean} */
43
43
  let showConverterError = $state(false);
44
44
 
45
+ /**
46
+ * Flag a conversion error, which takes the editor out of the rich text mode, and shows the error
47
+ * to the user.
48
+ * @param {boolean} newValue Whether there is an error.
49
+ */
50
+ const setConverterError = (newValue) => {
51
+ hasConverterError = newValue;
52
+
53
+ if (newValue) {
54
+ useRichText = false;
55
+ showConverterError = true;
56
+ }
57
+ };
58
+
45
59
  /**
46
60
  * Convert the Markdown {@link inputValue} to Lexical nodes. Disable the rich text mode and
47
61
  * restore the original value when there is an error while conversion.
@@ -59,7 +73,8 @@ export const createEditorStore = () => {
59
73
  // @see https://github.com/facebook/lexical/issues/2308
60
74
  await convertMarkdownToLexical(editor, inputValue || '', enabledTransformers);
61
75
  } catch (ex) {
62
- hasConverterError = true;
76
+ // Go through the setter, so the editor actually falls back to the plain text mode
77
+ setConverterError(true);
63
78
  inputValue = originalValue;
64
79
  // eslint-disable-next-line no-console
65
80
  console.error(ex);
@@ -123,12 +138,7 @@ export const createEditorStore = () => {
123
138
  return hasConverterError;
124
139
  },
125
140
  set hasConverterError(newValue) {
126
- hasConverterError = newValue;
127
-
128
- if (hasConverterError) {
129
- useRichText = false;
130
- showConverterError = true;
131
- }
141
+ setConverterError(newValue);
132
142
  },
133
143
  get showConverterError() {
134
144
  return showConverterError;