@svadmin/editor 0.5.16 → 0.7.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.
@@ -1,16 +1,10 @@
1
- <script lang="ts">
2
- import { definedOptions } from '@svadmin/core/options';
3
-
4
- import { Editor as TiptapEditor } from '@tiptap/core';
5
- import { fullPreset, minimalPreset, prosePreset, type EditorPresetOptions, type Lowlight } from '../extensions/index.js';
1
+ <script module lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements';
6
3
  import type { AnyExtension } from '@tiptap/core';
7
- import Toolbar from './Toolbar.svelte';
8
- import BubbleMenuComp from './BubbleMenu.svelte';
9
- import FloatingMenuComp from './FloatingMenu.svelte';
10
- import { t } from '@svadmin/core';
11
- import '../styles/editor.css';
4
+ import type { Lowlight } from '../extensions/index.js';
12
5
 
13
- interface Props {
6
+ export interface EditorProps extends Pick<HTMLAttributes<HTMLDivElement>,
7
+ 'id' | 'aria-label' | 'aria-labelledby' | 'aria-invalid' | 'aria-describedby' | 'aria-required'> {
14
8
  /** HTML string content (bindable) */
15
9
  value?: string;
16
10
  /** Placeholder text */
@@ -43,9 +37,25 @@
43
37
  minHeight?: string;
44
38
  /** Max height for the editor content area */
45
39
  maxHeight?: string;
40
+ /** 禁用编辑但保留当前 HTML 值。 */
41
+ disabled?: boolean;
46
42
  /** lowlight instance for syntax-highlighted code blocks */
47
43
  lowlight?: Lowlight;
48
44
  }
45
+ </script>
46
+
47
+ <script lang="ts">
48
+ import { definedOptions } from '@svadmin/core/options';
49
+
50
+ import { Editor as TiptapEditor } from '@tiptap/core';
51
+ import { fullPreset, minimalPreset, prosePreset, type EditorPresetOptions } from '../extensions/index.js';
52
+ import Toolbar from './Toolbar.svelte';
53
+ import BubbleMenuComp from './BubbleMenu.svelte';
54
+ import FloatingMenuComp from './FloatingMenu.svelte';
55
+ import { t } from '@svadmin/core';
56
+ import { RotateCw, X } from '@lucide/svelte';
57
+ import { untrack } from 'svelte';
58
+ import '../styles/editor.css';
49
59
 
50
60
  let {
51
61
  value = $bindable(''),
@@ -64,27 +74,116 @@
64
74
  class: className = '',
65
75
  minHeight = '200px',
66
76
  maxHeight = '600px',
77
+ id,
78
+ disabled = false,
79
+ 'aria-invalid': ariaInvalid,
80
+ 'aria-describedby': ariaDescribedBy,
81
+ 'aria-label': ariaLabel,
82
+ 'aria-labelledby': ariaLabelledBy,
83
+ 'aria-required': ariaRequired,
67
84
  lowlight,
68
- }: Props = $props();
85
+ }: EditorProps = $props();
69
86
 
70
87
  let editorElement: HTMLDivElement | undefined = $state();
71
88
  let editor: TiptapEditor | null = $state(null);
