svelora 3.0.0 → 3.0.2

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 (109) hide show
  1. package/dist/Accordion/Accordion.svelte +66 -97
  2. package/dist/Alert/Alert.svelte +39 -64
  3. package/dist/Alert/Alert.svelte.d.ts +1 -1
  4. package/dist/Avatar/Avatar.svelte +35 -75
  5. package/dist/AvatarGroup/AvatarGroup.svelte +38 -55
  6. package/dist/Badge/Badge.svelte +28 -50
  7. package/dist/Banner/Banner.svelte +46 -41
  8. package/dist/Banner/Banner.svelte.d.ts +1 -1
  9. package/dist/Breadcrumb/Breadcrumb.svelte +32 -26
  10. package/dist/Button/Button.svelte +70 -138
  11. package/dist/Calendar/Calendar.svelte +94 -157
  12. package/dist/Calendar/Calendar.svelte.d.ts +1 -1
  13. package/dist/Card/Card.svelte +18 -31
  14. package/dist/Carousel/Carousel.svelte +118 -173
  15. package/dist/Checkbox/Checkbox.svelte +52 -97
  16. package/dist/CheckboxGroup/CheckboxGroup.svelte +62 -107
  17. package/dist/CheckboxGroup/CheckboxGroup.svelte.d.ts +1 -1
  18. package/dist/Chip/Chip.svelte +22 -34
  19. package/dist/CodeBlock/CodeBlock.svelte +42 -59
  20. package/dist/Collapsible/Collapsible.svelte +22 -38
  21. package/dist/Collapsible/Collapsible.svelte.d.ts +1 -1
  22. package/dist/Collapsible/CollapsibleTestWrapper.svelte +2 -5
  23. package/dist/Collapsible/CollapsibleTestWrapper.svelte.d.ts +1 -1
  24. package/dist/Command/Command.svelte +40 -77
  25. package/dist/Command/Command.svelte.d.ts +1 -1
  26. package/dist/Command/CommandTestWrapper.svelte +2 -10
  27. package/dist/Command/CommandTestWrapper.svelte.d.ts +1 -1
  28. package/dist/Container/Container.svelte +11 -14
  29. package/dist/ContextMenu/ContextMenu.svelte +51 -114
  30. package/dist/ContextMenu/ContextMenu.svelte.d.ts +1 -1
  31. package/dist/Drawer/Drawer.svelte +72 -110
  32. package/dist/Drawer/DrawerTriggerTestWrapper.svelte +1 -2
  33. package/dist/DropdownMenu/DropdownMenu.svelte +63 -124
  34. package/dist/DropdownMenu/DropdownMenu.svelte.d.ts +1 -1
  35. package/dist/DropdownMenu/DropdownMenuTriggerTestWrapper.svelte +2 -5
  36. package/dist/Editor/Editor.svelte +441 -576
  37. package/dist/Editor/Editor.svelte.d.ts +1 -1
  38. package/dist/Editor/EditorUrlPrompt.svelte +40 -53
  39. package/dist/Editor/SlashPopup.svelte +12 -24
  40. package/dist/Empty/Empty.svelte +32 -63
  41. package/dist/FieldGroup/FieldGroup.svelte +23 -38
  42. package/dist/FileUpload/FileUpload.svelte +242 -320
  43. package/dist/FileUpload/FileUpload.svelte.d.ts +1 -1
  44. package/dist/Fonts/Fonts.svelte +15 -37
  45. package/dist/Form/Form.svelte +112 -170
  46. package/dist/FormField/FormField.svelte +102 -135
  47. package/dist/Icon/Icon.svelte +7 -32
  48. package/dist/Input/Input.svelte +71 -141
  49. package/dist/Input/Input.svelte.d.ts +2 -2
  50. package/dist/Kbd/Kbd.svelte +18 -34
  51. package/dist/Link/Link.svelte +129 -196
  52. package/dist/LocaleButton/LocaleButton.svelte +165 -0
  53. package/dist/LocaleButton/LocaleButton.svelte.d.ts +5 -0
  54. package/dist/LocaleButton/index.d.ts +2 -0
  55. package/dist/LocaleButton/index.js +1 -0
  56. package/dist/LocaleButton/locale-button.types.d.ts +182 -0
  57. package/dist/LocaleButton/locale-button.types.js +1 -0
  58. package/dist/LocaleButton/locale-button.variants.d.ts +61 -0
  59. package/dist/LocaleButton/locale-button.variants.js +34 -0
  60. package/dist/Modal/Modal.svelte +52 -106
  61. package/dist/Modal/ModalTriggerTestWrapper.svelte +1 -2
  62. package/dist/Pagination/Pagination.svelte +48 -92
  63. package/dist/Pagination/pagination.variants.d.ts +1 -1
  64. package/dist/PinInput/PinInput.svelte +57 -111
  65. package/dist/PinInput/PinInput.svelte.d.ts +1 -1
  66. package/dist/Popover/Popover.svelte +28 -61
  67. package/dist/Popover/Popover.svelte.d.ts +1 -1
  68. package/dist/Progress/Progress.svelte +75 -94
  69. package/dist/RadioGroup/RadioGroup.svelte +54 -99
  70. package/dist/RadioGroup/RadioGroup.svelte.d.ts +1 -1
  71. package/dist/Select/Select.svelte +112 -269
  72. package/dist/Select/Select.svelte.d.ts +1 -1
  73. package/dist/SelectMenu/SelectMenu.svelte +211 -409
  74. package/dist/SelectMenu/SelectMenu.svelte.d.ts +1 -1
  75. package/dist/SelectMenu/SelectMenuFormFieldTestWrapper.svelte +3 -6
  76. package/dist/Separator/Separator.svelte +29 -44
  77. package/dist/Skeleton/Skeleton.svelte +11 -23
  78. package/dist/Slideover/Slideover.svelte +52 -106
  79. package/dist/Slideover/SlideoverTriggerTestWrapper.svelte +1 -2
  80. package/dist/Slider/Slider.svelte +48 -84
  81. package/dist/Slider/Slider.svelte.d.ts +1 -1
  82. package/dist/Stepper/Stepper.svelte +139 -132
  83. package/dist/Stepper/Stepper.svelte.d.ts +1 -1
  84. package/dist/Switch/Switch.svelte +62 -98
  85. package/dist/Table/Table.svelte +232 -283
  86. package/dist/Table/table.variants.d.ts +1 -1
  87. package/dist/Tabs/Tabs.svelte +96 -129
  88. package/dist/Tabs/Tabs.svelte.d.ts +1 -1
  89. package/dist/Textarea/Textarea.svelte +90 -173
  90. package/dist/Textarea/Textarea.svelte.d.ts +1 -1
  91. package/dist/ThemeModeButton/ThemeModeButton.svelte +16 -38
  92. package/dist/Timeline/Timeline.svelte +75 -54
  93. package/dist/Toast/Toaster.svelte +8 -25
  94. package/dist/Tooltip/Tooltip.svelte +34 -66
  95. package/dist/Tooltip/Tooltip.svelte.d.ts +1 -1
  96. package/dist/Tooltip/TooltipTestWrapper.svelte +2 -5
  97. package/dist/User/User.svelte +33 -49
  98. package/dist/docs/navigation.d.ts +1 -1
  99. package/dist/docs/navigation.js +8 -1
  100. package/dist/hooks/HookContextProbe.svelte +2 -4
  101. package/dist/hooks/HookContextProvider.svelte +8 -6
  102. package/dist/hooks/HookEmitProbe.svelte +8 -11
  103. package/dist/i18n.d.ts +2 -0
  104. package/dist/i18n.js +19 -0
  105. package/dist/index.d.ts +1 -0
  106. package/dist/index.js +1 -0
  107. package/dist/mcp/svelora-docs.data.json +4 -2
  108. package/dist/theme.css +1 -1
  109. package/package.json +16 -8
