@svadmin/editor 0.1.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/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@svadmin/editor",
3
+ "version": "0.1.0",
4
+ "description": "Modern Tiptap-based rich text editor for Svelte 5 — headless, modular, production-ready",
5
+ "type": "module",
6
+ "files": [
7
+ "src/**/*"
8
+ ],
9
+ "main": "src/index.ts",
10
+ "types": "src/index.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/index.ts",
14
+ "default": "./src/index.ts"
15
+ },
16
+ "./components/*": "./src/components/*",
17
+ "./editor.css": "./src/styles/editor.css"
18
+ },
19
+ "peerDependencies": {
20
+ "svelte": "^5.0.0",
21
+ "@svadmin/core": "^0.5.14"
22
+ },
23
+ "devDependencies": {
24
+ "@svadmin/core": "^0.5.14"
25
+ },
26
+ "dependencies": {
27
+ "@tiptap/core": "^2.12.0",
28
+ "@tiptap/pm": "^2.12.0",
29
+ "@tiptap/starter-kit": "^2.12.0",
30
+ "@tiptap/extension-bubble-menu": "^2.12.0",
31
+ "@tiptap/extension-character-count": "^2.12.0",
32
+ "@tiptap/extension-code-block-lowlight": "^2.12.0",
33
+ "@tiptap/extension-color": "^2.12.0",
34
+ "@tiptap/extension-floating-menu": "^2.12.0",
35
+ "@tiptap/extension-highlight": "^2.12.0",
36
+ "@tiptap/extension-image": "^2.12.0",
37
+ "@tiptap/extension-link": "^2.12.0",
38
+ "@tiptap/extension-placeholder": "^2.12.0",
39
+ "@tiptap/extension-subscript": "^2.12.0",
40
+ "@tiptap/extension-superscript": "^2.12.0",
41
+ "@tiptap/extension-table": "^2.12.0",
42
+ "@tiptap/extension-table-cell": "^2.12.0",
43
+ "@tiptap/extension-table-header": "^2.12.0",
44
+ "@tiptap/extension-table-row": "^2.12.0",
45
+ "@tiptap/extension-task-item": "^2.12.0",
46
+ "@tiptap/extension-task-list": "^2.12.0",
47
+ "@tiptap/extension-text-align": "^2.12.0",
48
+ "@tiptap/extension-text-style": "^2.12.0",
49
+ "@tiptap/extension-typography": "^2.12.0",
50
+ "@tiptap/extension-underline": "^2.12.0",
51
+ "@tiptap/suggestion": "^2.12.0",
52
+ "lowlight": "^3.3.0",
53
+ "lucide-svelte": "^0.577.0"
54
+ },
55
+ "license": "MIT",
56
+ "publishConfig": {
57
+ "access": "public",
58
+ "registry": "https://registry.npmjs.org/"
59
+ },
60
+ "author": "zuohuadong",
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "https://github.com/zuohuadong/svadmin.git",
64
+ "directory": "packages/editor"
65
+ }
66
+ }
@@ -0,0 +1,104 @@
1
+ <script lang="ts">
2
+ import type { Editor } from '@tiptap/core';
3
+ import { BubbleMenu as TiptapBubbleMenu } from '@tiptap/extension-bubble-menu';
4
+ import ToolbarButton from './ToolbarButton.svelte';
5
+ import {
6
+ Bold, Italic, Underline as UnderlineIcon, Strikethrough,
7
+ Code, Link as LinkIcon, Highlighter,
8
+ } from 'lucide-svelte';
9
+ import { t } from '@svadmin/core';
10
+
11
+ let { editor } = $props<{
12
+ editor: Editor;
13
+ }>();
14
+
15
+ let menuElement: HTMLDivElement | undefined = $state();
16
+
17
+ let txn = $state(0);
18
+ $effect(() => {
19
+ const handler = () => { txn++; };
20
+ editor.on('transaction', handler);
21
+ return () => { editor.off('transaction', handler); };
22
+ });
23
+
24
+ const isBold = $derived((void txn, editor.isActive('bold')));
25
+ const isItalic = $derived((void txn, editor.isActive('italic')));
26
+ const isUnderline = $derived((void txn, editor.isActive('underline')));
27
+ const isStrike = $derived((void txn, editor.isActive('strike')));
28
+ const isCode = $derived((void txn, editor.isActive('code')));
29
+ const isLink = $derived((void txn, editor.isActive('link')));
30
+
31
+ // Register the bubble menu extension
32
+ $effect(() => {
33
+ if (!menuElement) return;
34
+
35
+ // Check if BubbleMenu is already registered
36
+ const existing = editor.extensionManager.extensions.find(
37
+ (e) => e.name === 'bubbleMenu'
38
+ );
39
+ if (existing) return;
40
+
41
+ // We need to register the extension dynamically
42
+ // Tiptap handles this through the plugin system
43
+ const plugin = TiptapBubbleMenu.configure({
44
+ element: menuElement,
45
+ tippyOptions: {
46
+ duration: 150,
47
+ placement: 'top',
48
+ animation: 'shift-away',
49
+ },
50
+ shouldShow: ({ editor: e, state }) => {
51
+ const { selection } = state;
52
+ const { empty } = selection;
53
+ // Don't show in code blocks or empty selections
54
+ if (empty || e.isActive('codeBlock')) return false;
55
+ return true;
56
+ },
57
+ });
58
+
59
+ editor.registerPlugin(plugin.options.plugin(plugin.options));
60
+ return () => {
61
+ // cleanup handled by editor destroy
62
+ };
63
+ });
64
+ </script>
65
+
66
+ <div class="svadmin-bubble-menu" bind:this={menuElement} style="visibility: hidden;">
67
+ <ToolbarButton title={t('editor.bold')} active={isBold} onclick={() => editor.chain().focus().toggleBold().run()}>
68
+ <Bold />
69
+ </ToolbarButton>
70
+ <ToolbarButton title={t('editor.italic')} active={isItalic} onclick={() => editor.chain().focus().toggleItalic().run()}>
71
+ <Italic />
72
+ </ToolbarButton>
73
+ <ToolbarButton title={t('editor.underline')} active={isUnderline} onclick={() => editor.chain().focus().toggleUnderline().run()}>
74
+ <UnderlineIcon />
75
+ </ToolbarButton>
76
+ <ToolbarButton title={t('editor.strikethrough')} active={isStrike} onclick={() => editor.chain().focus().toggleStrike().run()}>
77
+ <Strikethrough />
78
+ </ToolbarButton>
79
+ <ToolbarButton title={t('editor.code')} active={isCode} onclick={() => editor.chain().focus().toggleCode().run()}>
80
+ <Code />
81
+ </ToolbarButton>
82
+ <ToolbarButton
83
+ title={t('editor.link')}
84
+ active={isLink}
85
+ onclick={() => {
86
+ if (isLink) {
87
+ editor.chain().focus().unsetLink().run();
88
+ } else {
89
+ const url = window.prompt('Enter URL:');
90
+ if (url) {
91
+ editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
92
+ }
93
+ }
94
+ }}
95
+ >
96
+ <LinkIcon />
97
+ </ToolbarButton>
98
+ <ToolbarButton
99
+ title="Highlight"
100
+ onclick={() => editor.chain().focus().toggleHighlight().run()}
101
+ >
102
+ <Highlighter />
103
+ </ToolbarButton>
104
+ </div>
@@ -0,0 +1,69 @@
1
+ <script lang="ts">
2
+ let { onselect, onclose } = $props<{
3
+ onselect: (color: string) => void;
4
+ onclose?: () => void;
5
+ }>();
6
+
7
+ const colors = [
8
+ // Row 1: Reds
9
+ '#000000', '#434343', '#666666', '#999999',
10
+ '#b7b7b7', '#cccccc', '#d9d9d9', '#ffffff',
11
+ // Row 2: Saturated
12
+ '#e06666', '#f6b26b', '#ffd966', '#93c47d',
13
+ '#76a5af', '#6fa8dc', '#8e7cc3', '#c27ba0',
14
+ // Row 3: Deep
15
+ '#cc0000', '#e69138', '#f1c232', '#6aa84f',
16
+ '#45818e', '#3d85c6', '#674ea7', '#a64d79',
17
+ // Row 4: Vivid
18
+ '#ff0000', '#ff9900', '#ffff00', '#00ff00',
19
+ '#00ffff', '#0000ff', '#9900ff', '#ff00ff',
20
+ // Row 5: Dark
21
+ '#7f0000', '#783f04', '#7f6000', '#274e13',
22
+ '#0c343d', '#073763', '#20124d', '#4c1130',
23
+ ];
24
+
25
+ let popover: HTMLDivElement | undefined = $state();
26
+
27
+ function handleClickOutside(e: MouseEvent) {
28
+ if (popover && !popover.contains(e.target as Node)) {
29
+ onclose?.();
30
+ }
31
+ }
32
+
33
+ $effect(() => {
34
+ document.addEventListener('mousedown', handleClickOutside);
35
+ return () => document.removeEventListener('mousedown', handleClickOutside);
36
+ });
37
+ </script>
38
+
39
+ <div class="svadmin-color-picker" bind:this={popover}>
40
+ <div class="svadmin-color-grid">
41
+ {#each colors as color}
42
+ <button
43
+ type="button"
44
+ class="svadmin-color-swatch"
45
+ style="background-color: {color}"
46
+ title={color}
47
+ onclick={() => onselect(color)}
48
+ aria-label="Color {color}"
49
+ ></button>
50
+ {/each}
51
+ </div>
52
+ <div style="margin-top: 6px; display: flex; gap: 4px; align-items: center;">
53
+ <input
54
+ type="color"
55
+ style="width: 28px; height: 28px; border: none; padding: 0; cursor: pointer; border-radius: 4px;"
56
+ oninput={(e) => onselect((e.target as HTMLInputElement).value)}
57
+ aria-label="Custom color"
58
+ />
59
+ <button
60
+ type="button"
61
+ class="svadmin-toolbar-btn"
62
+ style="font-size: 11px; width: auto; padding: 0 8px;"
63
+ title="Remove color"
64
+ onclick={() => onselect('')}
65
+ >
66
+ Reset
67
+ </button>
68
+ </div>
69
+ </div>
@@ -0,0 +1,223 @@
1
+ <script lang="ts">
2
+ import { Editor } from '@tiptap/core';
3
+ import { onDestroy } from 'svelte';
4
+ import { fullPreset, minimalPreset, prosePreset, type EditorPresetOptions } from '../extensions/index.js';
5
+ import type { AnyExtension } from '@tiptap/core';
6
+ import Toolbar from './Toolbar.svelte';
7
+ import BubbleMenuComp from './BubbleMenu.svelte';
8
+ import FloatingMenuComp from './FloatingMenu.svelte';
9
+ import { t } from '@svadmin/core';
10
+ import '../styles/editor.css';
11
+
12
+ interface Props {
13
+ /** HTML string content (bindable) */
14
+ value?: string;
15
+ /** Placeholder text */
16
+ placeholder?: string;
17
+ /** Whether the editor is editable */
18
+ editable?: boolean;
19
+ /** Autofocus on mount */
20
+ autofocus?: boolean | 'start' | 'end';
21
+ /** Max character count */
22
+ maxLength?: number;
23
+ /** Show toolbar */
24
+ showToolbar?: boolean;
25
+ /** Show bubble menu on text selection */
26
+ showBubbleMenu?: boolean;
27
+ /** Show floating menu on empty lines */
28
+ showFloatingMenu?: boolean;
29
+ /** Show character count in footer */
30
+ showCharCount?: boolean;
31
+ /** Extension preset */
32
+ preset?: 'full' | 'minimal' | 'prose';
33
+ /** Custom extensions (overrides preset) */
34
+ extensions?: AnyExtension[];
35
+ /** Image upload handler */
36
+ onUpload?: (file: File) => Promise<string>;
37
+ /** Called on every content change */
38
+ onchange?: (html: string) => void;
39
+ /** Additional CSS class for the wrapper */
40
+ class?: string;
41
+ /** Min height for the editor content area */
42
+ minHeight?: string;
43
+ /** Max height for the editor content area */
44
+ maxHeight?: string;
45
+ /** lowlight instance for syntax-highlighted code blocks */
46
+ lowlight?: unknown;
47
+ }
48
+
49
+ let {
50
+ value = $bindable(''),
51
+ placeholder = t('editor.startWriting'),
52
+ editable = true,
53
+ autofocus = false,
54
+ maxLength = 0,
55
+ showToolbar = true,
56
+ showBubbleMenu = true,
57
+ showFloatingMenu = true,
58
+ showCharCount = true,
59
+ preset = 'full',
60
+ extensions: customExtensions,
61
+ onUpload,
62
+ onchange,
63
+ class: className = '',
64
+ minHeight = '200px',
65
+ maxHeight = '600px',
66
+ lowlight,
67
+ }: Props = $props();
68
+
69
+ let editorElement: HTMLDivElement | undefined = $state();
70
+ let editor: Editor | null = $state(null);
71
+
72
+ // Track whether we're updating externally to prevent loops
73
+ let isUpdatingFromExternal = false;
74
+ let isUpdatingFromEditor = false;
75
+
76
+ // Build extensions based on preset
77
+ function buildExtensions(): AnyExtension[] {
78
+ if (customExtensions) return customExtensions;
79
+
80
+ const opts: EditorPresetOptions = {
81
+ placeholder,
82
+ maxLength,
83
+ lowlight,
84
+ };
85
+
86
+ switch (preset) {
87
+ case 'minimal': return minimalPreset(opts);
88
+ case 'prose': return prosePreset(opts);
89
+ case 'full':
90
+ default: return fullPreset(opts);
91
+ }
92
+ }
93
+
94
+ // Create editor when element is available
95
+ $effect(() => {
96
+ if (!editorElement) return;
97
+
98
+ const ext = buildExtensions();
99
+
100
+ editor = new Editor({
101
+ element: editorElement,
102
+ extensions: ext,
103
+ content: value || '',
104
+ editable,
105
+ autofocus,
106
+ editorProps: {
107
+ attributes: {
108
+ class: 'tiptap',
109
+ },
110
+ // Handle image drop
111
+ handleDrop(view, event, slice, moved) {
112
+ if (!moved && event.dataTransfer?.files?.length && onUpload) {
113
+ const files = event.dataTransfer.files;
114
+ for (let i = 0; i < files.length; i++) {
115
+ const file = files[i];
116
+ if (file.type.startsWith('image/')) {
117
+ event.preventDefault();
118
+ onUpload(file).then((url) => {
119
+ if (editor) {
120
+ const { schema } = editor.state;
121
+ const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
122
+ if (pos) {
123
+ const node = schema.nodes.image.create({ src: url, alt: file.name });
124
+ const transaction = view.state.tr.insert(pos.pos, node);
125
+ view.dispatch(transaction);
126
+ }
127
+ }
128
+ });
129
+ return true;
130
+ }
131
+ }
132
+ }
133
+ return false;
134
+ },
135
+ // Handle image paste
136
+ handlePaste(view, event) {
137
+ const items = event.clipboardData?.items;
138
+ if (items && onUpload) {
139
+ for (let i = 0; i < items.length; i++) {
140
+ const item = items[i];
141
+ if (item.type.startsWith('image/')) {
142
+ event.preventDefault();
143
+ const file = item.getAsFile();
144
+ if (file) {
145
+ onUpload(file).then((url) => {
146
+ if (editor) {
147
+ editor.chain().focus().setImage({ src: url, alt: file.name }).run();
148
+ }
149
+ });
150
+ }
151
+ return true;
152
+ }
153
+ }
154
+ }
155
+ return false;
156
+ },
157
+ },
158
+ onUpdate({ editor: e }) {
159
+ if (isUpdatingFromExternal) return;
160
+ isUpdatingFromEditor = true;
161
+ const html = e.getHTML();
162
+ value = html;
163
+ onchange?.(html);
164
+ // Defer reset to allow Svelte to process
165
+ queueMicrotask(() => { isUpdatingFromEditor = false; });
166
+ },
167
+ });
168
+
169
+ return () => {
170
+ editor?.destroy();
171
+ editor = null;
172
+ };
173
+ });
174
+
175
+ // Sync external value changes into the editor
176
+ $effect(() => {
177
+ if (!editor || isUpdatingFromEditor) return;
178
+ const currentHtml = editor.getHTML();
179
+ if (value !== currentHtml) {
180
+ isUpdatingFromExternal = true;
181
+ editor.commands.setContent(value || '', false);
182
+ isUpdatingFromExternal = false;
183
+ }
184
+ });
185
+
186
+ // Sync editable state
187
+ $effect(() => {
188
+ if (editor) {
189
+ editor.setEditable(editable);
190
+ }
191
+ });
192
+
193
+ // Character count
194
+ const charCount = $derived(editor?.storage.characterCount?.characters() ?? 0);
195
+ const wordCount = $derived(editor?.storage.characterCount?.words() ?? 0);
196
+ </script>
197
+
198
+ <div class="svadmin-editor {className}">
199
+ {#if showToolbar && editor}
200
+ <Toolbar {editor} {onUpload} {preset} />
201
+ {/if}
202
+
203
+ <div
204
+ class="svadmin-editor-content"
205
+ style="min-height: {minHeight}; max-height: {maxHeight};"
206
+ bind:this={editorElement}
207
+ ></div>
208
+
209
+ {#if editor && showBubbleMenu && preset !== 'minimal'}
210
+ <BubbleMenuComp {editor} />
211
+ {/if}
212
+
213
+ {#if editor && showFloatingMenu && preset !== 'minimal'}
214
+ <FloatingMenuComp {editor} />
215
+ {/if}
216
+
217
+ {#if showCharCount && editor}
218
+ <div class="svadmin-editor-footer">
219
+ <span>{wordCount} words</span>
220
+ <span>{charCount} characters{maxLength ? ` / ${maxLength}` : ''}</span>
221
+ </div>
222
+ {/if}
223
+ </div>
@@ -0,0 +1,77 @@
1
+ <script lang="ts">
2
+ import type { Editor } from '@tiptap/core';
3
+ import { FloatingMenu as TiptapFloatingMenu } from '@tiptap/extension-floating-menu';
4
+ import ToolbarButton from './ToolbarButton.svelte';
5
+ import {
6
+ Heading1, Heading2, Heading3,
7
+ List, ListOrdered, ListChecks,
8
+ Quote, Code2, Minus, ImageIcon, TableIcon,
9
+ } from 'lucide-svelte';
10
+ import { t } from '@svadmin/core';
11
+
12
+ let { editor } = $props<{
13
+ editor: Editor;
14
+ }>();
15
+
16
+ let menuElement: HTMLDivElement | undefined = $state();
17
+
18
+ $effect(() => {
19
+ if (!menuElement) return;
20
+
21
+ const existing = editor.extensionManager.extensions.find(
22
+ (e) => e.name === 'floatingMenu'
23
+ );
24
+ if (existing) return;
25
+
26
+ const plugin = TiptapFloatingMenu.configure({
27
+ element: menuElement,
28
+ tippyOptions: {
29
+ duration: 150,
30
+ placement: 'left-start',
31
+ },
32
+ shouldShow: ({ editor: e, state }) => {
33
+ const { selection } = state;
34
+ const { $anchor: anchor, empty } = selection;
35
+ // Show only on empty paragraphs
36
+ if (!empty) return false;
37
+ const isRootDepth = anchor.depth === 1;
38
+ const isEmptyTextBlock = anchor.parent.isTextblock
39
+ && !anchor.parent.type.spec.code
40
+ && !anchor.parent.textContent;
41
+ return isRootDepth && isEmptyTextBlock;
42
+ },
43
+ });
44
+
45
+ editor.registerPlugin(plugin.options.plugin(plugin.options));
46
+ });
47
+ </script>
48
+
49
+ <div class="svadmin-floating-menu" bind:this={menuElement} style="visibility: hidden;">
50
+ <ToolbarButton title={t('editor.h1')} onclick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>
51
+ <Heading1 />
52
+ </ToolbarButton>
53
+ <ToolbarButton title={t('editor.h2')} onclick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
54
+ <Heading2 />
55
+ </ToolbarButton>
56
+ <ToolbarButton title={t('editor.h3')} onclick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
57
+ <Heading3 />
58
+ </ToolbarButton>
59
+ <ToolbarButton title={t('editor.bulletList')} onclick={() => editor.chain().focus().toggleBulletList().run()}>
60
+ <List />
61
+ </ToolbarButton>
62
+ <ToolbarButton title={t('editor.orderedList')} onclick={() => editor.chain().focus().toggleOrderedList().run()}>
63
+ <ListOrdered />
64
+ </ToolbarButton>
65
+ <ToolbarButton title={t('editor.taskList')} onclick={() => editor.chain().focus().toggleTaskList().run()}>
66
+ <ListChecks />
67
+ </ToolbarButton>
68
+ <ToolbarButton title={t('editor.blockquote')} onclick={() => editor.chain().focus().toggleBlockquote().run()}>
69
+ <Quote />
70
+ </ToolbarButton>
71
+ <ToolbarButton title={t('editor.codeBlock')} onclick={() => editor.chain().focus().toggleCodeBlock().run()}>
72
+ <Code2 />
73
+ </ToolbarButton>
74
+ <ToolbarButton title="Divider" onclick={() => editor.chain().focus().setHorizontalRule().run()}>
75
+ <Minus />
76
+ </ToolbarButton>
77
+ </div>
@@ -0,0 +1,159 @@
1
+ <script lang="ts">
2
+ import type { Editor } from '@tiptap/core';
3
+ import { ImageIcon, Upload, Link as LinkIcon, X } from 'lucide-svelte';
4
+ import { t } from '@svadmin/core';
5
+
6
+ let { editor, onUpload, onclose } = $props<{
7
+ editor: Editor;
8
+ onUpload?: (file: File) => Promise<string>;
9
+ onclose?: () => void;
10
+ }>();
11
+
12
+ let mode: 'url' | 'upload' = $state('url');
13
+ let url = $state('');
14
+ let alt = $state('');
15
+ let uploading = $state(false);
16
+ let dragover = $state(false);
17
+ let fileInput: HTMLInputElement | undefined = $state();
18
+
19
+ function insertByUrl() {
20
+ if (!url.trim()) return;
21
+ editor.chain().focus().setImage({ src: url, alt: alt || undefined }).run();
22
+ url = '';
23
+ alt = '';
24
+ onclose?.();
25
+ }
26
+
27
+ async function handleFiles(files: FileList | null) {
28
+ if (!files?.length || !onUpload) return;
29
+ uploading = true;
30
+ try {
31
+ for (const file of files) {
32
+ if (!file.type.startsWith('image/')) continue;
33
+ const uploadedUrl = await onUpload(file);
34
+ editor.chain().focus().setImage({ src: uploadedUrl, alt: file.name }).run();
35
+ }
36
+ } catch (e) {
37
+ console.error('Image upload failed:', e);
38
+ } finally {
39
+ uploading = false;
40
+ onclose?.();
41
+ }
42
+ }
43
+
44
+ function handleDrop(e: DragEvent) {
45
+ e.preventDefault();
46
+ dragover = false;
47
+ handleFiles(e.dataTransfer?.files ?? null);
48
+ }
49
+
50
+ function handleDragOver(e: DragEvent) {
51
+ e.preventDefault();
52
+ dragover = true;
53
+ }
54
+
55
+ function handleDragLeave() {
56
+ dragover = false;
57
+ }
58
+
59
+ function handleKeydown(e: KeyboardEvent) {
60
+ if (e.key === 'Enter') {
61
+ e.preventDefault();
62
+ insertByUrl();
63
+ }
64
+ if (e.key === 'Escape') {
65
+ onclose?.();
66
+ }
67
+ }
68
+ </script>
69
+
70
+ <div class="svadmin-color-picker" style="min-width: 300px;">
71
+ <div style="display: flex; gap: 2px; margin-bottom: 8px;">
72
+ <button
73
+ type="button"
74
+ class="svadmin-toolbar-btn"
75
+ style="width: auto; padding: 0 10px; font-size: 12px; gap: 4px; display: flex; align-items: center;"
76
+ data-active={mode === 'url'}
77
+ onclick={() => { mode = 'url'; }}
78
+ >
79
+ <LinkIcon style="width: 12px; height: 12px;" />
80
+ URL
81
+ </button>
82
+ {#if onUpload}
83
+ <button
84
+ type="button"
85
+ class="svadmin-toolbar-btn"
86
+ style="width: auto; padding: 0 10px; font-size: 12px; gap: 4px; display: flex; align-items: center;"
87
+ data-active={mode === 'upload'}
88
+ onclick={() => { mode = 'upload'; }}
89
+ >
90
+ <Upload style="width: 12px; height: 12px;" />
91
+ Upload
92
+ </button>
93
+ {/if}
94
+ </div>
95
+
96
+ {#if mode === 'url'}
97
+ <div style="display: flex; flex-direction: column; gap: 6px;">
98
+ <input
99
+ class="svadmin-link-input"
100
+ type="url"
101
+ placeholder={t('editor.image.url')}
102
+ bind:value={url}
103
+ onkeydown={handleKeydown}
104
+ style="width: 100%;"
105
+ />
106
+ <input
107
+ class="svadmin-link-input"
108
+ type="text"
109
+ placeholder="Alt (optional)"
110
+ bind:value={alt}
111
+ onkeydown={handleKeydown}
112
+ style="width: 100%;"
113
+ />
114
+ <button
115
+ type="button"
116
+ class="svadmin-toolbar-btn"
117
+ style="width: 100%; font-size: 13px; height: 34px; background: hsl(var(--primary) / 0.1); color: var(--editor-primary); font-weight: 500;"
118
+ onclick={insertByUrl}
119
+ disabled={!url.trim()}
120
+ >
121
+ {t('editor.image.apply') || t('editor.link.apply')}
122
+ </button>
123
+ </div>
124
+ {:else}
125
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
126
+ <div
127
+ style="
128
+ border: 2px dashed {dragover ? 'var(--editor-primary)' : 'var(--editor-border)'};
129
+ border-radius: 8px;
130
+ padding: 24px;
131
+ text-align: center;
132
+ cursor: pointer;
133
+ transition: border-color 0.15s ease, background 0.15s ease;
134
+ background: {dragover ? 'hsl(var(--primary) / 0.05)' : 'transparent'};
135
+ "
136
+ ondrop={handleDrop}
137
+ ondragover={handleDragOver}
138
+ ondragleave={handleDragLeave}
139
+ onclick={() => fileInput?.click()}
140
+ >
141
+ {#if uploading}
142
+ <div style="color: var(--editor-muted); font-size: 13px;">{t('editor.image.uploading')}</div>
143
+ {:else}
144
+ <ImageIcon style="width: 24px; height: 24px; margin: 0 auto 8px; color: var(--editor-muted);" />
145
+ <div style="font-size: 13px; color: var(--editor-muted);">
146
+ {t('editor.image.upload')}
147
+ </div>
148
+ {/if}
149
+ </div>
150
+ <input
151
+ bind:this={fileInput}
152
+ type="file"
153
+ accept="image/*"
154
+ multiple
155
+ style="display: none;"
156
+ onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
157
+ />
158
+ {/if}
159
+ </div>