@sveltia/ui 0.77.1 → 0.77.3

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 (37) hide show
  1. package/dist/components/calendar/calendar.d.ts +1 -1
  2. package/dist/components/calendar/calendar.js +12 -13
  3. package/dist/components/calendar/calendar.svelte +11 -7
  4. package/dist/components/checkbox/checkbox.svelte +1 -1
  5. package/dist/components/emoji/emoji-suggestions.svelte +1 -1
  6. package/dist/components/file/file-picker.svelte +4 -1
  7. package/dist/components/listbox/listbox.svelte +1 -1
  8. package/dist/components/progressbar/progressbar.svelte +6 -1
  9. package/dist/components/resizable-pane/resizable-handle.svelte +7 -0
  10. package/dist/components/scroll/infinite-scroll.svelte +14 -1
  11. package/dist/components/slider/slider.js +21 -3
  12. package/dist/components/slider/slider.svelte +4 -0
  13. package/dist/components/text-editor/code-editor.js +1 -1
  14. package/dist/components/text-editor/core.d.ts +2 -1
  15. package/dist/components/text-editor/core.js +110 -18
  16. package/dist/components/text-editor/lexical-root.svelte +27 -2
  17. package/dist/components/text-editor/lexical-root.svelte.d.ts +10 -0
  18. package/dist/components/text-editor/markdown.js +30 -11
  19. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  20. package/dist/components/text-editor/shiki/version.js +1 -1
  21. package/dist/components/text-editor/text-editor.svelte +3 -1
  22. package/dist/components/text-editor/text-editor.svelte.d.ts +4 -2
  23. package/dist/components/text-editor/toolbar/code-language-switcher.svelte +14 -9
  24. package/dist/components/text-editor/toolbar/insert-link-button.svelte +15 -6
  25. package/dist/components/text-field/number-input.js +16 -3
  26. package/dist/components/text-field/number-input.svelte +7 -5
  27. package/dist/components/text-field/secret-input.svelte +5 -0
  28. package/dist/components/toast/toast.svelte +5 -0
  29. package/dist/components/util/app-shell.js +11 -3
  30. package/dist/components/util/popup.svelte +1 -1
  31. package/dist/services/group.svelte.d.ts +6 -0
  32. package/dist/services/group.svelte.js +69 -31
  33. package/dist/services/popup.svelte.js +18 -3
  34. package/dist/services/select.svelte.js +42 -5
  35. package/dist/services/tree.svelte.d.ts +5 -0
  36. package/dist/services/tree.svelte.js +13 -1
  37. package/package.json +2 -2
@@ -14,4 +14,4 @@ export function getFirstDayOfMonth(date: Date): Date;
14
14
  export function addMonths(firstDay: Date, delta: number): Date;
15
15
  export function getCalendarDays(firstDay: Date): Date[];
16
16
  export function toDateString(date: Date): string;
