sv5ui 1.6.0 → 1.8.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 (71) hide show
  1. package/dist/Banner/Banner.svelte +162 -0
  2. package/dist/Banner/Banner.svelte.d.ts +5 -0
  3. package/dist/Banner/banner.types.d.ts +148 -0
  4. package/dist/Banner/banner.types.js +1 -0
  5. package/dist/Banner/banner.variants.d.ts +293 -0
  6. package/dist/Banner/banner.variants.js +86 -0
  7. package/dist/Banner/index.d.ts +2 -0
  8. package/dist/Banner/index.js +1 -0
  9. package/dist/Calendar/Calendar.svelte +48 -6
  10. package/dist/Calendar/calendar.types.d.ts +19 -0
  11. package/dist/Calendar/calendar.variants.js +2 -1
  12. package/dist/Carousel/Carousel.svelte +279 -0
  13. package/dist/Carousel/Carousel.svelte.d.ts +26 -0
  14. package/dist/Carousel/carousel.types.d.ts +242 -0
  15. package/dist/Carousel/carousel.types.js +1 -0
  16. package/dist/Carousel/carousel.variants.d.ts +408 -0
  17. package/dist/Carousel/carousel.variants.js +88 -0
  18. package/dist/Carousel/index.d.ts +2 -0
  19. package/dist/Carousel/index.js +1 -0
  20. package/dist/Editor/Editor.svelte +738 -0
  21. package/dist/Editor/Editor.svelte.d.ts +6 -0
  22. package/dist/Editor/EditorUrlPrompt.svelte +111 -0
  23. package/dist/Editor/EditorUrlPrompt.svelte.d.ts +15 -0
  24. package/dist/Editor/SlashPopup.svelte +67 -0
  25. package/dist/Editor/SlashPopup.svelte.d.ts +9 -0
  26. package/dist/Editor/editor.extensions.d.ts +23 -0
  27. package/dist/Editor/editor.extensions.js +123 -0
  28. package/dist/Editor/editor.schemas.d.ts +4 -0
  29. package/dist/Editor/editor.schemas.js +3 -0
  30. package/dist/Editor/editor.slash.svelte.d.ts +34 -0
  31. package/dist/Editor/editor.slash.svelte.js +273 -0
  32. package/dist/Editor/editor.suggestion.d.ts +7 -0
  33. package/dist/Editor/editor.suggestion.js +142 -0
  34. package/dist/Editor/editor.toolbar.d.ts +11 -0
  35. package/dist/Editor/editor.toolbar.js +212 -0
  36. package/dist/Editor/editor.types.d.ts +347 -0
  37. package/dist/Editor/editor.types.js +1 -0
  38. package/dist/Editor/editor.variants.d.ts +308 -0
  39. package/dist/Editor/editor.variants.js +150 -0
  40. package/dist/Editor/index.d.ts +53 -0
  41. package/dist/Editor/index.js +52 -0
  42. package/dist/FileUpload/FileUpload.svelte +81 -10
  43. package/dist/FileUpload/file-upload.types.d.ts +39 -0
  44. package/dist/FileUpload/index.d.ts +1 -1
  45. package/dist/Modal/Modal.svelte +14 -3
  46. package/dist/Modal/modal.types.d.ts +15 -4
  47. package/dist/Modal/modal.variants.d.ts +110 -20
  48. package/dist/Modal/modal.variants.js +27 -9
  49. package/dist/PinInput/PinInput.svelte +18 -4
  50. package/dist/PinInput/pin-input.types.d.ts +11 -0
  51. package/dist/Select/Select.svelte +98 -28
  52. package/dist/Select/select.types.d.ts +44 -2
  53. package/dist/SelectMenu/SelectMenu.svelte +210 -25
  54. package/dist/SelectMenu/select-menu.types.d.ts +62 -1
  55. package/dist/SelectMenu/select-menu.variants.d.ts +26 -0
  56. package/dist/SelectMenu/select-menu.variants.js +34 -6
  57. package/dist/Slideover/Slideover.svelte +13 -2
  58. package/dist/Slideover/slideover.types.d.ts +14 -3
  59. package/dist/Slideover/slideover.variants.d.ts +85 -5
  60. package/dist/Slideover/slideover.variants.js +42 -12
  61. package/dist/Stepper/Stepper.svelte +292 -0
  62. package/dist/Stepper/Stepper.svelte.d.ts +5 -0
  63. package/dist/Stepper/index.d.ts +2 -0
  64. package/dist/Stepper/index.js +1 -0
  65. package/dist/Stepper/stepper.types.d.ts +223 -0
  66. package/dist/Stepper/stepper.types.js +1 -0
  67. package/dist/Stepper/stepper.variants.d.ts +428 -0
  68. package/dist/Stepper/stepper.variants.js +204 -0
  69. package/dist/index.d.ts +3 -0
  70. package/dist/index.js +3 -0
  71. package/package.json +102 -1
