Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }

[dependencies]
tauri = { version = "2", features = ["macos-private-api"] }
tauri = { version = "2", features = ["macos-private-api", "protocol-asset"] }
tauri-plugin-single-instance = "2"
tauri-plugin-opener = "2"
tauri-plugin-fs = { version = "2", features = ["watch"] }
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["**"]
}
}
},
"plugins": {
Expand Down
21 changes: 18 additions & 3 deletions src/components/overlays/file-preview-overlay.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { readFile, readTextFile, stat } from "@tauri-apps/plugin-fs";
import { openPath } from "@tauri-apps/plugin-opener";
import { useI18n } from "@/lib";
import { basename, isSupportedTextPath, previewKindForPath, previewMimeForPath, validatePlainTextFile } from "@/lib";
import { basename, dirname, htmlDocWithBase, isSupportedTextPath, previewKindForPath, previewMimeForPath, validatePlainTextFile } from "@/lib";

const CLOSE_ICON_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`;
const EYE_ICON_SVG = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>`;
Expand Down Expand Up @@ -54,6 +55,13 @@ export function FilePreviewOverlay({ path, onClose, onOpenAsText }: Props) {
const [size, setSize] = useState<number | null>(null);
const [errMsg, setErrMsg] = useState("");

// html previews get a <base> pointing at the file's directory (via the asset
// protocol) so relative images/css/scripts resolve.
const htmlDoc = useMemo(
() => (path && kind === "html" ? htmlDocWithBase(text, convertFileSrc(dirname(path))) : ""),
[path, kind, text],
);

const urlRef = useRef<string | null>(null);

const revokeUrl = useCallback(() => {
Expand Down Expand Up @@ -102,7 +110,7 @@ export function FilePreviewOverlay({ path, onClose, onOpenAsText }: Props) {
return;
}

if (kind === "text") {
if (kind === "text" || kind === "html") {
const check = await validatePlainTextFile(path);
if (cancelled) return;
if (!check.ok) {
Expand Down Expand Up @@ -151,7 +159,7 @@ export function FilePreviewOverlay({ path, onClose, onOpenAsText }: Props) {
if (!path) return null;

const title = basename(path);
const showOpenAsText = kind === "text" && isSupportedTextPath(path) === false;
const showOpenAsText = (kind === "text" || kind === "html") && isSupportedTextPath(path) === false;

return (
<div
Expand Down Expand Up @@ -223,6 +231,13 @@ export function FilePreviewOverlay({ path, onClose, onOpenAsText }: Props) {
</div>
) : kind === "pdf" && url ? (
<iframe className="mdv-file-viewer__pdf" src={url} title={title} />
) : kind === "html" ? (
<iframe
className="mdv-file-viewer__html"
sandbox="allow-scripts"
srcDoc={htmlDoc}
title={title}
/>
) : kind === "text" ? (
<pre className="mdv-file-viewer__text">{text}</pre>
) : (
Expand Down
2 changes: 1 addition & 1 deletion src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export {
} from "./context-bundle";
export { startWindowDrag } from "./window-drag";
export { exportPreviewToPdf, PdfExportError } from "./pdf-export";
export { previewKindForPath, previewMimeForPath, type PreviewKind } from "./preview";
export { previewKindForPath, previewMimeForPath, htmlDocWithBase, type PreviewKind } from "./preview";
export { IS_MAC, IS_WINDOWS, IS_LINUX, displayKey, shortcutLabel } from "./platform";
export {
pickFolder,
Expand Down
28 changes: 24 additions & 4 deletions src/lib/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
*
* The sidebar opens `.md/.markdown/.mdx/.csv` in the editor; every other file
* goes through the preview overlay. `previewKindForPath` decides which renderer
* the overlay uses. Mime values are webview-native (no parsing library) so the
* overlay can build blob/data URLs straight from `readFile` bytes.
* the overlay uses (`.html/.htm` render in a sandboxed iframe with a `<base>`
* pointing at the file's directory via the asset protocol). Mime values are
* webview-native (no parsing library) so the overlay can build blob/data URLs
* straight from `readFile` bytes.
*/

export type PreviewKind =
| "image"
| "video"
| "audio"
| "pdf"
| "html"
| "office"
| "text"
| "unsupported";
Expand Down Expand Up @@ -87,8 +90,6 @@ const TEXT_EXT = new Set([
"properties",
"env",
"xml",
"html",
"htm",
"css",
"scss",
"less",
Expand Down Expand Up @@ -170,6 +171,7 @@ export function previewKindForPath(path: string): PreviewKind {
if (VIDEO_MIME[ext]) return "video";
if (AUDIO_MIME[ext]) return "audio";
if (ext === "pdf") return "pdf";
if (ext === "html" || ext === "htm") return "html";
if (OFFICE_EXT.has(ext)) return "office";
if (TEXT_EXT.has(ext)) return "text";
if (TEXT_BASENAMES.has(basenameLower(path))) return "text";
Expand All @@ -182,3 +184,21 @@ export function previewMimeForPath(path: string): string {
if (ext === "pdf") return "application/pdf";
return IMAGE_MIME[ext] ?? VIDEO_MIME[ext] ?? AUDIO_MIME[ext] ?? "";
}

/**
* Inject a `<base href>` into an html document so the previewed file resolves
* relative resources (images, css, scripts) against its own directory.
* `baseHref` is a `convertFileSrc()` asset URL of that directory. An existing
* `<base>` in the document would lose (the first one wins), so local files
* keep working.
*/
export function htmlDocWithBase(html: string, baseHref: string): string {
const href = baseHref.endsWith("/") ? baseHref : `${baseHref}/`;
const base = `<base href="${href.replace(/"/g, "&quot;")}">`;
const head = /<head[^>]*>/i.exec(html);
if (head) {
const at = head.index + head[0].length;
return html.slice(0, at) + base + html.slice(at);
}
return base + html;
}
10 changes: 10 additions & 0 deletions src/styles/overlays/file-preview.css
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,16 @@ html.is-mac .mdv-file-viewer__toolbar {
background: var(--surface);
}

/* html — pages assume a white canvas, so don't inherit the app theme. */
.mdv-file-viewer__html {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}

/* text */
.mdv-file-viewer__text {
position: absolute;
Expand Down
15 changes: 14 additions & 1 deletion tests/preview.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { previewKindForPath, previewMimeForPath } from "../src/lib/preview";
import { previewKindForPath, previewMimeForPath, htmlDocWithBase } from "../src/lib/preview";

test("classifies images by extension", () => {
expect(previewKindForPath("a.png")).toBe("image");
Expand All @@ -20,6 +20,19 @@ test("classifies pdf", () => {
expect(previewKindForPath("PAPER.PDF")).toBe("pdf");
});

test("classifies html for rendered preview", () => {
expect(previewKindForPath("index.html")).toBe("html");
expect(previewKindForPath("docs/page.HTM")).toBe("html");
});

test("injects a base href for relative html resources", () => {
expect(htmlDocWithBase("<html><head><title>x</title></head></html>", "asset://localhost/a/b"))
.toBe('<html><head><base href="asset://localhost/a/b/"><title>x</title></head></html>');
// no <head> — prepend
expect(htmlDocWithBase("<p>hi</p>", "asset://localhost/a/b/"))
.toBe('<base href="asset://localhost/a/b/"><p>hi</p>');
});

test("classifies office documents as info-card", () => {
expect(previewKindForPath("report.docx")).toBe("office");
expect(previewKindForPath("sheet.xlsx")).toBe("office");
Expand Down