17
- export function isSameDay(a: Date, b: Date): boolean;
17
+ export function getToday(now?: Date): Date;
@@ -38,11 +38,12 @@ export const MONTH_NAMES = Array.from({ length: 12 }, (__, i) =>
38
38
  /**
39
39
  * Get the first day of the month the given date is in.
40
40
  * @param {Date} date Any date within the month.
41
- * @returns {Date} Local midnight on the first day of the month, using the UTC year and month of
42
- * the given date, so a date parsed from a `YYYY-MM-DD` string lands in the right month regardless
43
- * of the time zone.
41
+ * @returns {Date} UTC midnight on the first day of the month, using the UTC year and month of the
42
+ * given date, so a date parsed from a `YYYY-MM-DD` string lands in the right month regardless of
43
+ * the time zone. The whole grid is UTC-based, like the `value`.
44
44
  */
45
- export const getFirstDayOfMonth = (date) => new Date(date.getUTCFullYear(), date.getUTCMonth(), 1);
45
+ export const getFirstDayOfMonth = (date) =>
46
+ new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
46
47
 
47
48
  /**
48
49
  * Get the first day of another month, relative to the given one.
@@ -68,7 +69,7 @@ export const getCalendarDays = (firstDay) => {
68
69
  const cursor = new Date(firstDay);
69
70
 
70
71
  // Start from Sunday
71
- cursor.setDate(1 - cursor.getUTCDay());
72
+ cursor.setUTCDate(1 - cursor.getUTCDay());
72
73
 
73
74
  return Array.from({ length: DAY_GRID_SIZE }, () => {
74
75
  const day = new Date(cursor);
@@ -87,12 +88,10 @@ export const getCalendarDays = (firstDay) => {
87
88
  export const toDateString = (date) => date.toJSON().split('T')[0];
88
89
 
89
90
  /**
90
- * Whether two dates fall on the same local calendar day.
91
- * @param {Date} a A date.
92
- * @param {Date} b Another date.
93
- * @returns {boolean} Result.
91
+ * Get today’s local calendar day as a UTC-based date, so it can be compared with the days on the
92
+ * grid and turned into a `value` with {@link toDateString}.
93
+ * @param {Date} [now] Current date and time.
94
+ * @returns {Date} UTC midnight on today’s local date.
94
95
  */
95
- export const isSameDay = (a, b) =>
96
- a.getFullYear() === b.getFullYear() &&
97
- a.getMonth() === b.getMonth() &&
98
- a.getDate() === b.getDate();
96
+ export const getToday = (now = new Date()) =>
97
+ new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
@@ -15,7 +15,7 @@
15
15
  formatDate,
16
16
  getCalendarDays,
17
17
  getFirstDayOfMonth,
18
- isSameDay,
18
+ getToday,
19
19
  MONTH_NAMES,
20
20
  toDateString,
21
21
  } from './calendar.js';
@@ -30,9 +30,11 @@
30
30
  */
31
31
  let { value = $bindable(undefined) } = $props();
32
32
 
33
- const now = new Date();
33
+ /** Today’s local date, UTC-based like the days on the grid. */
34
+ const today = getToday();
35
+ const todayString = toDateString(today);
34
36
 
35
- const date = $derived(value ? new Date(value) : now);
37
+ const date = $derived(value ? new Date(value) : today);
36
38
  /**
37
39
  * First day of the month being displayed. Follows the {@link value}, and is reassigned by the
38
40
  * month navigation below.
@@ -44,7 +46,9 @@
44
46
  * Month and year being displayed, in the current locale. It’s the caption of the day grid as
45
47
  * well as the label of the month switcher.
46
48
  */
47
- const monthLabel = $derived(formatDate(firstDay, { year: 'numeric', month: 'short' }));
49
+ const monthLabel = $derived(
50
+ formatDate(firstDay, { year: 'numeric', month: 'short', timeZone: 'UTC' }),
51
+ );
48
52
  const id = $props.id();
49
53
  /** @type {HTMLElement | undefined} */
50
54
  let grid = $state();
@@ -172,7 +176,7 @@
172
176
  >
