完善图片视频添加预览功能
This commit is contained in:
@@ -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 `})`;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user