sv5ui 2.0.0 → 2.1.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 (57) hide show
  1. package/dist/Accordion/Accordion.svelte +11 -0
  2. package/dist/Badge/badge.types.d.ts +1 -1
  3. package/dist/Calendar/Calendar.svelte +14 -1
  4. package/dist/Collapsible/collapsible.types.d.ts +1 -1
  5. package/dist/Command/command.types.d.ts +4 -2
  6. package/dist/Command/index.d.ts +1 -1
  7. package/dist/ContextMenu/ContextMenu.svelte +1 -1
  8. package/dist/Drawer/Drawer.svelte +4 -2
  9. package/dist/Drawer/DrawerTriggerTestWrapper.svelte +10 -0
  10. package/dist/Drawer/DrawerTriggerTestWrapper.svelte.d.ts +18 -0
  11. package/dist/Drawer/drawer.types.d.ts +13 -2
  12. package/dist/Editor/Editor.svelte +85 -61
  13. package/dist/Editor/SlashPopup.svelte +8 -1
  14. package/dist/Editor/SlashPopup.svelte.d.ts +2 -0
  15. package/dist/Editor/editor.extensions.d.ts +1 -1
  16. package/dist/Editor/editor.extensions.js +25 -16
  17. package/dist/Editor/editor.schemas.d.ts +1 -0
  18. package/dist/Editor/editor.schemas.js +24 -0
  19. package/dist/Editor/editor.slash.svelte.d.ts +0 -9
  20. package/dist/Editor/editor.slash.svelte.js +33 -7
  21. package/dist/Editor/editor.suggestion.js +23 -0
  22. package/dist/Editor/editor.toolbar.js +0 -8
  23. package/dist/Editor/editor.types.d.ts +20 -0
  24. package/dist/Editor/editor.variants.d.ts +0 -5
  25. package/dist/Editor/editor.variants.js +0 -15
  26. package/dist/Editor/index.d.ts +6 -4
  27. package/dist/Editor/index.js +6 -4
  28. package/dist/FileUpload/FileUpload.svelte +7 -0
  29. package/dist/Icon/icon.types.d.ts +1 -1
  30. package/dist/Input/index.d.ts +1 -1
  31. package/dist/Modal/Modal.svelte +4 -2
  32. package/dist/Modal/ModalTriggerTestWrapper.svelte +10 -0
  33. package/dist/Modal/ModalTriggerTestWrapper.svelte.d.ts +18 -0
  34. package/dist/Modal/modal.types.d.ts +13 -3
  35. package/dist/Pagination/pagination.types.d.ts +1 -1
  36. package/dist/Popover/Popover.svelte +1 -1
  37. package/dist/Popover/popover.types.d.ts +2 -0
  38. package/dist/Progress/Progress.svelte +14 -6
  39. package/dist/RadioGroup/RadioGroup.svelte +3 -1
  40. package/dist/Select/select.types.d.ts +1 -1
  41. package/dist/SelectMenu/SelectMenu.svelte +21 -5
  42. package/dist/SelectMenu/select-menu.types.d.ts +1 -1
  43. package/dist/Separator/separator.types.d.ts +1 -1
  44. package/dist/Skeleton/Skeleton.svelte +3 -5
  45. package/dist/Slideover/Slideover.svelte +4 -2
  46. package/dist/Slideover/SlideoverTriggerTestWrapper.svelte +10 -0
  47. package/dist/Slideover/SlideoverTriggerTestWrapper.svelte.d.ts +18 -0
  48. package/dist/Slideover/slideover.types.d.ts +13 -3
  49. package/dist/Stepper/Stepper.svelte +1 -3
  50. package/dist/Switch/Switch.svelte +12 -17
  51. package/dist/Table/table.utils.d.ts +7 -4
  52. package/dist/Table/table.utils.js +26 -25
  53. package/dist/Tabs/tabs.types.d.ts +1 -1
  54. package/dist/ThemeModeButton/ThemeModeButton.svelte +4 -3
  55. package/dist/Tooltip/Tooltip.svelte +1 -1
  56. package/dist/Tooltip/tooltip.types.d.ts +2 -0
  57. package/package.json +1 -1
