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.
- 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/Calendar/Calendar.svelte +48 -6
- package/dist/Calendar/calendar.types.d.ts +19 -0
- package/dist/Calendar/calendar.variants.js +2 -1
- package/dist/Carousel/Carousel.svelte +279 -0
- package/dist/Carousel/Carousel.svelte.d.ts +26 -0
- package/dist/Carousel/carousel.types.d.ts +242 -0
- package/dist/Carousel/carousel.types.js +1 -0
- package/dist/Carousel/carousel.variants.d.ts +408 -0
- package/dist/Carousel/carousel.variants.js +88 -0
- package/dist/Carousel/index.d.ts +2 -0
- package/dist/Carousel/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/FileUpload/FileUpload.svelte +81 -10
- package/dist/FileUpload/file-upload.types.d.ts +39 -0
- package/dist/FileUpload/index.d.ts +1 -1
- package/dist/Modal/Modal.svelte +14 -3
- package/dist/Modal/modal.types.d.ts +15 -4
- package/dist/Modal/modal.variants.d.ts +110 -20
- package/dist/Modal/modal.variants.js +27 -9
- package/dist/PinInput/PinInput.svelte +18 -4
- package/dist/PinInput/pin-input.types.d.ts +11 -0
- package/dist/Select/Select.svelte +98 -28
- package/dist/Select/select.types.d.ts +44 -2
- package/dist/SelectMenu/SelectMenu.svelte +210 -25
- package/dist/SelectMenu/select-menu.types.d.ts +62 -1
- package/dist/SelectMenu/select-menu.variants.d.ts +26 -0
- package/dist/SelectMenu/select-menu.variants.js +34 -6
- package/dist/Slideover/Slideover.svelte +13 -2
- package/dist/Slideover/slideover.types.d.ts +14 -3
- package/dist/Slideover/slideover.variants.d.ts +85 -5
- package/dist/Slideover/slideover.variants.js +42 -12
- 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 +3 -0
- package/dist/index.js +3 -0
- package/package.json +102 -1
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { EditorProps } from './editor.types.js'
|
|
3
|
+
|
|
4
|
+
export type Props = EditorProps
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { Editor } from '@tiptap/core'
|
|
9
|
+
import BubbleMenuExt from '@tiptap/extension-bubble-menu'
|
|
10
|
+
import { untrack } from 'svelte'
|
|
11
|
+
import { editorVariants, editorDefaults } from './editor.variants.js'
|
|
12
|
+
import { getComponentConfig } from '../config.js'
|
|
13
|
+
import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
|
|
14
|
+
import { TOOLBAR_ACTIONS, DEFAULT_TOOLBAR, type ToolbarActionDef } from './editor.toolbar.js'
|
|
15
|
+
import { buildExtensions } from './editor.extensions.js'
|
|
16
|
+
import { buildMentionSuggestion } from './editor.suggestion.js'
|
|
17
|
+
import { buildDefaultSlashCommands } from './editor.slash.svelte.js'
|
|
18
|
+
import type {
|
|
19
|
+
EditorApi,
|
|
20
|
+
EditorJSON,
|
|
21
|
+
EditorReactiveState,
|
|
22
|
+
ToolbarAction
|
|
23
|
+
} from './editor.types.js'
|
|
24
|
+
import Icon from '../Icon/Icon.svelte'
|
|
25
|
+
import Tooltip from '../Tooltip/Tooltip.svelte'
|
|
26
|
+
import EditorUrlPrompt from './EditorUrlPrompt.svelte'
|
|
27
|
+
import { httpUrlSchema, youtubeUrlSchema, type UrlSchema } from './editor.schemas.js'
|
|
28
|
+
|
|
29
|
+
const config = getComponentConfig('editor', editorDefaults)
|
|
30
|
+
|
|
31
|
+
let {
|
|
32
|
+
ref = $bindable(null),
|
|
33
|
+
api = $bindable(),
|
|
34
|
+
value = $bindable(),
|
|
35
|
+
output = 'html',
|
|
36
|
+
placeholder,
|
|
37
|
+
id,
|
|
38
|
+
name,
|
|
39
|
+
onValueChange,
|
|
40
|
+
onFocus,
|
|
41
|
+
onBlur,
|
|
42
|
+
readonly = false,
|
|
43
|
+
disabled = false,
|
|
44
|
+
autofocus = false,
|
|
45
|
+
maxLength,
|
|
46
|
+
showCount = false,
|
|
47
|
+
toolbar = true,
|
|
48
|
+
stickyToolbar = false,
|
|
49
|
+
bubbleMenu = false,
|
|
50
|
+
headingLevels = [1, 2, 3],
|
|
51
|
+
autolink = true,
|
|
52
|
+
linkOpenInNewTab = true,
|
|
53
|
+
markdownAllowHtml = false,
|
|
54
|
+
image = false,
|
|
55
|
+
onImageUpload,
|
|
56
|
+
tables = false,
|
|
57
|
+
onMention,
|
|
58
|
+
mentionTrigger = '@',
|
|
59
|
+
slash = false,
|
|
60
|
+
slashCommands,
|
|
61
|
+
slashTrigger = '/',
|
|
62
|
+
youtube = false,
|
|
63
|
+
dragHandle = false,
|
|
64
|
+
extensions: extraExtensions,
|
|
65
|
+
extensionsOverride,
|
|
66
|
+
size = config.defaultVariants.size ?? 'md',
|
|
67
|
+
color = config.defaultVariants.color ?? 'primary',
|
|
68
|
+
class: className,
|
|
69
|
+
ui,
|
|
70
|
+
toolbarSlot,
|
|
71
|
+
bubbleMenuSlot,
|
|
72
|
+
header,
|
|
73
|
+
footer,
|
|
74
|
+
...restProps
|
|
75
|
+
}: Props = $props()
|
|
76
|
+
|
|
77
|
+
const formFieldContext = useFormField()
|
|
78
|
+
const emit = useFormFieldEmit()
|
|
79
|
+
|
|
80
|
+
const hasError = $derived(
|
|
81
|
+
formFieldContext?.error !== undefined && formFieldContext?.error !== false
|
|
82
|
+
)
|
|
83
|
+
const resolvedColor = $derived(hasError ? 'error' : color)
|
|
84
|
+
const resolvedId = $derived(id ?? formFieldContext?.ariaId)
|
|
85
|
+
const resolvedName = $derived(name ?? formFieldContext?.name)
|
|
86
|
+
const ariaDescribedBy = $derived(
|
|
87
|
+
!formFieldContext
|
|
88
|
+
? undefined
|
|
89
|
+
: hasError
|
|
90
|
+
? `${formFieldContext.ariaId}-error`
|
|
91
|
+
: `${formFieldContext.ariaId}-description ${formFieldContext.ariaId}-help`
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
let contentElement: HTMLDivElement | null = $state(null)
|
|
95
|
+
let bubbleElement: HTMLDivElement | null = $state(null)
|
|
96
|
+
let editor: Editor | null = $state(null)
|
|
97
|
+
|
|
98
|
+
let editorState = $state<EditorReactiveState>({
|
|
99
|
+
active: {},
|
|
100
|
+
can: { undo: false, redo: false },
|
|
101
|
+
charCount: 0,
|
|
102
|
+
wordCount: 0,
|
|
103
|
+
isEmpty: true,
|
|
104
|
+
isFocused: false
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
function syncState(ed: Editor): void {
|
|
108
|
+
const cc = ed.storage.characterCount
|
|
109
|
+
const can = ed.can()
|
|
110
|
+
editorState = {
|
|
111
|
+
active: {
|
|
112
|
+
bold: ed.isActive('bold'),
|
|
113
|
+
italic: ed.isActive('italic'),
|
|
114
|
+
underline: ed.isActive('underline'),
|
|
115
|
+
strike: ed.isActive('strike'),
|
|
116
|
+
code: ed.isActive('code'),
|
|
117
|
+
h1: ed.isActive('heading', { level: 1 }),
|
|
118
|
+
h2: ed.isActive('heading', { level: 2 }),
|
|
119
|
+
h3: ed.isActive('heading', { level: 3 }),
|
|
120
|
+
paragraph: ed.isActive('paragraph'),
|
|
121
|
+
bulletList: ed.isActive('bulletList'),
|
|
122
|
+
orderedList: ed.isActive('orderedList'),
|
|
123
|
+
blockquote: ed.isActive('blockquote'),
|
|
124
|
+
codeBlock: ed.isActive('codeBlock'),
|
|
125
|
+
link: ed.isActive('link'),
|
|
126
|
+
alignLeft: ed.isActive({ textAlign: 'left' }),
|
|
127
|
+
alignCenter: ed.isActive({ textAlign: 'center' }),
|
|
128
|
+
alignRight: ed.isActive({ textAlign: 'right' }),
|
|
129
|
+
alignJustify: ed.isActive({ textAlign: 'justify' }),
|
|
130
|
+
image: ed.isActive('image'),
|
|
131
|
+
table: ed.isActive('table'),
|
|
132
|
+
youtube: ed.isActive('youtube')
|
|
133
|
+
},
|
|
134
|
+
can: {
|
|
135
|
+
undo: can.undo(),
|
|
136
|
+
redo: can.redo()
|
|
137
|
+
},
|
|
138
|
+
charCount: typeof cc?.characters === 'function' ? cc.characters() : 0,
|
|
139
|
+
wordCount: typeof cc?.words === 'function' ? cc.words() : 0,
|
|
140
|
+
isEmpty: ed.isEmpty,
|
|
141
|
+
isFocused: ed.isFocused
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
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
|
|
151
|
+
if (md && typeof md.getMarkdown === 'function') {
|
|
152
|
+
return md.getMarkdown()
|
|
153
|
+
}
|
|
154
|
+
return ed.getHTML()
|
|
155
|
+
}
|
|
156
|
+
return ed.getHTML()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isContentEqual(a: unknown, b: unknown): boolean {
|
|
160
|
+
if (a === b) return true
|
|
161
|
+
if (typeof a !== typeof b) return false
|
|
162
|
+
if (typeof a === 'string' && typeof b === 'string') return a === b
|
|
163
|
+
try {
|
|
164
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
165
|
+
} catch {
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveSlashCommands() {
|
|
171
|
+
if (slashCommands) return slashCommands
|
|
172
|
+
if (!slash) return undefined
|
|
173
|
+
return buildDefaultSlashCommands({
|
|
174
|
+
image,
|
|
175
|
+
tables,
|
|
176
|
+
youtube,
|
|
177
|
+
promptUrl: (opts) =>
|
|
178
|
+
new Promise<string | null>((resolve) => {
|
|
179
|
+
let done = false
|
|
180
|
+
const settle = (value: string | null): void => {
|
|
181
|
+
if (done) return
|
|
182
|
+
done = true
|
|
183
|
+
resolve(value)
|
|
184
|
+
}
|
|
185
|
+
openUrlPrompt({
|
|
186
|
+
...opts,
|
|
187
|
+
onConfirm: (value) => settle(value),
|
|
188
|
+
onCancel: () => settle(null)
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function resolveExtensions() {
|
|
195
|
+
if (extensionsOverride) return extensionsOverride
|
|
196
|
+
return buildExtensions({
|
|
197
|
+
headingLevels,
|
|
198
|
+
placeholder,
|
|
199
|
+
autolink,
|
|
200
|
+
linkOpenInNewTab,
|
|
201
|
+
maxLength,
|
|
202
|
+
image,
|
|
203
|
+
tables,
|
|
204
|
+
youtube,
|
|
205
|
+
dragHandle,
|
|
206
|
+
markdown: output === 'markdown',
|
|
207
|
+
markdownAllowHtml,
|
|
208
|
+
mentionTrigger,
|
|
209
|
+
mentionSuggestion: onMention
|
|
210
|
+
? buildMentionSuggestion({ onQuery: onMention })
|
|
211
|
+
: undefined,
|
|
212
|
+
slashCommands: resolveSlashCommands(),
|
|
213
|
+
slashTrigger,
|
|
214
|
+
extra: [
|
|
215
|
+
...(extraExtensions ?? []),
|
|
216
|
+
...(bubbleMenu && bubbleElement
|
|
217
|
+
? [
|
|
218
|
+
BubbleMenuExt.configure({
|
|
219
|
+
element: bubbleElement,
|
|
220
|
+
options: {
|
|
221
|
+
placement: 'top'
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
]
|
|
225
|
+
: [])
|
|
226
|
+
]
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let suppressUpdate = false
|
|
231
|
+
|
|
232
|
+
$effect(() => {
|
|
233
|
+
if (!contentElement) return
|
|
234
|
+
|
|
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
|
+
const initialContent = untrack(() => value ?? '')
|
|
240
|
+
const initialEditable = untrack(() => !disabled && !readonly)
|
|
241
|
+
const initialAutofocus = untrack(() => autofocus)
|
|
242
|
+
const initialAttrs = untrack(() => ({
|
|
243
|
+
id: resolvedId,
|
|
244
|
+
...(resolvedName ? { 'data-name': resolvedName } : {}),
|
|
245
|
+
...(ariaDescribedBy ? { 'aria-describedby': ariaDescribedBy } : {}),
|
|
246
|
+
...(hasError ? { 'aria-invalid': 'true' } : {})
|
|
247
|
+
}))
|
|
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
|
+
})
|
|
280
|
+
|
|
281
|
+
editor = ed
|
|
282
|
+
|
|
283
|
+
return () => {
|
|
284
|
+
ed.destroy()
|
|
285
|
+
editor = null
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
$effect(() => {
|
|
290
|
+
if (!editor) return
|
|
291
|
+
const target = !disabled && !readonly
|
|
292
|
+
if (editor.isEditable !== target) {
|
|
293
|
+
editor.setEditable(target)
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
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
|
+
$effect(() => {
|
|
301
|
+
if (!contentElement) return
|
|
302
|
+
const pm = contentElement.querySelector('.ProseMirror') as HTMLElement | null
|
|
303
|
+
if (!pm) return
|
|
304
|
+
if (hasError) pm.setAttribute('aria-invalid', 'true')
|
|
305
|
+
else pm.removeAttribute('aria-invalid')
|
|
306
|
+
if (ariaDescribedBy) pm.setAttribute('aria-describedby', ariaDescribedBy)
|
|
307
|
+
else pm.removeAttribute('aria-describedby')
|
|
308
|
+
if (resolvedId) pm.setAttribute('id', resolvedId)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
$effect(() => {
|
|
312
|
+
if (!editor) return
|
|
313
|
+
if (value === undefined) return
|
|
314
|
+
const current = serialize(editor)
|
|
315
|
+
if (isContentEqual(current, value)) return
|
|
316
|
+
suppressUpdate = true
|
|
317
|
+
editor.commands.setContent(value as string | EditorJSON, { emitUpdate: false })
|
|
318
|
+
suppressUpdate = false
|
|
319
|
+
syncState(editor)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
const apiInstance: EditorApi = {
|
|
323
|
+
get editor() {
|
|
324
|
+
return editor
|
|
325
|
+
},
|
|
326
|
+
get state() {
|
|
327
|
+
return editorState
|
|
328
|
+
},
|
|
329
|
+
focus(position) {
|
|
330
|
+
editor?.commands.focus(position)
|
|
331
|
+
},
|
|
332
|
+
run(action) {
|
|
333
|
+
if (!editor) return
|
|
334
|
+
TOOLBAR_ACTIONS[action].run(editor)
|
|
335
|
+
},
|
|
336
|
+
getValue(format) {
|
|
337
|
+
if (!editor) return output === 'json' ? ({} as EditorJSON) : ''
|
|
338
|
+
const fmt = format ?? output
|
|
339
|
+
if (fmt === 'json') return editor.getJSON() as EditorJSON
|
|
340
|
+
if (fmt === 'markdown') {
|
|
341
|
+
const md = (editor.storage as unknown as Record<string, unknown>).markdown as
|
|
342
|
+
| { getMarkdown?: () => string }
|
|
343
|
+
| undefined
|
|
344
|
+
if (md && typeof md.getMarkdown === 'function') return md.getMarkdown()
|
|
345
|
+
return editor.getHTML()
|
|
346
|
+
}
|
|
347
|
+
return editor.getHTML()
|
|
348
|
+
},
|
|
349
|
+
setValue(next) {
|
|
350
|
+
if (!editor) return
|
|
351
|
+
suppressUpdate = true
|
|
352
|
+
editor.commands.setContent(next as string | EditorJSON, { emitUpdate: false })
|
|
353
|
+
suppressUpdate = false
|
|
354
|
+
syncState(editor)
|
|
355
|
+
},
|
|
356
|
+
clear() {
|
|
357
|
+
editor?.chain().focus().clearContent().run()
|
|
358
|
+
},
|
|
359
|
+
insert(content) {
|
|
360
|
+
editor
|
|
361
|
+
?.chain()
|
|
362
|
+
.focus()
|
|
363
|
+
.insertContent(content as string | EditorJSON)
|
|
364
|
+
.run()
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
api = apiInstance
|
|
369
|
+
|
|
370
|
+
const resolvedToolbar = $derived.by<(ToolbarAction | '|')[]>(() => {
|
|
371
|
+
if (toolbar === false) return []
|
|
372
|
+
if (toolbar === true) return DEFAULT_TOOLBAR
|
|
373
|
+
return toolbar as (ToolbarAction | '|')[]
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const classes = $derived.by(() => {
|
|
377
|
+
const slots = editorVariants({
|
|
378
|
+
size,
|
|
379
|
+
color: resolvedColor,
|
|
380
|
+
sticky: stickyToolbar
|
|
381
|
+
})
|
|
382
|
+
const c = config.slots
|
|
383
|
+
const u = ui ?? {}
|
|
384
|
+
return {
|
|
385
|
+
root: slots.root({ class: [c.root, className, u.root] }),
|
|
386
|
+
toolbar: slots.toolbar({ class: [c.toolbar, u.toolbar] }),
|
|
387
|
+
toolbarGroup: slots.toolbarGroup({ class: [c.toolbarGroup, u.toolbarGroup] }),
|
|
388
|
+
toolbarButton: slots.toolbarButton({ class: [c.toolbarButton, u.toolbarButton] }),
|
|
389
|
+
toolbarSeparator: slots.toolbarSeparator({
|
|
390
|
+
class: [c.toolbarSeparator, u.toolbarSeparator]
|
|
391
|
+
}),
|
|
392
|
+
content: slots.content({ class: [c.content, u.content] }),
|
|
393
|
+
footer: slots.footer({ class: [c.footer, u.footer] }),
|
|
394
|
+
countLabel: slots.countLabel({ class: [c.countLabel, u.countLabel] }),
|
|
395
|
+
bubbleMenu: slots.bubbleMenu({ class: [c.bubbleMenu, u.bubbleMenu] })
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// ----- URL prompt modal (shared by YouTube/Image/Link toolbar + slash) -----
|
|
400
|
+
interface UrlPromptState {
|
|
401
|
+
open: boolean
|
|
402
|
+
title: string
|
|
403
|
+
description?: string
|
|
404
|
+
placeholder: string
|
|
405
|
+
initialValue: string
|
|
406
|
+
confirmLabel: string
|
|
407
|
+
schema?: UrlSchema
|
|
408
|
+
onConfirm?: (url: string) => void
|
|
409
|
+
onCancel?: () => void
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let urlPrompt = $state<UrlPromptState>({
|
|
413
|
+
open: false,
|
|
414
|
+
title: 'Enter URL',
|
|
415
|
+
placeholder: 'https://',
|
|
416
|
+
initialValue: '',
|
|
417
|
+
confirmLabel: 'Insert'
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
function openUrlPrompt(opts: {
|
|
421
|
+
title: string
|
|
422
|
+
description?: string
|
|
423
|
+
placeholder?: string
|
|
424
|
+
initialValue?: string
|
|
425
|
+
confirmLabel?: string
|
|
426
|
+
schema?: UrlSchema
|
|
427
|
+
onConfirm: (url: string) => void
|
|
428
|
+
onCancel?: () => void
|
|
429
|
+
}): void {
|
|
430
|
+
urlPrompt = {
|
|
431
|
+
open: true,
|
|
432
|
+
title: opts.title,
|
|
433
|
+
description: opts.description,
|
|
434
|
+
placeholder: opts.placeholder ?? 'https://',
|
|
435
|
+
initialValue: opts.initialValue ?? '',
|
|
436
|
+
confirmLabel: opts.confirmLabel ?? 'Insert',
|
|
437
|
+
schema: opts.schema,
|
|
438
|
+
onConfirm: opts.onConfirm,
|
|
439
|
+
onCancel: opts.onCancel
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ----- Image upload via hidden file input -----
|
|
444
|
+
let fileInput: HTMLInputElement | null = $state(null)
|
|
445
|
+
|
|
446
|
+
async function handleFileSelected(event: Event): Promise<void> {
|
|
447
|
+
if (!editor) return
|
|
448
|
+
const input = event.currentTarget as HTMLInputElement
|
|
449
|
+
const file = input.files?.[0]
|
|
450
|
+
input.value = ''
|
|
451
|
+
if (!file) return
|
|
452
|
+
if (!onImageUpload) return
|
|
453
|
+
try {
|
|
454
|
+
const url = await onImageUpload(file)
|
|
455
|
+
editor.chain().focus().setImage({ src: url }).run()
|
|
456
|
+
} catch (err) {
|
|
457
|
+
// eslint-disable-next-line no-console
|
|
458
|
+
console.error('[Editor] image upload failed', err)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function openImagePicker(): void {
|
|
463
|
+
if (!editor) return
|
|
464
|
+
if (!onImageUpload) {
|
|
465
|
+
openUrlPrompt({
|
|
466
|
+
title: 'Image URL',
|
|
467
|
+
placeholder: 'https://example.com/image.png',
|
|
468
|
+
schema: httpUrlSchema,
|
|
469
|
+
onConfirm: (url) => {
|
|
470
|
+
editor?.chain().focus().setImage({ src: url }).run()
|
|
471
|
+
}
|
|
472
|
+
})
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
fileInput?.click()
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function openYoutubePrompt(): void {
|
|
479
|
+
if (!editor) return
|
|
480
|
+
openUrlPrompt({
|
|
481
|
+
title: 'Embed YouTube video',
|
|
482
|
+
description: 'Paste the share link or full URL.',
|
|
483
|
+
placeholder: 'https://youtu.be/...',
|
|
484
|
+
confirmLabel: 'Embed',
|
|
485
|
+
schema: youtubeUrlSchema,
|
|
486
|
+
onConfirm: (url) => {
|
|
487
|
+
editor?.commands.setYoutubeVideo({ src: url })
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function openLinkPrompt(): void {
|
|
493
|
+
if (!editor) return
|
|
494
|
+
const previous = (editor.getAttributes('link').href as string | undefined) ?? ''
|
|
495
|
+
openUrlPrompt({
|
|
496
|
+
title: 'Insert link',
|
|
497
|
+
placeholder: 'https://',
|
|
498
|
+
initialValue: previous,
|
|
499
|
+
schema: httpUrlSchema,
|
|
500
|
+
onConfirm: (url) => {
|
|
501
|
+
editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
|
502
|
+
}
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ----- Table dimension picker -----
|
|
507
|
+
let tableMenuOpen = $state(false)
|
|
508
|
+
let tablePickerRows = $state(0)
|
|
509
|
+
let tablePickerCols = $state(0)
|
|
510
|
+
let tablePickerEl: HTMLDivElement | null = $state(null)
|
|
511
|
+
let tableButtonEl: HTMLButtonElement | null = $state(null)
|
|
512
|
+
const TABLE_MAX_ROWS = 8
|
|
513
|
+
const TABLE_MAX_COLS = 8
|
|
514
|
+
|
|
515
|
+
function insertTable(rows: number, cols: number): void {
|
|
516
|
+
if (!editor) return
|
|
517
|
+
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run()
|
|
518
|
+
tableMenuOpen = false
|
|
519
|
+
tablePickerRows = 0
|
|
520
|
+
tablePickerCols = 0
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
$effect(() => {
|
|
524
|
+
if (!tableMenuOpen) return
|
|
525
|
+
function onDocPointerDown(event: PointerEvent): void {
|
|
526
|
+
const target = event.target as Node | null
|
|
527
|
+
if (!target) return
|
|
528
|
+
if (tablePickerEl?.contains(target)) return
|
|
529
|
+
if (tableButtonEl?.contains(target)) return
|
|
530
|
+
tableMenuOpen = false
|
|
531
|
+
tablePickerRows = 0
|
|
532
|
+
tablePickerCols = 0
|
|
533
|
+
}
|
|
534
|
+
document.addEventListener('pointerdown', onDocPointerDown, true)
|
|
535
|
+
return () => document.removeEventListener('pointerdown', onDocPointerDown, true)
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
function runAction(def: ToolbarActionDef, id: ToolbarAction): void {
|
|
539
|
+
if (!editor) return
|
|
540
|
+
if (id === 'image') {
|
|
541
|
+
openImagePicker()
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
if (id === 'youtube') {
|
|
545
|
+
openYoutubePrompt()
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
if (id === 'link') {
|
|
549
|
+
openLinkPrompt()
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
if (id === 'table') {
|
|
553
|
+
tableMenuOpen = !tableMenuOpen
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
def.run(editor)
|
|
557
|
+
}
|
|
558
|
+
</script>
|
|
559
|
+
|
|
560
|
+
<div
|
|
561
|
+
bind:this={ref}
|
|
562
|
+
class={classes.root}
|
|
563
|
+
data-disabled={disabled}
|
|
564
|
+
data-readonly={readonly}
|
|
565
|
+
data-error={hasError ? '' : undefined}
|
|
566
|
+
{...restProps}
|
|
567
|
+
>
|
|
568
|
+
{#if toolbarSlot}
|
|
569
|
+
{@render toolbarSlot({ state: editorState, api: apiInstance })}
|
|
570
|
+
{:else if toolbar !== false}
|
|
571
|
+
<div class={classes.toolbar} role="toolbar" aria-label="Editor toolbar">
|
|
572
|
+
{#each resolvedToolbar as item, i (i)}
|
|
573
|
+
{#if item === '|'}
|
|
574
|
+
<span class={classes.toolbarSeparator} aria-hidden="true"></span>
|
|
575
|
+
{:else}
|
|
576
|
+
{@const def = TOOLBAR_ACTIONS[item]}
|
|
577
|
+
{@const active = def.isActive?.(editorState) ?? false}
|
|
578
|
+
{@const inactive = def.isDisabled?.(editorState) ?? false}
|
|
579
|
+
{#if item === 'table'}
|
|
580
|
+
<Tooltip text={def.label}>
|
|
581
|
+
<span class="relative inline-flex">
|
|
582
|
+
<button
|
|
583
|
+
bind:this={tableButtonEl}
|
|
584
|
+
type="button"
|
|
585
|
+
class={classes.toolbarButton}
|
|
586
|
+
data-active={active || tableMenuOpen}
|
|
587
|
+
data-action={item}
|
|
588
|
+
disabled={disabled || readonly || inactive}
|
|
589
|
+
aria-label={def.label}
|
|
590
|
+
aria-expanded={tableMenuOpen}
|
|
591
|
+
aria-haspopup="dialog"
|
|
592
|
+
onclick={() => runAction(def, item)}
|
|
593
|
+
>
|
|
594
|
+
<Icon name={def.icon} />
|
|
595
|
+
</button>
|
|
596
|
+
{#if tableMenuOpen}
|
|
597
|
+
<div
|
|
598
|
+
bind:this={tablePickerEl}
|
|
599
|
+
class="absolute top-full left-0 z-30 mt-1 w-max rounded-lg border border-outline-variant bg-surface p-2 shadow-md"
|
|
600
|
+
data-editor-table-picker
|
|
601
|
+
role="dialog"
|
|
602
|
+
aria-label="Insert table"
|
|
603
|
+
>
|
|
604
|
+
<div
|
|
605
|
+
class="mb-2 text-center text-xs text-on-surface-variant"
|
|
606
|
+
>
|
|
607
|
+
{tablePickerRows || 1} × {tablePickerCols || 1}
|
|
608
|
+
</div>
|
|
609
|
+
<div
|
|
610
|
+
class="grid grid-cols-8 gap-0.5"
|
|
611
|
+
role="presentation"
|
|
612
|
+
onmouseleave={() => {
|
|
613
|
+
tablePickerRows = 0
|
|
614
|
+
tablePickerCols = 0
|
|
615
|
+
}}
|
|
616
|
+
>
|
|
617
|
+
{#each Array.from({ length: TABLE_MAX_ROWS * TABLE_MAX_COLS }, (_v, i) => i) as idx (idx)}
|
|
618
|
+
{@const r = Math.floor(idx / TABLE_MAX_COLS) + 1}
|
|
619
|
+
{@const c = (idx % TABLE_MAX_COLS) + 1}
|
|
620
|
+
{@const on =
|
|
621
|
+
r <= tablePickerRows && c <= tablePickerCols}
|
|
622
|
+
<button
|
|
623
|
+
type="button"
|
|
624
|
+
class="size-5 rounded border border-outline-variant {on
|
|
625
|
+
? 'border-primary bg-primary'
|
|
626
|
+
: 'bg-surface-container hover:border-primary/50'}"
|
|
627
|
+
aria-label={`Insert ${r}×${c} table`}
|
|
628
|
+
onmouseenter={() => {
|
|
629
|
+
tablePickerRows = r
|
|
630
|
+
tablePickerCols = c
|
|
631
|
+
}}
|
|
632
|
+
onmousedown={(e) => {
|
|
633
|
+
e.preventDefault()
|
|
634
|
+
insertTable(r, c)
|
|
635
|
+
}}
|
|
636
|
+
></button>
|
|
637
|
+
{/each}
|
|
638
|
+
</div>
|
|
639
|
+
</div>
|
|
640
|
+
{/if}
|
|
641
|
+
</span>
|
|
642
|
+
</Tooltip>
|
|
643
|
+
{:else}
|
|
644
|
+
<Tooltip text={def.label}>
|
|
645
|
+
<button
|
|
646
|
+
type="button"
|
|
647
|
+
class={classes.toolbarButton}
|
|
648
|
+
data-active={active}
|
|
649
|
+
data-action={item}
|
|
650
|
+
disabled={disabled || readonly || inactive}
|
|
651
|
+
aria-label={def.label}
|
|
652
|
+
aria-pressed={def.isActive ? active : undefined}
|
|
653
|
+
onclick={() => runAction(def, item)}
|
|
654
|
+
>
|
|
655
|
+
<Icon name={def.icon} />
|
|
656
|
+
</button>
|
|
657
|
+
</Tooltip>
|
|
658
|
+
{/if}
|
|
659
|
+
{/if}
|
|
660
|
+
{/each}
|
|
661
|
+
</div>
|
|
662
|
+
{/if}
|
|
663
|
+
|
|
664
|
+
{#if header}
|
|
665
|
+
{@render header()}
|
|
666
|
+
{/if}
|
|
667
|
+
|
|
668
|
+
<div
|
|
669
|
+
bind:this={contentElement}
|
|
670
|
+
class={classes.content}
|
|
671
|
+
data-editor-content
|
|
672
|
+
role="textbox"
|
|
673
|
+
aria-multiline="true"
|
|
674
|
+
aria-readonly={readonly}
|
|
675
|
+
aria-disabled={disabled}
|
|
676
|
+
></div>
|
|
677
|
+
|
|
678
|
+
{#if footer}
|
|
679
|
+
{@render footer()}
|
|
680
|
+
{:else if showCount || maxLength !== undefined}
|
|
681
|
+
<div class={classes.footer} data-editor-footer>
|
|
682
|
+
<span class={classes.countLabel} data-editor-count>
|
|
683
|
+
{editorState.charCount}{#if maxLength !== undefined} / {maxLength}{/if}
|
|
684
|
+
chars
|
|
685
|
+
</span>
|
|
686
|
+
<span class={classes.countLabel}>{editorState.wordCount} words</span>
|
|
687
|
+
</div>
|
|
688
|
+
{/if}
|
|
689
|
+
|
|
690
|
+
{#if image}
|
|
691
|
+
<input
|
|
692
|
+
bind:this={fileInput}
|
|
693
|
+
type="file"
|
|
694
|
+
accept="image/*"
|
|
695
|
+
class="hidden"
|
|
696
|
+
data-editor-image-input
|
|
697
|
+
tabindex="-1"
|
|
698
|
+
aria-hidden="true"
|
|
699
|
+
onchange={handleFileSelected}
|
|
700
|
+
/>
|
|
701
|
+
{/if}
|
|
702
|
+
|
|
703
|
+
{#if bubbleMenu}
|
|
704
|
+
<div bind:this={bubbleElement} class={classes.bubbleMenu} data-editor-bubble>
|
|
705
|
+
{#if bubbleMenuSlot}
|
|
706
|
+
{@render bubbleMenuSlot({ state: editorState, api: apiInstance })}
|
|
707
|
+
{:else}
|
|
708
|
+
{#each ['bold', 'italic', 'link'] as const as item (item)}
|
|
709
|
+
{@const def = TOOLBAR_ACTIONS[item]}
|
|
710
|
+
{@const active = def.isActive?.(editorState) ?? false}
|
|
711
|
+
<button
|
|
712
|
+
type="button"
|
|
713
|
+
class={classes.toolbarButton}
|
|
714
|
+
data-active={active}
|
|
715
|
+
data-action={item}
|
|
716
|
+
aria-label={def.label}
|
|
717
|
+
aria-pressed={active}
|
|
718
|
+
onclick={() => runAction(def, item)}
|
|
719
|
+
>
|
|
720
|
+
<Icon name={def.icon} />
|
|
721
|
+
</button>
|
|
722
|
+
{/each}
|
|
723
|
+
{/if}
|
|
724
|
+
</div>
|
|
725
|
+
{/if}
|
|
726
|
+
</div>
|
|
727
|
+
|
|
728
|
+
<EditorUrlPrompt
|
|
729
|
+
bind:open={urlPrompt.open}
|
|
730
|
+
title={urlPrompt.title}
|
|
731
|
+
description={urlPrompt.description}
|
|
732
|
+
placeholder={urlPrompt.placeholder}
|
|
733
|
+
initialValue={urlPrompt.initialValue}
|
|
734
|
+
confirmLabel={urlPrompt.confirmLabel}
|
|
735
|
+
schema={urlPrompt.schema}
|
|
736
|
+
onConfirm={(url) => urlPrompt.onConfirm?.(url)}
|
|
737
|
+
onCancel={() => urlPrompt.onCancel?.()}
|
|
738
|
+
/>
|