173
177
  {#each dayList.slice(0, 7) as { day } (day.getUTCDay())}
174
178
  <div role="none" class="weekday">
175
- {formatDate(day, { weekday: 'narrow' })}
179
+ {formatDate(day, { weekday: 'narrow', timeZone: 'UTC' })}
176
180
  </div>
177
181
  {/each}
178
182
  {#each dayList as { day } (toDateString(day))}
@@ -180,7 +184,7 @@
180
184
  <div
181
185
  role="none"
182
186
  class:other-month={day.getUTCMonth() !== firstDay.getUTCMonth()}
183
- class:today={isSameDay(day, now)}
187
+ class:today={dateString === todayString}
184
188
  >
185
189
  <Button
186
190
  role="option"
@@ -208,7 +212,7 @@
208
212
  <Spacer flex={true} />
209
213
  <Button
210
214
  onclick={() => {
211
- value = toDateString(now);
215
+ value = todayString;
212
216
  }}
213
217
  >
214
218
  {_('_sui.calendar.today')}
@@ -109,7 +109,7 @@
109
109
  {hidden}
110
110
  {disabled}
111
111
  {readonly}
112
- {required}
112
+ aria-required={required}
113
113
  aria-invalid={invalid}
114
114
  aria-checked={checked}
115
115
  aria-label={ariaLabel || undefined}
@@ -412,7 +412,7 @@
412
412
  margin: 0;
413
413
  border-width: var(--sui-listbox-border-width, 1px);
414
414
  border-style: var(--sui-listbox-border-style, solid);
415
- border-color: var(--sui-listbox-border-width, var(--sui-secondary-border-color));
415
+ border-color: var(--sui-listbox-border-color, var(--sui-secondary-border-color));
416
416
  border-radius: var(--sui-listbox-border-radius, 4px);
417
417
  padding: var(--sui-listbox-padding, 4px);
418
418
  width: 280px;
@@ -37,8 +37,11 @@
37
37
  {multiple}
38
38
  bind:this={filePicker}
39
39
  onchange={({ target }) => {
40
- const files = [.../** @type {FileList} */ (/** @type {HTMLInputElement} */ (target).files)];
40
+ const input = /** @type {HTMLInputElement} */ (target);
41
+ const files = [.../** @type {FileList} */ (input.files)];
41
42
 
43
+ // Clear the selection, so picking the same file again still fires the `change` event
44
+ input.value = '';
42
45
  onSelect?.({ files, file: files[0] });
43
46
  }}
44
47
  oncancel={(event) => {
@@ -96,7 +96,7 @@
96
96
  margin: var(--sui-focus-ring-width);
97
97
  border-width: var(--sui-listbox-border-width, 1px);
98
98
  border-style: var(--sui-listbox-border-style, solid);
99
- border-color: var(--sui-listbox-border-width, var(--sui-secondary-border-color));
99
+ border-color: var(--sui-listbox-border-color, var(--sui-secondary-border-color));
100
100
  border-radius: var(--sui-listbox-border-radius, 4px);
101
101
  padding: var(--sui-listbox-padding, 4px);
102
102
  min-width: var(--sui-listbox-min-width, calc(var(--sui-option-height) * 5));
@@ -28,6 +28,11 @@
28
28
  ...restProps
29
29
  /* eslint-enable prefer-const */
30
30
  } = $props();
31
+
32
+ /** Position of `now` within the range, as a percentage, clamped to the range. */
33
+ const percentage = $derived(
34
+ max > min ? Math.min(Math.max(((now - min) / (max - min)) * 100, 0), 100) : 0,
35
+ );
31
36
  </script>
32
37
 
33
38
  <div
@@ -40,7 +45,7 @@
40
45
  aria-valuetext={text}
41
46
  aria-label={ariaLabel}
42
47
  >
43
- <div role="none" style:width={`${now}%`}></div>
48
+ <div role="none" style:width={`${percentage}%`}></div>
44
49
  </div>
45
50
 
46
51
  <style>.progressbar {
@@ -216,6 +216,13 @@
216
216
  onResizeEnd?.();
217
217
  }
218
218
  };
219
+
220
+ $effect(() => () => {
221
+ // Stop listening if the handle is removed in the middle of a drag
222
+ document.removeEventListener('pointermove', onPointerMove);
223
+ document.removeEventListener('pointerup', onPointerUp);
224
+ document.removeEventListener('pointercancel', onPointerUp);
225
+ });
219
226
  </script>
220
227
 
221
228
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
@@ -47,10 +47,23 @@
47
47
  }
48
48
  });
49
49
 
50
+ // Observe the spinner anew once each chunk is rendered: the observer only reports changes, so a
51
+ // spinner that stays in view because the chunk didn’t fill the viewport would stall the loading.
52
+ // Observing an element always reports its current state first. Stop observing on unmount too.
50
53
  $effect(() => {
54
+ void loadedItemSize;
55
+
51
56
  if (spinner) {
52
- observer.observe(spinner);
57
+ const target = spinner;
58
+
59
+ observer.observe(target);
60
+
61
+ return () => {
62
+ observer.unobserve(target);
63
+ };
53
64
  }
65
+
66
+ return undefined;
54
67
  });
55
68
  </script>
56
69
 
@@ -3,6 +3,18 @@
3
3
  * calculations can be tested without a rendered track.
