@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.
- package/README.md +1 -1
- package/build.log +2 -2
- package/custom/MarkdownEditor.vue +553 -223
- package/custom/package-lock.json +39 -2780
- package/custom/package.json +4 -4
- package/dist/custom/MarkdownEditor.vue +553 -223
- package/dist/custom/package-lock.json +39 -2780
- package/dist/custom/package.json +4 -4
- package/dist/index.js +0 -4
- package/index.ts +0 -4
- package/package.json +1 -1
- package/types.ts +3 -0
|
@@ -1,157 +1,572 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="mb-2"></div>
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
15
|
-
import
|
|
16
|
-
import
|
|
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:
|
|
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
|
-
|
|
33
|
-
|
|
34
|
+
const debug = (...args: any[]) => console.warn('[adminforth-markdown]', ...args);
|
|
35
|
+
debug('MarkdownEditor module loaded');
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 ``;
|
|
130
226
|
}
|
|
227
|
+
|
|
228
|
+
const escapedTitle = escapeMarkdownLinkTitle(titleFinal);
|
|
229
|
+
return ``;
|
|
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:  or 
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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(``);
|
|
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
|
|
144
|
-
return null;
|
|
560
|
+
console.error('Failed to initialize editor:', error);
|
|
145
561
|
}
|
|
146
|
-
}
|
|
147
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
265
|
-
@apply bg-gray-200
|
|
266
|
-
}
|
|
616
|
+
clearImagePreviews();
|
|
267
617
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
618
|
+
removePasteListener?.();
|
|
619
|
+
removePasteListener = null;
|
|
271
620
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
621
|
+
removePasteListenerSecondary?.();
|
|
622
|
+
removePasteListenerSecondary = null;
|
|
275
623
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
624
|
+
removeGlobalPasteListener?.();
|
|
625
|
+
removeGlobalPasteListener = null;
|
|
279
626
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
627
|
+
removeGlobalKeydownListener?.();
|
|
628
|
+
removeGlobalKeydownListener = null;
|
|
283
629
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
630
|
+
for (const d of disposables) d.dispose();
|
|
631
|
+
disposables.length = 0;
|
|
287
632
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
310
|
-
background-color: #6b7280;
|
|
311
|
-
}
|
|
640
|
+
<style lang="scss">
|
|
312
641
|
|
|
313
|
-
|
|
314
|
-
|
|
642
|
+
#editor {
|
|
643
|
+
min-height: 20rem;
|
|
644
|
+
height: 42rem;
|
|
315
645
|
}
|
|
316
646
|
|
|
317
647
|
</style>
|