完善图片视频添加预览功能
This commit is contained in:
@@ -16,7 +16,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|||||||
import { convertFileSrc, invoke, isTauri } from "@tauri-apps/api/core";
|
import { convertFileSrc, invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
type DocumentMode = "preview" | "edit" | "mixed";
|
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_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
|
||||||
const VIDEO_MIME_TYPES: Record<string, string> = {
|
const VIDEO_MIME_TYPES: Record<string, string> = {
|
||||||
mp4: "video/mp4",
|
mp4: "video/mp4",
|
||||||
@@ -78,7 +80,8 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
function getFileExtension(path: string): string {
|
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(".");
|
const dotIndex = fileName.lastIndexOf(".");
|
||||||
if (dotIndex < 0) return "";
|
if (dotIndex < 0) return "";
|
||||||
|
|
||||||
@@ -724,6 +727,152 @@ function imageExtFromMime(mime: string): string {
|
|||||||
return map[mime] || 'png';
|
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
|
* Core paste handler: detects images/files in clipboard, saves them to the
|
||||||
* hidden resources folder, and inserts markdown image syntax at the cursor.
|
* hidden resources folder, and inserts markdown image syntax at the cursor.
|
||||||
@@ -735,30 +884,35 @@ async function processPaste(
|
|||||||
selectionEnd: number,
|
selectionEnd: number,
|
||||||
applyValue: (newValue: string, cursorPos: number) => void,
|
applyValue: (newValue: string, cursorPos: number) => void,
|
||||||
) {
|
) {
|
||||||
const items = e.clipboardData?.items;
|
const clipboardData = e.clipboardData;
|
||||||
if (!items || items.length === 0) return;
|
if (!clipboardData) return;
|
||||||
|
|
||||||
// Collect media items (images from screenshots, or files from file-manager copy)
|
const htmlMarkdown = clipboardHtmlToMarkdown(clipboardData);
|
||||||
const mediaItems: { item: DataTransferItem }[] = [];
|
const mediaItems = clipboardData.items ? getClipboardMediaItems(clipboardData.items) : [];
|
||||||
for (let i = 0; i < items.length; i++) {
|
const shouldInsertHtmlMarkdown = Boolean(htmlMarkdown);
|
||||||
const item = items[i];
|
const shouldSaveMedia = mediaItems.length > 0 && !shouldInsertHtmlMarkdown;
|
||||||
if (item.type.startsWith('image/') || item.type.startsWith('video/') || item.kind === 'file') {
|
|
||||||
mediaItems.push({ item });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.preventDefault();
|
||||||
e.stopPropagation();
|
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();
|
const filePath = props.filePath;
|
||||||
if (!dir) return;
|
if (!filePath || !dir) return;
|
||||||
|
|
||||||
// Derive the hidden resources folder name from the current markdown file
|
// 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 stem = mdFileName.replace(/\.md$/i, '');
|
||||||
const resourcesDir = `${dir}/.${stem}`;
|
const resourcesDir = `${dir}/.${stem}`;
|
||||||
const resourcesRelDir = `.${stem}`;
|
const resourcesRelDir = `.${stem}`;
|
||||||
@@ -766,32 +920,13 @@ async function processPaste(
|
|||||||
// Process each media item and build a replacement string
|
// Process each media item and build a replacement string
|
||||||
const insertParts: string[] = [];
|
const insertParts: string[] = [];
|
||||||
|
|
||||||
for (const { item } of mediaItems) {
|
for (const { file, ext } 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = generateMediaFilename(ext);
|
const filename = generateMediaFilename(ext);
|
||||||
const assetPath = `${resourcesDir}/${filename}`;
|
const assetPath = `${resourcesDir}/${filename}`;
|
||||||
const relativePath = `${resourcesRelDir}/${filename}`;
|
const relativePath = `${resourcesRelDir}/${filename}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const base64 = await blobToBase64(blob);
|
const base64 = await blobToBase64(file);
|
||||||
await invoke("save_media_file", { path: assetPath, data: base64 });
|
await invoke("save_media_file", { path: assetPath, data: base64 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save pasted media:", err);
|
console.error("Failed to save pasted media:", err);
|
||||||
|
|||||||
Reference in New Issue
Block a user