4
4
  */
5
5
 
6
+ /**
7
+ * Count the fraction digits of a number, including one written in exponential notation, such as
8
+ * `1e-7`.
9
+ * @param {number} num Number.
10
+ * @returns {number} Number of fraction digits, `0` for an integer.
11
+ */
12
+ const countFractionDigits = (num) => {
13
+ const [mantissa, exponent = '0'] = String(num).split('e');
14
+
15
+ return Math.min(Math.max((mantissa.split('.')[1] ?? '').length - Number(exponent), 0), 100);
16
+ };
17
+
6
18
  /**
7
19
  * Get the values a thumb can take and where each of them sits along the track.
8
20
  * @param {object} options Options.
@@ -14,12 +26,18 @@
14
26
  * increments, and the matching pixel offsets from the logical start of the track.
15
27
  */
16
28
  export const getSliderSteps = ({ min, max, step, barWidth }) => {
17
- const stepCount = (max - min) / step + 1;
18
- const stepWidth = barWidth / (stepCount - 1);
29
+ const stepRatio = (max - min) / step;
30
+ // Tolerate floating point errors, e.g. `0.3 / 0.1` is `2.9999999999999996`, which would otherwise
31
+ // drop the maximum
32
+ const stepCount = Math.floor(stepRatio + 1e-9) + 1;
33
+ const stepWidth = barWidth / stepRatio;
19
34
  const emptyArray = Array.from({ length: stepCount });
35
+ // Round the values to the precision of the minimum and the step, e.g. `3 * 0.1` is
36
+ // `0.30000000000000004`, which would otherwise never match the `0.3` value
37
+ const decimals = Math.max(countFractionDigits(min), countFractionDigits(step));
20
38
 
21
39
  return {
22
- valueList: emptyArray.map((_, index) => index * step + min),
40
+ valueList: emptyArray.map((_, index) => Number((index * step + min).toFixed(decimals))),
23
41
  positionList: emptyArray.map((_, index) => index * stepWidth),
24
42
  };
25
43
  };
@@ -313,6 +313,10 @@
313
313
  return () => {
314
314
  observer.disconnect();
315
315
  query.removeEventListener('change', init);
316
+ // Stop listening if the slider is removed in the middle of a drag
317
+ document.removeEventListener('pointermove', onPointerMove);
318
+ document.removeEventListener('pointerup', onPointerUp);
319
+ document.removeEventListener('pointercancel', onPointerUp);
316
320
  };
317
321
  });
318
322
 
@@ -22,7 +22,7 @@ export const toCodeBlock = (lang, code) =>
22
22
  */
