完善图片视频添加预览功能

This commit is contained in:
2026-07-10 14:52:26 +08:00
parent 266a7ffacd
commit 0a3534a7e0

View File

@@ -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<string, string> = {
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<string, string> = {
'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 || !/<img\b/i.test(html)) return "";
const doc = new DOMParser().parseFromString(html, "text/html");
if (!doc.body.querySelector("img[src]")) return "";
return normalizePastedMarkdown(htmlChildrenToMarkdown(doc.body));
}
/**
* Core paste handler: detects images/files in clipboard, saves them to the
* hidden resources folder, and inserts markdown image syntax at the cursor.
@@ -735,30 +884,35 @@ async function processPaste(
selectionEnd: number,
applyValue: (newValue: string, cursorPos: number) => void,
) {
const items = e.clipboardData?.items;
if (!items || items.length === 0) return;
const clipboardData = e.clipboardData;
if (!clipboardData) return;
// Collect media items (images from screenshots, or files from file-manager copy)
const mediaItems: { item: DataTransferItem }[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith('image/') || item.type.startsWith('video/') || item.kind === 'file') {
mediaItems.push({ item });
}
}
const htmlMarkdown = clipboardHtmlToMarkdown(clipboardData);
const mediaItems = clipboardData.items ? getClipboardMediaItems(clipboardData.items) : [];
const shouldInsertHtmlMarkdown = Boolean(htmlMarkdown);
const shouldSaveMedia = mediaItems.length > 0 && !shouldInsertHtmlMarkdown;
if (mediaItems.length === 0) return; // No media — let default text paste happen
if (!shouldInsertHtmlMarkdown && !shouldSaveMedia) return; // No media — let default text paste happen
const dir = getCurrentFileDirectory();
if (shouldSaveMedia && (!props.filePath || !dir)) return;
e.preventDefault();
e.stopPropagation();
if (!props.filePath) return; // No file open, nowhere to save
if (shouldInsertHtmlMarkdown) {
const newValue =
value.slice(0, selectionStart) + htmlMarkdown + value.slice(selectionEnd);
const cursorPos = selectionStart + htmlMarkdown.length;
applyValue(newValue, cursorPos);
return;
}
const dir = getCurrentFileDirectory();
if (!dir) return;
const filePath = props.filePath;
if (!filePath || !dir) return;
// Derive the hidden resources folder name from the current markdown file
const mdFileName = props.filePath.split(/[/\\]/).pop() || 'document';
const mdFileName = filePath.split(/[/\\]/).pop() || 'document';
const stem = mdFileName.replace(/\.md$/i, '');
const resourcesDir = `${dir}/.${stem}`;
const resourcesRelDir = `.${stem}`;
@@ -766,32 +920,13 @@ async function processPaste(
// Process each media item and build a replacement string
const insertParts: string[] = [];
for (const { item } of mediaItems) {
const file = item.getAsFile();
if (!file) continue;
let ext: string;
let blob: Blob;
if (item.type.startsWith('image/')) {
ext = imageExtFromMime(item.type);
blob = file;
} else if (item.type.startsWith('video/')) {
ext = item.type.split('/')[1] || 'mp4';
blob = file;
} else {
// File from file manager — derive extension from file name
const dot = file.name.lastIndexOf('.');
ext = dot >= 0 ? file.name.slice(dot + 1).toLowerCase() : 'bin';
blob = file;
}
for (const { file, ext } of mediaItems) {
const filename = generateMediaFilename(ext);
const assetPath = `${resourcesDir}/${filename}`;
const relativePath = `${resourcesRelDir}/${filename}`;
try {
const base64 = await blobToBase64(blob);
const base64 = await blobToBase64(file);
await invoke("save_media_file", { path: assetPath, data: base64 });
} catch (err) {
console.error("Failed to save pasted media:", err);