89
+ let editorGeneration = 0;
90
+ let uploadTaskId = 0;
91
+ let uploadTasks = $state<UploadTask[]>([]);
92
+ const canEdit = $derived(editable && !disabled);
93
+ const extensionPreset = $derived(preset);
94
+ const extensionPlaceholder = $derived(placeholder);
95
+ const extensionLimit = $derived(maxLength);
96
+ const extensionLowlight = $derived(lowlight);
97
+ const extensionOverride = $derived(customExtensions);
98
+ const uploadHandler = $derived(onUpload);
99
+
100
+ interface UploadTask {
101
+ id: number;
102
+ file: File;
103
+ position: number;
104
+ end: number;
105
+ attempt: number;
106
+ pending: boolean;
107
+ error: boolean;
108
+ }
109
+
110
+ $effect.pre(() => {
111
+ void canEdit;
112
+ void uploadHandler;
113
+ editorGeneration++;
114
+ uploadTasks = [];
115
+ });
116
+
117
+ function currentUpload(instance: TiptapEditor, generation: number): boolean {
118
+ return canEdit && editor === instance && !instance.isDestroyed && editorGeneration === generation;
119
+ }
120
+
121
+ function updateUploadTask(id: number, update: Partial<UploadTask>): void {
122
+ uploadTasks = uploadTasks.map(task => task.id === id ? { ...task, ...update } : task);
123
+ }
124
+
125
+ function removeUploadTask(id: number): void {
126
+ uploadTasks = uploadTasks.filter(task => task.id !== id);
127
+ }
128
+
129
+ function insertUploadedImage(instance: TiptapEditor, task: UploadTask, url: string): void {
130
+ const imageNode = instance.state.schema.nodes['image'];
131
+ if (!imageNode) return;
132
+ const safePosition = Math.max(0, Math.min(task.position, instance.state.doc.content.size));
133
+ const safeEnd = Math.max(safePosition, Math.min(task.end, instance.state.doc.content.size));
134
+ const transaction = instance.state.tr.replaceRangeWith(safePosition, safeEnd, imageNode.create({ src: url, alt: task.file.name }));
135
+ instance.view.dispatch(transaction);
136
+ }
137
+
138
+ async function startImageUpload(instance: TiptapEditor, file: File, position: number, end = position, task?: UploadTask): Promise<void> {
139
+ if (!onUpload || !currentUpload(instance, editorGeneration)) return;
140
+ const currentTask = task ? uploadTasks.find(item => item.id === task.id) : {
141
+ id: ++uploadTaskId,
142
+ file,
143
+ position,
144
+ end,
145
+ attempt: 0,
146
+ pending: false,
147
+ error: false,
148
+ };
149
+ if (!currentTask || currentTask.pending) return;
150
+ const attempt = currentTask.attempt + 1;
151
+ if (!task) uploadTasks = [...uploadTasks, currentTask];
152
+ updateUploadTask(currentTask.id, { attempt, pending: true, error: false });
153
+ const generation = editorGeneration;
154
+ const handler = onUpload;
155
+ try {
156
+ const url = await handler(file);
157
+ const active = uploadTasks.find(item => item.id === currentTask.id);
158
+ if (!active || active.attempt !== attempt || !currentUpload(instance, generation)) return;
159
+ insertUploadedImage(instance, active, url);
160
+ removeUploadTask(currentTask.id);
161
+ } catch {
162
+ const active = uploadTasks.find(item => item.id === currentTask.id);
163
+ if (!active || active.attempt !== attempt || !currentUpload(instance, generation)) return;
164
+ updateUploadTask(currentTask.id, { pending: false, error: true });
165
+ }
166
+ }
167
+
168
+ function retryImageUpload(task: UploadTask): void {
169
+ if (task.pending || !editor || !canEdit) return;
170
+ void startImageUpload(editor, task.file, task.position, task.end, task);
171
+ }
72
172
 
73
173
  // Track whether we're updating externally to prevent loops
74
174
  let isUpdatingFromExternal = false;
75
- let isUpdatingFromEditor = false;
76
175
 
77
176
  // Build extensions based on preset