23
23
  export const parseCodeBlock = (markdown) => {
24
24
  const { lang = 'plain', code = '' } =
25
- markdown.match(/^```(?<lang>\w+?)?\n(?:(?<code>.*)\n)?```/s)?.groups ?? {};
25
+ markdown.match(/^```(?<lang>[^\s`]+)?\n(?:(?<code>.*)\n)?```/s)?.groups ?? {};
26
26
 
27
27
  return { lang, code };
28
28
  };
@@ -1,5 +1,6 @@
1
+ export function isSafeLinkURL(url: string): boolean;
1
2
  export function getSelectionTypes(): TextEditorSelectionState;
2
- export function onEditorUpdate(editor: LexicalEditor, enabledTransformers: Transformer[]): void;
3
+ export function onEditorUpdate(editor: LexicalEditor, enabledTransformers: Transformer[], cachedValue?: string): string;
3
4
  export function initEditor({ enabledButtons, components, useMarkdownShortcuts, isCodeEditor, defaultLanguage, }: TextEditorConfig): InitEditorResult;
4
5
  export function loadCodeHighlighter(lang: string): Promise<void>;
5
6
  export function convertMarkdownToLexical(editor: LexicalEditor, value: string, enabledTransformers: Transformer[]): Promise<void>;
@@ -99,6 +99,40 @@ import { TABLE } from './transformers/table.js';
99
99
  * @property {() => void} dispose Remove all registered Lexical listeners.
100
100
  */
101
101
 
102
+ /**
103
+ * Maximum number of entries in the undo stack.
104
+ */
105
+ const HISTORY_MAX_DEPTH = 200;
106
+ /**
107
+ * URL schemes allowed in a link, the same as the ones Lexical renders as is.
108
+ */
109
+ const SAFE_LINK_URL_SCHEMES = ['http', 'https', 'mailto', 'sms', 'tel'];
110
+
111
+ /**
112
+ * Whether the given URL is safe to be used as a link, meaning it’s either relative or uses one of
113
+ * the {@link SAFE_LINK_URL_SCHEMES}. This keeps a URL like `javascript:alert(1)` out of the
114
+ * Markdown, where the site that renders it may not sanitize it.
115
+ * @param {string} url URL.
116
+ * @returns {boolean} Result.
117
+ */
118
+ export const isSafeLinkURL = (url) => {
119
+ // Browsers ignore control characters and spaces around a URL as well as tabs and newlines within
120
+ // it, e.g. `java\tscript:`, so strip them all before looking for the scheme
121
+ // eslint-disable-next-line no-control-regex
122
+ const normalizedURL = url.replace(/[\u0000-\u0020]/g, '');
123
+
124
+ // Markdown decodes character references and backslash escapes in a link destination, so a scheme
125
+ // can be hidden with them, e.g. `&#106;avascript:`, `javascript&colon;` or `javascript\:`. Reject
126
+ // any of them before the path, query or fragment.
127
+ if (/^[^/?#]*[&\\]/.test(normalizedURL)) {
128
+ return false;
129
+ }
130
+
131
+ const scheme = /^([a-z][a-z\d+.-]*):/i.exec(normalizedURL)?.[1];
132
+
133
+ return !scheme || SAFE_LINK_URL_SCHEMES.includes(scheme.toLowerCase());
134
+ };
135
+
102
136
  /**
103
137
  * Get the current selection’s block node key as well as block and inline level types.
104
138
  * @internal
@@ -126,7 +160,9 @@ export const getSelectionTypes = () => {
126
160
 
127
161
  if (isLinkNode(parent)) {
128
162
  inlineTypes.push('link');
129
- parent = getNearestNodeOfType(parent, ElementNode);
163
+ // `getNearestNodeOfType()` would return the link itself, so start from its parent to find the
164
+ // block the link is in
165
+ parent = parent.getParent();
130
166
  }
131
167
 
132
168
  if (isListItemNode(parent)) {
@@ -178,31 +214,40 @@ export const getSelectionTypes = () => {
178
214
  * @internal
179
215
  * @param {LexicalEditor} editor Editor instance.
180
216
  * @param {Transformer[]} enabledTransformers Enabled Markdown transformers.
217
+ * @param {string} [cachedValue] Markdown value from a previous call, to be reused instead of
218
+ * converting the whole document again when only the selection has changed.
219
+ * @returns {string} Markdown value.
181
220
  */
