@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 {
|
|
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
|
|
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
|
|
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
|
-
}:
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
98
|
-
|
|
196
|
+
const element = editorElement;
|
|
197
|
+
if (!element) return;
|
|
99
198
|
const ext = buildExtensions();
|
|
100
199
|
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
2
|
import type { AnyExtension } from '@tiptap/core';
|
|
3
|
-
import '../
|
|
4
|
-
interface
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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=
|
|
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={
|
|
257
|
+
onchange={chooseFiles}
|
|
167
258
|
/>
|
|
168
259
|
{/if}
|
|
169
260
|
</div>
|
package/dist/extensions/index.js
CHANGED
|
@@ -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';
|
package/dist/styles/editor.css
CHANGED
|
@@ -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.
|
|
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.
|
|
40
|
+
"@svadmin/core": "^0.65.0"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@lucide/svelte": "^1.35.0",
|