78
177
  function buildExtensions(): AnyExtension[] {
79
- if (customExtensions) return customExtensions;
178
+ if (extensionOverride) return extensionOverride;
80
179
 
81
180
  const opts: EditorPresetOptions = definedOptions({
82
- placeholder,
83
- maxLength,
84
- lowlight,
181
+ placeholder: extensionPlaceholder,
182
+ maxLength: extensionLimit,
183
+ lowlight: extensionLowlight,
85
184
  });
86
185
 
87
- switch (preset) {
186
+ switch (extensionPreset) {
88
187
  case 'minimal': return minimalPreset(opts);
89
188
  case 'prose': return prosePreset(opts);
90
189
  case 'full':
@@ -94,89 +193,106 @@
94
193
 
95
194
  // Create editor when element is available
96
195
  $effect(() => {
97
- if (!editorElement) return;
98
-
196
+ const element = editorElement;
197
+ if (!element) return;
99
198
  const ext = buildExtensions();
100
199
 
101
- editor = new TiptapEditor({
102
- element: editorElement,
200
+ // 内容、反馈和禁用状态另行同步,避免普通字段更新销毁选区与编辑历史。
201
+ const instance: TiptapEditor = untrack(() => new TiptapEditor({
202
+ element,
103
203
  extensions: ext,
104
204
  content: value || '',
105
- editable,
106
- autofocus,
205
+ editable: canEdit,
206
+ autofocus: canEdit ? autofocus : false,
107
207
  editorProps: {
108
208
  attributes: {
109
209
  class: 'tiptap',
110
210
  },
111
211
  // Handle image drop
112
212
  handleDrop(view, event, slice, moved) {
213
+ if (!canEdit || editor !== instance || !instance.schema.nodes['image']) return false;
113
214
  if (!moved && event.dataTransfer?.files?.length && onUpload) {
114
215
  const files = event.dataTransfer.files;
216
+ let handled = false;
217
+ const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
115
218
  for (const file of files) {
116
219
  if (file.type.startsWith('image/')) {
117
220
  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
- const imageNode = schema.nodes['image'];
123
- if (pos && imageNode) {
124
- const node = imageNode.create({ src: url, alt: file.name });
125
- const transaction = view.state.tr.insert(pos.pos, node);
126
- view.dispatch(transaction);
127
- }
128
- }
129
- });
130
- return true;
221
+ void startImageUpload(instance, file, pos?.pos ?? instance.state.selection.from);
222
+ handled = true;
131
223
  }
132
224
  }
225
+ return handled;
133
226
  }
134
227
  return false;
135
228
  },
136
229
  // Handle image paste
137
230
  handlePaste(view, event) {
231
+ if (!canEdit || editor !== instance || !instance.schema.nodes['image']) return false;
138
232
  const items = event.clipboardData?.items;
139
233
  if (items && onUpload) {
234
+ let handled = false;
140
235
  for (const item of items) {
141
236
  if (item.type.startsWith('image/')) {
142
- event.preventDefault();
143
237
  const file = item.getAsFile();
144
238
  if (file) {
145
- onUpload(file).then((url) => {
146
- if (editor) {
147
- editor.chain().focus().setImage({ src: url, alt: file.name }).run();
148
- }
149
- });
239
+ event.preventDefault();
240
+ void startImageUpload(instance, file, instance.state.selection.from, instance.state.selection.to);
241
+ handled = true;
150
242
  }
151
- return true;
152
243
  }
153
244
  }
245
+ return handled;
154
246
  }
155
247
  return false;
156
248
  },
157
249
  },
250
+ onTransaction({ transaction, appendedTransactions }) {
251
+ const transactions = [transaction, ...appendedTransactions].filter(item => item.docChanged);
252
+ if (!transactions.length) return;
253
+ // 跟随文档交易映射插入点,不使用异步回执到达时的鼠标坐标或当前选区。
254
+ untrack(() => {
255
+ uploadTasks = uploadTasks.map(task => {
256
+ let position = task.position;
257
+ let end = task.end;
258
+ for (const item of transactions) {
259
+ for (const step of item.mapping.maps) {
260
+ let edited = false;
261
+ step.forEach((from, to) => {
262
+ if (from < end && to > position) edited = true;
263
+ });
264
+ position = step.map(position);
265
+ end = edited ? position : Math.max(position, step.map(end, -1));
266
+ }
267
+ }
268
+ return { ...task, position, end };
269
+ });
270
+ });
271
+ },
158
272
  onUpdate({ editor: e }) {
159
273
  if (isUpdatingFromExternal) return;
160
- isUpdatingFromEditor = true;
161
274
  const html = e.getHTML();
162
275
  value = html;
163
276
  onchange?.(html);
164
- // Defer reset to allow Svelte to process
165
- queueMicrotask(() => { isUpdatingFromEditor = false; });
166
277
  },
167
- });
278
+ }));
279
+ editor = instance;
168
280
 
169
281
  return () => {
170
- editor?.destroy();
282
+ instance.destroy();
283
+ editorGeneration++;
284
+ uploadTasks = [];
171
285
  editor = null;
172
286
  };
173
287
  });
174
288
 
175
289
  // Sync external value changes into the editor
