@adminforth/markdown 1.2.10 → 1.3.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,157 +1,572 @@
1
1
  <template>
2
2
  <div class="mb-2"></div>
3
- <div ref="editorContainer" id="editor" :class="[
4
- 'text-sm rounded-lg block w-full transition-all box-border',
5
- isFocused
6
- ? 'ring-1 ring-lightPrimary border ring-lightPrimary border-lightPrimary dark:ring-darkPrimary dark:border-darkPrimary bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
7
- : 'bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white',
8
- ]"></div>
3
+ <div
4
+ ref="editorContainer"
5
+ id="editor"
6
+ :class="[
7
+ 'text-sm rounded-lg block w-full transition-all box-border overflow-hidden',
8
+ isFocused
9
+ ? 'ring-1 ring-lightPrimary border ring-lightPrimary border-lightPrimary dark:ring-darkPrimary dark:border-darkPrimary'
10
+ : 'border border-gray-300 dark:border-gray-600',
11
+ ]"
12
+ ></div>
9
13
  </template>
10
14
 
11
15
  <script setup lang="ts">
12
16
  import { ref, onMounted, onBeforeUnmount } from 'vue';
13
17
  import { callAdminForthApi } from '@/utils';
14
- import { Editor } from '@milkdown/core';
15
- import { Crepe } from '@milkdown/crepe';
16
- import type { AdminForthColumn } from '@/types/Common';
17
- import '@milkdown/crepe/theme/common/style.css';
18
- import '@milkdown/crepe/theme/frame.css';
18
+ import * as monaco from 'monaco-editor';
19
+ import TurndownService from 'turndown';
20
+ import { gfm, tables } from 'turndown-plugin-gfm';
19
21
 
20
22
  const props = defineProps<{
21
- column: AdminForthColumn,
23
+ column: any,
22
24
  record: any,
23
25
  meta: any,
24
26
  }>()
25
27
 
26
28
  const emit = defineEmits(['update:value']);
27
29
  const editorContainer = ref<HTMLElement | null>(null);
28
- const content = ref(props.record[props.column.name] || '');
30
+ const content = ref(String(props.record?.[props.column.name] ?? ''));
29
31
 
30
32
  const isFocused = ref(false);
31
33
 
32
- let milkdownInstance: Editor | null = null;
33
- let crepeInstance: Crepe | null = null;
34
+ const debug = (...args: any[]) => console.warn('[adminforth-markdown]', ...args);
35
+ debug('MarkdownEditor module loaded');
34
36
 