@@ -1,581 +1,446 @@
1
- <script lang="ts" module>import type { EditorProps } from './editor.types.js';
2
- export type Props = EditorProps;
1
+ <script lang="ts" module>export {};
3
2
  </script>
4
3
 
5
- <script lang="ts">
6
- import { type AnyExtension, Editor } from '@tiptap/core'
7
- import BubbleMenuExt from '@tiptap/extension-bubble-menu'
8
- import { untrack } from 'svelte'
9
- import { getComponentConfig } from '../config.js'
10
- import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
11
- import Icon from '../Icon/Icon.svelte'
12
- import Tooltip from '../Tooltip/Tooltip.svelte'
13
- import EditorUrlPrompt from './EditorUrlPrompt.svelte'
14
- import { buildExtensions } from './editor.extensions.js'
15
- import {
16
- httpUrlSchema,
17
- isSafeImageSrc,
18
- type UrlSchema,
19
- youtubeUrlSchema
20
- } from './editor.schemas.js'
21
- import { buildDefaultSlashCommands } from './editor.slash.svelte.js'
22
- import { buildMentionSuggestion } from './editor.suggestion.js'
23
- import { DEFAULT_TOOLBAR, TOOLBAR_ACTIONS, type ToolbarActionDef } from './editor.toolbar.js'
24
- import type {
25
- EditorApi,
26
- EditorJSON,
27
- EditorReactiveState,
28
- ToolbarAction
29
- } from './editor.types.js'
30
- import { editorDefaults, editorVariants } from './editor.variants.js'
31
-
32
- const config = getComponentConfig('editor', editorDefaults)
33
-
34
- let {
35
- ref = $bindable(null),
36
- api = $bindable(),
37
- value = $bindable(),
38
- output = 'html',
39
- placeholder,
40
- id,
41
- name,
42
- onValueChange,
43
- onFocus,
44
- onBlur,
45
- readonly = false,
46
- disabled = false,
47
- autofocus = false,
48
- maxLength,
49
- showCount = false,
50
- toolbar = true,
51
- stickyToolbar = false,
52
- bubbleMenu = false,
53
- headingLevels = [1, 2, 3],
54
- autolink = true,
55
- linkOpenInNewTab = true,
56
- markdownAllowHtml = false,
57
- image = false,
58
- onImageUpload,
59
- onImageUploadError,
60
- tables = false,
61
- onMention,
62
- mentionTrigger = '@',
63
- slash = false,
64
- slashCommands,
65
- slashTrigger = '/',
66
- youtube = false,
67
- dragHandle = false,
68
- extensions: extraExtensions,
69
- extensionsOverride,
70
- size = config.defaultVariants.size ?? 'md',
71
- color = config.defaultVariants.color ?? 'primary',
72
- class: className,
73
- ui,
74
- toolbarSlot,
75
- bubbleMenuSlot,
76
- header,
77
- footer,
78
- ...restProps
79
- }: Props = $props()
80
-
81
- const formFieldContext = useFormField()
82
- const emit = useFormFieldEmit()
83
-
84
- const resolvedOutput = untrack(() => output)
85
-
86
- function getMarkdownStorage(ed: Editor): { getMarkdown?: () => string } | undefined {
87
- return (ed.storage as unknown as Record<string, unknown>).markdown as
88
- | { getMarkdown?: () => string }
89
- | undefined
90
- }
91
-
92
- const hasError = $derived(
93
- formFieldContext?.error !== undefined && formFieldContext?.error !== false
94
- )
95
- const resolvedColor = $derived(hasError ? 'error' : color)
96
- const resolvedId = $derived(id ?? formFieldContext?.ariaId)
97
- const resolvedName = $derived(name ?? formFieldContext?.name)
98
- const ariaDescribedBy = $derived(
99
- !formFieldContext
100
- ? undefined
101
- : hasError
102
- ? `${formFieldContext.ariaId}-error`
103
- : `${formFieldContext.ariaId}-description ${formFieldContext.ariaId}-help`
104
- )
105
-
106
- let contentElement: HTMLDivElement | null = $state(null)
107
- let bubbleElement: HTMLDivElement | null = $state(null)
108
- let editor: Editor | null = $state(null)
109
-
110
- let editorState = $state<EditorReactiveState>({
111
- active: {},
112
- can: { undo: false, redo: false },
113
- charCount: 0,
114
- wordCount: 0,
115
- isEmpty: true,
116
- isFocused: false
117
- })
118
-
119
- function syncState(ed: Editor): void {
120
- const cc = ed.storage.characterCount
121
- const can = ed.can()
122
- editorState = {
123
- active: {
124
- bold: ed.isActive('bold'),
125
- italic: ed.isActive('italic'),
126
- underline: ed.isActive('underline'),
127
- strike: ed.isActive('strike'),
128
- code: ed.isActive('code'),
129
- h1: ed.isActive('heading', { level: 1 }),
130
- h2: ed.isActive('heading', { level: 2 }),
131
- h3: ed.isActive('heading', { level: 3 }),
132
- paragraph: ed.isActive('paragraph'),
133
- bulletList: ed.isActive('bulletList'),
134
- orderedList: ed.isActive('orderedList'),
135
- blockquote: ed.isActive('blockquote'),
136
- codeBlock: ed.isActive('codeBlock'),
137
- link: ed.isActive('link'),
138
- alignLeft: ed.isActive({ textAlign: 'left' }),
139
- alignCenter: ed.isActive({ textAlign: 'center' }),
140
- alignRight: ed.isActive({ textAlign: 'right' }),
141
- alignJustify: ed.isActive({ textAlign: 'justify' }),
142
- image: ed.isActive('image'),
143
- table: ed.isActive('table'),
144
- youtube: ed.isActive('youtube')
145
- },
146
- can: {
147
- undo: can.undo(),
148
- redo: can.redo()
149
- },
150
- charCount: typeof cc?.characters === 'function' ? cc.characters() : 0,
151
- wordCount: typeof cc?.words === 'function' ? cc.words() : 0,
152
- isEmpty: ed.isEmpty,
153
- isFocused: ed.isFocused
154
- }
155
- }
156
-
157
- function serialize(ed: Editor): string | EditorJSON {
158
- if (resolvedOutput === 'json') return ed.getJSON() as EditorJSON
159
- if (resolvedOutput === 'markdown') {
160
- const md = getMarkdownStorage(ed)
161
- if (md && typeof md.getMarkdown === 'function') {
162
- return md.getMarkdown()
163
- }
164
- return ed.getHTML()
165
- }
166
- return ed.getHTML()
167
- }
168
-
169
- function isContentEqual(a: unknown, b: unknown): boolean {
170
- if (a === b) return true
171
- if (typeof a !== typeof b) return false
172
- if (typeof a === 'string' && typeof b === 'string') return a === b
173
- try {
174
- return JSON.stringify(a) === JSON.stringify(b)
175
- } catch {
176
- return false
177
- }
178
- }
179
-
180
- function resolveSlashCommands() {
181
- if (slashCommands) return slashCommands
182
- if (!slash) return undefined
183
- return buildDefaultSlashCommands({
184
- image,
185
- tables,
186
- youtube,
187
- promptUrl: (opts) =>
188
- new Promise<string | null>((resolve) => {
189
- let done = false
190
- const settle = (value: string | null): void => {
191
- if (done) return
192
- done = true
193
- resolve(value)
194
- }
195
- openUrlPrompt({
196
- ...opts,
197
- onConfirm: (value) => settle(value),
198
- onCancel: () => settle(null)
199
- })
200
- })
201
- })
202
- }
203
-
204
- function resolveExtensions(): AnyExtension[] | Promise<AnyExtension[]> {
205
- if (extensionsOverride) return extensionsOverride
206
- return buildExtensions({
207
- headingLevels,
208
- placeholder,
209
- autolink,
210
- linkOpenInNewTab,
211
- maxLength,
212
- image,
213
- tables,
214
- youtube,
215
- dragHandle,
216
- markdown: resolvedOutput === 'markdown',
217
- markdownAllowHtml,
218
- mentionTrigger,
219
- mentionSuggestion: onMention
220
- ? buildMentionSuggestion({ onQuery: onMention })
221
- : undefined,
222
- slashCommands: resolveSlashCommands(),
223
- slashTrigger,
224
- extra: [
225
- ...(extraExtensions ?? []),
226
- ...(bubbleMenu && bubbleElement
227
- ? [
228
- BubbleMenuExt.configure({
229
- element: bubbleElement,
230
- options: {
231
- placement: 'top'
232
- }
233
- })
234
- ]
235
- : [])
236
- ]
237
- })
238
- }
239
-
240
- let suppressUpdate = false
241
- let lastEmitted: string | EditorJSON | undefined
242
-
243
- $effect(() => {
244
- if (!contentElement) return
245
-
246
- const initialContent = untrack(() => value ?? '')
247
- const initialEditable = untrack(() => !disabled && !readonly)
248
- const initialAutofocus = untrack(() => autofocus)
249
- const initialAttrs = untrack(() => ({
250
- id: resolvedId,
251
- ...(resolvedName ? { 'data-name': resolvedName } : {}),
252
- ...(ariaDescribedBy ? { 'aria-describedby': ariaDescribedBy } : {}),
253
- ...(hasError ? { 'aria-invalid': 'true' } : {})
254
- }))
255
- const el = contentElement
256
- const result = untrack(() => resolveExtensions())
257
-
258
- let ed: Editor | null = null
259
- let cancelled = false
260
-
261
- const create = (exts: AnyExtension[]) => {
262
- if (cancelled) return
263
- ed = new Editor({
264
- element: el,
265
- extensions: exts,
266
- content: initialContent,
267
- editable: initialEditable,
268
- autofocus: initialAutofocus,
269
- editorProps: {
270
- attributes: initialAttrs as Record<string, string>
271
- },
272
- onCreate: ({ editor: e }) => syncState(e),
273
- onUpdate: ({ editor: e }) => {
274
- syncState(e)
275
- if (suppressUpdate) return
276
- const serialized = serialize(e)
277
- lastEmitted = serialized
278
- value = serialized
279
- emit.onInput()
280
- onValueChange?.(serialized)
281
- },
282
- onSelectionUpdate: ({ editor: e }) => syncState(e),
283
- onFocus: ({ editor: e }) => {
284
- syncState(e)
285
- emit.onFocus()
286
- onFocus?.()
287
- },
288
- onBlur: ({ editor: e }) => {
289
- syncState(e)
290
- emit.onBlur()
291
- onBlur?.()
292
- }
293
- })
294
- editor = ed
295
- }
296
-
297
- if (result instanceof Promise) {
298
- result.then(create)
299
- } else {
300
- create(result)
301
- }
302
-
303
- return () => {
304
- cancelled = true
305
- ed?.destroy()
306
- editor = null
307
- }
308
- })
309
-
310
- $effect(() => {
311
- if (!editor) return
312
- const target = !disabled && !readonly
313
- if (editor.isEditable !== target) {
314
- editor.setEditable(target)
315
- }
316
- })
317
-
318
- $effect(() => {
319
- if (!contentElement) return
320
- const pm = contentElement.querySelector('.ProseMirror') as HTMLElement | null
321
- if (!pm) return
322
- if (hasError) pm.setAttribute('aria-invalid', 'true')
323
- else pm.removeAttribute('aria-invalid')
324
- if (ariaDescribedBy) pm.setAttribute('aria-describedby', ariaDescribedBy)
325
- else pm.removeAttribute('aria-describedby')
326
- if (resolvedId) pm.setAttribute('id', resolvedId)
327
- })
328
-
329
- $effect(() => {
330
- if (!editor) return
331
- if (value === undefined) return
332
- if (typeof value === 'string' && value === lastEmitted) return
333
- const current = serialize(editor)
334
- if (isContentEqual(current, value)) return
335
- suppressUpdate = true
336
- editor.commands.setContent(value as string | EditorJSON, { emitUpdate: false })
337
- suppressUpdate = false
338
- syncState(editor)
339
- })
340
-
341
- const apiInstance: EditorApi = {
342
- get editor() {
343
- return editor
344
- },
345
- get state() {
346
- return editorState
347
- },
348
- focus(position) {
349
- editor?.commands.focus(position)
350
- },
351
- run(action) {
352
- if (!editor) return
353
- TOOLBAR_ACTIONS[action].run(editor)
354
- },
355
- getValue(format) {
356
- if (!editor) return resolvedOutput === 'json' ? ({} as EditorJSON) : ''
357
- const fmt = format ?? resolvedOutput
358
- if (fmt === 'json') return editor.getJSON() as EditorJSON
359
- if (fmt === 'markdown') {
360
- const md = getMarkdownStorage(editor)
361
- if (md && typeof md.getMarkdown === 'function') return md.getMarkdown()
362
- return editor.getHTML()
363
- }
364
- return editor.getHTML()
365
- },
366
- setValue(next) {
367
- if (!editor) return
368
- suppressUpdate = true
369
- editor.commands.setContent(next as string | EditorJSON, { emitUpdate: false })
370
- suppressUpdate = false
371
- syncState(editor)
372
- },
373
- clear() {
374
- editor?.chain().focus().clearContent().run()
375
- },
376
- insert(content) {
377
- editor
378
- ?.chain()
379
- .focus()
380
- .insertContent(content as string | EditorJSON)
381
- .run()
382
- }
383
- }
384
-
385
- api = apiInstance
386
-
387
- const resolvedToolbar = $derived.by<(ToolbarAction | '|')[]>(() => {
388
- if (toolbar === false) return []
389
- if (toolbar === true) return DEFAULT_TOOLBAR
390
- return toolbar as (ToolbarAction | '|')[]
391
- })
392
-
393
- const classes = $derived.by(() => {
394
- const slots = editorVariants({
395
- size,
396
- color: resolvedColor,
397
- sticky: stickyToolbar
398
- })
399
- const c = config.slots
400
- const u = ui ?? {}
401
- return {
402
- root: slots.root({ class: [c.root, className, u.root] }),
403
- toolbar: slots.toolbar({ class: [c.toolbar, u.toolbar] }),
404
- toolbarButton: slots.toolbarButton({ class: [c.toolbarButton, u.toolbarButton] }),
405
- toolbarSeparator: slots.toolbarSeparator({
406
- class: [c.toolbarSeparator, u.toolbarSeparator]
407
- }),
408
- content: slots.content({ class: [c.content, u.content] }),
409
- footer: slots.footer({ class: [c.footer, u.footer] }),
410
- countLabel: slots.countLabel({ class: [c.countLabel, u.countLabel] }),
411
- bubbleMenu: slots.bubbleMenu({ class: [c.bubbleMenu, u.bubbleMenu] })
412
- }
413
- })
414
-
415
- interface UrlPromptState {
416
- open: boolean
417
- title: string
418
- description?: string
419
- placeholder: string
420
- initialValue: string
421
- confirmLabel: string
422
- schema?: UrlSchema
423
- onConfirm?: (url: string) => void
424
- onCancel?: () => void
425
- }
426
-
427
- let urlPrompt = $state<UrlPromptState>({
428
- open: false,
429
- title: 'Enter URL',
430
- placeholder: 'https://',
431
- initialValue: '',
432
- confirmLabel: 'Insert'
433
- })
434
-
435
- function openUrlPrompt(opts: {
436
- title: string
437
- description?: string
438
- placeholder?: string
439
- initialValue?: string
440
- confirmLabel?: string
441
- schema?: UrlSchema
442
- onConfirm: (url: string) => void
443
- onCancel?: () => void
444
- }): void {
445
- urlPrompt = {
446
- open: true,
447
- title: opts.title,
448
- description: opts.description,
449
- placeholder: opts.placeholder ?? 'https://',
450
- initialValue: opts.initialValue ?? '',
451
- confirmLabel: opts.confirmLabel ?? 'Insert',
452
- schema: opts.schema,
453
- onConfirm: opts.onConfirm,
454
- onCancel: opts.onCancel
455
- }
456
- }
457
-
458
- let fileInput: HTMLInputElement | null = $state(null)
459
-
460
- async function handleFileSelected(event: Event): Promise<void> {
461
- if (!editor) return
462
- const input = event.currentTarget as HTMLInputElement
463
- const file = input.files?.[0]
464
- input.value = ''
465
- if (!file) return
466
- if (!onImageUpload) return
467
- try {
468
- const url = await onImageUpload(file)
469
- if (!isSafeImageSrc(url)) {
470
- console.warn(
471
- `[svelora] Blocked unsafe image src returned by onImageUpload: ${url}`
472
- )
473
- return
474
- }
475
- editor.chain().focus().setImage({ src: url }).run()
476
- } catch (err) {
477
- if (onImageUploadError) {
478
- onImageUploadError(err)
479
- } else {
480
- }
481
- }
482
- }
483
-
484
- function openImagePicker(): void {
485
- if (!editor) return
486
- if (!onImageUpload) {
487
- openUrlPrompt({
488
- title: 'Image URL',
489
- placeholder: 'https://example.com/image.png',
490
- schema: httpUrlSchema,
491
- onConfirm: (url) => {
492
- editor?.chain().focus().setImage({ src: url }).run()
493
- }
494
- })
495
- return
496
- }
497
- fileInput?.click()
498
- }
499
-
500
- function openYoutubePrompt(): void {
501
- if (!editor) return
502
- openUrlPrompt({
503
- title: 'Embed YouTube video',
504
- description: 'Paste the share link or full URL.',
505
- placeholder: 'https://youtu.be/...',
506
- confirmLabel: 'Embed',
507
- schema: youtubeUrlSchema,
508
- onConfirm: (url) => {
509
- editor?.commands.setYoutubeVideo({ src: url })
510
- }
511
- })
512
- }
513
-
514
- function openLinkPrompt(): void {
515
- if (!editor) return
516
- const previous = (editor.getAttributes('link').href as string | undefined) ?? ''
517
- openUrlPrompt({
518
- title: 'Insert link',
519
- placeholder: 'https://',
520
- initialValue: previous,
521
- schema: httpUrlSchema,
522
- onConfirm: (url) => {
523
- editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
524
- }
525
- })
526
- }
527
-
528
- let tableMenuOpen = $state(false)
529
- let tablePickerRows = $state(0)
530
- let tablePickerCols = $state(0)
531
- let tablePickerEl: HTMLDivElement | null = $state(null)
532
- let tableButtonEl: HTMLButtonElement | null = $state(null)
533
- const TABLE_MAX_ROWS = 8
534
- const TABLE_MAX_COLS = 8
535
-
536
- function insertTable(rows: number, cols: number): void {
537
- if (!editor) return
538
- editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run()
539
- tableMenuOpen = false
540
- tablePickerRows = 0
541
- tablePickerCols = 0
542
- }
543
-
544
- $effect(() => {
545
- if (!tableMenuOpen) return
546
- function onDocPointerDown(event: PointerEvent): void {
547
- const target = event.target as Node | null
548
- if (!target) return
549
- if (tablePickerEl?.contains(target)) return
550
- if (tableButtonEl?.contains(target)) return
551
- tableMenuOpen = false
552
- tablePickerRows = 0
553
- tablePickerCols = 0
554
- }
555
- document.addEventListener('pointerdown', onDocPointerDown, true)
556
- return () => document.removeEventListener('pointerdown', onDocPointerDown, true)
557
- })
558
-
559
- function runAction(def: ToolbarActionDef, id: ToolbarAction): void {
560
- if (!editor) return
561
- if (id === 'image') {
562
- openImagePicker()
563
- return
564
- }
565
- if (id === 'youtube') {
566
- openYoutubePrompt()
567
- return
568
- }
569
- if (id === 'link') {
570
- openLinkPrompt()
571
- return
572
- }
573
- if (id === 'table') {
574
- tableMenuOpen = !tableMenuOpen
575
- return
576
- }
577
- def.run(editor)
578
- }
4
+ <script lang="ts">import { Editor } from "@tiptap/core";
5
+ import BubbleMenuExt from "@tiptap/extension-bubble-menu";
6
+ import { untrack } from "svelte";
7
+ import { getComponentConfig } from "../config.js";
8
+ import { useFormField, useFormFieldEmit } from "../hooks/useFormField.svelte.js";
9
+ import Icon from "../Icon/Icon.svelte";
10
+ import Tooltip from "../Tooltip/Tooltip.svelte";
11
+ import EditorUrlPrompt from "./EditorUrlPrompt.svelte";
12
+ import { buildExtensions } from "./editor.extensions.js";
13
+ import { httpUrlSchema, isSafeImageSrc, youtubeUrlSchema } from "./editor.schemas.js";
14
+ import { buildDefaultSlashCommands } from "./editor.slash.svelte.js";
15
+ import { buildMentionSuggestion } from "./editor.suggestion.js";
16
+ import { DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from "./editor.toolbar.js";
17
+ import { editorDefaults, editorVariants } from "./editor.variants.js";
18
+ const config = getComponentConfig("editor", editorDefaults);
19
+ let { ref = $bindable(null), api = $bindable(), value = $bindable(), output = "html", placeholder, id, name, onValueChange, onFocus, onBlur, readonly = false, disabled = false, autofocus = false, maxLength, showCount = false, toolbar = true, stickyToolbar = false, bubbleMenu = false, headingLevels = [
20
+ 1,
21
+ 2,
22
+ 3
23
+ ], autolink = true, linkOpenInNewTab = true, markdownAllowHtml = false, image = false, onImageUpload, onImageUploadError, tables = false, onMention, mentionTrigger = "@", slash = false, slashCommands, slashTrigger = "/", youtube = false, dragHandle = false, extensions: extraExtensions, extensionsOverride, size = config.defaultVariants.size ?? "md", color = config.defaultVariants.color ?? "primary", class: className, ui, toolbarSlot, bubbleMenuSlot, header, footer, ...restProps } = $props();
24
+ const formFieldContext = useFormField();
25
+ const emit = useFormFieldEmit();
26
+ const resolvedOutput = untrack(() => output);
27
+ function getMarkdownStorage(ed) {
28
+ return ed.storage.markdown;
29
+ }
30
+ const hasError = $derived(formFieldContext?.error !== undefined && formFieldContext?.error !== false);
31
+ const resolvedColor = $derived(hasError ? "error" : color);
32
+ const resolvedId = $derived(id ?? formFieldContext?.ariaId);
33
+ const resolvedName = $derived(name ?? formFieldContext?.name);
34
+ const ariaDescribedBy = $derived(!formFieldContext ? undefined : hasError ? `${formFieldContext.ariaId}-error` : `${formFieldContext.ariaId}-description ${formFieldContext.ariaId}-help`);
35
+ let contentElement = $state(null);
36
+ let bubbleElement = $state(null);
37
+ let editor = $state(null);
38
+ let editorState = $state({
39
+ active: {},
40
+ can: {
41
+ undo: false,
42
+ redo: false
43
+ },
44
+ charCount: 0,
45
+ wordCount: 0,
46
+ isEmpty: true,
47
+ isFocused: false
48
+ });
49
+ function syncState(ed) {
50
+ const cc = ed.storage.characterCount;
51
+ const can = ed.can();
52
+ editorState = {
53
+ active: {
54
+ bold: ed.isActive("bold"),
55
+ italic: ed.isActive("italic"),
56
+ underline: ed.isActive("underline"),
57
+ strike: ed.isActive("strike"),
58
+ code: ed.isActive("code"),
59
+ h1: ed.isActive("heading", { level: 1 }),
60
+ h2: ed.isActive("heading", { level: 2 }),
61
+ h3: ed.isActive("heading", { level: 3 }),
62
+ paragraph: ed.isActive("paragraph"),
63
+ bulletList: ed.isActive("bulletList"),
64
+ orderedList: ed.isActive("orderedList"),
65
+ blockquote: ed.isActive("blockquote"),
66
+ codeBlock: ed.isActive("codeBlock"),
67
+ link: ed.isActive("link"),
68
+ alignLeft: ed.isActive({ textAlign: "left" }),
69
+ alignCenter: ed.isActive({ textAlign: "center" }),
70
+ alignRight: ed.isActive({ textAlign: "right" }),
71
+ alignJustify: ed.isActive({ textAlign: "justify" }),
72
+ image: ed.isActive("image"),
73
+ table: ed.isActive("table"),
74
+ youtube: ed.isActive("youtube")
75
+ },
76
+ can: {
77
+ undo: can.undo(),
78
+ redo: can.redo()
79
+ },
80
+ charCount: typeof cc?.characters === "function" ? cc.characters() : 0,
81
+ wordCount: typeof cc?.words === "function" ? cc.words() : 0,
82
+ isEmpty: ed.isEmpty,
83
+ isFocused: ed.isFocused
84
+ };
85
+ }
86
+ function serialize(ed) {
87
+ if (resolvedOutput === "json") return ed.getJSON();
88
+ if (resolvedOutput === "markdown") {
89
+ const md = getMarkdownStorage(ed);
90
+ if (md && typeof md.getMarkdown === "function") {
91
+ return md.getMarkdown();
92
+ }
93
+ return ed.getHTML();
94
+ }
95
+ return ed.getHTML();
96
+ }
97
+ function isContentEqual(a, b) {
98
+ if (a === b) return true;
99
+ if (typeof a !== typeof b) return false;
100
+ if (typeof a === "string" && typeof b === "string") return a === b;
101
+ try {
102
+ return JSON.stringify(a) === JSON.stringify(b);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+ function resolveSlashCommands() {
108
+ if (slashCommands) return slashCommands;
109
+ if (!slash) return undefined;
110
+ return buildDefaultSlashCommands({
111
+ image,
112
+ tables,
113
+ youtube,
114
+ promptUrl: (opts) => new Promise((resolve) => {
115
+ let done = false;
116
+ const settle = (value) => {
117
+ if (done) return;
118
+ done = true;
119
+ resolve(value);
120
+ };
121
+ openUrlPrompt({
122
+ ...opts,
123
+ onConfirm: (value) => settle(value),
124
+ onCancel: () => settle(null)
125
+ });
126
+ })
127
+ });
128
+ }
129
+ function resolveExtensions() {
130
+ if (extensionsOverride) return extensionsOverride;
131
+ return buildExtensions({
132
+ headingLevels,
133
+ placeholder,
134
+ autolink,
135
+ linkOpenInNewTab,
136
+ maxLength,
137
+ image,
138
+ tables,
139
+ youtube,
140
+ dragHandle,
141
+ markdown: resolvedOutput === "markdown",
142
+ markdownAllowHtml,
143
+ mentionTrigger,
144
+ mentionSuggestion: onMention ? buildMentionSuggestion({ onQuery: onMention }) : undefined,
145
+ slashCommands: resolveSlashCommands(),
146
+ slashTrigger,
147
+ extra: [...extraExtensions ?? [], ...bubbleMenu && bubbleElement ? [BubbleMenuExt.configure({
148
+ element: bubbleElement,
149
+ options: { placement: "top" }
150
+ })] : []]
151
+ });
152
+ }
153
+ let suppressUpdate = false;
154
+ let lastEmitted;
155
+ $effect(() => {
156
+ if (!contentElement) return;
157
+ const initialContent = untrack(() => value ?? "");
158
+ const initialEditable = untrack(() => !disabled && !readonly);
159
+ const initialAutofocus = untrack(() => autofocus);
160
+ const initialAttrs = untrack(() => ({
161
+ id: resolvedId,
162
+ ...resolvedName ? { "data-name": resolvedName } : {},
163
+ ...ariaDescribedBy ? { "aria-describedby": ariaDescribedBy } : {},
164
+ ...hasError ? { "aria-invalid": "true" } : {}
165
+ }));
166
+ const el = contentElement;
167
+ const result = untrack(() => resolveExtensions());
168
+ let ed = null;
169
+ let cancelled = false;
170
+ const create = (exts) => {
171
+ if (cancelled) return;
172
+ ed = new Editor({
173
+ element: el,
174
+ extensions: exts,
175
+ content: initialContent,
176
+ editable: initialEditable,
177
+ autofocus: initialAutofocus,
178
+ editorProps: { attributes: initialAttrs },
179
+ onCreate: ({ editor: e }) => syncState(e),
180
+ onUpdate: ({ editor: e }) => {
181
+ syncState(e);
182
+ if (suppressUpdate) return;
183
+ const serialized = serialize(e);
184
+ lastEmitted = serialized;
185
+ value = serialized;
186
+ emit.onInput();
187
+ onValueChange?.(serialized);
188
+ },
189
+ onSelectionUpdate: ({ editor: e }) => syncState(e),
190
+ onFocus: ({ editor: e }) => {
191
+ syncState(e);
192
+ emit.onFocus();
193
+ onFocus?.();
194
+ },
195
+ onBlur: ({ editor: e }) => {
196
+ syncState(e);
197
+ emit.onBlur();
198
+ onBlur?.();
199
+ }
200
+ });
201
+ editor = ed;
202
+ };
203
+ if (result instanceof Promise) {
204
+ result.then(create);
205
+ } else {
206
+ create(result);
207
+ }
208
+ return () => {
209
+ cancelled = true;
210
+ ed?.destroy();
211
+ editor = null;
212
+ };
213
+ });
214
+ $effect(() => {
215
+ if (!editor) return;
216
+ const target = !disabled && !readonly;
217
+ if (editor.isEditable !== target) {
218
+ editor.setEditable(target);
219
+ }
220
+ });
221
+ $effect(() => {
222
+ if (!contentElement) return;
223
+ const pm = contentElement.querySelector(".ProseMirror");
224
+ if (!pm) return;
225
+ if (hasError) pm.setAttribute("aria-invalid", "true");
226
+ else pm.removeAttribute("aria-invalid");
227
+ if (ariaDescribedBy) pm.setAttribute("aria-describedby", ariaDescribedBy);
228
+ else pm.removeAttribute("aria-describedby");
229
+ if (resolvedId) pm.setAttribute("id", resolvedId);
230
+ });
231
+ $effect(() => {
232
+ if (!editor) return;
233
+ if (value === undefined) return;
234
+ if (typeof value === "string" && value === lastEmitted) return;
235
+ const current = serialize(editor);
236
+ if (isContentEqual(current, value)) return;
237
+ suppressUpdate = true;
238
+ editor.commands.setContent(value, { emitUpdate: false });
239
+ suppressUpdate = false;
240
+ syncState(editor);
241
+ });
242
+ const apiInstance = {
243
+ get editor() {
244
+ return editor;
245
+ },
246
+ get state() {
247
+ return editorState;
248
+ },
249
+ focus(position) {
250
+ editor?.commands.focus(position);
251
+ },
252
+ run(action) {
253
+ if (!editor) return;
254
+ TOOLBAR_ACTIONS[action].run(editor);
255
+ },
256
+ getValue(format) {
257
+ if (!editor) return resolvedOutput === "json" ? {} : "";
258
+ const fmt = format ?? resolvedOutput;
259
+ if (fmt === "json") return editor.getJSON();
260
+ if (fmt === "markdown") {
261
+ const md = getMarkdownStorage(editor);
262
+ if (md && typeof md.getMarkdown === "function") return md.getMarkdown();
263
+ return editor.getHTML();
264
+ }
265
+ return editor.getHTML();
266
+ },
267
+ setValue(next) {
268
+ if (!editor) return;
269
+ suppressUpdate = true;
270
+ editor.commands.setContent(next, { emitUpdate: false });
271
+ suppressUpdate = false;
272
+ syncState(editor);
273
+ },
274
+ clear() {
275
+ editor?.chain().focus().clearContent().run();
276
+ },
277
+ insert(content) {
278
+ editor?.chain().focus().insertContent(content).run();
279
+ }
280
+ };
281
+ api = apiInstance;
282
+ const resolvedToolbar = $derived.by(() => {
283
+ if (toolbar === false) return [];
284
+ if (toolbar === true) return DEFAULT_TOOLBAR;
285
+ return toolbar;
286
+ });
287
+ const classes = $derived.by(() => {
288
+ const slots = editorVariants({
289
+ size,
290
+ color: resolvedColor,
291
+ sticky: stickyToolbar
292
+ });
293
+ const c = config.slots;
294
+ const u = ui ?? {};
295
+ return {
296
+ root: slots.root({ class: [
297
+ c.root,
298
+ className,
299
+ u.root
300
+ ] }),
301
+ toolbar: slots.toolbar({ class: [c.toolbar, u.toolbar] }),
302
+ toolbarButton: slots.toolbarButton({ class: [c.toolbarButton, u.toolbarButton] }),
303
+ toolbarSeparator: slots.toolbarSeparator({ class: [c.toolbarSeparator, u.toolbarSeparator] }),
304
+ content: slots.content({ class: [c.content, u.content] }),
305
+ footer: slots.footer({ class: [c.footer, u.footer] }),
306
+ countLabel: slots.countLabel({ class: [c.countLabel, u.countLabel] }),
307
+ bubbleMenu: slots.bubbleMenu({ class: [c.bubbleMenu, u.bubbleMenu] })
308
+ };
309
+ });
310
+ let urlPrompt = $state({
311
+ open: false,
312
+ title: "Enter URL",
313
+ placeholder: "https://",
314
+ initialValue: "",
315
+ confirmLabel: "Insert"
316
+ });
317
+ function openUrlPrompt(opts) {
318
+ urlPrompt = {
319
+ open: true,
320
+ title: opts.title,
321
+ description: opts.description,
322
+ placeholder: opts.placeholder ?? "https://",
323
+ initialValue: opts.initialValue ?? "",
324
+ confirmLabel: opts.confirmLabel ?? "Insert",
325
+ schema: opts.schema,
326
+ onConfirm: opts.onConfirm,
327
+ onCancel: opts.onCancel
328
+ };
329
+ }
330
+ let fileInput = $state(null);
331
+ async function handleFileSelected(event) {
332
+ if (!editor) return;
333
+ const input = event.currentTarget;
334
+ const file = input.files?.[0];
335
+ input.value = "";
336
+ if (!file) return;
337
+ if (!onImageUpload) return;
338
+ try {
339
+ const url = await onImageUpload(file);
340
+ if (!isSafeImageSrc(url)) {
341
+ console.warn(`[svelora] Blocked unsafe image src returned by onImageUpload: ${url}`);
342
+ return;
343
+ }
344
+ editor.chain().focus().setImage({ src: url }).run();
345
+ } catch (err) {
346
+ if (onImageUploadError) {
347
+ onImageUploadError(err);
348
+ } else {}
349
+ }
350
+ }
351
+ function openImagePicker() {
352
+ if (!editor) return;
353
+ if (!onImageUpload) {
354
+ openUrlPrompt({
355
+ title: "Image URL",
356
+ placeholder: "https://example.com/image.png",
357
+ schema: httpUrlSchema,
358
+ onConfirm: (url) => {
359
+ editor?.chain().focus().setImage({ src: url }).run();
360
+ }
361
+ });
362
+ return;
363
+ }
364
+ fileInput?.click();
365
+ }
366
+ function openYoutubePrompt() {
367
+ if (!editor) return;
368
+ openUrlPrompt({
369
+ title: "Embed YouTube video",
370
+ description: "Paste the share link or full URL.",
371
+ placeholder: "https://youtu.be/...",
372
+ confirmLabel: "Embed",
373
+ schema: youtubeUrlSchema,
374
+ onConfirm: (url) => {
375
+ editor?.commands.setYoutubeVideo({ src: url });
376
+ }
377
+ });
378
+ }
379
+ function openLinkPrompt() {
380
+ if (!editor) return;
381
+ const previous = editor.getAttributes("link").href ?? "";
382
+ openUrlPrompt({
383
+ title: "Insert link",
384
+ placeholder: "https://",
385
+ initialValue: previous,
386
+ schema: httpUrlSchema,
387
+ onConfirm: (url) => {
388
+ editor?.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
389
+ }
390
+ });
391
+ }
392
+ let tableMenuOpen = $state(false);
393
+ let tablePickerRows = $state(0);
394
+ let tablePickerCols = $state(0);
395
+ let tablePickerEl = $state(null);
396
+ let tableButtonEl = $state(null);
397
+ const TABLE_MAX_ROWS = 8;
398
+ const TABLE_MAX_COLS = 8;
399
+ function insertTable(rows, cols) {
400
+ if (!editor) return;
401
+ editor.chain().focus().insertTable({
402
+ rows,
403
+ cols,
404
+ withHeaderRow: true
405
+ }).run();
406
+ tableMenuOpen = false;
407
+ tablePickerRows = 0;
408
+ tablePickerCols = 0;
409
+ }
410
+ $effect(() => {
411
+ if (!tableMenuOpen) return;
412
+ function onDocPointerDown(event) {
413
+ const target = event.target;
414
+ if (!target) return;
415
+ if (tablePickerEl?.contains(target)) return;
416
+ if (tableButtonEl?.contains(target)) return;
417
+ tableMenuOpen = false;
418
+ tablePickerRows = 0;
419
+ tablePickerCols = 0;
420
+ }
421
+ document.addEventListener("pointerdown", onDocPointerDown, true);
422
+ return () => document.removeEventListener("pointerdown", onDocPointerDown, true);
423
+ });
424
+ function runAction(def, id) {
425
+ if (!editor) return;
426
+ if (id === "image") {
427
+ openImagePicker();
428
+ return;
429
+ }
430
+ if (id === "youtube") {
431
+ openYoutubePrompt();
432
+ return;
433
+ }
434
+ if (id === "link") {
435
+ openLinkPrompt();
436
+ return;
437
+ }
438
+ if (id === "table") {
439
+ tableMenuOpen = !tableMenuOpen;
440
+ return;
441
+ }
442
+ def.run(editor);
443
+ }
579
444
  </script>
580
445
 
581
446
  <div