176
290
  $effect(() => {
177
- if (!editor || isUpdatingFromEditor) return;
291
+ if (!editor) return;
178
292
  const currentHtml = editor.getHTML();
179
293
  if (value !== currentHtml) {
294
+ uploadTasks = [];
295
+ editorGeneration++;
180
296
  isUpdatingFromExternal = true;
181
297
  editor.commands.setContent(value || '', { emitUpdate: false });
182
298
  isUpdatingFromExternal = false;
@@ -185,9 +301,29 @@
185
301
 
186
302
  // Sync editable state
187
303
  $effect(() => {
188
- if (editor) {
189
- editor.setEditable(editable);
190
- }
304
+ if (editor) editor.setEditable(canEdit, false);
305
+ });
306
+
307
+ $effect(() => {
308
+ if (!editor) return;
309
+ editor.setOptions({
310
+ editorProps: {
311
+ ...editor.options.editorProps,
312
+ attributes: {
313
+ class: 'tiptap',
314
+ role: 'textbox',
315
+ 'aria-multiline': 'true',
316
+ 'aria-readonly': String(!canEdit),
317
+ ...(disabled ? { 'aria-disabled': 'true', tabindex: '-1' } : {}),
318
+ ...(id ? { id } : {}),
319
+ ...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
320
+ ...(ariaLabelledBy ? { 'aria-labelledby': ariaLabelledBy } : {}),
321
+ ...(ariaInvalid !== undefined ? { 'aria-invalid': String(ariaInvalid) } : {}),
322
+ ...(ariaDescribedBy ? { 'aria-describedby': ariaDescribedBy } : {}),
323
+ ...(ariaRequired !== undefined ? { 'aria-required': String(ariaRequired) } : {}),
324
+ },
325
+ },
326
+ });
191
327
  });
192
328
 
193
329
  // Character count
@@ -196,7 +332,7 @@
196
332
  </script>
197
333
 
198
334
  <div class="svadmin-editor {className}">
199
- {#if showToolbar && editor}
335
+ {#if showToolbar && editor && canEdit}
200
336
  <Toolbar {editor} {...definedOptions({ "onUpload": onUpload })} {preset} />
201
337
  {/if}
202
338
 
@@ -206,11 +342,32 @@
206
342
  bind:this={editorElement}
207
343
  ></div>
208
344
 
209
- {#if editor && showBubbleMenu && preset !== 'minimal'}
345
+ {#if uploadTasks.length}
346
+ <div class="svadmin-editor-upload-feedback" role="group" aria-label={t('editor.image.upload')}>
347
+ {#each uploadTasks as task (task.id)}
348
+ <div class="svadmin-editor-upload-item" aria-busy={task.pending}>
349
+ <span class="svadmin-editor-upload-name">{task.file.name}</span>
350
+ {#if task.error}
351
+ <span role="alert">{t('common.error')}</span>
352
+ <button type="button" class="svadmin-toolbar-btn" title={t('common.retry')} aria-label={`${t('common.retry')}: ${task.file.name}`} onclick={() => retryImageUpload(task)}>
353
+ <RotateCw aria-hidden="true" />
354
+ </button>
355
+ {:else}
356
+ <span role="status">{t('editor.image.uploading')}</span>
357
+ {/if}
358
+ <button type="button" class="svadmin-toolbar-btn" title={t('common.remove')} aria-label={`${t('common.remove')}: ${task.file.name}`} onclick={() => removeUploadTask(task.id)}>
359
+ <X aria-hidden="true" />
360
+ </button>
361
+ </div>
362
+ {/each}
363
+ </div>
364
+ {/if}
365
+
366
+ {#if editor && canEdit && showBubbleMenu && preset !== 'minimal'}
210
367
  <BubbleMenuComp {editor} />
211
368
  {/if}
212
369
 
213
- {#if editor && showFloatingMenu && preset !== 'minimal'}
370
+ {#if editor && canEdit && showFloatingMenu && preset !== 'minimal'}
214
371
  <FloatingMenuComp {editor} />
215
372
  {/if}
216
373
 
@@ -1,7 +1,7 @@
1
- import { type Lowlight } from '../extensions/index.js';
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
2
  import type { AnyExtension } from '@tiptap/core';
3
- import '../styles/editor.css';
4
- interface Props {
3
+ import type { Lowlight } from '../extensions/index.js';
4
+ export interface EditorProps extends Pick<HTMLAttributes<HTMLDivElement>, 'id' | 'aria-label' | 'aria-labelledby' | 'aria-invalid' | 'aria-describedby' | 'aria-required'> {
5
5
  /** HTML string content (bindable) */
6
6
  value?: string;
7
7
  /** Placeholder text */
@@ -34,9 +34,12 @@ interface Props {
34
34
  minHeight?: string;
35
35
  /** Max height for the editor content area */
36
36
  maxHeight?: string;
37
+ /** 禁用编辑但保留当前 HTML 值。 */
38
+ disabled?: boolean;
37
39
  /** lowlight instance for syntax-highlighted code blocks */
38
40
  lowlight?: Lowlight;
39
41
  }
40
- declare const Editor: import("svelte").Component<Props, {}, "value">;
42
+ import '../styles/editor.css';
43
+ declare const Editor: import("svelte").Component<EditorProps, {}, "value">;
41
44
  type Editor = ReturnType<typeof Editor>;
42
45
  export default Editor;
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Editor } from '@tiptap/core';
3
- import { ImageIcon, Upload, Link as LinkIcon } from '@lucide/svelte';
3
+ import { ImageIcon, Upload, Link as LinkIcon, RotateCw } from '@lucide/svelte';
4
4
  import { t } from '@svadmin/core';
5
5
 
6
6
  let { editor, onUpload, onclose } = $props<{
@@ -15,32 +15,104 @@
15
15
  let uploading = $state(false);
16
16
  let dragover = $state(false);
17
17
  let fileInput: HTMLInputElement | undefined = $state();
18
+ let uploadGeneration = 0;
19
+ let disposed = false;
20
+ let uploadError = $state(false);
21
+ let retryFiles = $state<File[]>([]);
22
+
23
+ function invalidateUpload() {
24
+ uploadGeneration++;
25
+ }
26
+
27
+ function close() {
28
+ invalidateUpload();
29
+ uploading = false;
30
+ uploadError = false;
31
+ retryFiles = [];
32
+ onclose?.();
33
+ }
34
+
35
+ function canCommit(generation: number): boolean {
36
+ return !disposed
37
+ && generation === uploadGeneration
38
+ && !editor.isDestroyed
39
+ && editor.isEditable;
40
+ }
41
+
42
+ $effect.pre(() => {
43
+ const instance = editor;
44
+ void onUpload;
45
+ disposed = false;
46
+ uploading = false;
47
+ uploadError = false;
48
+ retryFiles = [];
49
+ const handleDestroy = () => {
50
+ disposed = true;
51
+ invalidateUpload();
52
+ };
53
+ instance.on('destroy', handleDestroy);
54
+ return () => {
55
+ disposed = true;
56
+ invalidateUpload();
57
+ instance.off('destroy', handleDestroy);
58
+ };
59
+ });
18
60
 
19
61
  function insertByUrl() {
20
62
  if (!url.trim()) return;
63
+ if (disposed || editor.isDestroyed || !editor.isEditable) return;
21
64
  editor.chain().focus().setImage({ src: url, alt: alt || undefined }).run();
22
65
  url = '';
23
66
  alt = '';
24
- onclose?.();
67
+ close();
25
68
  }
26
69
 
27
- async function handleFiles(files: FileList | null) {
28
- if (!files?.length || !onUpload) return;
70
+ async function handleFiles(files: FileList | File[] | null) {
71
+ if (!files?.length || !onUpload || uploading || disposed || editor.isDestroyed || !editor.isEditable) return;
72
+ const selectedFiles = Array.from(files).filter(file => file.type.startsWith('image/'));
73
+ if (!selectedFiles.length) return;
74
+ const generation = ++uploadGeneration;
75
+ const upload = onUpload;
76
+ const instance = editor;
77
+ uploadError = false;
78
+ retryFiles = [];
29
79
  uploading = true;
80
+ let currentIndex = 0;
30
81
  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();
82
+ for (const file of selectedFiles) {
83
+ currentIndex++;
84
+ if (!canCommit(generation)) break;
85
+ const uploadedUrl = await upload(file);
86
+ if (canCommit(generation)) {
87
+ instance.chain().focus().setImage({ src: uploadedUrl, alt: file.name }).run();
88
+ } else {
89
+ break;
90
+ }
91
+ }
92
+ } catch {
93
+ if (canCommit(generation)) {
94
+ uploadError = true;
95
+ retryFiles = selectedFiles.slice(currentIndex - 1);
35
96
  }
36
- } catch (e) {
37
- console.error('Image upload failed:', e);
38
97
  } finally {
39
- uploading = false;
40
- onclose?.();
98
+ if (generation === uploadGeneration && !disposed) {
99
+ uploading = false;
100
+ if (canCommit(generation) && !uploadError) close();
101
+ }
41
102
  }
42
103
  }
43
104
 
105
+ function retryUpload() {
106
+ const files = retryFiles;
107
+ if (files.length) void handleFiles(files);
108
+ }
109
+
110
+ function chooseFiles(event: Event) {
111
+ if (!(event.currentTarget instanceof HTMLInputElement)) return;
112
+ void handleFiles(event.currentTarget.files);
113
+ event.currentTarget.value = '';
114
+ }
115
+
44
116
  function handleDrop(e: DragEvent) {
45
117
  e.preventDefault();
46
118
  dragover = false;
@@ -49,6 +121,7 @@
49
121
 
50
122
  function handleDragOver(e: DragEvent) {
51
123
  e.preventDefault();
124
+ if (uploading) return;
52
125
  dragover = true;
53
126
  }
54
127
 
@@ -57,9 +130,13 @@
57
130
  }
58
131
 
59
132
  function handleDropZoneKeydown(e: KeyboardEvent) {
133
+ if (e.key === 'Escape') {
134
+ close();
135
+ return;
136
+ }
60
137
  if (e.key === 'Enter' || e.key === ' ') {
61
138
  e.preventDefault();
62
- fileInput?.click();
139
+ if (!uploading) fileInput?.click();
63
140
  }
64
141
  }
65
142
 
@@ -69,7 +146,7 @@
69
146
  insertByUrl();
70
147
  }
71
148
  if (e.key === 'Escape') {
72
- onclose?.();
149
+ close();
73
150
  }
74
151
  }
75
152
  </script>
@@ -81,6 +158,7 @@
81
158
  class="svadmin-toolbar-btn"
82
159
  style="width: auto; padding: 0 10px; font-size: 12px; gap: 4px; display: flex; align-items: center;"
83
160
  data-active={mode === 'url'}
161
+ disabled={uploading}
84
162
  onclick={() => { mode = 'url'; }}
85
163
  >
86
164
  <LinkIcon style="width: 12px; height: 12px;" />
@@ -92,6 +170,7 @@
92
170
  class="svadmin-toolbar-btn"
93
171
  style="width: auto; padding: 0 10px; font-size: 12px; gap: 4px; display: flex; align-items: center;"
94
172
  data-active={mode === 'upload'}
173
+ disabled={uploading}
95
174
  onclick={() => { mode = 'upload'; }}
96
175
  >
97
176
  <Upload style="width: 12px; height: 12px;" />
@@ -131,7 +210,9 @@
131
210
  {:else}
132
211
  <div
133
212
  role="button"
134
- tabindex="0"
213
+ tabindex={uploading ? -1 : 0}
214
+ aria-disabled={uploading}
215
+ aria-busy={uploading}
135
216
  aria-label={t('editor.image.upload')}
136
217
  style="
137
218
  border: 2px dashed {dragover ? 'var(--editor-primary)' : 'var(--editor-border)'};
@@ -146,7 +227,7 @@
146
227
  ondragover={handleDragOver}
147
228
  ondragleave={handleDragLeave}
148
229
  onkeydown={handleDropZoneKeydown}
149
- onclick={() => fileInput?.click()}
230
+ onclick={() => { if (!uploading) fileInput?.click(); }}
150
231
  >
151
232
  {#if uploading}
152
233
  <div style="color: var(--editor-muted); font-size: 13px;">{t('editor.image.uploading')}</div>
@@ -157,13 +238,23 @@
157
238
  </div>
158
239
  {/if}
159
240
  </div>
241
+ {#if uploadError}
242
+ <div role="alert" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: 8px; color: var(--destructive); font-size: 13px;">
243
+ <span>{t('common.error')}</span>
244
+ <button type="button" class="svadmin-toolbar-btn" style="width: auto; padding: 0 8px; gap: 4px;" onclick={retryUpload} disabled={uploading || !retryFiles.length}>
245
+ <RotateCw aria-hidden="true" style="width: 12px; height: 12px;" />
246
+ {t('common.retry')}
247
+ </button>
248
+ </div>
249
+ {/if}
160
250
  <input
161
251
  bind:this={fileInput}
162
252
  type="file"
163
253
  accept="image/*"
164
254
  multiple
255
+ disabled={uploading}
165
256
  style="display: none;"
166
- onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
257
+ onchange={chooseFiles}
167
258
  />
168
259
  {/if}
169
260
  </div>
@@ -32,6 +32,10 @@ import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
32
32
  export function fullPreset(options = {}) {
33
33
  const extensions = [
34
34
  StarterKit.configure(definedOptions({
35
+ // StarterKit v3 默认自带 link/underline;预设会在下方注册已配置的变体,
36
+ // 这里必须关闭内置版本,避免重复注册同名扩展。
37
+ link: false,
38
+ underline: false,
35
39
  // Disable default code block in favor of CodeBlockLowlight
36
40
  codeBlock: options.codeHighlight ? false : undefined,
37
41
  })),
@@ -93,6 +97,9 @@ export function fullPreset(options = {}) {
93
97
  export function minimalPreset(options = {}) {
94
98
  const extensions = [
95
99
  StarterKit.configure({
100
+ // 同上:关闭 StarterKit 内置 link/underline,只保留下方注册的配置版本。
101
+ link: false,
102
+ underline: false,
96
103
  heading: { levels: [1, 2, 3] },
97
104
  codeBlock: false,
98
105
  }),
@@ -119,6 +126,9 @@ export function minimalPreset(options = {}) {
119
126
  export function prosePreset(options = {}) {
120
127
  const extensions = [
121
128
  StarterKit.configure(definedOptions({
129
+ // 同上:关闭 StarterKit 内置 link/underline,避免与下方配置版本重复。
130
+ link: false,
131
+ underline: false,
122
132
  codeBlock: options.codeHighlight ? false : undefined,
123
133
  })),
124
134
  Underline,
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * ```
14
14
  */
15
15
  export { default as Editor } from './components/Editor.svelte';
16
+ export type { EditorProps } from './components/Editor.svelte';
16
17
  export { default as Toolbar } from './components/Toolbar.svelte';
17
18
  export { default as ToolbarButton } from './components/ToolbarButton.svelte';
18
19
  export { default as ToolbarGroup } from './components/ToolbarGroup.svelte';
@@ -24,6 +24,33 @@
24
24
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 15%, transparent);
25
25
  }
26
26
 
27
+ .svadmin-editor-upload-feedback {
28
+ border-top: 1px solid var(--editor-border);
29
+ padding: 8px;
30
+ }
31
+
32
+ .svadmin-editor-upload-item {
33
+ display: flex;
34
+ flex-wrap: wrap;
35
+ align-items: center;
36
+ gap: 8px;
37
+ font-size: 13px;
38
+ }
39
+
40
+ .svadmin-editor-upload-name {
41
+ flex: 1 1 160px;
42
+ min-width: 0;
43
+ overflow-wrap: anywhere;
44
+ }
45
+
46
+ .svadmin-editor-upload-item [role="alert"] {
47
+ color: var(--destructive);
48
+ }
49
+
50
+ .svadmin-editor-upload-item [role="status"] {
51
+ color: var(--editor-muted);
52
+ }
53
+
27
54
  /* ─── Toolbar ─────────────────────────────────────────────────── */
28
55
 
29
56
  .svadmin-editor-toolbar {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/editor",
3
- "version": "0.5.16",
3
+ "version": "0.7.0",
4
4
  "description": "Modern Tiptap-based rich text editor for Svelte 5 — headless, modular, production-ready",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "peerDependencies": {
39
39
  "svelte": "^5.56.10",
40
- "@svadmin/core": "^0.61.1"
40
+ "@svadmin/core": "^0.65.0"
41
41
  },
42
42
  "dependencies": {
43
43
  "@lucide/svelte": "^1.35.0",