@adminforth/markdown 1.2.9 → 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,144 +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
- onMounted(async () => {
36
- if (!editorContainer.value) return;
37
- try {
38
- // Milkdown
39
- // console.log('props.cole', props.column)
40
- // if (props.column.components.edit.meta.pluginType === 'milkdown' || props.column.components.create.meta.pluginType === 'milkdown') {
41
- // milkdownInstance = await Editor.make()
42
- // .config((ctx) => {
43
- // ctx.set(rootCtx, editorContainer.value!);
44
- // ctx.set(defaultValueCtx, content.value);
45
- // ctx.get(listenerCtx).markdownUpdated((_ctx, markdown) => {
46
- // content.value = markdown;
47
- // emit('update:value', markdown);
48
- // });
49
- // ctx.get(listenerCtx).focus(() => {
50
- // isFocused.value = true;
51
- // });
52
-
53
- // ctx.get(listenerCtx).blur(() => {
54
- // isFocused.value = false;
55
- // });
56
- // })
57
- // .use(commonmark)
58
- // .use(gfm)
59
- // .use(listener)
60
- // .create();
61
-
62
- // console.log('Milkdown editor created');
63
- // }
64
-
65
- // Crepe
66
- if (props.column.components.edit.meta.pluginType === 'crepe' || props.column.components.create.meta.pluginType === 'crepe') {
67
- crepeInstance = await new Crepe({
68
- root: editorContainer.value,
69
- defaultValue: content.value,
70
- });
37
+ let turndownService: TurndownService | null = null;
71
38
 
72
- crepeInstance.on((listener) => {
73
- listener.markdownUpdated(async () => {
74
- let markdownContent = crepeInstance.getMarkdown();
75
- markdownContent = await replaceBlobsWithS3Urls(markdownContent);
76
- emit('update:value', markdownContent);
77
- });
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;
48
+ }
78
49
 
79
- listener.focus(() => {
80
- isFocused.value = true;
81
- });
82
- listener.blur(() => {
83
- isFocused.value = false;
84
- });
85
- });
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
+ }
63
+
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
+ }
70
+
71
+ function markdownTableLine(cells: string[]): string {
72
+ return `| ${cells.join(' | ')} |`;
73
+ }
74
+
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];
86
79
 
87
- await crepeInstance.create();
88
- console.log('Crepe editor created');
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
+ }
85
+
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);
89
92
  }
90
- } catch (error) {
91
- console.error('Failed to initialize editor:', error);
92
93
  }
93
- });
94
94
 
95
- async function replaceBlobsWithS3Urls(markdownContent: string): Promise<string> {
96
- const blobUrls = markdownContent.match(/blob:[^\s)]+/g);
97
- const base64Images = markdownContent.match(/data:image\/[^;]+;base64,[^\s)]+/g);
98
- if (blobUrls) {
99
- for (let blobUrl of blobUrls) {
100
- const file = await getFileFromBlobUrl(blobUrl);
101
- if (file) {
102
- const s3Url = await uploadFileToS3(file);
103
- if (s3Url) {
104
- markdownContent = markdownContent.replace(blobUrl, s3Url);
105
- }
106
- }
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];
107
102
  }
108
103
  }
