添加显示图片和视频功能
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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -3291,6 +3297,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
|
||||
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use std::fs;
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
||||
use tauri::Emitter;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
@@ -43,8 +43,12 @@ fn pick_folder(app: tauri::AppHandle) -> Option<String> {
|
||||
}
|
||||
|
||||
#[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))?;
|
||||
app.asset_protocol_scope()
|
||||
.allow_directory(&path, true)
|
||||
.map_err(|e| format!("无法授权媒体预览目录: {}", e))?;
|
||||
|
||||
let allowed_extensions = [
|
||||
"md", "png", "jpg", "jpeg", "gif", "webp", "avif", "mp4", "webm", "mov", "m4v", "ogv",
|
||||
];
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
214
src/App.vue
214
src/App.vue
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user