sv5ui 1.7.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.
- package/dist/Banner/Banner.svelte +162 -0
- package/dist/Banner/Banner.svelte.d.ts +5 -0
- package/dist/Banner/banner.types.d.ts +148 -0
- package/dist/Banner/banner.types.js +1 -0
- package/dist/Banner/banner.variants.d.ts +293 -0
- package/dist/Banner/banner.variants.js +86 -0
- package/dist/Banner/index.d.ts +2 -0
- package/dist/Banner/index.js +1 -0
- package/dist/Editor/Editor.svelte +738 -0
- package/dist/Editor/Editor.svelte.d.ts +6 -0
- package/dist/Editor/EditorUrlPrompt.svelte +111 -0
- package/dist/Editor/EditorUrlPrompt.svelte.d.ts +15 -0
- package/dist/Editor/SlashPopup.svelte +67 -0
- package/dist/Editor/SlashPopup.svelte.d.ts +9 -0
- package/dist/Editor/editor.extensions.d.ts +23 -0
- package/dist/Editor/editor.extensions.js +123 -0
- package/dist/Editor/editor.schemas.d.ts +4 -0
- package/dist/Editor/editor.schemas.js +3 -0
- package/dist/Editor/editor.slash.svelte.d.ts +34 -0
- package/dist/Editor/editor.slash.svelte.js +273 -0
- package/dist/Editor/editor.suggestion.d.ts +7 -0
- package/dist/Editor/editor.suggestion.js +142 -0
- package/dist/Editor/editor.toolbar.d.ts +11 -0
- package/dist/Editor/editor.toolbar.js +212 -0
- package/dist/Editor/editor.types.d.ts +347 -0
- package/dist/Editor/editor.types.js +1 -0
- package/dist/Editor/editor.variants.d.ts +308 -0
- package/dist/Editor/editor.variants.js +150 -0
- package/dist/Editor/index.d.ts +53 -0
- package/dist/Editor/index.js +52 -0
- package/dist/Stepper/Stepper.svelte +292 -0
- package/dist/Stepper/Stepper.svelte.d.ts +5 -0
- package/dist/Stepper/index.d.ts +2 -0
- package/dist/Stepper/index.js +1 -0
- package/dist/Stepper/stepper.types.d.ts +223 -0
- package/dist/Stepper/stepper.types.js +1 -0
- package/dist/Stepper/stepper.variants.d.ts +428 -0
- package/dist/Stepper/stepper.variants.js +204 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +97 -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,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
|
+
}
|