109
- if (base64Images) {
110
- for (let base64Image of base64Images) {
111
- const file = await fetch(base64Image).then(res => res.blob()).then(blob => new File([blob], 'image.jpg', { type: blob.type }));
112
- if (file) {
113
- const s3Url = await uploadFileToS3(file);
114
- if (s3Url) {
115
- markdownContent = markdownContent.replace(base64Image, s3Url);
116
- }
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})`;
117
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 });
118
275
  }
119
276
  }
120
- 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 = [];
289
+ }
290
+
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);
121
364
  }
122
365
 
123
- async function getFileFromBlobUrl(blobUrl: string): Promise<File | null> {
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;
124
392
  try {
125
- const response = await fetch(blobUrl);
126
- const blob = await response.blob();
127
- const file = new File([blob], 'uploaded-image.jpg', { type: blob.type });
128
- 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();
129
559
  } catch (error) {
130
- console.error('Failed to get file from blob URL:', error);
131
- return null;
560
+ console.error('Failed to initialize editor:', error);
132
561
  }
133
- }
134
- async function uploadFileToS3(file: File) {
562
+ });
563
+
564
+ async function uploadFileToS3(file: File): Promise<string | undefined> {
135
565
  if (!file || !file.name) {
136
566
  console.error('File or file name is undefined');
137
567
  return;
138
568
  }
139
569
 
140
- const formData = new FormData();
141
- formData.append('image', file);
142
570
  const originalFilename = file.name.split('.').slice(0, -1).join('.');
143
571
  const originalExtension = file.name.split('.').pop();
144
572
 
@@ -164,10 +592,10 @@ async function uploadFileToS3(file: File) {
164
592
  xhr.setRequestHeader('x-amz-tagging', tagline);
165
593
  xhr.send(file);
166
594
 
167
- return new Promise((resolve, reject) => {
595
+ return new Promise<string>((resolve, reject) => {
168
596
  xhr.onload = () => {
169
597
  if (xhr.status === 200) {
170
- resolve(previewUrl);
598
+ resolve(previewUrl as string);
171
599
  } else {
172
600
  reject('Error uploading to S3');
173
601
  }
@@ -180,125 +608,40 @@ async function uploadFileToS3(file: File) {
180
608
  }
181
609
 
182
610
  onBeforeUnmount(() => {
183
- milkdownInstance?.destroy();
184
- crepeInstance?.destroy();
185
- console.log('Editor destroyed');
186
- });
187
- </script>
188
-
189
- <style lang="scss">
190
- #editor [contenteditable="true"] {
191
- @apply bg-transparent outline-none border-none shadow-none transition-none min-h-10 p-2;
192
- }
193
-
194
- #editor [contenteditable="true"].is-focused {
195
- @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;
196
- }
197
-
198
- #editor [contenteditable="true"]:not(.is-focused) {
199
- @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;
200
- }
201
-
202
- .milkdown milkdown-slash-menu {
203
- @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;
204
- }
205
-
206
- .milkdown milkdown-slash-menu .menu-groups .menu-group li.hover {
207
- @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;
208
- }
209
-
210
- .milkdown milkdown-slash-menu .tab-group ul li.selected {
211
- @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;
212
- }
213
-
214
- .milkdown-slash-menu .tab-group ul li.selected:hover {
215
- @apply bg-gray-50 border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:text-white;
216
- }
217
-
218
- .milkdown milkdown-code-block {
219
- @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;
220
- }
221
-
222
- .milkdown milkdown-code-block .cm-gutters {
223
- @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;
224
- }
225
-
226
-
227
- .editor, .milkdown {
228
- border-radius: 6px;
229
- }
230
-
231
- .ProseMirror [data-placeholder]::before {
232
- color: #6b7280;
233
- }
234
-
235
- .milkdown milkdown-block-handle .operation-item:hover {
236
- @apply bg-gray-200 dark:bg-gray-600;
237
- }
238
-
239
- .milkdown milkdown-slash-menu .tab-group ul li:hover {
240
- @apply bg-gray-200 dark:bg-gray-600;
241
- }
242
-
243
- .milkdown milkdown-toolbar {
244
- @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;
245
- }
246
-
247
- .milkdown milkdown-toolbar .toolbar-item:hover {
248
- @apply bg-gray-200 dark:bg-gray-600;
249
- }
611
+ if (imagePreviewUpdateTimer !== null) {
612
+ window.clearTimeout(imagePreviewUpdateTimer);
613
+ imagePreviewUpdateTimer = null;
614
+ }
250
615
 
251
- .milkdown milkdown-latex-inline-edit {
252
- @apply bg-gray-200
253
- }
616
+ clearImagePreviews();
254
617
 
255
- .milkdown milkdown-latex-inline-edit .container button:hover {
256
- @apply bg-gray-300 dark:bg-gray-500;
257
- }
618
+ removePasteListener?.();
619
+ removePasteListener = null;
258
620
 
259
- .milkdown milkdown-link-edit > .link-edit {
260
- @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;
261
- }
621
+ removePasteListenerSecondary?.();
622
+ removePasteListenerSecondary = null;
262
623
 
263
- .milkdown milkdown-code-block .cm-editor {
264
- @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;
265
- }
624
+ removeGlobalPasteListener?.();
625
+ removeGlobalPasteListener = null;
266
626
 
267
- .ͼo .cm-activeLineGutter {
268
- @apply bg-gray-200 dark:bg-gray-600;
269
- }
627
+ removeGlobalKeydownListener?.();
628
+ removeGlobalKeydownListener = null;
270
629
 
271
- .ͼo .cm-activeLine {
272
- @apply bg-gray-200 dark:bg-gray-600;
273
- }
630
+ for (const d of disposables) d.dispose();
631
+ disposables.length = 0;
274
632
 
275
- .cm-content {
276
- padding-left: 0px !important;
277
- padding-right: 0px !important;
278
- }
279
-
280
- .milkdown milkdown-code-block .list-wrapper {
281
- @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;
282
- }
283
-
284
- .milkdown milkdown-code-block .language-list-item:hover {
285
- @apply bg-gray-200 dark:bg-gray-600;
286
- }
287
-
288
- .milkdown milkdown-code-block .tools .language-button {
289
- @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;
290
- }
291
-
292
- .milkdown milkdown-code-block .tools .language-button:hover {
293
- @apply bg-gray-200 dark:bg-gray-600;
294
- }
633
+ editor?.dispose();
634
+ editor = null;
635
+ model?.dispose();
636
+ model = null;
637
+ });
638
+ </script>
295
639
 
296
- .milkdown::selection {
297
- background-color: #6b7280;
298
- }
640
+ <style lang="scss">
299
641
 
300
- .ͼ4 .cm-line ::selection, .ͼ4 .cm-line::selection {
301
- background-color: #6b7280 !important;
642
+ #editor {
643
+ min-height: 20rem;
644
+ height: 42rem;
302
645
  }
303
646
 
304
647
  </style>