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.
Files changed (41) 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/Editor/Editor.svelte +738 -0
  10. package/dist/Editor/Editor.svelte.d.ts +6 -0
  11. package/dist/Editor/EditorUrlPrompt.svelte +111 -0
  12. package/dist/Editor/EditorUrlPrompt.svelte.d.ts +15 -0
  13. package/dist/Editor/SlashPopup.svelte +67 -0
  14. package/dist/Editor/SlashPopup.svelte.d.ts +9 -0
  15. package/dist/Editor/editor.extensions.d.ts +23 -0
  16. package/dist/Editor/editor.extensions.js +123 -0
  17. package/dist/Editor/editor.schemas.d.ts +4 -0
  18. package/dist/Editor/editor.schemas.js +3 -0
  19. package/dist/Editor/editor.slash.svelte.d.ts +34 -0
  20. package/dist/Editor/editor.slash.svelte.js +273 -0
  21. package/dist/Editor/editor.suggestion.d.ts +7 -0
  22. package/dist/Editor/editor.suggestion.js +142 -0
  23. package/dist/Editor/editor.toolbar.d.ts +11 -0
  24. package/dist/Editor/editor.toolbar.js +212 -0
  25. package/dist/Editor/editor.types.d.ts +347 -0
  26. package/dist/Editor/editor.types.js +1 -0
  27. package/dist/Editor/editor.variants.d.ts +308 -0
  28. package/dist/Editor/editor.variants.js +150 -0
  29. package/dist/Editor/index.d.ts +53 -0
  30. package/dist/Editor/index.js +52 -0
  31. package/dist/Stepper/Stepper.svelte +292 -0
  32. package/dist/Stepper/Stepper.svelte.d.ts +5 -0
  33. package/dist/Stepper/index.d.ts +2 -0
  34. package/dist/Stepper/index.js +1 -0
  35. package/dist/Stepper/stepper.types.d.ts +223 -0
  36. package/dist/Stepper/stepper.types.js +1 -0
  37. package/dist/Stepper/stepper.variants.d.ts +428 -0
  38. package/dist/Stepper/stepper.variants.js +204 -0
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.js +2 -0
  41. package/package.json +97 -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}&nbsp;/&nbsp;{maxLength}{/if}
684
+ &nbsp;chars
685
+ </span>
686
+ <span class={classes.countLabel}>{editorState.wordCount}&nbsp;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
+ />