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.
- package/dist/Accordion/Accordion.svelte +11 -0
- package/dist/Badge/badge.types.d.ts +1 -1
- package/dist/Calendar/Calendar.svelte +14 -1
- package/dist/Collapsible/collapsible.types.d.ts +1 -1
- package/dist/Command/command.types.d.ts +4 -2
- package/dist/Command/index.d.ts +1 -1
- package/dist/ContextMenu/ContextMenu.svelte +1 -1
- package/dist/Drawer/Drawer.svelte +4 -2
- package/dist/Drawer/DrawerTriggerTestWrapper.svelte +10 -0
- package/dist/Drawer/DrawerTriggerTestWrapper.svelte.d.ts +18 -0
- package/dist/Drawer/drawer.types.d.ts +13 -2
- package/dist/Editor/Editor.svelte +85 -61
- package/dist/Editor/SlashPopup.svelte +8 -1
- package/dist/Editor/SlashPopup.svelte.d.ts +2 -0
- package/dist/Editor/editor.extensions.d.ts +1 -1
- package/dist/Editor/editor.extensions.js +25 -16
- package/dist/Editor/editor.schemas.d.ts +1 -0
- package/dist/Editor/editor.schemas.js +24 -0
- package/dist/Editor/editor.slash.svelte.d.ts +0 -9
- package/dist/Editor/editor.slash.svelte.js +33 -7
- package/dist/Editor/editor.suggestion.js +23 -0
- package/dist/Editor/editor.toolbar.js +0 -8
- package/dist/Editor/editor.types.d.ts +20 -0
- package/dist/Editor/editor.variants.d.ts +0 -5
- package/dist/Editor/editor.variants.js +0 -15
- package/dist/Editor/index.d.ts +6 -4
- package/dist/Editor/index.js +6 -4
- package/dist/FileUpload/FileUpload.svelte +7 -0
- package/dist/Icon/icon.types.d.ts +1 -1
- package/dist/Input/index.d.ts +1 -1
- package/dist/Modal/Modal.svelte +4 -2
- package/dist/Modal/ModalTriggerTestWrapper.svelte +10 -0
- package/dist/Modal/ModalTriggerTestWrapper.svelte.d.ts +18 -0
- package/dist/Modal/modal.types.d.ts +13 -3
- package/dist/Pagination/pagination.types.d.ts +1 -1
- package/dist/Popover/Popover.svelte +1 -1
- package/dist/Popover/popover.types.d.ts +2 -0
- package/dist/Progress/Progress.svelte +14 -6
- package/dist/RadioGroup/RadioGroup.svelte +3 -1
- package/dist/Select/select.types.d.ts +1 -1
- package/dist/SelectMenu/SelectMenu.svelte +21 -5
- package/dist/SelectMenu/select-menu.types.d.ts +1 -1
- package/dist/Separator/separator.types.d.ts +1 -1
- package/dist/Skeleton/Skeleton.svelte +3 -5
- package/dist/Slideover/Slideover.svelte +4 -2
- package/dist/Slideover/SlideoverTriggerTestWrapper.svelte +10 -0
- package/dist/Slideover/SlideoverTriggerTestWrapper.svelte.d.ts +18 -0
- package/dist/Slideover/slideover.types.d.ts +13 -3
- package/dist/Stepper/Stepper.svelte +1 -3
- package/dist/Switch/Switch.svelte +12 -17
- package/dist/Table/table.utils.d.ts +7 -4
- package/dist/Table/table.utils.js +26 -25
- package/dist/Tabs/tabs.types.d.ts +1 -1
- package/dist/ThemeModeButton/ThemeModeButton.svelte +4 -3
- package/dist/Tooltip/Tooltip.svelte +1 -1
- package/dist/Tooltip/tooltip.types.d.ts +2 -0
- 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
|
|
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}`]:
|
|
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. */
|
package/dist/Command/index.d.ts
CHANGED
|
@@ -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';
|
|
@@ -177,8 +177,10 @@
|
|
|
177
177
|
|
|
178
178
|
{#snippet drawerBody()}
|
|
179
179
|
{#if children}
|
|
180
|
-
<Drawer.Trigger
|
|
181
|
-
{
|
|
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,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
|
-
*
|
|
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 {
|
|
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 (
|
|
147
|
-
if (
|
|
148
|
-
const md = (ed
|
|
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:
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
syncState(e)
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
299
|
+
if (result instanceof Promise) {
|
|
300
|
+
result.then(create)
|
|
301
|
+
} else {
|
|
302
|
+
create(result)
|
|
303
|
+
}
|
|
282
304
|
|
|
283
305
|
return () => {
|
|
284
|
-
|
|
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
|
|
338
|
-
const fmt = format ??
|
|
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
|
|
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
|
-
|
|
458
|
-
|
|
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
|
|
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 (
|
|
115
|
-
|
|
115
|
+
if (result instanceof Promise)
|
|
116
|
+
lazy.push(result);
|
|
117
|
+
else if (Array.isArray(result))
|
|
118
|
+
sync.push(...result);
|
|
116
119
|
else
|
|
117
|
-
|
|
120
|
+
sync.push(result);
|
|
118
121
|
}
|
|
119
|
-
|
|
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
|
-
|
|
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>;
|