35
- function normalizeMarkdownForMilkdown(markdown: string): string {
36
- if (!markdown) return '';
37
- // Milkdown/Crepe’s remark parser can choke on raw HTML nodes inside list items
38
- // (e.g. `<br />` gets parsed as an `html` AST node). Convert those line breaks
39
- // back into plain markdown newlines before parsing.
40
- return markdown.replace(/<br\s*\/?\s*>/gi, '\n');
37
+ let turndownService: TurndownService | null = null;
38
+
39
+ function normalizeTableCellText(text: string): string {
40
+ let value = text;
41
+ value = value.replace(/\u00a0/g, ' ');
42
+ value = value.replace(/\r\n/g, '\n');
43
+ value = value.replace(/\r/g, '\n');
44
+ value = value.trim();
45
+ value = value.replace(/\n+/g, '<br>');
46
+ value = value.replace(/\|/g, '\\|');
47
+ return value;
41
48
  }
42
49
 
43
- onMounted(async () => {
44
- if (!editorContainer.value) return;
45
- try {
46
- // Milkdown
47
- // console.log('props.cole', props.column)
48
- // if (props.column.components.edit.meta.pluginType === 'milkdown' || props.column.components.create.meta.pluginType === 'milkdown') {
49
- // milkdownInstance = await Editor.make()
50
- // .config((ctx) => {
51
- // ctx.set(rootCtx, editorContainer.value!);
52
- // ctx.set(defaultValueCtx, content.value);
53
- // ctx.get(listenerCtx).markdownUpdated((_ctx, markdown) => {
54
- // content.value = markdown;
55
- // emit('update:value', markdown);
56
- // });
57
- // ctx.get(listenerCtx).focus(() => {
58
- // isFocused.value = true;
59
- // });
60
-
61
- // ctx.get(listenerCtx).blur(() => {
62
- // isFocused.value = false;
63
- // });
64
- // })
65
- // .use(commonmark)
66
- // .use(gfm)
67
- // .use(listener)
68
- // .create();
69
-
70
- // console.log('Milkdown editor created');
71
- // }
72
-
73
- // Crepe
74
- if (props.column.components.edit.meta.pluginType === 'crepe' || props.column.components.create.meta.pluginType === 'crepe') {
75
- const normalizedInitialValue = normalizeMarkdownForMilkdown(content.value);
76
- if (normalizedInitialValue !== content.value) {
77
- content.value = normalizedInitialValue;
78
- }
50
+ function extractRowCells(row: HTMLTableRowElement): string[] {
51
+ const cells: string[] = [];
52
+ const rowCells = Array.from(row.cells);
53
+ for (const cell of rowCells) {
54
+ const text = cell.textContent ? cell.textContent : '';
55
+ cells.push(normalizeTableCellText(text));
56
+ const span = (cell as HTMLTableCellElement).colSpan;
57
+ if (span && span > 1) {
58
+ for (let i = 1; i < span; i += 1) cells.push('');
59
+ }
60
+ }
61
+ return cells;
62
+ }
79
63
 
80
- crepeInstance = await new Crepe({
81
- root: editorContainer.value,
82
- defaultValue: normalizedInitialValue,
83
- });
64
+ function padRow(cells: string[], columnCount: number): string[] {
65
+ if (cells.length >= columnCount) return cells;
66
+ const out = cells.slice();
67
+ while (out.length < columnCount) out.push('');
68
+ return out;
69
+ }
84
70
 
85
- crepeInstance.on((listener) => {
86
- listener.markdownUpdated(async () => {
87
- let markdownContent = normalizeMarkdownForMilkdown(crepeInstance.getMarkdown());
88
- markdownContent = await replaceBlobsWithS3Urls(markdownContent);
89
- emit('update:value', markdownContent);
90
- });
71
+ function markdownTableLine(cells: string[]): string {
72
+ return `| ${cells.join(' | ')} |`;
73
+ }
91
74
 
92
- listener.focus(() => {
93
- isFocused.value = true;
94
- });
95
- listener.blur(() => {
96
- isFocused.value = false;
97
- });
98
- });
75
+ function htmlTableToMarkdown(table: HTMLTableElement): string {
76
+ const thead = table.tHead;
77
+ let headerRow: HTMLTableRowElement | null = null;
78
+ if (thead && thead.rows && thead.rows.length) headerRow = thead.rows[0];
79
+
80
+ const bodyRows: HTMLTableRowElement[] = [];
81
+ const bodies = Array.from(table.tBodies);
82
+ for (const body of bodies) {
83
+ bodyRows.push(...Array.from(body.rows));
84
+ }
99
85
 
100
- await crepeInstance.create();
101
- console.log('Crepe editor created');
86
+ // If no <tbody>, fall back to all rows not in <thead>.
87
+ if (!bodyRows.length) {
88
+ const allRows = Array.from(table.rows);
89
+ for (const row of allRows) {
90
+ if (thead && thead.contains(row)) continue;
91
+ bodyRows.push(row);
102
92
  }
103
- } catch (error) {
104
- console.error('Failed to initialize editor:', error);
105
93
  }
106
- });
107
94
 
108
- async function replaceBlobsWithS3Urls(markdownContent: string): Promise<string> {
109
- const blobUrls = markdownContent.match(/blob:[^\s)]+/g);
110
- const base64Images = markdownContent.match(/data:image\/[^;]+;base64,[^\s)]+/g);
111
- if (blobUrls) {
112
- for (let blobUrl of blobUrls) {
113
- const file = await getFileFromBlobUrl(blobUrl);
114
- if (file) {
115
- const s3Url = await uploadFileToS3(file);
116
- if (s3Url) {
117
- markdownContent = markdownContent.replace(blobUrl, s3Url);
118
- }
119
- }
95
+ // If no explicit <thead>, treat the first row as the header.
96
+ if (!headerRow) {
97
+ if (bodyRows.length) {
98
+ headerRow = bodyRows.shift() || null;
99
+ } else {
100
+ const allRows = Array.from(table.rows);
101
+ if (allRows.length) headerRow = allRows[0];
120
102
  }
121
103
  }
122
- if (base64Images) {
123
- for (let base64Image of base64Images) {
124
- const file = await fetch(base64Image).then(res => res.blob()).then(blob => new File([blob], 'image.jpg', { type: blob.type }));
125
- if (file) {
126
- const s3Url = await uploadFileToS3(file);
127
- if (s3Url) {
128
- markdownContent = markdownContent.replace(base64Image, s3Url);
129
- }
104
+
105
+ if (!headerRow) return '';
106
+
107
+ const headerCells = extractRowCells(headerRow);
108
+ const dataCells = bodyRows.map(extractRowCells);
109
+
110
+ let columnCount = headerCells.length;
111
+ for (const row of dataCells) {
112
+ if (row.length > columnCount) columnCount = row.length;
113
+ }
114
+ if (columnCount < 1) columnCount = 1;
115
+
116
+ const header = padRow(headerCells, columnCount);
117
+ const separator: string[] = [];
118
+ for (let i = 0; i < columnCount; i += 1) separator.push(':---');
119
+
120
+ const lines: string[] = [];
121
+ lines.push(markdownTableLine(header));
122
+ lines.push(markdownTableLine(separator));
123
+ for (const row of dataCells) {
124
+ lines.push(markdownTableLine(padRow(row, columnCount)));
125
+ }
126
+
127
+ return `\n\n${lines.join('\n')}\n\n`;
128
+ }
129
+
130
+ function stripOneTrailingNewline(text: string): string {
131
+ if (text.endsWith('\r\n')) return text.slice(0, -2);
132
+ if (text.endsWith('\n')) return text.slice(0, -1);
133
+ if (text.endsWith('\r')) return text.slice(0, -1);
134
+ return text;
135
+ }
136
+
137
+ function escapeMarkdownLinkTitle(text: string): string {
138
+ return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
139
+ }
140
+
141
+ function fenceForCodeBlock(text: string): string {
142
+ let maxBackticks = 0;
143
+ let current = 0;
144
+
145
+ for (let i = 0; i < text.length; i += 1) {
146
+ if (text[i] === '`') {
147
+ current += 1;
148
+ if (current > maxBackticks) maxBackticks = current;
149
+ } else {
150
+ current = 0;
151
+ }
152
+ }
153
+
154
+ let fenceLen = maxBackticks + 1;
155
+ if (fenceLen < 3) fenceLen = 3;
156
+ return '`'.repeat(fenceLen);
157
+ }
158
+
159
+ function getTurndownService(): TurndownService {
160
+ if (turndownService) return turndownService;
161
+ turndownService = new TurndownService();
162
+ // Enable GitHub Flavored Markdown features like tables.
163
+ (turndownService as any).use(gfm);
164
+ (turndownService as any).use(tables);
165
+
166
+ // Convert <pre> without nested <code> into fenced code blocks.
167
+ turndownService.addRule('pre-fenced-code', {
168
+ filter(node) {
169
+ if (!node) return false;
170
+ if (node.nodeName !== 'PRE') return false;
171
+ const el = node as HTMLElement;
172
+ return el.querySelector('code') === null;
173
+ },
174
+ replacement(_content, node) {
175
+ const el = node as HTMLElement;
176
+ const raw = el.textContent ? el.textContent : '';
177
+ const code = stripOneTrailingNewline(raw);
178
+ const fence = fenceForCodeBlock(code);
179
+ return `\n\n${fence}\n${code}\n${fence}\n\n`;
180
+ },
181
+ });
182
+
183
+ // Custom table conversion: emit classic Markdown tables with a header separator.
184
+ // This rule is added last, so it takes precedence over plugin table handling.
185
+ turndownService.addRule('table-classic-markdown', {
186
+ filter(node) {
187
+ return Boolean(node && node.nodeName === 'TABLE');
188
+ },
189
+ replacement(_content, node) {
190
+ const table = node as HTMLTableElement;
191
+ return htmlTableToMarkdown(table);
192
+ },
193
+ });
194
+
195
+ turndownService.addRule('image-with-title', {
196
+ filter(node) {
197
+ return Boolean(node && node.nodeName === 'IMG');
198
+ },
199
+ replacement(_content, node) {
200
+ const img = node as HTMLImageElement;
201
+
202
+ const srcAttr = img.getAttribute('src');
203
+ const src = srcAttr ? srcAttr.trim() : '';
204
+ if (!src) return '';
205
+
206
+ const altAttr = img.getAttribute('alt');
207
+ const titleAttr = img.getAttribute('title');
208
+
209
+ const alt = altAttr ? altAttr.trim() : '';
210
+ const title = titleAttr ? titleAttr.trim() : '';
211
+
212
+ let altFinal = '';
213
+ let titleFinal = '';
214
+
215
+ if (alt && title) {
216
+ altFinal = alt;
217
+ titleFinal = title;
218
+ } else if (alt && !title) {
219
+ altFinal = alt;
220
+ titleFinal = alt;
221
+ } else if (!alt && title) {
222
+ altFinal = title;
223
+ titleFinal = title;
224
+ } else {
225
+ return `![](${src})`;
130
226
  }
227
+
228
+ const escapedTitle = escapeMarkdownLinkTitle(titleFinal);
229
+ return `![${altFinal}](${src} "${escapedTitle}")`;
230
+ },
231
+ });
232
+
233
+ return turndownService;
234
+ }
235
+
236
+ let editor: monaco.editor.IStandaloneCodeEditor | null = null;
237
+ let model: monaco.editor.ITextModel | null = null;
238
+ const disposables: monaco.IDisposable[] = [];
239
+ let removePasteListener: (() => void) | null = null;
240
+ let removePasteListenerSecondary: (() => void) | null = null;
241
+ let removeGlobalPasteListener: (() => void) | null = null;
242
+ let removeGlobalKeydownListener: (() => void) | null = null;
243
+
244
+ type MarkdownImageRef = {
245
+ lineNumber: number;
246
+ src: string;
247
+ };
248
+
249
+ let imageViewZoneIds: string[] = [];
250
+ let imagePreviewUpdateTimer: number | null = null;
251
+
252
+ function normalizeMarkdownImageSrc(raw: string): string {
253
+ let src = raw.trim();
254
+ if (src.startsWith('<') && src.endsWith('>')) src = src.slice(1, -1).trim();
255
+ return src;
256
+ }
257
+
258
+ function findImagesInModel(textModel: monaco.editor.ITextModel): MarkdownImageRef[] {
259
+ const images: MarkdownImageRef[] = [];
260
+ const lineCount = textModel.getLineCount();
261
+
262
+ // Minimal image syntax: ![alt](src) or ![alt](src "title")
263
+ // This intentionally keeps parsing simple and line-based.
264
+ const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
265
+
266
+ for (let lineNumber = 1; lineNumber <= lineCount; lineNumber += 1) {
267
+ const line = textModel.getLineContent(lineNumber);
268
+ re.lastIndex = 0;
269
+ let match: RegExpExecArray | null;
270
+ // Allow multiple images on the same line.
271
+ while ((match = re.exec(line))) {
272
+ const src = normalizeMarkdownImageSrc(match[1] ?? '');
273
+ if (!src) continue;
274
+ images.push({ lineNumber, src });
131
275
  }
132
276
  }
133
- return markdownContent;
277
+
278
+ return images;
279
+ }
280
+
281
+ function clearImagePreviews() {
282
+ if (!editor) return;
283
+ if (!imageViewZoneIds.length) return;
284
+
285
+ editor.changeViewZones((accessor) => {
286
+ for (const zoneId of imageViewZoneIds) accessor.removeZone(zoneId);
287
+ });
288
+ imageViewZoneIds = [];
134
289
  }
135
290
 
136
- async function getFileFromBlobUrl(blobUrl: string): Promise<File | null> {
291
+ function updateImagePreviews() {
292
+ if (!editor || !model) return;
293
+ const images = findImagesInModel(model);
294
+
295
+ clearImagePreviews();
296
+
297
+ // View zones reserve vertical space and thus shift lines down.
298
+ // We keep the implementation minimal: one zone per image ref.
299
+ editor.changeViewZones((accessor) => {
300
+ const newZoneIds: string[] = [];
301
+
302
+ images.forEach((img) => {
303
+ const wrapper = document.createElement('div');
304
+ wrapper.style.padding = '6px 0';
305
+ wrapper.style.pointerEvents = 'none';
306
+
307
+ const preview = document.createElement('div');
308
+ preview.style.display = 'inline-block';
309
+ preview.style.borderRadius = '6px';
310
+ preview.style.overflow = 'hidden';
311
+ preview.style.maxWidth = '220px';
312
+ preview.style.maxHeight = '140px';
313
+
314
+ const imageEl = document.createElement('img');
315
+ imageEl.src = img.src;
316
+ imageEl.style.maxWidth = '220px';
317
+ imageEl.style.maxHeight = '140px';
318
+ imageEl.style.display = 'block';
319
+ imageEl.style.opacity = '0.95';
320
+
321
+ preview.appendChild(imageEl);
322
+ wrapper.appendChild(preview);
323
+
324
+ const zone: monaco.editor.IViewZone = {
325
+ afterLineNumber: img.lineNumber,
326
+ heightInPx: 160,
327
+ domNode: wrapper,
328
+ };
329
+
330
+ const zoneId = accessor.addZone(zone);
331
+ newZoneIds.push(zoneId);
332
+
333
+ // Once image loads, adjust zone height to the rendered node.
334
+ imageEl.onload = () => {
335
+ if (!editor) return;
336
+ const measured = wrapper.offsetHeight;
337
+ const nextHeight = Math.max(40, Math.min(200, measured || 160));
338
+ if (zone.heightInPx !== nextHeight) {
339
+ zone.heightInPx = nextHeight;
340
+ editor.changeViewZones((a) => a.layoutZone(zoneId));
341
+ }
342
+ };
343
+
344
+ imageEl.onerror = () => {
345
+ // Keep the zone small if the image can't be loaded.
346
+ if (!editor) return;
347
+ zone.heightInPx = 40;
348
+ editor.changeViewZones((a) => a.layoutZone(zoneId));
349
+ };
350
+ });
351
+
352
+ imageViewZoneIds = newZoneIds;
353
+ });
354
+ }
355
+
356
+ function scheduleImagePreviewUpdate() {
357
+ if (imagePreviewUpdateTimer !== null) {
358
+ window.clearTimeout(imagePreviewUpdateTimer);
359
+ }
360
+ imagePreviewUpdateTimer = window.setTimeout(() => {
361
+ imagePreviewUpdateTimer = null;
362
+ updateImagePreviews();
363
+ }, 120);
364
+ }
365
+
366
+ function isDarkMode(): boolean {
367
+ return document.documentElement.classList.contains('dark');
368
+ }
369
+
370
+ function insertAtCursor(text: string) {
371
+ if (!editor) return;
372
+ const selection = editor.getSelection();
373
+ if (selection) {
374
+ editor.executeEdits('insert', [{ range: selection, text, forceMoveMarkers: true }]);
375
+ return;
376
+ }
377
+ const position = editor.getPosition();
378
+ if (!position) return;
379
+ const range = new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column);
380
+ editor.executeEdits('insert', [{ range, text, forceMoveMarkers: true }]);
381
+ }
382
+
383
+ function fileFromClipboardImage(blob: Blob): File {
384
+ const type = blob.type || 'image/png';
385
+ const ext = type.split('/')[1] || 'png';
386
+ const filename = `pasted-image-${Date.now()}.${ext}`;
387
+ return new File([blob], filename, { type });
388
+ }
389
+
390
+ onMounted(async () => {
391
+ if (!editorContainer.value) return;
137
392
  try {
138
- const response = await fetch(blobUrl);
139
- const blob = await response.blob();
140
- const file = new File([blob], 'uploaded-image.jpg', { type: blob.type });
141
- return file;
393
+ monaco.editor.setTheme(isDarkMode() ? 'vs-dark' : 'vs');
394
+
395
+ model = monaco.editor.createModel(content.value, 'markdown');
396
+ editor = monaco.editor.create(editorContainer.value, {
397
+ model,
398
+ language: 'markdown',
399
+ automaticLayout: true,
400
+ });
401
+
402
+ debug('Monaco editor created', {
403
+ hasUploadPluginInstanceId: Boolean(props.meta?.uploadPluginInstanceId),
404
+ });
405
+
406
+ disposables.push(
407
+ editor.onDidChangeModelContent(() => {
408
+ const markdown = model?.getValue() ?? '';
409
+ content.value = markdown;
410
+ emit('update:value', markdown);
411
+
412
+ // Keep image previews in sync with markdown edits.
413
+ scheduleImagePreviewUpdate();
414
+ }),
415
+ );
416
+
417
+ disposables.push(
418
+ editor.onDidFocusEditorText(() => {
419
+ isFocused.value = true;
420
+ }),
421
+ );
422
+ disposables.push(
423
+ editor.onDidBlurEditorText(() => {
424
+ isFocused.value = false;
425
+ }),
426
+ );
427
+
428
+ const domNode = editor.getDomNode();
429
+ // NOTE: Monaco may stop propagation at document-level capture, so editor DOM listeners
430
+ // may never fire. We'll still attach them, but the real handling is done in the
431
+ // global (document capture) paste listener below.
432
+ if (domNode) {
433
+ const noopPaste = () => {};
434
+ domNode.addEventListener('paste', noopPaste, true);
435
+ removePasteListener = () => domNode.removeEventListener('paste', noopPaste, true);
436
+ }
437
+ if (editorContainer.value) {
438
+ const noopPaste = () => {};
439
+ editorContainer.value.addEventListener('paste', noopPaste, true);
440
+ removePasteListenerSecondary = () => editorContainer.value?.removeEventListener('paste', noopPaste, true);
441
+ }
442
+
443
+ // Global listeners for diagnostics: if these don't fire,
444
+ // the component isn't running or logs are being stripped.
445
+ const onGlobalPaste = async (e: ClipboardEvent) => {
446
+ if ((e as any).__adminforthMarkdownHandled) return;
447
+ (e as any).__adminforthMarkdownHandled = true;
448
+
449
+ const targetEl = e.target as HTMLElement | null;
450
+ const dt = e.clipboardData;
451
+ debug('GLOBAL paste', {
452
+ target: targetEl?.tagName,
453
+ hasClipboardData: Boolean(dt),
454
+ types: dt ? Array.from(dt.types) : [],
455
+ items: dt ? dt.items.length : 0,
456
+ files: dt ? dt.files.length : 0,
457
+ editorHasTextFocus: Boolean(editor?.hasTextFocus?.()),
458
+ });
459
+
460
+ if (!editor || !domNode) return;
461
+ if (!targetEl || !domNode.contains(targetEl)) return;
462
+ if (!(editor.hasTextFocus?.() || isFocused.value)) return;
463
+ if (!dt) return;
464
+
465
+ const imageBlobs: Blob[] = [];
466
+
467
+ for (const item of Array.from(dt.items)) {
468
+ debug('clipboard item', { kind: item.kind, type: item.type });
469
+ if (item.kind === 'file' && item.type.startsWith('image/')) {
470
+ const blob = item.getAsFile();
471
+ if (blob) imageBlobs.push(blob);
472
+ }
473
+ }
474
+
475
+ if (!imageBlobs.length && dt.files?.length) {
476
+ for (const file of Array.from(dt.files)) {
477
+ debug('clipboard file', { name: file.name, type: file.type, size: file.size });
478
+ if (file.type?.startsWith('image/')) {
479
+ imageBlobs.push(file);
480
+ }
481
+ }
482
+ }
483
+
484
+ if (imageBlobs.length) {
485
+ if (!props.meta?.uploadPluginInstanceId) {
486
+ console.error('[adminforth-markdown] uploadPluginInstanceId is missing; cannot upload pasted image.');
487
+ return;
488
+ }
489
+
490
+ e.preventDefault();
491
+ e.stopPropagation();
492
+
493
+ editor.focus();
494
+ debug('uploading pasted images', { count: imageBlobs.length });
495
+
496
+ const markdownTags: string[] = [];
497
+ for (const blob of imageBlobs) {
498
+ const file = blob instanceof File ? blob : fileFromClipboardImage(blob);
499
+ try {
500
+ const url = await uploadFileToS3(file);
501
+ debug('upload result', { url });
502
+ if (typeof url === 'string' && url.length) {
503
+ markdownTags.push(`![](${url})`);
504
+ }
505
+ } catch (err) {
506
+ console.error('[adminforth-markdown] upload failed', err);
507
+ }
508
+ }
509
+
510
+ if (markdownTags.length) {
511
+ insertAtCursor(`${markdownTags.join('\n')}\n`);
512
+ }
513
+ return;
514
+ }
515
+
516
+ const html = dt.getData('text/html');
517
+ if (html && html.trim()) {
518
+ e.preventDefault();
519
+ e.stopPropagation();
520
+
521
+ editor.focus();
522
+ try {
523
+ const markdown = getTurndownService().turndown(html);
524
+ if (markdown && markdown.trim()) {
525
+ insertAtCursor(markdown);
526
+ } else {
527
+ const text = dt.getData('text/plain');
528
+ if (text) insertAtCursor(text);
529
+ }
530
+ } catch (err) {
531
+ console.error('[adminforth-markdown] failed to convert HTML clipboard to markdown', err);
532
+ const text = dt.getData('text/plain');
533
+ if (text) insertAtCursor(text);
534
+ }
535
+ }
536
+ };
537
+
538
+ // Use document capture only (avoid duplicates).
539
+ document.addEventListener('paste', onGlobalPaste, true);
540
+ removeGlobalPasteListener = () => {
541
+ document.removeEventListener('paste', onGlobalPaste, true);
542
+ };
543
+
544
+ const onGlobalKeydown = (e: KeyboardEvent) => {
545
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V')) {
546
+ debug('GLOBAL keydown Ctrl+V', {
547
+ target: (e.target as HTMLElement | null)?.tagName,
548
+ editorHasTextFocus: Boolean(editor?.hasTextFocus?.()),
549
+ });
550
+ }
551
+ };
552
+ document.addEventListener('keydown', onGlobalKeydown, true);
553
+ removeGlobalKeydownListener = () => {
554
+ document.removeEventListener('keydown', onGlobalKeydown, true);
555
+ };
556
+
557
+ // Initial render of previews.
558
+ scheduleImagePreviewUpdate();
142
559
  } catch (error) {
143
- console.error('Failed to get file from blob URL:', error);
144
- return null;
560
+ console.error('Failed to initialize editor:', error);
145
561
  }
146
- }
147
- async function uploadFileToS3(file: File) {
562
+ });
563
+
564
+ async function uploadFileToS3(file: File): Promise<string | undefined> {
148
565
  if (!file || !file.name) {
149
566
  console.error('File or file name is undefined');
150
567
  return;
151
568
  }
152
569
 
153
- const formData = new FormData();
154
- formData.append('image', file);
155
570
  const originalFilename = file.name.split('.').slice(0, -1).join('.');
156
571
  const originalExtension = file.name.split('.').pop();
157
572
 
@@ -177,10 +592,10 @@ async function uploadFileToS3(file: File) {
177
592
  xhr.setRequestHeader('x-amz-tagging', tagline);
178
593
  xhr.send(file);
179
594
 
180
- return new Promise((resolve, reject) => {
595
+ return new Promise<string>((resolve, reject) => {
181
596
  xhr.onload = () => {
182
597
  if (xhr.status === 200) {
183
- resolve(previewUrl);
598
+ resolve(previewUrl as string);
184
599
  } else {
185
600
  reject('Error uploading to S3');
186
601
  }
@@ -193,125 +608,40 @@ async function uploadFileToS3(file: File) {
193
608
  }
194
609
 
195
610
  onBeforeUnmount(() => {
196
- milkdownInstance?.destroy();
197
- crepeInstance?.destroy();
198
- console.log('Editor destroyed');
199
- });
200
- </script>
201
-
202
- <style lang="scss">
203
- #editor [contenteditable="true"] {
204
- @apply bg-transparent outline-none border-none shadow-none transition-none min-h-10 p-2;
205
- }
206
-
207
- #editor [contenteditable="true"].is-focused {
208
- @apply ring-1 ring-lightPrimary border ring-lightPrimary border-lightPrimary bg-white text-gray-900 dark:ring-darkPrimary dark:border-darkPrimary dark:bg-gray-700 dark:text-white;
209
- }
210
-
211
- #editor [contenteditable="true"]:not(.is-focused) {
212
- @apply bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white;
213
- }
214
-
215
- .milkdown milkdown-slash-menu {
216
- @apply bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white z-10;
217
- }
218
-
219
- .milkdown milkdown-slash-menu .menu-groups .menu-group li.hover {
220
- @apply bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white;
221
- }
222
-
223
- .milkdown milkdown-slash-menu .tab-group ul li.selected {
224
- @apply bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white;
225
- }
226
-
227
- .milkdown-slash-menu .tab-group ul li.selected:hover {
228
- @apply bg-gray-50 border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:text-white;
229
- }
230
-
231
- .milkdown milkdown-code-block {
232
- @apply bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white;
233
- }
234
-
235
- .milkdown milkdown-code-block .cm-gutters {
236
- @apply bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white;
237
- }
238
-
239
-
240
- .editor, .milkdown {
241
- border-radius: 6px;
242
- }
243
-
244
- .ProseMirror [data-placeholder]::before {
245
- color: #6b7280;
246
- }
247
-
248
- .milkdown milkdown-block-handle .operation-item:hover {
249
- @apply bg-gray-200 dark:bg-gray-600;
250
- }
251
-
252
- .milkdown milkdown-slash-menu .tab-group ul li:hover {
253
- @apply bg-gray-200 dark:bg-gray-600;
254
- }
255
-
256
- .milkdown milkdown-toolbar {
257
- @apply bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white;
258
- }
259
-
260
- .milkdown milkdown-toolbar .toolbar-item:hover {
261
- @apply bg-gray-200 dark:bg-gray-600;
262
- }
611
+ if (imagePreviewUpdateTimer !== null) {
612
+ window.clearTimeout(imagePreviewUpdateTimer);
613
+ imagePreviewUpdateTimer = null;
614
+ }
263
615
 
264
- .milkdown milkdown-latex-inline-edit {
265
- @apply bg-gray-200
266
- }
616
+ clearImagePreviews();
267
617
 
268
- .milkdown milkdown-latex-inline-edit .container button:hover {
269
- @apply bg-gray-300 dark:bg-gray-500;
270
- }
618
+ removePasteListener?.();
619
+ removePasteListener = null;
271
620
 
272
- .milkdown milkdown-link-edit > .link-edit {
273
- @apply bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white;
274
- }
621
+ removePasteListenerSecondary?.();
622
+ removePasteListenerSecondary = null;
275
623
 
276
- .milkdown milkdown-code-block .cm-editor {
277
- @apply bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white;
278
- }
624
+ removeGlobalPasteListener?.();
625
+ removeGlobalPasteListener = null;
279
626
 
280
- .ͼo .cm-activeLineGutter {
281
- @apply bg-gray-200 dark:bg-gray-600;
282
- }
627
+ removeGlobalKeydownListener?.();
628
+ removeGlobalKeydownListener = null;
283
629
 
284
- .ͼo .cm-activeLine {
285
- @apply bg-gray-200 dark:bg-gray-600;
286
- }
630
+ for (const d of disposables) d.dispose();
631
+ disposables.length = 0;
287
632
 
288
- .cm-content {
289
- padding-left: 0px !important;
290
- padding-right: 0px !important;
291
- }
292
-
293
- .milkdown milkdown-code-block .list-wrapper {
294
- @apply bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white;
295
- }
296
-
297
- .milkdown milkdown-code-block .language-list-item:hover {
298
- @apply bg-gray-200 dark:bg-gray-600;
299
- }
300
-
301
- .milkdown milkdown-code-block .tools .language-button {
302
- @apply bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white;
303
- }
304
-
305
- .milkdown milkdown-code-block .tools .language-button:hover {
306
- @apply bg-gray-200 dark:bg-gray-600;
307
- }
633
+ editor?.dispose();
634
+ editor = null;
635
+ model?.dispose();
636
+ model = null;
637
+ });
638
+ </script>
308
639
 
309
- .milkdown::selection {
310
- background-color: #6b7280;
311
- }
640
+ <style lang="scss">
312
641
 
313
- .ͼ4 .cm-line ::selection, .ͼ4 .cm-line::selection {
314
- background-color: #6b7280 !important;
642
+ #editor {
643
+ min-height: 20rem;
644
+ height: 42rem;
315
645
  }
316
646
 
317
647
  </style>