添加显示图片和视频功能
This commit is contained in:
7
src-tauri/Cargo.lock
generated
7
src-tauri/Cargo.lock
generated
@@ -1465,6 +1465,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -3291,6 +3297,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"http-range",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["protocol-asset"] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
||||||
use tauri::Emitter;
|
use tauri::{Emitter, Manager};
|
||||||
use tauri_plugin_dialog::DialogExt;
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
@@ -43,8 +43,12 @@ fn pick_folder(app: tauri::AppHandle) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn read_dir(path: String) -> Result<Vec<DirEntry>, String> {
|
fn read_dir(app: tauri::AppHandle, path: String) -> Result<Vec<DirEntry>, String> {
|
||||||
let entries = fs::read_dir(&path).map_err(|e| format!("无法读取目录: {}", e))?;
|
let entries = fs::read_dir(&path).map_err(|e| format!("无法读取目录: {}", e))?;
|
||||||
|
app.asset_protocol_scope()
|
||||||
|
.allow_directory(&path, true)
|
||||||
|
.map_err(|e| format!("无法授权媒体预览目录: {}", e))?;
|
||||||
|
|
||||||
let allowed_extensions = [
|
let allowed_extensions = [
|
||||||
"md", "png", "jpg", "jpeg", "gif", "webp", "avif", "mp4", "webm", "mov", "m4v", "ogv",
|
"md", "png", "jpg", "jpeg", "gif", "webp", "avif", "mp4", "webm", "mov", "m4v", "ogv",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,7 +21,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null,
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
214
src/App.vue
214
src/App.vue
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, watch, onMounted, onUnmounted} from "vue";
|
import {computed, ref, watch, onMounted, onUnmounted} from "vue";
|
||||||
import {listen, type EventCallback, type EventName} from "@tauri-apps/api/event";
|
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 {getCurrentWindow} from "@tauri-apps/api/window";
|
||||||
import MarkdownEditor from "./components/MarkdownEditor.vue";
|
import MarkdownEditor from "./components/MarkdownEditor.vue";
|
||||||
import DirectorySidebar from "./components/DirectorySidebar.vue";
|
import DirectorySidebar from "./components/DirectorySidebar.vue";
|
||||||
@@ -13,8 +13,19 @@ const charCount = ref(0);
|
|||||||
|
|
||||||
type DocumentMode = "preview" | "edit" | "mixed";
|
type DocumentMode = "preview" | "edit" | "mixed";
|
||||||
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
|
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
|
||||||
|
type WorkspaceFileType = "markdown" | "image" | "video";
|
||||||
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
|
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
|
||||||
const APP_THEME_KEY = "yurou:app-theme";
|
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 {
|
function isDocumentMode(value: string | null): value is DocumentMode {
|
||||||
return value === "preview" || value === "edit" || value === "mixed";
|
return value === "preview" || value === "edit" || value === "mixed";
|
||||||
@@ -62,6 +73,10 @@ function applyAppTheme(theme: AppTheme) {
|
|||||||
|
|
||||||
// File state
|
// File state
|
||||||
const currentFilePath = ref<string | null>(null);
|
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 saveStatus = ref("");
|
||||||
const hasDraftDocument = ref(false);
|
const hasDraftDocument = ref(false);
|
||||||
|
|
||||||
@@ -145,6 +160,11 @@ const settingsRequest = ref(0);
|
|||||||
const settingsInitialPage = ref<"about" | "editor">("about");
|
const settingsInitialPage = ref<"about" | "editor">("about");
|
||||||
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined } | null>(null);
|
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined } | null>(null);
|
||||||
const isWindowMaximized = ref(false);
|
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(() => {
|
const showStartActions = computed(() => {
|
||||||
return !folderPath.value && !currentFilePath.value && !hasDraftDocument.value;
|
return !folderPath.value && !currentFilePath.value && !hasDraftDocument.value;
|
||||||
});
|
});
|
||||||
@@ -351,6 +371,8 @@ function setDocumentContent(md: string) {
|
|||||||
function resetMarkdownEditorState() {
|
function resetMarkdownEditorState() {
|
||||||
setDocumentContent("");
|
setDocumentContent("");
|
||||||
currentFilePath.value = null;
|
currentFilePath.value = null;
|
||||||
|
currentFileType.value = "markdown";
|
||||||
|
resetMediaPreviewState();
|
||||||
hasDraftDocument.value = false;
|
hasDraftDocument.value = false;
|
||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
showOutline.value = false;
|
showOutline.value = false;
|
||||||
@@ -521,6 +543,7 @@ async function loadDir(path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveCurrentWorkspaceDocumentBeforeSwitch() {
|
async function saveCurrentWorkspaceDocumentBeforeSwitch() {
|
||||||
|
if (!isMarkdownDocument.value) return true;
|
||||||
if (!currentFilePath.value && !hasDraftDocument.value) return true;
|
if (!currentFilePath.value && !hasDraftDocument.value) return true;
|
||||||
|
|
||||||
const contentToSave = getLatestEditorContent();
|
const contentToSave = getLatestEditorContent();
|
||||||
@@ -591,6 +614,60 @@ function getFileName(path: string): string {
|
|||||||
return path.split(/[/\\]/).pop() || path;
|
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 ----
|
// ---- file tree helpers ----
|
||||||
function findEntry(entries: DirEntry[], path: string): DirEntry | null {
|
function findEntry(entries: DirEntry[], path: string): DirEntry | null {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -615,8 +692,16 @@ async function onToggleFolder(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onOpenFile(path: string) {
|
async function onOpenFile(path: string) {
|
||||||
|
const fileType = getWorkspaceFileType(path);
|
||||||
|
if (fileType === "image" || fileType === "video") {
|
||||||
|
openMediaPreview(path, fileType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileContent = await invoke<string>("read_file", {path});
|
const fileContent = await invoke<string>("read_file", {path});
|
||||||
|
currentFileType.value = "markdown";
|
||||||
|
resetMediaPreviewState();
|
||||||
setDocumentContent(fileContent);
|
setDocumentContent(fileContent);
|
||||||
currentFilePath.value = path;
|
currentFilePath.value = path;
|
||||||
hasDraftDocument.value = false;
|
hasDraftDocument.value = false;
|
||||||
@@ -628,10 +713,13 @@ async function onOpenFile(path: string) {
|
|||||||
|
|
||||||
function getLatestEditorContent(contentOverride?: string) {
|
function getLatestEditorContent(contentOverride?: string) {
|
||||||
if (typeof contentOverride === "string") return contentOverride;
|
if (typeof contentOverride === "string") return contentOverride;
|
||||||
|
if (!isMarkdownDocument.value) return content.value;
|
||||||
return markdownEditorRef.value?.flushPendingChanges() ?? content.value;
|
return markdownEditorRef.value?.flushPendingChanges() ?? content.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSave(contentOverride?: string) {
|
async function doSave(contentOverride?: string) {
|
||||||
|
if (!isMarkdownDocument.value) return false;
|
||||||
|
|
||||||
const contentToSave = getLatestEditorContent(contentOverride);
|
const contentToSave = getLatestEditorContent(contentOverride);
|
||||||
|
|
||||||
if (!currentFilePath.value) {
|
if (!currentFilePath.value) {
|
||||||
@@ -662,6 +750,8 @@ async function doSave(contentOverride?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doSaveAs() {
|
async function doSaveAs() {
|
||||||
|
if (!isMarkdownDocument.value) return;
|
||||||
|
|
||||||
const contentToSave = getLatestEditorContent();
|
const contentToSave = getLatestEditorContent();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -680,6 +770,8 @@ async function doSaveAs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function doNew() {
|
function doNew() {
|
||||||
|
currentFileType.value = "markdown";
|
||||||
|
resetMediaPreviewState();
|
||||||
setDocumentContent("");
|
setDocumentContent("");
|
||||||
currentFilePath.value = null;
|
currentFilePath.value = null;
|
||||||
hasDraftDocument.value = true;
|
hasDraftDocument.value = true;
|
||||||
@@ -755,7 +847,7 @@ registerTauriListener("menu-event", (event) => {
|
|||||||
>
|
>
|
||||||
<!-- Left: Outline toggle -->
|
<!-- Left: Outline toggle -->
|
||||||
<div
|
<div
|
||||||
v-if="!showStartActions"
|
v-if="!showStartActions && isMarkdownDocument"
|
||||||
class="flex items-center shrink-0"
|
class="flex items-center shrink-0"
|
||||||
data-no-window-drag
|
data-no-window-drag
|
||||||
>
|
>
|
||||||
@@ -787,7 +879,7 @@ registerTauriListener("menu-event", (event) => {
|
|||||||
data-no-window-drag
|
data-no-window-drag
|
||||||
>
|
>
|
||||||
<!-- Document mode -->
|
<!-- Document mode -->
|
||||||
<div class="mode-toggle" role="group" aria-label="文档模式">
|
<div v-if="isMarkdownDocument" class="mode-toggle" role="group" aria-label="文档模式">
|
||||||
<button
|
<button
|
||||||
class="mode-toggle-btn"
|
class="mode-toggle-btn"
|
||||||
:class="{ 'mode-toggle-btn-active': documentMode === 'preview' }"
|
:class="{ 'mode-toggle-btn-active': documentMode === 'preview' }"
|
||||||
@@ -815,7 +907,7 @@ registerTauriListener("menu-event", (event) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- More menu -->
|
<!-- More menu -->
|
||||||
<div class="relative">
|
<div v-if="isMarkdownDocument" class="relative">
|
||||||
<button
|
<button
|
||||||
class="toolbar-btn"
|
class="toolbar-btn"
|
||||||
title="更多"
|
title="更多"
|
||||||
@@ -875,23 +967,67 @@ registerTauriListener("menu-event", (event) => {
|
|||||||
<div class="flex-1 flex min-h-0 overflow-hidden">
|
<div class="flex-1 flex min-h-0 overflow-hidden">
|
||||||
<Transition name="outline-slide">
|
<Transition name="outline-slide">
|
||||||
<OutlinePanel
|
<OutlinePanel
|
||||||
v-if="showOutline && !showStartActions"
|
v-if="showOutline && !showStartActions && isMarkdownDocument"
|
||||||
:markdown-content="content"
|
:markdown-content="content"
|
||||||
@heading-click="onHeadingClick"
|
@heading-click="onHeadingClick"
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
v-if="!showStartActions"
|
v-if="!showStartActions && isMarkdownDocument"
|
||||||
:key="editorResetKey"
|
:key="editorResetKey"
|
||||||
ref="markdownEditorRef"
|
ref="markdownEditorRef"
|
||||||
:model-value="content"
|
:model-value="content"
|
||||||
:document-mode="documentMode"
|
:document-mode="documentMode"
|
||||||
|
:file-path="currentFilePath"
|
||||||
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等"
|
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等"
|
||||||
@update:model-value="onUpdate"
|
@update:model-value="onUpdate"
|
||||||
@save-shortcut="doSave"
|
@save-shortcut="doSave"
|
||||||
class="flex-1 w-full min-w-0"
|
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
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="start-actions flex-1 flex items-center justify-center px-8"
|
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);
|
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 ───────────────────────────────────────────────────── */
|
/* ── Toolbar ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.titlebar-drag-surface {
|
.titlebar-drag-surface {
|
||||||
|
|||||||
@@ -7,14 +7,24 @@ import {
|
|||||||
watch,
|
watch,
|
||||||
type ComponentPublicInstance,
|
type ComponentPublicInstance,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import { unified } from "unified";
|
import { unified, type Plugin, type Transformer } from "unified";
|
||||||
import remarkParse from "remark-parse";
|
import remarkParse from "remark-parse";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import remarkRehype from "remark-rehype";
|
import remarkRehype from "remark-rehype";
|
||||||
import rehypeStringify from "rehype-stringify";
|
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";
|
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 {
|
interface MarkdownAstNode {
|
||||||
position?: {
|
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 {
|
interface MarkdownBlock {
|
||||||
id: string;
|
id: string;
|
||||||
source: string;
|
source: string;
|
||||||
@@ -37,12 +61,14 @@ const props = withDefaults(
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
documentMode?: DocumentMode;
|
documentMode?: DocumentMode;
|
||||||
previewOnly?: boolean;
|
previewOnly?: boolean;
|
||||||
|
filePath?: string | null;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: "",
|
modelValue: "",
|
||||||
placeholder: "开始写作...",
|
placeholder: "开始写作...",
|
||||||
documentMode: undefined,
|
documentMode: undefined,
|
||||||
previewOnly: false,
|
previewOnly: false,
|
||||||
|
filePath: null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -51,14 +77,133 @@ const emit = defineEmits<{
|
|||||||
"save-shortcut": [value?: string];
|
"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 ─────────────────────────────────────────────
|
// ── 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 */
|
/** Full pipeline: markdown → HTML */
|
||||||
const md2html = unified()
|
const md2html = unified()
|
||||||
.use(remarkParse)
|
.use(remarkParse)
|
||||||
.use(remarkGfm)
|
.use(remarkGfm)
|
||||||
|
.use(remarkLocalMedia)
|
||||||
.use(remarkRehype, { allowDangerousHtml: false })
|
.use(remarkRehype, { allowDangerousHtml: false })
|
||||||
.use(rehypeSanitize)
|
.use(rehypeSanitize, mediaSanitizeSchema)
|
||||||
.use(rehypeStringify);
|
.use(rehypeStringify);
|
||||||
|
|
||||||
const mdParser = unified().use(remarkParse).use(remarkGfm);
|
const mdParser = unified().use(remarkParse).use(remarkGfm);
|
||||||
|
|||||||
Reference in New Issue
Block a user