diff --git a/src/components/MarkdownEditor.vue b/src/components/MarkdownEditor.vue index e972a6b..f4853f9 100644 --- a/src/components/MarkdownEditor.vue +++ b/src/components/MarkdownEditor.vue @@ -16,7 +16,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import { convertFileSrc, invoke, isTauri } from "@tauri-apps/api/core"; type DocumentMode = "preview" | "edit" | "mixed"; -const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]); +type ClipboardMediaKind = "image" | "video"; + +const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "svg"]); const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]); const VIDEO_MIME_TYPES: Record = { mp4: "video/mp4", @@ -78,7 +80,8 @@ const emit = defineEmits<{ }>(); function getFileExtension(path: string): string { - const fileName = path.split(/[/\\]/).pop() || path; + const cleanPath = path.split(/[?#]/, 1)[0] || path; + const fileName = cleanPath.split(/[/\\]/).pop() || cleanPath; const dotIndex = fileName.lastIndexOf("."); if (dotIndex < 0) return ""; @@ -724,6 +727,152 @@ function imageExtFromMime(mime: string): string { return map[mime] || 'png'; } +function videoExtFromMime(mime: string): string { + const map: Record = { + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', + 'video/ogg': 'ogv', + }; + return map[mime] || 'mp4'; +} + +function clipboardMediaKindFromMime(mime: string): ClipboardMediaKind | null { + if (mime.startsWith('image/')) return 'image'; + if (mime.startsWith('video/')) return 'video'; + return null; +} + +function clipboardMediaKindFromExtension(ext: string): ClipboardMediaKind | null { + if (IMAGE_EXTENSIONS.has(ext)) return 'image'; + if (VIDEO_EXTENSIONS.has(ext)) return 'video'; + return null; +} + +function getClipboardMediaItems(items: DataTransferItemList) { + const mediaItems: { + file: File; + kind: ClipboardMediaKind; + ext: string; + }[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind !== 'file') continue; + + const file = item.getAsFile(); + if (!file) continue; + + const mime = item.type || file.type; + const ext = getFileExtension(file.name); + const kind = clipboardMediaKindFromMime(mime) ?? clipboardMediaKindFromExtension(ext); + if (!kind) continue; + + mediaItems.push({ + file, + kind, + ext: kind === 'image' + ? (mime ? imageExtFromMime(mime) : ext || 'png') + : (mime ? videoExtFromMime(mime) : ext || 'mp4'), + }); + } + + return mediaItems; +} + +function escapeMarkdownAlt(value: string) { + return value.replace(/[\r\n]+/g, " ").replace(/[[\]]/g, ""); +} + +function formatMarkdownDestination(value: string) { + const trimmed = value.trim(); + if (/[\s()<>]/.test(trimmed)) return `<${trimmed.replace(/>/g, "%3E")}>`; + return trimmed; +} + +function htmlImageToMarkdown(element: HTMLImageElement) { + const src = element.getAttribute("src")?.trim() || ""; + if (!src || /^(data|blob|cid):/i.test(src)) return ""; + + const alt = escapeMarkdownAlt(element.getAttribute("alt") || ""); + return `![${alt}](${formatMarkdownDestination(src)})`; +} + +function htmlChildrenToMarkdown(node: Node): string { + return Array.from(node.childNodes).map(htmlNodeToMarkdown).join(""); +} + +function blockMarkdown(value: string) { + const trimmed = value.trim(); + return trimmed ? `\n\n${trimmed}\n\n` : ""; +} + +function htmlNodeToMarkdown(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) { + return (node.textContent || "").replace(/\s+/g, " "); + } + + if (node.nodeType !== Node.ELEMENT_NODE) return ""; + + const element = node as HTMLElement; + const tagName = element.tagName.toLowerCase(); + + if (tagName === "img") { + return htmlImageToMarkdown(element as HTMLImageElement); + } + + if (tagName === "br") return "\n"; + + const children = htmlChildrenToMarkdown(element); + switch (tagName) { + case "p": + case "div": + case "section": + case "article": + case "header": + case "footer": + case "figure": + return blockMarkdown(children); + case "li": + return `- ${children.trim()}\n`; + case "ul": + case "ol": + return blockMarkdown(children); + case "strong": + case "b": + return children.trim() ? `**${children.trim()}**` : ""; + case "em": + case "i": + return children.trim() ? `*${children.trim()}*` : ""; + case "code": + return children.trim() ? `\`${children.trim().replace(/`/g, "\\`")}\`` : ""; + case "a": { + const href = element.getAttribute("href")?.trim(); + const label = children.trim() || href || ""; + return href ? `[${label}](${formatMarkdownDestination(href)})` : label; + } + default: + return children; + } +} + +function normalizePastedMarkdown(value: string) { + return value + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function clipboardHtmlToMarkdown(data: DataTransfer) { + const html = data.getData("text/html"); + if (!html || !/