@@ -0,0 +1,6 @@
1
+ import type { EditorProps } from './editor.types.js';
2
+ export type Props = EditorProps;
3
+ import { Editor } from '@tiptap/core';
4
+ declare const Editor: import("svelte").Component<EditorProps, {}, "ref" | "value" | "api">;
5
+ type Editor = ReturnType<typeof Editor>;
6
+ export default Editor;
@@ -0,0 +1,111 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte'
3
+ import Modal from '../Modal/Modal.svelte'
4
+ import Input from '../Input/Input.svelte'
5
+ import Button from '../Button/Button.svelte'
6
+ import Form from '../Form/Form.svelte'
7
+ import FormField from '../FormField/FormField.svelte'
8
+ import type { FormApi, FormError, FormSubmitEvent } from '../Form/form.types.js'
9
+ import { httpUrlSchema, type UrlSchema } from './editor.schemas.js'
10
+
11
+ interface Props {
12
+ open: boolean
13
+ title?: string
14
+ description?: string
15
+ placeholder?: string
16
+ initialValue?: string
17
+ confirmLabel?: string
18
+ schema?: UrlSchema
19
+ onConfirm?: (value: string) => void
20
+ onCancel?: () => void
21
+ }
22
+
23
+ let {
24
+ open = $bindable(false),
25
+ title = 'Enter URL',
26
+ description,
27
+ placeholder = 'https://',
28
+ initialValue = '',
29
+ confirmLabel = 'Insert',
30
+ schema,
31
+ onConfirm,
32
+ onCancel
33
+ }: Props = $props()
34
+
35
+ type UrlFormState = { url: string }
36
+
37
+ let formApi = $state<FormApi<UrlFormState>>()
38
+ let formState = $state<UrlFormState>({ url: '' })
39
+ let inputRef: HTMLInputElement | null = $state(null)
40
+ let settled = $state(false)
41
+
42
+ const resolvedSchema = $derived(schema ?? httpUrlSchema)
43
+
44
+ $effect(() => {
45
+ if (open) {
46
+ const initial = untrack(() => initialValue)
47
+ formState = { url: initial }
48
+ settled = false
49
+ untrack(() => formApi?.reset())
50
+ requestAnimationFrame(() => inputRef?.focus())
51
+ } else if (!settled) {
52
+ settled = true
53
+ onCancel?.()
54
+ }
55
+ })
56
+
57
+ async function validate(state: object): Promise<FormError[]> {
58
+ const url = (state as Partial<UrlFormState>).url ?? ''
59
+ const result = await resolvedSchema['~standard'].validate(url)
60
+ if (result.issues) {
61
+ return [{ name: 'url', message: result.issues[0]?.message ?? 'Invalid value' }]
62
+ }
63
+ return []
64
+ }
65
+
66
+ function handleSubmit(event: FormSubmitEvent<unknown>): void {
67
+ const data = event.data as UrlFormState
68
+ settled = true
69
+ onConfirm?.(data.url.trim())
70
+ open = false
71
+ }
72
+
73
+ function cancel(): void {
74
+ settled = true
75
+ onCancel?.()
76
+ open = false
77
+ }
78
+ </script>
79
+
80
+ <Modal bind:open {title} {description} size="sm">
81
+ {#snippet body()}
82
+ <Form
83
+ bind:api={formApi}
84
+ bind:state={formState}
85
+ {validate}
86
+ onsubmit={handleSubmit}
87
+ class="py-1"
88
+ >
89
+ <FormField name="url">
90
+ <Input
91
+ bind:ref={inputRef}
92
+ bind:value={formState.url}
93
+ {placeholder}
94
+ class="w-full"
95
+ />
96
+ </FormField>
97
+ </Form>
98
+ {/snippet}
99
+ {#snippet footer()}
100
+ <div class="flex items-center justify-end gap-2">
101
+ <Button variant="ghost" size="sm" label="Cancel" onclick={cancel} />
102
+ <Button
103
+ color="primary"
104
+ size="sm"
105
+ label={confirmLabel}
106
+ loading={formApi?.loading}
107
+ onclick={() => void formApi?.submit()}
108
+ />
109
+ </div>
110
+ {/snippet}
111
+ </Modal>
@@ -0,0 +1,15 @@
1
+ import { type UrlSchema } from './editor.schemas.js';
2
+ interface Props {
3
+ open: boolean;
4
+ title?: string;
5
+ description?: string;
6
+ placeholder?: string;
7
+ initialValue?: string;
8
+ confirmLabel?: string;
9
+ schema?: UrlSchema;
10
+ onConfirm?: (value: string) => void;
11
+ onCancel?: () => void;
12
+ }
13
+ declare const EditorUrlPrompt: import("svelte").Component<Props, {}, "open">;
14
+ type EditorUrlPrompt = ReturnType<typeof EditorUrlPrompt>;
15
+ export default EditorUrlPrompt;
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import type { SlashCommand } from './editor.types.js'
3
+ import Icon from '../Icon/Icon.svelte'
4
+
5
+ interface Props {
6
+ items: SlashCommand[]
7
+ selectedIndex: number
8
+ onPick: (index: number) => void
9
+ }
10
+
11
+ let { items, selectedIndex, onPick }: Props = $props()
12
+
13
+ let listEl: HTMLDivElement | null = $state(null)
14
+
15
+ $effect(() => {
16
+ if (!listEl) return
17
+ const active = listEl.querySelector(`[data-index="${selectedIndex}"]`)
18
+ if (!active) return
19
+ requestAnimationFrame(() => {
20
+ ;(active as HTMLElement).scrollIntoView({ block: 'nearest' })
21
+ })
22
+ })
23
+ </script>
24
+
25
+ <div
26
+ bind:this={listEl}
27
+ role="listbox"
28
+ data-editor-slash-popup
29
+ 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
+ >
31
+ {#if items.length === 0}
32
+ <div class="px-3 py-2 text-sm text-on-surface-variant">No matches</div>
33
+ {:else}
34
+ {#each items as cmd, i (cmd.id)}
35
+ {@const active = i === selectedIndex}
36
+ <button
37
+ type="button"
38
+ data-slash-item
39
+ data-id={cmd.id}
40
+ data-index={i}
41
+ class="flex w-full items-start gap-3 px-3 py-2 text-start hover:bg-surface-container-high {active
42
+ ? 'bg-primary-container text-on-primary-container'
43
+ : 'text-on-surface'}"
44
+ onmousedown={(e) => {
45
+ e.preventDefault()
46
+ onPick(i)
47
+ }}
48
+ >
49
+ {#if cmd.icon}
50
+ <span
51
+ class="flex size-7 shrink-0 items-center justify-center rounded border border-outline-variant"
52
+ >
53
+ <Icon name={cmd.icon} class="size-4" />
54
+ </span>
55
+ {/if}
56
+ <span class="flex min-w-0 flex-col">
57
+ <span class="truncate text-sm font-medium">{cmd.label}</span>
58
+ {#if cmd.description}
59
+ <span class="truncate text-xs text-on-surface-variant"
60
+ >{cmd.description}</span
61
+ >
62
+ {/if}
63
+ </span>
64
+ </button>
65
+ {/each}
66
+ {/if}
67
+ </div>
@@ -0,0 +1,9 @@
1
+ import type { SlashCommand } from './editor.types.js';
2
+ interface Props {
3
+ items: SlashCommand[];
4
+ selectedIndex: number;
5
+ onPick: (index: number) => void;
6
+ }
7
+ declare const SlashPopup: import("svelte").Component<Props, {}, "">;
8
+ type SlashPopup = ReturnType<typeof SlashPopup>;
9
+ export default SlashPopup;
@@ -0,0 +1,23 @@
1
+ import type { AnyExtension } from '@tiptap/core';
2
+ import type { SuggestionOptions } from '@tiptap/suggestion';
3
+ import type { SlashCommand } from './editor.types.js';
4
+ interface BuildExtensionsOptions {
5
+ headingLevels?: (1 | 2 | 3 | 4 | 5 | 6)[];
6
+ placeholder?: string;
7
+ autolink?: boolean;
8
+ linkOpenInNewTab?: boolean;
9
+ maxLength?: number;
10
+ image?: boolean;
11
+ tables?: boolean;
12
+ markdown?: boolean;
13
+ markdownAllowHtml?: boolean;
14
+ mentionSuggestion?: Omit<SuggestionOptions, 'editor'>;
15
+ mentionTrigger?: string;
16
+ slashCommands?: SlashCommand[];
17
+ slashTrigger?: string;
18
+ youtube?: boolean;
19
+ dragHandle?: boolean;
20
+ extra?: AnyExtension[];
21
+ }
22
+ export declare function buildExtensions(options?: BuildExtensionsOptions): AnyExtension[];
23
+ export {};
@@ -0,0 +1,123 @@
1
+ import StarterKit from '@tiptap/starter-kit';
2
+ import Placeholder from '@tiptap/extension-placeholder';
3
+ import TextAlign from '@tiptap/extension-text-align';
4
+ import Typography from '@tiptap/extension-typography';
5
+ import CharacterCount from '@tiptap/extension-character-count';
6
+ import Image from '@tiptap/extension-image';
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
+ import Youtube from '@tiptap/extension-youtube';
14
+ import { DragHandle } from '@tiptap/extension-drag-handle';
15
+ import { buildSlashExtension } from './editor.slash.svelte.js';
16
+ function buildCore(options) {
17
+ const { headingLevels = [1, 2, 3], autolink = true, linkOpenInNewTab = true, maxLength } = options;
18
+ return [
19
+ StarterKit.configure({
20
+ heading: { levels: headingLevels },
21
+ link: {
22
+ autolink,
23
+ openOnClick: false,
24
+ HTMLAttributes: linkOpenInNewTab
25
+ ? { target: '_blank', rel: 'noopener noreferrer' }
26
+ : {}
27
+ }
28
+ }),
29
+ TextAlign.configure({ types: ['heading', 'paragraph'] }),
30
+ Typography,
31
+ CharacterCount.configure({ limit: maxLength })
32
+ ];
33
+ }
34
+ function buildImageExt() {
35
+ return Image.configure({
36
+ inline: false,
37
+ allowBase64: true,
38
+ HTMLAttributes: { class: 'sv5ui-editor-image' }
39
+ });
40
+ }
41
+ function buildTableExts() {
42
+ return [
43
+ Table.configure({
44
+ resizable: true,
45
+ HTMLAttributes: { class: 'sv5ui-editor-table' }
46
+ }),
47
+ TableRow,
48
+ TableHeader,
49
+ TableCell
50
+ ];
51
+ }
52
+ function buildMarkdownExt(allowHtml) {
53
+ return Markdown.configure({
54
+ html: allowHtml,
55
+ tightLists: true,
56
+ bulletListMarker: '-',
57
+ linkify: true,
58
+ breaks: true,
59
+ transformPastedText: true,
60
+ transformCopiedText: false
61
+ });
62
+ }
63
+ function buildMentionExt(suggestion, trigger) {
64
+ return Mention.configure({
65
+ HTMLAttributes: { class: 'sv5ui-editor-mention' },
66
+ suggestion: { char: trigger, ...suggestion }
67
+ });
68
+ }
69
+ function buildYoutubeExt() {
70
+ return Youtube.configure({
71
+ controls: true,
72
+ nocookie: true,
73
+ HTMLAttributes: { class: 'sv5ui-editor-youtube' }
74
+ });
75
+ }
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
+ 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
+ function buildDragHandleExt() {
80
+ return DragHandle.configure({
81
+ render() {
82
+ const handle = document.createElement('div');
83
+ handle.setAttribute('data-editor-drag-handle', '');
84
+ handle.setAttribute('aria-label', 'Drag to reorder');
85
+ handle.className = [
86
+ 'inline-flex size-6 items-center justify-center',
87
+ 'rounded text-on-surface-variant/70',
88
+ 'hover:bg-surface-container-high hover:text-on-surface',
89
+ 'cursor-grab active:cursor-grabbing select-none transition-colors'
90
+ ].join(' ');
91
+ handle.innerHTML = GRIP_VERTICAL_SVG;
92
+ return handle;
93
+ }
94
+ });
95
+ }
96
+ const OPTIONAL_BUILDERS = [
97
+ (o) => (o.placeholder ? Placeholder.configure({ placeholder: o.placeholder }) : null),
98
+ (o) => (o.image ? buildImageExt() : null),
99
+ (o) => (o.tables ? buildTableExts() : null),
100
+ (o) => (o.youtube ? buildYoutubeExt() : null),
101
+ (o) => (o.markdown ? buildMarkdownExt(o.markdownAllowHtml ?? false) : null),
102
+ (o) => o.mentionSuggestion ? buildMentionExt(o.mentionSuggestion, o.mentionTrigger ?? '@') : null,
103
+ (o) => o.slashCommands && o.slashCommands.length > 0
104
+ ? buildSlashExtension(o.slashCommands, o.slashTrigger ?? '/')
105
+ : null,
106
+ (o) => (o.dragHandle ? buildDragHandleExt() : null)
107
+ ];
108
+ function collectOptionalExts(options) {
109
+ const acc = [];
110
+ for (const build of OPTIONAL_BUILDERS) {
111
+ const result = build(options);
112
+ if (result === null)
113
+ continue;
114
+ if (Array.isArray(result))
115
+ acc.push(...result);
116
+ else
117
+ acc.push(result);
118
+ }
119
+ return acc;
120
+ }
121
+ export function buildExtensions(options = {}) {
122
+ return [...buildCore(options), ...collectOptionalExts(options), ...(options.extra ?? [])];
123
+ }
@@ -0,0 +1,4 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ export type UrlSchema = StandardSchemaV1<string, string>;
3
+ export declare const httpUrlSchema: UrlSchema;
4
+ export declare const youtubeUrlSchema: UrlSchema;
@@ -0,0 +1,3 @@
1
+ import * as v from 'valibot';
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
+ 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)'));
@@ -0,0 +1,34 @@
1
+ import { Extension } from '@tiptap/core';
2
+ import { type SuggestionOptions } from '@tiptap/suggestion';
3
+ import type { SlashCommand } from './editor.types.js';
4
+ import { type UrlSchema } from './editor.schemas.js';
5
+ export interface UrlPromptOptions {
6
+ title: string;
7
+ description?: string;
8
+ placeholder?: string;
9
+ initialValue?: string;
10
+ confirmLabel?: string;
11
+ schema?: UrlSchema;
12
+ }
13
+ interface SlashCommandsContext {
14
+ image?: boolean;
15
+ tables?: boolean;
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
+ promptUrl?: (opts: UrlPromptOptions) => Promise<string | null>;
23
+ }
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
+ export declare function buildDefaultSlashCommands(ctx?: SlashCommandsContext): SlashCommand[];
29
+ interface SlashExtensionOptions {
30
+ suggestion: Partial<SuggestionOptions>;
31
+ }
32
+ export declare const SlashCommandsExtension: Extension<SlashExtensionOptions, any>;
33
+ export declare function buildSlashExtension(commands: SlashCommand[], trigger?: string): Extension;
34
+ export {};
@@ -0,0 +1,273 @@
1
+ import { mount, unmount } from 'svelte';
2
+ import { Extension } from '@tiptap/core';
3
+ import { Suggestion } from '@tiptap/suggestion';
4
+ import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
5
+ import { httpUrlSchema, youtubeUrlSchema } from './editor.schemas.js';
6
+ import SlashPopup from './SlashPopup.svelte';
7
+ function defaultPromptUrl(opts) {
8
+ if (typeof window === 'undefined')
9
+ return Promise.resolve(null);
10
+ return Promise.resolve(window.prompt(opts.title, opts.initialValue ?? opts.placeholder ?? ''));
11
+ }
12
+ /**
13
+ * Returns the built-in slash command list, optionally including media
14
+ * commands based on which features are enabled in the host Editor.
15
+ */
16
+ export function buildDefaultSlashCommands(ctx = {}) {
17
+ const commands = [
18
+ {
19
+ id: 'paragraph',
20
+ label: 'Text',
21
+ description: 'Plain paragraph',
22
+ icon: 'lucide:pilcrow',
23
+ keywords: ['p', 'text', 'paragraph'],
24
+ run: ({ editor }) => editor.chain().focus().setParagraph().run()
25
+ },
26
+ {
27
+ id: 'h1',
28
+ label: 'Heading 1',
29
+ description: 'Large section heading',
30
+ icon: 'lucide:heading-1',
31
+ keywords: ['h1', 'header', 'title'],
32
+ run: ({ editor }) => editor.chain().focus().toggleHeading({ level: 1 }).run()
33
+ },
34
+ {
35
+ id: 'h2',
36
+ label: 'Heading 2',
37
+ description: 'Medium section heading',
38
+ icon: 'lucide:heading-2',
39
+ keywords: ['h2', 'subtitle'],
40
+ run: ({ editor }) => editor.chain().focus().toggleHeading({ level: 2 }).run()
41
+ },
42
+ {
43
+ id: 'h3',
44
+ label: 'Heading 3',
45
+ description: 'Small section heading',
46
+ icon: 'lucide:heading-3',
47
+ keywords: ['h3'],
48
+ run: ({ editor }) => editor.chain().focus().toggleHeading({ level: 3 }).run()
49
+ },
50
+ {
51
+ id: 'bulletList',
52
+ label: 'Bullet list',
53
+ description: 'Unordered list',
54
+ icon: 'lucide:list',
55
+ keywords: ['ul', 'bullet', 'list'],
56
+ run: ({ editor }) => editor.chain().focus().toggleBulletList().run()
57
+ },
58
+ {
59
+ id: 'orderedList',
60
+ label: 'Numbered list',
61
+ description: 'Ordered list',
62
+ icon: 'lucide:list-ordered',
63
+ keywords: ['ol', 'numbered', 'ordered'],
64
+ run: ({ editor }) => editor.chain().focus().toggleOrderedList().run()
65
+ },
66
+ {
67
+ id: 'blockquote',
68
+ label: 'Quote',
69
+ description: 'Highlight a passage',
70
+ icon: 'lucide:quote',
71
+ keywords: ['quote', 'blockquote'],
72
+ run: ({ editor }) => editor.chain().focus().toggleBlockquote().run()
73
+ },
74
+ {
75
+ id: 'codeBlock',
76
+ label: 'Code block',
77
+ description: 'Fenced code with syntax highlighting',
78
+ icon: 'lucide:square-code',
79
+ keywords: ['code', 'pre', 'fence'],
80
+ run: ({ editor }) => editor.chain().focus().toggleCodeBlock().run()
81
+ },
82
+ {
83
+ id: 'horizontalRule',
84
+ label: 'Divider',
85
+ description: 'Horizontal rule',
86
+ icon: 'lucide:minus',
87
+ keywords: ['hr', 'divider', 'rule', 'separator'],
88
+ run: ({ editor }) => editor.chain().focus().setHorizontalRule().run()
89
+ }
90
+ ];
91
+ const promptUrl = ctx.promptUrl ?? defaultPromptUrl;
92
+ if (ctx.image) {
93
+ commands.push({
94
+ id: 'image',
95
+ label: 'Image',
96
+ description: 'Insert an image from URL',
97
+ icon: 'lucide:image',
98
+ keywords: ['image', 'picture', 'photo'],
99
+ run: async ({ editor }) => {
100
+ const url = await promptUrl({
101
+ title: 'Image URL',
102
+ placeholder: 'https://example.com/image.png',
103
+ schema: httpUrlSchema
104
+ });
105
+ if (!url)
106
+ return;
107
+ editor.chain().focus().setImage({ src: url }).run();
108
+ }
109
+ });
110
+ }
111
+ if (ctx.tables) {
112
+ commands.push({
113
+ id: 'table',
114
+ label: 'Table',
115
+ description: 'Insert a 3×3 table',
116
+ icon: 'lucide:table',
117
+ keywords: ['table', 'grid'],
118
+ run: ({ editor }) => {
119
+ editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
120
+ }
121
+ });
122
+ }
123
+ if (ctx.youtube) {
124
+ commands.push({
125
+ id: 'youtube',
126
+ label: 'YouTube',
127
+ description: 'Embed a YouTube video',
128
+ icon: 'lucide:youtube',
129
+ keywords: ['youtube', 'video', 'embed'],
130
+ run: async ({ editor }) => {
131
+ const url = await promptUrl({
132
+ title: 'Embed YouTube video',
133
+ description: 'Paste the share link or full URL.',
134
+ placeholder: 'https://youtu.be/...',
135
+ confirmLabel: 'Embed',
136
+ schema: youtubeUrlSchema
137
+ });
138
+ if (!url)
139
+ return;
140
+ editor.commands.setYoutubeVideo({ src: url });
141
+ }
142
+ });
143
+ }
144
+ return commands;
145
+ }
146
+ function substringFilter(commands, query) {
147
+ const q = query.trim().toLowerCase();
148
+ if (!q)
149
+ return commands;
150
+ return commands.filter((cmd) => {
151
+ if (cmd.label.toLowerCase().includes(q))
152
+ return true;
153
+ if (cmd.description?.toLowerCase().includes(q))
154
+ return true;
155
+ if (cmd.keywords?.some((k) => k.toLowerCase().includes(q)))
156
+ return true;
157
+ return cmd.id.toLowerCase().includes(q);
158
+ });
159
+ }
160
+ function buildSuggestionRender() {
161
+ let handle = null;
162
+ return {
163
+ onStart: (props) => {
164
+ if (typeof document === 'undefined')
165
+ return;
166
+ const container = document.createElement('div');
167
+ container.setAttribute('data-editor-slash-container', '');
168
+ container.style.cssText = 'position:absolute;top:0;left:0;z-index:50;';
169
+ document.body.appendChild(container);
170
+ const state = $state({
171
+ items: props.items,
172
+ selectedIndex: 0,
173
+ onPick: (i) => {
174
+ const cmd = state.items[i];
175
+ if (!cmd)
176
+ return;
177
+ props.command(cmd);
178
+ }
179
+ });
180
+ const component = mount(SlashPopup, {
181
+ target: container,
182
+ props: state
183
+ });
184
+ handle = { container, component, state, cleanup: null };
185
+ const rect = props.clientRect?.();
186
+ if (rect) {
187
+ const virtualEl = { getBoundingClientRect: () => rect };
188
+ handle.cleanup = autoUpdate(virtualEl, container, () => {
189
+ void computePosition(virtualEl, container, {
190
+ placement: 'bottom-start',
191
+ middleware: [offset(6), flip(), shift({ padding: 8 })]
192
+ }).then(({ x, y }) => {
193
+ container.style.left = `${x}px`;
194
+ container.style.top = `${y}px`;
195
+ });
196
+ });
197
+ }
198
+ },
199
+ onUpdate: (props) => {
200
+ if (!handle)
201
+ return;
202
+ handle.state.items = props.items;
203
+ handle.state.selectedIndex = 0;
204
+ },
205
+ onKeyDown: (props) => {
206
+ if (!handle)
207
+ return false;
208
+ const len = Math.max(handle.state.items.length, 1);
209
+ if (props.event.key === 'ArrowDown') {
210
+ handle.state.selectedIndex = (handle.state.selectedIndex + 1) % len;
211
+ return true;
212
+ }
213
+ if (props.event.key === 'ArrowUp') {
214
+ handle.state.selectedIndex = (handle.state.selectedIndex + len - 1) % len;
215
+ return true;
216
+ }
217
+ if (props.event.key === 'Enter') {
218
+ handle.state.onPick(handle.state.selectedIndex);
219
+ return true;
220
+ }
221
+ return false;
222
+ },
223
+ onExit: () => {
224
+ if (!handle)
225
+ return;
226
+ handle.cleanup?.();
227
+ unmount(handle.component);
228
+ handle.container.remove();
229
+ handle = null;
230
+ }
231
+ };
232
+ }
233
+ export const SlashCommandsExtension = Extension.create({
234
+ name: 'slashCommands',
235
+ addOptions() {
236
+ return {
237
+ suggestion: {
238
+ char: '/',
239
+ startOfLine: false,
240
+ allowSpaces: false,
241
+ command: ({ editor, range, props }) => {
242
+ editor.chain().focus().deleteRange(range).run();
243
+ props.run({ editor });
244
+ }
245
+ }
246
+ };
247
+ },
248
+ addProseMirrorPlugins() {
249
+ return [
250
+ Suggestion({
251
+ editor: this.editor,
252
+ ...this.options.suggestion
253
+ })
254
+ ];
255
+ }
256
+ });
257
+ export function buildSlashExtension(commands, trigger = '/') {
258
+ return SlashCommandsExtension.configure({
259
+ suggestion: {
260
+ char: trigger,
261
+ startOfLine: false,
262
+ allowSpaces: false,
263
+ items: ({ query }) => substringFilter(commands, query),
264
+ render: buildSuggestionRender,
265
+ // Tiptap merges `suggestion` shallowly when an extension is .configure()'d,
266
+ // so the command default in addOptions gets overwritten. Include it here.
267
+ command: ({ editor, range, props }) => {
268
+ editor.chain().focus().deleteRange(range).run();
269
+ props.run({ editor });
270
+ }
271
+ }
272
+ });
273
+ }