@@ -53,7 +53,18 @@
53
53
 
54
54
  type SlotName = (typeof slotNames)[number]
55
55
 
56
+ const itemDefaults = $derived.by(() => {
57
+ const result = {} as Record<SlotName, string>
58
+ for (const slot of slotNames) {
59
+ result[slot] = variantSlots[slot]({ class: [config.slots[slot], ui?.[slot]] })
60
+ }
61
+ return result
62
+ })
63
+
56
64
  function getItemClasses(item: AccordionItem) {
65
+ if (!item.ui && item.class === undefined && item.disabled === undefined) {
66
+ return itemDefaults
67
+ }
57
68
  const result = {} as Record<SlotName, string>
58
69
  for (const slot of slotNames) {
59
70
  const baseClass = [
@@ -17,7 +17,7 @@ export type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, 'class'> & {
17
17
  * Override styles for specific badge slots.
18
18
  * Available slots: base, label, leadingIcon, trailingIcon, leadingAvatar.
19
19
  */
20
- ui?: Partial<Record<BadgeSlots, ClassNameValue>>;
20
+ ui?: Partial<Record<Exclude<BadgeSlots, 'leadingAvatarSize'>, ClassNameValue>>;
21
21
  /**
22
22
  * Sets the text content displayed inside the badge.
23
23
  */
@@ -14,7 +14,7 @@
14
14
  import { calendarVariants, calendarDefaults } from './calendar.variants.js'
15
15
  import { getComponentConfig } from '../config.js'
16
16
  import Icon from '../Icon/Icon.svelte'
17
- import type { DateValue } from '@internationalized/date'
17
+ import { type DateValue, today, getLocalTimeZone } from '@internationalized/date'
18
18
  import type { Month } from 'bits-ui'
19
19
  import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
20
20
 
@@ -63,6 +63,19 @@
63
63
  ...restProps
64
64
  }: Props = $props()
65
65
 
66
+ function firstDateOf(val: unknown): DateValue | undefined {
67
+ if (!val) return undefined
68
+ if (Array.isArray(val)) return val[0] as DateValue | undefined
69
+ if (typeof val === 'object' && 'start' in val) {
70
+ return (val as { start?: DateValue }).start
71
+ }
72
+ return val as DateValue
73
+ }
74
+
75
+ if (placeholder === undefined) {
76
+ placeholder = firstDateOf(value) ?? today(getLocalTimeZone())
77
+ }
78
+
66
79
  const formFieldContext = useFormField()
67
80
  const emit = useFormFieldEmit()
68
81
 
@@ -23,7 +23,7 @@ import type { CollapsibleSlots } from './collapsible.variants.js';
23
23
  */
24
24
  export interface CollapsibleProps extends Pick<CollapsibleRootPropsWithoutHTML, 'open' | 'onOpenChange' | 'onOpenChangeComplete' | 'disabled'>, Pick<CollapsibleRootProps, 'id' | 'style' | 'title' | 'role' | 'tabindex' | 'aria-label' | 'aria-labelledby' | 'aria-describedby' | 'onclick' | 'onkeydown' | 'onmouseenter' | 'onmouseleave' | 'onfocus' | 'onblur'> {
25
25
  /** Custom data attributes are forwarded to the root element. */
26
- [key: `data-${string}`]: unknown;
26
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
27
27
  /**
28
28
  * Bindable reference to the root DOM element.
29
29
  */
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { ClassNameValue } from 'tailwind-merge';
3
- import type { CommandRootPropsWithoutHTML } from 'bits-ui';
3
+ import type { CommandRootProps, CommandRootPropsWithoutHTML } from 'bits-ui';
4
4
  import type { CommandSlots, CommandVariantProps } from './command.variants.js';
5
5
  /**
6
6
  * Configuration for an individual command item.
@@ -57,7 +57,9 @@ export interface CommandItemSlotProps {
57
57
  *
58
58
  * @see https://bits-ui.com/docs/components/command
59
59
  */
60
- export interface CommandProps extends Pick<CommandRootPropsWithoutHTML, 'value' | 'onValueChange' | 'filter' | 'shouldFilter' | 'loop' | 'vimBindings' | 'label'> {
60
+ export interface CommandProps extends Pick<CommandRootPropsWithoutHTML, 'value' | 'onValueChange' | 'filter' | 'shouldFilter' | 'loop' | 'vimBindings' | 'label'>, Pick<CommandRootProps, 'id'> {
61
+ /** Custom data attributes are forwarded to the root element. */
62
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
61
63
  /** Bindable reference to the root DOM element. */
62
64
  ref?: HTMLElement | null;
63
65
  /** Array of grouped command items. */
@@ -1,2 +1,2 @@
1
1
  export { default as Command } from './Command.svelte';
2
- export type { CommandProps } from './command.types.js';
2
+ export type { CommandProps, CommandGroup, CommandItem } from './command.types.js';
@@ -329,7 +329,7 @@
329
329
 
330
330
  <ContextMenu.Root bind:open {onOpenChange}>
331
331
  {#if children}
332
- <ContextMenu.Trigger class={className as string}>
332
+ <ContextMenu.Trigger class={[className]}>
333
333
  {@render children({ open })}
334
334
  </ContextMenu.Trigger>
335
335
  {/if}
@@ -177,8 +177,10 @@
177
177
 
178
178
  {#snippet drawerBody()}
179
179
  {#if children}
180
- <Drawer.Trigger class={className as string}>
181
- {@render children()}
180
+ <Drawer.Trigger>
181
+ {#snippet child({ props })}
182
+ {@render children({ props })}
183
+ {/snippet}
182
184
  </Drawer.Trigger>
183
185
  {/if}
184
186
 
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import Drawer from './Drawer.svelte'
3
+ </script>
4
+
5
+ <Drawer>
6
+ {#snippet children({ props })}
7
+ <button data-testid="trigger" {...props}>Open</button>
8
+ {/snippet}
9
+ {#snippet content()}<p>Body</p>{/snippet}
10
+ </Drawer>
@@ -0,0 +1,18 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ declare const DrawerTriggerTestWrapper: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type DrawerTriggerTestWrapper = InstanceType<typeof DrawerTriggerTestWrapper>;
18
+ export default DrawerTriggerTestWrapper;
@@ -45,9 +45,20 @@ export type DrawerProps = VaulRootProps & {
45
45
  */
46
46
  class?: ClassNameValue;
47
47
  /**
48
- * Default slot renders the trigger element.
48
+ * Trigger content. Spread the provided `props` onto your own focusable
49
+ * element (e.g. a `<Button>`) so the drawer's trigger ARIA and event
50
+ * handlers land on the real control instead of a nested wrapper button.
51
+ *
52
+ * @example
53
+ * ```svelte
54
+ * {#snippet children({ props })}
55
+ * <Button {...props}>Open</Button>
56
+ * {/snippet}
57
+ * ```
49
58
  */
50
- children?: Snippet;
59
+ children?: Snippet<[{
60
+ props: Record<string, unknown>;
61
+ }]>;
51
62
  /**
52
63
  * Custom content slot (replaces default layout with header/body/footer).
53
64
  */
@@ -5,7 +5,7 @@
5
5
  </script>
6
6
 
7
7
  <script lang="ts">
8
- import { Editor } from '@tiptap/core'
8
+ import { Editor, type AnyExtension } from '@tiptap/core'
9
9
  import BubbleMenuExt from '@tiptap/extension-bubble-menu'
10
10
  import { untrack } from 'svelte'
11
11
  import { editorVariants, editorDefaults } from './editor.variants.js'
@@ -24,7 +24,12 @@
24
24
  import Icon from '../Icon/Icon.svelte'
25
25
  import Tooltip from '../Tooltip/Tooltip.svelte'
26
26
  import EditorUrlPrompt from './EditorUrlPrompt.svelte'
27
- import { httpUrlSchema, youtubeUrlSchema, type UrlSchema } from './editor.schemas.js'
27
+ import {
28
+ httpUrlSchema,
29
+ youtubeUrlSchema,
30
+ isSafeImageSrc,
31
+ type UrlSchema
32
+ } from './editor.schemas.js'
28
33
 
29
34
  const config = getComponentConfig('editor', editorDefaults)
30
35
 
@@ -53,6 +58,7 @@
53
58
  markdownAllowHtml = false,
54
59
  image = false,
55
60
  onImageUpload,
61
+ onImageUploadError,
56
62
  tables = false,
57
63
  onMention,
58
64
  mentionTrigger = '@',
@@ -77,6 +83,14 @@
77
83
  const formFieldContext = useFormField()
78
84
  const emit = useFormFieldEmit()
79
85
 
86
+ const resolvedOutput = untrack(() => output)
87
+
88
+ function getMarkdownStorage(ed: Editor): { getMarkdown?: () => string } | undefined {
89
+ return (ed.storage as unknown as Record<string, unknown>).markdown as
90
+ | { getMarkdown?: () => string }
91
+ | undefined
92
+ }
93
+
80
94
  const hasError = $derived(
81
95
  formFieldContext?.error !== undefined && formFieldContext?.error !== false
82
96
  )
@@ -143,11 +157,9 @@
143
157
  }
144
158
 
145
159
  function serialize(ed: Editor): string | EditorJSON {
146
- if (output === 'json') return ed.getJSON() as EditorJSON
147
- if (output === 'markdown') {
148
- const md = (ed.storage as unknown as Record<string, unknown>).markdown as
149
- | { getMarkdown?: () => string }
150
- | undefined
160
+ if (resolvedOutput === 'json') return ed.getJSON() as EditorJSON
161
+ if (resolvedOutput === 'markdown') {
162
+ const md = getMarkdownStorage(ed)
151
163
  if (md && typeof md.getMarkdown === 'function') {
152
164
  return md.getMarkdown()
153
165
  }
@@ -191,7 +203,7 @@
191
203
  })
192
204
  }
193
205
 
194
- function resolveExtensions() {
206
+ function resolveExtensions(): AnyExtension[] | Promise<AnyExtension[]> {
195
207
  if (extensionsOverride) return extensionsOverride
196
208
  return buildExtensions({
197
209
  headingLevels,
@@ -203,7 +215,7 @@
203
215
  tables,
204
216
  youtube,
205
217
  dragHandle,
206
- markdown: output === 'markdown',
218
+ markdown: resolvedOutput === 'markdown',
207
219
  markdownAllowHtml,
208
220
  mentionTrigger,
209
221
  mentionSuggestion: onMention
@@ -228,14 +240,11 @@
228
240
  }
229
241
 
230
242
  let suppressUpdate = false
243
+ let lastEmitted: string | EditorJSON | undefined
231
244
 
232
245
  $effect(() => {
233
246
  if (!contentElement) return
234
247
 
235
- // Untrack: these props would otherwise cause editor recreation on every
236
- // keystroke (value changes via onUpdate → effect re-runs → destroy + rebuild
237
- // → cursor lost → can only type 1 char). value sync is handled by a
238
- // dedicated effect below; disabled/readonly toggle via setEditable.
239
248
  const initialContent = untrack(() => value ?? '')
240
249
  const initialEditable = untrack(() => !disabled && !readonly)
241
250
  const initialAutofocus = untrack(() => autofocus)
@@ -245,43 +254,57 @@
245
254
  ...(ariaDescribedBy ? { 'aria-describedby': ariaDescribedBy } : {}),
246
255
  ...(hasError ? { 'aria-invalid': 'true' } : {})
247
256
  }))
248
- const exts = untrack(() => resolveExtensions())
249
-
250
- const ed = new Editor({
251
- element: contentElement,
252
- extensions: exts,
253
- content: initialContent,
254
- editable: initialEditable,
255
- autofocus: initialAutofocus,
256
- editorProps: {
257
- attributes: initialAttrs as Record<string, string>
258
- },
259
- onCreate: ({ editor: e }) => syncState(e),
260
- onUpdate: ({ editor: e }) => {
261
- syncState(e)
262
- if (suppressUpdate) return
263
- const serialized = serialize(e)
264
- value = serialized
265
- emit.onInput()
266
- onValueChange?.(serialized)
267
- },
268
- onSelectionUpdate: ({ editor: e }) => syncState(e),
269
- onFocus: ({ editor: e }) => {
270
- syncState(e)
271
- emit.onFocus()
272
- onFocus?.()
273
- },
274
- onBlur: ({ editor: e }) => {
275
- syncState(e)
276
- emit.onBlur()
277
- onBlur?.()
278
- }
279
- })
257
+ const el = contentElement
258
+ const result = untrack(() => resolveExtensions())
259
+
260
+ let ed: Editor | null = null
261
+ let cancelled = false
262
+
263
+ const create = (exts: AnyExtension[]) => {
264
+ if (cancelled) return
265
+ ed = new Editor({
266
+ element: el,
267
+ extensions: exts,
268
+ content: initialContent,
269
+ editable: initialEditable,
270
+ autofocus: initialAutofocus,
271
+ editorProps: {
272
+ attributes: initialAttrs as Record<string, string>
273
+ },
274
+ onCreate: ({ editor: e }) => syncState(e),
275
+ onUpdate: ({ editor: e }) => {
276
+ syncState(e)
277
+ if (suppressUpdate) return
278
+ const serialized = serialize(e)
279
+ lastEmitted = serialized
280
+ value = serialized
281
+ emit.onInput()
282
+ onValueChange?.(serialized)
283
+ },
284
+ onSelectionUpdate: ({ editor: e }) => syncState(e),
285
+ onFocus: ({ editor: e }) => {
286
+ syncState(e)
287
+ emit.onFocus()
288
+ onFocus?.()
289
+ },
290
+ onBlur: ({ editor: e }) => {
291
+ syncState(e)
292
+ emit.onBlur()
293
+ onBlur?.()
294
+ }
295
+ })
296
+ editor = ed
297
+ }
280
298
 
281
- editor = ed
299
+ if (result instanceof Promise) {
300
+ result.then(create)
301
+ } else {
302
+ create(result)
303
+ }
282
304
 
283
305
  return () => {
284
- ed.destroy()
306
+ cancelled = true
307
+ ed?.destroy()
285
308
  editor = null
286
309
  }
287
310
  })
@@ -294,9 +317,6 @@
294
317
  }
295
318
  })
296
319
 
297
- // Sync aria attributes on the ProseMirror element when error/id state changes.
298
- // Tiptap's editorProps.attributes is read once at init, so toggling needs
299
- // direct DOM access. Run on every state change.
300
320
  $effect(() => {
301
321
  if (!contentElement) return
302
322
  const pm = contentElement.querySelector('.ProseMirror') as HTMLElement | null
@@ -311,6 +331,7 @@
311
331
  $effect(() => {
312
332
  if (!editor) return
313
333
  if (value === undefined) return
334
+ if (typeof value === 'string' && value === lastEmitted) return
314
335
  const current = serialize(editor)
315
336
  if (isContentEqual(current, value)) return
316
337
  suppressUpdate = true
@@ -334,13 +355,11 @@
334
355
  TOOLBAR_ACTIONS[action].run(editor)
335
356
  },
336
357
  getValue(format) {
337
- if (!editor) return output === 'json' ? ({} as EditorJSON) : ''
338
- const fmt = format ?? output
358
+ if (!editor) return resolvedOutput === 'json' ? ({} as EditorJSON) : ''
359
+ const fmt = format ?? resolvedOutput
339
360
  if (fmt === 'json') return editor.getJSON() as EditorJSON
340
361
  if (fmt === 'markdown') {
341
- const md = (editor.storage as unknown as Record<string, unknown>).markdown as
342
- | { getMarkdown?: () => string }
343
- | undefined
362
+ const md = getMarkdownStorage(editor)
344
363
  if (md && typeof md.getMarkdown === 'function') return md.getMarkdown()
345
364
  return editor.getHTML()
346
365
  }
@@ -384,7 +403,6 @@
384
403
  return {
385
404
  root: slots.root({ class: [c.root, className, u.root] }),
386
405
  toolbar: slots.toolbar({ class: [c.toolbar, u.toolbar] }),
387
- toolbarGroup: slots.toolbarGroup({ class: [c.toolbarGroup, u.toolbarGroup] }),
388
406
  toolbarButton: slots.toolbarButton({ class: [c.toolbarButton, u.toolbarButton] }),
389
407
  toolbarSeparator: slots.toolbarSeparator({
390
408
  class: [c.toolbarSeparator, u.toolbarSeparator]
@@ -396,7 +414,6 @@
396
414
  }
397
415
  })
398
416
 
399
- // ----- URL prompt modal (shared by YouTube/Image/Link toolbar + slash) -----
400
417
  interface UrlPromptState {
401
418
  open: boolean
402
419
  title: string
@@ -440,7 +457,6 @@
440
457
  }
441
458
  }
442
459
 
443
- // ----- Image upload via hidden file input -----
444
460
  let fileInput: HTMLInputElement | null = $state(null)
445
461
 
446
462
  async function handleFileSelected(event: Event): Promise<void> {
@@ -452,10 +468,19 @@
452
468
  if (!onImageUpload) return
453
469
  try {
454
470
  const url = await onImageUpload(file)
471
+ if (!isSafeImageSrc(url)) {
472
+ // eslint-disable-next-line no-console
473
+ console.warn('[Editor] blocked unsafe image src from onImageUpload:', url)
474
+ return
475
+ }
455
476
  editor.chain().focus().setImage({ src: url }).run()
456
477
  } catch (err) {
457
- // eslint-disable-next-line no-console
458
- console.error('[Editor] image upload failed', err)
478
+ if (onImageUploadError) {
479
+ onImageUploadError(err)
480
+ } else {
481
+ // eslint-disable-next-line no-console
482
+ console.error('[Editor] image upload failed', err)
483
+ }
459
484
  }
460
485
  }
461
486
 
@@ -503,7 +528,6 @@
503
528
  })
504
529
  }
505
530
 
506
- // ----- Table dimension picker -----
507
531
  let tableMenuOpen = $state(false)
508
532
  let tablePickerRows = $state(0)
509
533
  let tablePickerCols = $state(0)
@@ -6,9 +6,11 @@
6
6
  items: SlashCommand[]
7
7
  selectedIndex: number
8
8
  onPick: (index: number) => void
9
+ listboxId: string
10
+ optionIdPrefix: string
9
11
  }
10
12
 
11
- let { items, selectedIndex, onPick }: Props = $props()
13
+ let { items, selectedIndex, onPick, listboxId, optionIdPrefix }: Props = $props()
12
14
 
13
15
  let listEl: HTMLDivElement | null = $state(null)
14
16
 
@@ -24,7 +26,9 @@
24
26
 
25
27
  <div
26
28
  bind:this={listEl}
29
+ id={listboxId}
27
30
  role="listbox"
31
+ aria-label="Slash commands"
28
32
  data-editor-slash-popup
29
33
  class="max-h-72 max-w-80 min-w-64 overflow-y-auto rounded-lg border border-outline-variant bg-surface py-1 shadow-lg"
30
34
  >
@@ -35,6 +39,9 @@
35
39
  {@const active = i === selectedIndex}
36
40
  <button
37
41
  type="button"
42
+ role="option"
43
+ id={`${optionIdPrefix}${i}`}
44
+ aria-selected={active}
38
45
  data-slash-item
39
46
  data-id={cmd.id}
40
47
  data-index={i}
@@ -3,6 +3,8 @@ interface Props {
3
3
  items: SlashCommand[];
4
4
  selectedIndex: number;
5
5
  onPick: (index: number) => void;
6
+ listboxId: string;
7
+ optionIdPrefix: string;
6
8
  }
7
9
  declare const SlashPopup: import("svelte").Component<Props, {}, "">;
8
10
  type SlashPopup = ReturnType<typeof SlashPopup>;
@@ -19,5 +19,5 @@ interface BuildExtensionsOptions {
19
19
  dragHandle?: boolean;
20
20
  extra?: AnyExtension[];
21
21
  }
22
- export declare function buildExtensions(options?: BuildExtensionsOptions): AnyExtension[];
22
+ export declare function buildExtensions(options?: BuildExtensionsOptions): AnyExtension[] | Promise<AnyExtension[]>;
23
23
  export {};
@@ -5,11 +5,6 @@ import Typography from '@tiptap/extension-typography';
5
5
  import CharacterCount from '@tiptap/extension-character-count';
6
6
  import Image from '@tiptap/extension-image';
7
7
  import Mention from '@tiptap/extension-mention';
8
- import { Table } from '@tiptap/extension-table';
9
- import { TableRow } from '@tiptap/extension-table-row';
10
- import { TableCell } from '@tiptap/extension-table-cell';
11
- import { TableHeader } from '@tiptap/extension-table-header';
12
- import { Markdown } from 'tiptap-markdown';
13
8
  import Youtube from '@tiptap/extension-youtube';
14
9
  import { DragHandle } from '@tiptap/extension-drag-handle';
15
10
  import { buildSlashExtension } from './editor.slash.svelte.js';
@@ -38,7 +33,13 @@ function buildImageExt() {
38
33
  HTMLAttributes: { class: 'sv5ui-editor-image' }
39
34
  });
40
35
  }
41
- function buildTableExts() {
36
+ async function buildTableExts() {
37
+ const [{ Table }, { TableRow }, { TableCell }, { TableHeader }] = await Promise.all([
38
+ import('@tiptap/extension-table'),
39
+ import('@tiptap/extension-table-row'),
40
+ import('@tiptap/extension-table-cell'),
41
+ import('@tiptap/extension-table-header')
42
+ ]);
42
43
  return [
43
44
  Table.configure({
44
45
  resizable: true,
@@ -49,7 +50,8 @@ function buildTableExts() {
49
50
  TableCell
50
51
  ];
51
52
  }
52
- function buildMarkdownExt(allowHtml) {
53
+ async function buildMarkdownExt(allowHtml) {
54
+ const { Markdown } = await import('tiptap-markdown');
53
55
  return Markdown.configure({
54
56
  html: allowHtml,
55
57
  tightLists: true,
@@ -73,8 +75,6 @@ function buildYoutubeExt() {
73
75
  HTMLAttributes: { class: 'sv5ui-editor-youtube' }
74
76
  });
75
77
  }
76
- // lucide:grip-vertical inline SVG. Used directly because @iconify/svelte
77
- // component would need to be mounted via Svelte; this is a vanilla DOM helper.
78
78
  const GRIP_VERTICAL_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/></svg>';
79
79
  function buildDragHandleExt() {
80
80
  return DragHandle.configure({
@@ -106,18 +106,27 @@ const OPTIONAL_BUILDERS = [
106
106
  (o) => (o.dragHandle ? buildDragHandleExt() : null)
107
107
  ];
108
108
  function collectOptionalExts(options) {
109
- const acc = [];
109
+ const sync = [];
110
+ const lazy = [];
110
111
  for (const build of OPTIONAL_BUILDERS) {
111
112
  const result = build(options);
112
- if (result === null)
113
+ if (result === null || result === undefined)
113
114
  continue;
114
- if (Array.isArray(result))
115
- acc.push(...result);
115
+ if (result instanceof Promise)
116
+ lazy.push(result);
117
+ else if (Array.isArray(result))
118
+ sync.push(...result);
116
119
  else
117
- acc.push(result);
120
+ sync.push(result);
118
121
  }
119
- return acc;
122
+ if (lazy.length === 0)
123
+ return sync;
124
+ return Promise.all(lazy).then((resolved) => [...sync, ...resolved.flat()]);
120
125
  }
121
126
  export function buildExtensions(options = {}) {
122
- return [...buildCore(options), ...collectOptionalExts(options), ...(options.extra ?? [])];
127
+ const optional = collectOptionalExts(options);
128
+ if (optional instanceof Promise) {
129
+ return optional.then((opt) => [...buildCore(options), ...opt, ...(options.extra ?? [])]);
130
+ }
131
+ return [...buildCore(options), ...optional, ...(options.extra ?? [])];
123
132
  }
@@ -1,4 +1,5 @@
1
1
  import type { StandardSchemaV1 } from '@standard-schema/spec';
2
2
  export type UrlSchema = StandardSchemaV1<string, string>;
3
3
  export declare const httpUrlSchema: UrlSchema;
4
+ export declare function isSafeImageSrc(src: string): boolean;
4
5
  export declare const youtubeUrlSchema: UrlSchema;
@@ -1,3 +1,27 @@
1
1
  import * as v from 'valibot';
2
2
  export const httpUrlSchema = v.pipe(v.string(), v.trim(), v.nonEmpty('URL is required'), v.url('Please enter a valid URL'), v.regex(/^https?:\/\//i, 'URL must start with http:// or https://'));
3
+ function normalizeUrl(src) {
4
+ const s = src.replace(/[\t\n\r]/g, '');
5
+ let start = 0;
6
+ let end = s.length;
7
+ while (start < end && s.charCodeAt(start) <= 0x20)
8
+ start++;
9
+ while (end > start && s.charCodeAt(end - 1) <= 0x20)
10
+ end--;
11
+ return s.slice(start, end);
12
+ }
13
+ export function isSafeImageSrc(src) {
14
+ const s = normalizeUrl(src);
15
+ if (!s)
16
+ return false;
17
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(s)?.[1]?.toLowerCase();
18
+ if (!scheme)
19
+ return true;
20
+ if (scheme === 'http' || scheme === 'https')
21
+ return true;
22
+ if (scheme === 'data') {
23
+ return /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon)[;,]/i.test(s);
24
+ }
25
+ return false;
26
+ }
3
27
  export const youtubeUrlSchema = v.pipe(v.string(), v.trim(), v.nonEmpty('URL is required'), v.url('Please enter a valid URL'), v.regex(/^https?:\/\/(?:www\.|m\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)\//i, 'Must be a YouTube URL (youtube.com or youtu.be)'));
@@ -14,17 +14,8 @@ interface SlashCommandsContext {
14
14
  image?: boolean;
15
15
  tables?: boolean;
16
16
  youtube?: boolean;
17
- /**
18
- * Override for the URL prompt used by image/youtube commands. Pass an
19
- * async function that resolves to a URL string, or `null`/empty string
20
- * to cancel. When omitted, falls back to `window.prompt`.
21
- */
22
17
  promptUrl?: (opts: UrlPromptOptions) => Promise<string | null>;
23
18
  }
24
- /**
25
- * Returns the built-in slash command list, optionally including media
26
- * commands based on which features are enabled in the host Editor.
27
- */
28
19
  export declare function buildDefaultSlashCommands(ctx?: SlashCommandsContext): SlashCommand[];
29
20
  interface SlashExtensionOptions {
30
21
  suggestion: Partial<SuggestionOptions>;