@sveltia/ui 0.71.1 → 0.72.1
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/alert/infobar.svelte +1 -1
- package/dist/components/button/button.svelte +1 -1
- package/dist/components/button/select-button.svelte +2 -1
- package/dist/components/calendar/calendar.d.ts +20 -0
- package/dist/components/calendar/calendar.js +81 -0
- package/dist/components/calendar/calendar.svelte +23 -45
- package/dist/components/checkbox/checkbox-group.svelte +1 -1
- package/dist/components/checkbox/checkbox.svelte +6 -1
- package/dist/components/dialog/dialog.svelte +1 -1
- package/dist/components/drawer/drawer.svelte +1 -1
- package/dist/components/emoji/emoji-suggestions.svelte +14 -40
- package/dist/components/emoji/position.d.ts +44 -0
- package/dist/components/emoji/position.js +60 -0
- package/dist/components/progressbar/progressbar.svelte +1 -1
- package/dist/components/radio/radio-group.svelte +1 -1
- package/dist/components/resizable-pane/resizable-handle.svelte +15 -47
- package/dist/components/resizable-pane/resizable-pane-group.svelte +37 -100
- package/dist/components/resizable-pane/resizable-pane.svelte +1 -1
- package/dist/components/resizable-pane/sizing.d.ts +48 -0
- package/dist/components/resizable-pane/sizing.js +190 -0
- package/dist/components/scroll/infinite-scroll.svelte +2 -0
- package/dist/components/select/combobox.svelte +3 -1
- package/dist/components/select/select-tags.d.ts +17 -0
- package/dist/components/select/select-tags.js +95 -0
- package/dist/components/select/select-tags.svelte +26 -28
- package/dist/components/slider/slider.d.ts +17 -0
- package/dist/components/slider/slider.js +96 -0
- package/dist/components/slider/slider.svelte +30 -56
- package/dist/components/tabs/tab-box.svelte +1 -1
- package/dist/components/tabs/tab-list.d.ts +6 -0
- package/dist/components/tabs/tab-list.js +19 -0
- package/dist/components/tabs/tab-list.svelte +2 -14
- package/dist/components/text-editor/code-editor.d.ts +5 -0
- package/dist/components/text-editor/code-editor.js +28 -0
- package/dist/components/text-editor/code-editor.svelte +7 -8
- package/dist/components/text-editor/emoji-autocomplete.svelte +9 -1
- package/dist/components/text-editor/lexical-root.svelte +3 -1
- package/dist/components/text-editor/shiki/facade.js +1 -1
- package/dist/components/text-editor/shiki/highlighter.js +40 -15
- 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 +17 -7
- package/dist/components/text-editor/text-editor.svelte +4 -0
- package/dist/components/text-editor/toolbar/code-editor-toolbar.svelte +1 -1
- package/dist/components/text-editor/toolbar/code-language-switcher.svelte +4 -1
- package/dist/components/text-editor/toolbar/format-text-button.svelte +3 -1
- package/dist/components/text-editor/toolbar/insert-item-button.svelte +1 -1
- package/dist/components/text-editor/toolbar/insert-link-button.svelte +10 -3
- package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +4 -5
- package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +2 -0
- package/dist/components/text-field/emoji-autocomplete.svelte +2 -0
- package/dist/components/text-field/number-input.d.ts +15 -0
- package/dist/components/text-field/number-input.js +98 -0
- package/dist/components/text-field/number-input.svelte +27 -23
- package/dist/components/text-field/text-input.svelte +9 -3
- package/dist/components/toast/toast.svelte +7 -2
- package/dist/components/toolbar/toolbar.svelte +1 -1
- package/dist/components/typography/truncated-text.svelte +2 -1
- package/dist/components/util/app-shell.d.ts +1 -0
- package/dist/components/util/app-shell.js +29 -0
- package/dist/components/util/app-shell.svelte +4 -18
- package/dist/components/util/modal.d.ts +5 -0
- package/dist/components/util/modal.js +22 -0
- package/dist/components/util/modal.svelte +9 -10
- package/dist/components/util/popup.svelte +11 -0
- package/dist/locales/ar.yaml +2 -2
- package/dist/locales/ca.yaml +2 -2
- package/dist/locales/cs.yaml +2 -2
- package/dist/locales/da.yaml +2 -2
- package/dist/locales/de.yaml +1 -1
- package/dist/locales/el.yaml +2 -2
- package/dist/locales/en-CA.yaml +1 -1
- package/dist/locales/en-GB.yaml +1 -1
- package/dist/locales/en-US.yaml +1 -1
- package/dist/locales/es-CO.yaml +2 -2
- package/dist/locales/fa.yaml +2 -2
- package/dist/locales/fi.yaml +1 -1
- package/dist/locales/fr.yaml +2 -2
- package/dist/locales/hr.yaml +116 -0
- package/dist/locales/it.yaml +2 -2
- package/dist/locales/nl.yaml +1 -1
- package/dist/locales/pl.yaml +2 -2
- package/dist/locales/pt-BR.yaml +2 -2
- package/dist/locales/pt-PT.yaml +2 -2
- package/dist/locales/sv.yaml +2 -2
- package/dist/locales/zh-CN.yaml +1 -1
- package/dist/locales/zh-TW.yaml +1 -1
- package/package.json +8 -4
|
@@ -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
|
-
|
|
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
|
-
(
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
133
|
+
if (!direction) {
|
|
134
|
+
return;
|
|
154
135
|
}
|
|
155
136
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
index = valueList.indexOf(_value) + 1;
|
|
159
|
-
}
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
event.stopPropagation();
|
|
160
139
|
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
(
|
|
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=
|
|
349
|
-
style:width=
|
|
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=
|
|
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=
|
|
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>
|
|
@@ -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
|
-
|
|
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,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
|
-
|
|
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
|
|
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=
|
|
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.
|
|
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.
|
|
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
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
257
|
+
for (let index = 0; index < children.length; index += 1) {
|
|
258
|
+
const child = children[index];
|
|
245
259
|
|
|
246
|
-
|
|
247
|
-
|
|
260
|
+
if (isTextNode(child)) {
|
|
261
|
+
const textContentSize = child.getTextContentSize();
|
|
248
262
|
|
|
249
|
-
|
|
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
|
-
|
|
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(
|
|
@@ -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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
if (hasConverterError) {
|
|
129
|
-
useRichText = false;
|
|
130
|
-
showConverterError = true;
|
|
131
|
-
}
|
|
141
|
+
setConverterError(newValue);
|
|
132
142
|
},
|
|
133
143
|
get showConverterError() {
|
|
134
144
|
return showConverterError;
|