添加显示图片和视频功能

This commit is contained in:
2026-07-08 17:07:54 +08:00
parent 52abffc3c4
commit 7ee990f7a2
6 changed files with 375 additions and 13 deletions

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import {computed, ref, watch, onMounted, onUnmounted} from "vue";
import {listen, type EventCallback, type EventName} from "@tauri-apps/api/event";
import {invoke, isTauri} from "@tauri-apps/api/core";
import {convertFileSrc, invoke, isTauri} from "@tauri-apps/api/core";
import {getCurrentWindow} from "@tauri-apps/api/window";
import MarkdownEditor from "./components/MarkdownEditor.vue";
import DirectorySidebar from "./components/DirectorySidebar.vue";
@@ -13,8 +13,19 @@ const charCount = ref(0);
type DocumentMode = "preview" | "edit" | "mixed";
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
type WorkspaceFileType = "markdown" | "image" | "video";
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
const APP_THEME_KEY = "yurou:app-theme";
const MARKDOWN_EXTENSIONS = new Set(["md"]);
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
const VIDEO_MIME_TYPES: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
mov: "video/quicktime",
m4v: "video/mp4",
ogv: "video/ogg",
};
function isDocumentMode(value: string | null): value is DocumentMode {
return value === "preview" || value === "edit" || value === "mixed";
@@ -62,6 +73,10 @@ function applyAppTheme(theme: AppTheme) {
// File state
const currentFilePath = ref<string | null>(null);
const currentFileType = ref<WorkspaceFileType>("markdown");
const mediaPreviewSrc = ref("");
const mediaPreviewError = ref("");
const mediaPreviewKey = ref(0);
const saveStatus = ref("");
const hasDraftDocument = ref(false);
@@ -145,6 +160,11 @@ const settingsRequest = ref(0);
const settingsInitialPage = ref<"about" | "editor">("about");
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined } | null>(null);
const isWindowMaximized = ref(false);
const isMarkdownDocument = computed(() => currentFileType.value === "markdown");
const currentVideoMimeType = computed(() => {
if (!currentFilePath.value) return "";
return VIDEO_MIME_TYPES[getFileExtension(currentFilePath.value)] || "";
});
const showStartActions = computed(() => {
return !folderPath.value && !currentFilePath.value && !hasDraftDocument.value;
});
@@ -351,6 +371,8 @@ function setDocumentContent(md: string) {
function resetMarkdownEditorState() {
setDocumentContent("");
currentFilePath.value = null;
currentFileType.value = "markdown";
resetMediaPreviewState();
hasDraftDocument.value = false;
saveStatus.value = "";
showOutline.value = false;
@@ -521,6 +543,7 @@ async function loadDir(path: string): Promise<boolean> {
}
async function saveCurrentWorkspaceDocumentBeforeSwitch() {
if (!isMarkdownDocument.value) return true;
if (!currentFilePath.value && !hasDraftDocument.value) return true;
const contentToSave = getLatestEditorContent();
@@ -591,6 +614,60 @@ function getFileName(path: string): string {
return path.split(/[/\\]/).pop() || path;
}
function getFileExtension(path: string): string {
const fileName = getFileName(path);
const dotIndex = fileName.lastIndexOf(".");
if (dotIndex < 0) return "";
return fileName.slice(dotIndex + 1).toLowerCase();
}
function getWorkspaceFileType(path: string): WorkspaceFileType {
const ext = getFileExtension(path);
if (IMAGE_EXTENSIONS.has(ext)) return "image";
if (VIDEO_EXTENSIONS.has(ext)) return "video";
if (MARKDOWN_EXTENSIONS.has(ext)) return "markdown";
return "markdown";
}
function getLocalAssetSrc(path: string): string {
if (isTauri()) return convertFileSrc(path);
if (/^[A-Za-z]:[\\/]/.test(path)) return `file:///${path.replace(/\\/g, "/")}`;
if (path.startsWith("\\\\")) return `file:${path.replace(/\\/g, "/")}`;
return path;
}
function resetMediaPreviewState() {
mediaPreviewSrc.value = "";
mediaPreviewError.value = "";
mediaPreviewKey.value += 1;
}
function openMediaPreview(path: string, fileType: Extract<WorkspaceFileType, "image" | "video">) {
currentFilePath.value = path;
currentFileType.value = fileType;
mediaPreviewSrc.value = getLocalAssetSrc(path);
mediaPreviewError.value = "";
mediaPreviewKey.value += 1;
setDocumentContent("");
hasDraftDocument.value = false;
saveStatus.value = "";
showOutline.value = false;
showMoreMenu.value = false;
editorResetKey.value += 1;
}
function clearMediaPreviewError() {
mediaPreviewError.value = "";
}
function markMediaPreviewError() {
const kind = currentFileType.value === "video" ? "视频" : "图片";
mediaPreviewError.value = `无法预览此${kind}`;
}
// ---- file tree helpers ----
function findEntry(entries: DirEntry[], path: string): DirEntry | null {
for (const entry of entries) {
@@ -615,8 +692,16 @@ async function onToggleFolder(path: string) {
}
async function onOpenFile(path: string) {
const fileType = getWorkspaceFileType(path);
if (fileType === "image" || fileType === "video") {
openMediaPreview(path, fileType);
return;
}
try {
const fileContent = await invoke<string>("read_file", {path});
currentFileType.value = "markdown";
resetMediaPreviewState();
setDocumentContent(fileContent);
currentFilePath.value = path;
hasDraftDocument.value = false;
@@ -628,10 +713,13 @@ async function onOpenFile(path: string) {
function getLatestEditorContent(contentOverride?: string) {
if (typeof contentOverride === "string") return contentOverride;
if (!isMarkdownDocument.value) return content.value;
return markdownEditorRef.value?.flushPendingChanges() ?? content.value;
}
async function doSave(contentOverride?: string) {
if (!isMarkdownDocument.value) return false;
const contentToSave = getLatestEditorContent(contentOverride);
if (!currentFilePath.value) {
@@ -662,6 +750,8 @@ async function doSave(contentOverride?: string) {
}
async function doSaveAs() {
if (!isMarkdownDocument.value) return;
const contentToSave = getLatestEditorContent();
try {
@@ -680,6 +770,8 @@ async function doSaveAs() {
}
function doNew() {
currentFileType.value = "markdown";
resetMediaPreviewState();
setDocumentContent("");
currentFilePath.value = null;
hasDraftDocument.value = true;
@@ -755,7 +847,7 @@ registerTauriListener("menu-event", (event) => {
>
<!-- Left: Outline toggle -->
<div
v-if="!showStartActions"
v-if="!showStartActions && isMarkdownDocument"
class="flex items-center shrink-0"
data-no-window-drag
>
@@ -787,7 +879,7 @@ registerTauriListener("menu-event", (event) => {
data-no-window-drag
>
<!-- Document mode -->
<div class="mode-toggle" role="group" aria-label="文档模式">
<div v-if="isMarkdownDocument" class="mode-toggle" role="group" aria-label="文档模式">
<button
class="mode-toggle-btn"
:class="{ 'mode-toggle-btn-active': documentMode === 'preview' }"
@@ -815,7 +907,7 @@ registerTauriListener("menu-event", (event) => {
</div>
<!-- More menu -->
<div class="relative">
<div v-if="isMarkdownDocument" class="relative">
<button
class="toolbar-btn"
title="更多"
@@ -875,23 +967,67 @@ registerTauriListener("menu-event", (event) => {
<div class="flex-1 flex min-h-0 overflow-hidden">
<Transition name="outline-slide">
<OutlinePanel
v-if="showOutline && !showStartActions"
v-if="showOutline && !showStartActions && isMarkdownDocument"
:markdown-content="content"
@heading-click="onHeadingClick"
/>
</Transition>
<MarkdownEditor
v-if="!showStartActions"
v-if="!showStartActions && isMarkdownDocument"
:key="editorResetKey"
ref="markdownEditorRef"
:model-value="content"
:document-mode="documentMode"
:file-path="currentFilePath"
placeholder="输入 Markdown 内容... 支持标题粗体斜体代码块等"
@update:model-value="onUpdate"
@save-shortcut="doSave"
class="flex-1 w-full min-w-0"
/>
<div
v-else-if="!showStartActions && currentFileType === 'image'"
class="media-preview flex-1 min-w-0"
>
<div class="media-preview-stage">
<img
:key="mediaPreviewKey"
class="media-preview-image"
:src="mediaPreviewSrc"
:alt="getFileName(currentFilePath || '')"
@load="clearMediaPreviewError"
@error="markMediaPreviewError"
/>
<div v-if="mediaPreviewError" class="media-preview-error">
<i class="ri-image-line"></i>
<span>{{ mediaPreviewError }}</span>
</div>
</div>
</div>
<div
v-else-if="!showStartActions && currentFileType === 'video'"
class="media-preview flex-1 min-w-0"
>
<div class="media-preview-stage">
<video
:key="mediaPreviewKey"
class="media-preview-video"
controls
preload="metadata"
@loadeddata="clearMediaPreviewError"
@error="markMediaPreviewError"
>
<source
:src="mediaPreviewSrc"
:type="currentVideoMimeType || undefined"
/>
</video>
<div v-if="mediaPreviewError" class="media-preview-error">
<i class="ri-film-line"></i>
<span>{{ mediaPreviewError }}</span>
</div>
</div>
</div>
<div
v-else
class="start-actions flex-1 flex items-center justify-center px-8"
@@ -998,6 +1134,72 @@ registerTauriListener("menu-event", (event) => {
color: var(--app-control-contrast);
}
/* ── Media preview ──────────────────────────────────────────────── */
.media-preview {
overflow: auto;
background:
linear-gradient(45deg, color-mix(in srgb, var(--app-border) 46%, transparent) 25%, transparent 25%),
linear-gradient(-45deg, color-mix(in srgb, var(--app-border) 46%, transparent) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--app-border) 46%, transparent) 75%),
linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--app-border) 46%, transparent) 75%),
var(--app-bg);
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
background-size: 20px 20px;
}
.media-preview-stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
width: 100%;
padding: 32px;
}
.media-preview-image,
.media-preview-video {
display: block;
max-width: 100%;
max-height: calc(100vh - 104px);
border: 1px solid var(--app-border-strong);
border-radius: 6px;
background: var(--app-surface);
box-shadow: 0 16px 44px color-mix(in srgb, var(--app-shadow) 64%, transparent);
}
.media-preview-image {
object-fit: contain;
}
.media-preview-video {
width: min(100%, 1040px);
/*aspect-ratio: 16 / 9;*/
}
.media-preview-error {
position: absolute;
inset: 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border: 1px solid var(--app-border-strong);
border-radius: 8px;
background: color-mix(in srgb, var(--app-surface) 88%, transparent);
color: var(--app-muted);
font-size: 13px;
text-align: center;
}
.media-preview-error i {
color: var(--app-subtle);
font-size: 28px;
line-height: 1;
}
/* ── Toolbar ───────────────────────────────────────────────────── */
.titlebar-drag-surface {

View File

@@ -7,14 +7,24 @@ import {
watch,
type ComponentPublicInstance,
} from "vue";
import { unified } from "unified";
import { unified, type Plugin, type Transformer } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeSanitize from "rehype-sanitize";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { convertFileSrc, isTauri } from "@tauri-apps/api/core";
type DocumentMode = "preview" | "edit" | "mixed";
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
const VIDEO_MIME_TYPES: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
mov: "video/quicktime",
m4v: "video/mp4",
ogv: "video/ogg",
};
interface MarkdownAstNode {
position?: {
@@ -23,6 +33,20 @@ interface MarkdownAstNode {
};
}
interface MarkdownTreeNode {
type: string;
url?: string;
alt?: string | null;
children?: MarkdownTreeNode[];
data?: Record<string, unknown>;
}
interface MarkdownTree {
type: string;
children?: MarkdownTreeNode[];
data?: Record<string, unknown>;
}
interface MarkdownBlock {
id: string;
source: string;
@@ -37,12 +61,14 @@ const props = withDefaults(
placeholder?: string;
documentMode?: DocumentMode;
previewOnly?: boolean;
filePath?: string | null;
}>(),
{
modelValue: "",
placeholder: "开始写作...",
documentMode: undefined,
previewOnly: false,
filePath: null,
},
);
@@ -51,14 +77,133 @@ const emit = defineEmits<{
"save-shortcut": [value?: string];
}>();
function getFileExtension(path: string): string {
const fileName = path.split(/[/\\]/).pop() || path;
const dotIndex = fileName.lastIndexOf(".");
if (dotIndex < 0) return "";
return fileName.slice(dotIndex + 1).toLowerCase();
}
function getCurrentFileDirectory() {
if (!props.filePath) return "";
const separatorIndex = Math.max(
props.filePath.lastIndexOf("/"),
props.filePath.lastIndexOf("\\"),
);
if (separatorIndex < 0) return "";
return props.filePath.slice(0, separatorIndex);
}
function isExternalUrl(url: string) {
return /^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("//") || url.startsWith("#");
}
function normalizeLocalMediaPath(url: string) {
const trimmed = url.trim();
if (!trimmed || isExternalUrl(trimmed)) return trimmed;
if (/^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith("\\\\")) {
return trimmed;
}
const directory = getCurrentFileDirectory();
if (!directory) return trimmed;
const separator = directory.includes("\\") ? "\\" : "/";
const normalizedRelativePath = trimmed.replace(/[\\/]+/g, separator);
return `${directory}${separator}${normalizedRelativePath}`;
}
function toAssetSrc(path: string) {
if (isExternalUrl(path)) return path;
if (isTauri()) return convertFileSrc(path);
if (/^[A-Za-z]:[\\/]/.test(path)) return `file:///${path.replace(/\\/g, "/")}`;
if (path.startsWith("\\\\")) return `file:${path.replace(/\\/g, "/")}`;
return path;
}
function getVideoProperties(url: string, alt?: string | null) {
const ext = getFileExtension(url);
const properties: Record<string, string | boolean> = {
controls: true,
preload: "metadata",
src: toAssetSrc(normalizeLocalMediaPath(url)),
};
if (alt) properties.title = alt;
if (VIDEO_MIME_TYPES[ext]) properties.type = VIDEO_MIME_TYPES[ext];
return properties;
}
const remarkLocalMedia: Plugin<[], MarkdownTree> = function (): Transformer<MarkdownTree> {
return (tree) => {
visitMediaNodes(tree.children || []);
};
};
function visitMediaNodes(children: MarkdownTreeNode[]) {
for (let index = 0; index < children.length; index += 1) {
const child = children[index];
if (child.children) {
visitMediaNodes(child.children);
}
if (child.type !== "image" || !child.url) continue;
const ext = getFileExtension(child.url);
if (VIDEO_EXTENSIONS.has(ext)) {
child.data = {
...child.data,
hName: "video",
hProperties: getVideoProperties(child.url, child.alt),
hChildren: [],
};
continue;
}
if (!IMAGE_EXTENSIONS.has(ext) || isExternalUrl(child.url)) continue;
child.url = toAssetSrc(normalizeLocalMediaPath(child.url));
}
}
// ── Unified processors ─────────────────────────────────────────────
const mediaSanitizeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
img: [...(defaultSchema.attributes?.img || []), "alt", "title"],
source: [...(defaultSchema.attributes?.source || []), "src", "type"],
video: [
...(defaultSchema.attributes?.video || []),
"controls",
"preload",
"src",
"title",
"type",
],
},
protocols: {
...defaultSchema.protocols,
src: [...(defaultSchema.protocols?.src || []), "asset"],
},
tagNames: [...(defaultSchema.tagNames || []), "video"],
};
/** Full pipeline: markdown → HTML */
const md2html = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkLocalMedia)
.use(remarkRehype, { allowDangerousHtml: false })
.use(rehypeSanitize)
.use(rehypeSanitize, mediaSanitizeSchema)
.use(rehypeStringify);
const mdParser = unified().use(remarkParse).use(remarkGfm);