182
- export const onEditorUpdate = (editor, enabledTransformers) => {
221
+ export const onEditorUpdate = (editor, enabledTransformers, cachedValue) => {
183
222
  const transformers = enabledTransformers.filter(
184
223
  (/** @type {any} */ { tag }) => !DISABLED_MARKDOWN_TAGS.includes(tag),
185
224
  );
186
225
 
226
+ const value =
227
+ cachedValue ??
228
+ trimBlankBlockquoteLines(
229
+ convertToMarkdownString(transformers)
230
+ // Remove unnecessary backslash for underscore and backslash characters
231
+ // @see https://github.com/sveltia/sveltia-cms/issues/430
232
+ // @see https://github.com/sveltia/sveltia-cms/issues/512
233
+ .replace(/\\([_\\])/g, '$1')
234
+ // Replace encoded spaces with regular spaces. The HTML entity can appear with a
235
+ // combination of bold and italic text
236
+ // @see https://github.com/sveltia/sveltia-cms/issues/511
237
+ // @see https://github.com/sveltia/sveltia-cms/issues/534
238
+ .replace(/&#32;/g, ' '),
239
+ );
240
+
187
241
  editor.getRootElement()?.dispatchEvent(
188
242
  new CustomEvent('Update', {
189
243
  detail: {
190
- value: trimBlankBlockquoteLines(
191
- convertToMarkdownString(transformers)
192
- // Remove unnecessary backslash for underscore and backslash characters
193
- // @see https://github.com/sveltia/sveltia-cms/issues/430
194
- // @see https://github.com/sveltia/sveltia-cms/issues/512
195
- .replace(/\\([_\\])/g, '$1')
196
- // Replace encoded spaces with regular spaces. The HTML entity can appear with a
197
- // combination of bold and italic text
198
- // @see https://github.com/sveltia/sveltia-cms/issues/511
199
- // @see https://github.com/sveltia/sveltia-cms/issues/534
200
- .replace(/&#32;/g, ' '),
201
- ),
244
+ value,
202
245
  selection: getSelectionTypes(),
203
246
  },
204
247
  }),
205
248
  );
249
+
250
+ return value;
206
251
  };
207
252
 
208
253
  /**
@@ -267,7 +312,17 @@ export const initEditor = ({
267
312
 
268
313
  addUnregister(registerRichText(editor));
269
314
  addUnregister(registerDragonSupport(editor));
270
- addUnregister(registerHistory(editor, createEmptyHistoryState(), 1000));
315
+ // Cap the undo stack, as each entry holds a snapshot of the whole document
316
+ addUnregister(
317
+ registerHistory(
318
+ editor,
319
+ createEmptyHistoryState(),
320
+ 1000,
321
+ undefined,
322
+ undefined,
323
+ HISTORY_MAX_DEPTH,
324
+ ),
325
+ );
271
326
 
272
327
  if (useMarkdownShortcuts) {
273
328
  addUnregister(registerMarkdownShortcuts(editor, enabledTransformers));
@@ -291,6 +346,11 @@ export const initEditor = ({
291
346
  editor.registerCommand(
292
347
  TOGGLE_LINK_COMMAND,
293
348
  (payload) => {
349
+ // Ignore an unsafe URL rather than linking to it
350
+ if (typeof payload === 'string' && !isSafeLinkURL(payload)) {
351
+ return true;
352
+ }
353
+
294
354
  toggleLink(typeof payload === 'string' ? payload : null);
295
355
 
296
356
  return true;
@@ -316,7 +376,8 @@ export const initEditor = ({
316
376
 
317
377
  const clipboardText = event.clipboardData.getData('text').trim();
318
378
 
319
- if (!isURL(clipboardText)) {
379
+ // Paste an unsafe URL as plain text rather than as a link
380
+ if (!isURL(clipboardText) || !isSafeLinkURL(clipboardText)) {
320
381
  return false;
321
382
  }
322
383
 
@@ -380,15 +441,41 @@ export const initEditor = ({
380
441
  );
381
442
  }
382
443
 
444
+ /** Incremented on every update, so only the latest one in a burst triggers the Update event. */
445
+ let updateCount = 0;
446
+ /** Whether the content has changed since the Update event was last triggered. */
447
+ let contentChanged = true;
448
+ /** @type {string | undefined} */
449
+ let lastValue = undefined;
450
+ let disposed = false;
451
+
452
+ addUnregister(() => {
453
+ disposed = true;
454
+ });
455
+
383
456
  addUnregister(
384
- editor.registerUpdateListener(() => {
457
+ editor.registerUpdateListener(({ dirtyElements, dirtyLeaves } = /** @type {any} */ ({})) => {
458
+ // An update that only moves the selection leaves no dirty nodes. Remember whether any update
459
+ // in the burst changed the content, so the document is only converted again when needed.
460
+ // This includes the updates made while composing, which are otherwise skipped.
461
+ contentChanged ||= !dirtyElements || dirtyElements.size > 0 || dirtyLeaves.size > 0;
462
+
385
463
  if (editor?.isComposing()) {
386
464
  return;
387
465
  }
388
466
 
467
+ updateCount += 1;
468
+
469
+ const currentCount = updateCount;
470
+
389
471
  (async () => {
390
472
  await sleep(100);
391
473
 
474
+ // Skip if a newer update has been made in the meantime, or the editor has been disposed
475
+ if (currentCount !== updateCount || disposed) {
476
+ return;
477
+ }
478
+
392
479
  editor.update(() => {
393
480
  // Prevent CodeNode from being removed
394
481
  /* v8 ignore next */
@@ -409,7 +496,12 @@ export const initEditor = ({
409
496
  }
410
497
  }
411
498
 
412
- onEditorUpdate(editor, enabledTransformers);
499
+ lastValue = onEditorUpdate(
500
+ editor,
501
+ enabledTransformers,
502
+ contentChanged ? undefined : lastValue,
503
+ );
504
+ contentChanged = false;
413
505
  });
414
506
  })();
415
507
  }),
@@ -1,4 +1,5 @@
1
1
  <script>
2
+ import { $getRoot as getRoot } from 'lexical';
2
3
  import { getContext, onMount } from 'svelte';
3
4
  import { initEditor } from './core.js';
4
5
 
@@ -10,6 +11,8 @@
10
11
  /**
11
12
  * @typedef {object} Props
12
13
  * @property {string} [class] The `class` attribute on the wrapper element.
14
+ * @property {'ltr' | 'rtl' | 'auto'} [dir] Direction of the text. With `auto` or no value, each
15
+ * paragraph takes the direction of its own text.
13
16
  * @property {boolean} [hidden] Whether to hide the widget.
14
17
  * @property {boolean} [disabled] Whether to disable the widget. An alias of the `aria-disabled`
15
18
  * attribute.
@@ -28,6 +31,7 @@
28
31
  let {
29
32
  /* eslint-disable prefer-const */
30
33
  class: className,
34
+ dir = undefined,
31
35
  hidden = false,
32
36
  disabled = false,
33
37
  readonly = false,
@@ -53,6 +57,26 @@
53
57
  editorStore.editor?.setEditable(editable);
54
58
  });
55
59
 
60
+ $effect(() => {
61
+ // Lexical owns the root element’s `dir` attribute, so set the direction on the root node, which
62
+ // the paragraphs then follow. Without one, each paragraph gets `dir="auto"`
63
+ const { editor } = editorStore;
64
+ const direction = dir === 'ltr' || dir === 'rtl' ? dir : null;
65
+
66
+ // Check first, as even an update that changes nothing makes the editor convert its content
67
+ if (!editor || editor.getEditorState().read(() => getRoot().getDirection()) === direction) {
68
+ return;
69
+ }
70
+
71
+ editor.update(
72
+ () => {
73
+ getRoot().setDirection(direction);
74
+ },
75
+ // Keep the change out of the undo history
76
+ { tag: 'history-merge' },
77
+ );
78
+ });
79
+
56
80
  /**
57
81
  * Update {@link value} and other state variables whenever the editor content is updated.
58
82
  * @param {Event} event `Update` custom event.
@@ -78,11 +102,12 @@
78
102
  };
79
103
 
80
104
  /**
81
- * Listen to `click` events on the editor. Ignore a click on a link.
105
+ * Listen to `click` events on the editor. Ignore a click on a link, including one on the text
106
+ * within it, which Lexical renders as a child element of the `<a>`.
82
107
  * @param {MouseEvent} event `click` event.
83
108
  */
84
109
  const onClick = (event) => {
85
- if (/** @type {HTMLElement} */ (event.target)?.matches('a')) {
110
+ if (/** @type {HTMLElement} */ (event.target)?.closest('a')) {
86
111
  event.preventDefault();
87
112
  }
88
113
  };
@@ -8,6 +8,11 @@ declare const LexicalRoot: import("svelte").Component<{
8
8
  * The `class` attribute on the wrapper element.
9
9
  */
10
10
  class?: string | undefined;
11
+ /**
12
+ * Direction of the text. With `auto` or no value, each
13
+ * paragraph takes the direction of its own text.
14
+ */
15
+ dir?: "ltr" | "rtl" | "auto" | undefined;
11
16
  /**
12
17
  * Whether to hide the widget.
13
18
  */
@@ -42,6 +47,11 @@ type Props = {
42
47
  * The `class` attribute on the wrapper element.
43
48
  */
44
49
  class?: string | undefined;
50
+ /**
51
+ * Direction of the text. With `auto` or no value, each
52
+ * paragraph takes the direction of its own text.
53
+ */
54
+ dir?: "ltr" | "rtl" | "auto" | undefined;
45
55
  /**
46
56
  * Whether to hide the widget.
47
57
  */
@@ -6,10 +6,12 @@
6
6
  */
7
7
  export const splitMultilineFormatting = (value) =>
8
8
  value
9
- .replace(/(\s+)_([^_\n]+?)\n([^_\n]+?)_(\s+)/gm, '$1_$2_\n_$3_$4')
10
- .replace(/(\s+)\*\*([^*\n]+?)\n([^*\n]+?)\*\*(\s+)/gm, '$1**$2**\n**$3**$4')
11
- .replace(/(\s+)~~([^~\n]+?)\n([^~\n]+?)~~(\s+)/gm, '$1~~$2~~\n~~$3~~$4')
12
- .replace(/(\s+)`([^`\n]+?)\n([^`\n]+?)`(\s+)/gm, '$1`$2`\n`$3`$4');
9
+ // The surrounding whitespace is checked with lookarounds rather than matched with `\s+`, which
10
+ // would take quadratic time on a long run of whitespace not followed by a formatting marker
11
+ .replace(/(?<=\s)_([^_\n]+?)\n([^_\n]+?)_(?=\s)/g, '_$1_\n_$2_')
12
+ .replace(/(?<=\s)\*\*([^*\n]+?)\n([^*\n]+?)\*\*(?=\s)/g, '**$1**\n**$2**')
13
+ .replace(/(?<=\s)~~([^~\n]+?)\n([^~\n]+?)~~(?=\s)/g, '~~$1~~\n~~$2~~')
14
+ .replace(/(?<=\s)`([^`\n]+?)\n([^`\n]+?)`(?=\s)/g, '`$1`\n`$2`');
13
15
 
14
16
  /**
15
17
  * Apply a function to each line of a Markdown string, leaving the lines inside fenced code blocks
@@ -19,22 +21,39 @@ export const splitMultilineFormatting = (value) =>
19
21
  * @returns {string} Processed Markdown string.
20
22
  */
21
23
  const mapLinesOutsideCodeBlocks = (value, callback) => {
22
- let inCodeBlock = false;
24
+ /**
25
+ * Opening fence of the code block being processed, like ```` ``` ````, or an empty string when
26
+ * outside a code block.
27
+ */
28
+ let openingFence = '';
23
29
 
24
30
  return value
25
31
  .split('\n')
26
32
  .map((line) => {
27
33
  // Like Lexical's `CODE_START_REGEX`, allow an indented fence
28
- if (/^[ \t]*(`{3,}|~{3,})/.test(line)) {
29
- inCodeBlock = !inCodeBlock;
30
- return line;
34
+ const fence = line.match(/^[ \t]*(`{3,}|~{3,})/)?.[1];
35
+
36
+ if (!openingFence) {
37
+ if (fence) {
38
+ openingFence = fence;
39
+ return line;
40
+ }
41
+
42
+ return callback(line);
31
43
  }
32
44
 
33
- if (inCodeBlock) {
34
- return line;
45
+ // Like Lexical's `CODE_END_REGEX`, a code block is only closed by a bare fence of the same
46
+ // character that is at least as long as the opening one, so a shorter fence within the block,
47
+ // like ```` ``` ```` within ```` ```` ````, is part of the code
48
+ if (
49
+ fence?.[0] === openingFence[0] &&
50
+ fence.length >= openingFence.length &&
51
+ !line.trim().slice(fence.length)
52
+ ) {
53
+ openingFence = '';
35
54
  }
36
55
 
37
- return callback(line);
56
+ return line;
38
57
  })
39
58
  .join('\n');
40
59
  };
@@ -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.1";
4
+ export const UI_VERSION: "0.77.3";
@@ -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.1';
6
+ export const UI_VERSION = '0.77.3';