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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ refactors, dependency updates, CI changes, and code cleanup do not belong here.

---

## [1.2.9] - 2026-08-17

### Added

- Added click-to-open image previews for Markdown images and image files, with trackpad zoom, panning, toolbar zoom controls, reset, Escape-to-close, and a close button.

### Fixed

- Kept the original Mermaid editor and rendered diagram mounted while the full-screen preview is open, preventing editor rerenders and scroll-position shifts when the preview closes.

## [1.2.8] - 2026-08-15

### Changed
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/native-backend/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "neverwrite-native-backend"
version = "1.2.8"
version = "1.2.9"
edition = "2021"

[dependencies]
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/package-lock.json

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

2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "bifrostwrite-desktop",
"private": true,
"version": "1.2.8",
"version": "1.2.9",
"homepage": "https://github.com/bifrost-proxy/BifrostWrite",
"type": "module",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "bifrostwrite-desktop"
version = "1.2.8"
version = "1.2.9"
edition = "2021"
description = "Tauri desktop shell for BifrostWrite"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "BifrostWrite",
"version": "1.2.8",
"version": "1.2.9",
"identifier": "com.bifrostwrite.desktop",
"build": {
"beforeDevCommand": "npm run renderer:dev",
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/app/i18n/zh-CN.curated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,9 @@ export const CURATED_ZH_CN_TRANSLATIONS: Readonly<Record<string, string>> = {
"Mermaid canvas": "Mermaid 画布",
"Mermaid file mode": "Mermaid 文件模式",
"Mermaid preview": "Mermaid 预览",
"Image preview": "图片预览",
"Image canvas": "图片画布",
"Open image preview": "放大查看图片",
"Mermaid source is empty.": "Mermaid 源码为空。",
"Open Mermaid fullscreen": "全屏查看 Mermaid",
"Pinch to zoom • Scroll to pan • Esc to close":
Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/src/features/editor/FileTabView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import {
setVaultEntries,
} from "../../test/test-utils";
import { resetExternalReloadBaselinesForTests } from "./externalReloadBaselineCache";
import {
OPEN_IMAGE_PREVIEW_EVENT,
type OpenImagePreviewPayload,
} from "./imagePreview";

function seedTrackedDiff(
targetPath: string,
Expand Down Expand Up @@ -111,6 +115,40 @@ describe("FileTabView", () => {
);
});

it("opens image files in the zoom preview by click", async () => {
const user = userEvent.setup();
const previews: OpenImagePreviewPayload[] = [];
const handlePreview = (event: Event) => {
previews.push(
(event as CustomEvent<OpenImagePreviewPayload>).detail,
);
};
window.addEventListener(OPEN_IMAGE_PREVIEW_EVENT, handlePreview);

setEditorTabs([
{
id: "image-tab",
kind: "file",
relativePath: "assets/photo.webp",
title: "photo.webp",
path: "/vault/assets/photo.webp",
mimeType: "image/webp",
viewer: "image",
content: "",
},
]);

renderComponent(<FileTabView />);
await user.click(
screen.getByRole("button", { name: "Open image preview" }),
);

expect(previews).toEqual([
expect.objectContaining({ alt: "photo.webp" }),
]);
window.removeEventListener(OPEN_IMAGE_PREVIEW_EVENT, handlePreview);
});

it("supports Command + wheel zoom from fit mode", async () => {
setEditorTabs([
{
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/features/editor/FileTabView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { formatZoomPercentage } from "../../app/utils/zoom";
import { FileTextTabView } from "./FileTextTabView";
import { CsvFileTabView } from "./CsvFileTabView";
import { HtmlTabView } from "./HtmlTabView";
import { openImagePreview } from "./imagePreview";

const IMG_MIN_ZOOM = 0.1;
const IMG_MAX_ZOOM = 10;
Expand Down Expand Up @@ -234,6 +235,17 @@ function ImageFileViewer({ tab }: { tab: FileTab }) {
return (
<div className="h-full min-w-0 flex flex-col overflow-hidden">
<FileHeader tab={tab}>
<button
type="button"
onClick={() =>
previewUrl && openImagePreview(previewUrl, tab.title)
}
className="inline-flex items-center rounded px-1.5 text-[10px]"
style={headerButtonStyle}
disabled={!previewUrl || status === "error"}
>
{translate("Preview")}
</button>
<button
type="button"
onClick={setFit}
Expand Down Expand Up @@ -314,6 +326,21 @@ function ImageFileViewer({ tab }: { tab: FileTab }) {
src={previewUrl ?? ""}
alt={tab.title}
draggable={false}
role="button"
tabIndex={0}
aria-label={translate("Open image preview")}
onClick={() =>
previewUrl &&
openImagePreview(previewUrl, tab.title)
}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ")
return;
event.preventDefault();
if (previewUrl) {
openImagePreview(previewUrl, tab.title);
}
}}
onLoad={() => setStatus("ready")}
onError={() => setStatus("error")}
style={{
Expand All @@ -322,6 +349,7 @@ function ImageFileViewer({ tab }: { tab: FileTab }) {
width: "auto",
height: "auto",
objectFit: "contain",
cursor: "zoom-in",
touchAction: IMAGE_TOUCH_ACTION,
boxShadow: "0 16px 40px rgba(0, 0, 0, 0.18)",
}}
Expand All @@ -338,6 +366,21 @@ function ImageFileViewer({ tab }: { tab: FileTab }) {
src={previewUrl ?? ""}
alt={tab.title}
draggable={false}
role="button"
tabIndex={0}
aria-label={translate("Open image preview")}
onClick={() =>
previewUrl &&
openImagePreview(previewUrl, tab.title)
}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ")
return;
event.preventDefault();
if (previewUrl) {
openImagePreview(previewUrl, tab.title);
}
}}
onLoad={() => setStatus("ready")}
onError={() => setStatus("error")}
style={{
Expand All @@ -348,6 +391,7 @@ function ImageFileViewer({ tab }: { tab: FileTab }) {
transformOrigin: "center top",
transform: `scale(${zoom})`,
touchAction: IMAGE_TOUCH_ACTION,
cursor: "zoom-in",
boxShadow: "0 16px 40px rgba(0, 0, 0, 0.18)",
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
} from "./livePreviewBlocks";
import { exitFencedCodeBlock, livePreviewExtension } from "./livePreview";
import { renderMermaidDiagram } from "../mermaid/mermaidRenderer";
import {
OPEN_IMAGE_PREVIEW_EVENT,
type OpenImagePreviewPayload,
} from "../imagePreview";

vi.mock("../mermaid/mermaidRenderer", () => ({
renderMermaidDiagram: vi.fn(),
Expand Down Expand Up @@ -89,6 +93,45 @@ describe("resolvePreviewAssetPath", () => {
});
});

describe("image live preview", () => {
it("opens a rendered image in the zoom overlay without replacing its widget", () => {
const parent = document.createElement("div");
document.body.appendChild(parent);
const doc = [
"![Architecture](https://example.com/architecture.png)",
"",
"After image",
].join("\n");
const state = createLivePreviewState(doc, doc.length);
const view = new EditorView({ state, parent });
const image = view.dom.querySelector<HTMLImageElement>(
".cm-inline-image",
);
const previews: OpenImagePreviewPayload[] = [];
const handlePreview = (event: Event) => {
previews.push(
(event as CustomEvent<OpenImagePreviewPayload>).detail,
);
};
window.addEventListener(OPEN_IMAGE_PREVIEW_EVENT, handlePreview);

expect(image).not.toBeNull();
image!.click();

expect(previews).toEqual([
{
src: "https://example.com/architecture.png",
alt: "Architecture",
},
]);
expect(image!.isConnected).toBe(true);

window.removeEventListener(OPEN_IMAGE_PREVIEW_EVENT, handlePreview);
view.destroy();
parent.remove();
});
});

describe("code block live preview", () => {
it("classifies Mermaid info strings separately from regular code blocks", () => {
expect(getFencedCodeBlockKind("mermaid")).toBe("mermaid");
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
import { createMermaidFullscreenButton } from "../mermaid/mermaidFullscreen";
import { parseMermaidSvg } from "../mermaid/mermaidSvg";
import { formatCodeFenceLanguageLabel } from "../codeFencePresentation";
import { openImagePreview } from "../imagePreview";

const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|svg|webp|bmp|ico|avif)([?#].*)?$/i;
const PDF_EXTENSION = /\.pdf([?#].*)?$/i;
Expand Down Expand Up @@ -483,6 +484,20 @@ class ImageWidget extends WidgetType {
img.draggable = false;
img.loading = "lazy";
img.decoding = "async";
img.tabIndex = 0;
img.setAttribute("role", "button");
img.setAttribute("aria-label", translate("Open image preview"));
Comment on lines +487 to +489

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain image alt text in the preview button's accessible name

For Markdown images with meaningful alt text, this explicit generic aria-label overrides the native accessible name supplied by img.alt, so every rendered image is announced only as “Open image preview” and screen-reader users lose the image's description. Include this.alt in the button label (with a sensible fallback when it is empty) instead of replacing it entirely.

Useful? React with 👍 / 👎.

img.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
openImagePreview(this.src, this.alt);
});
Comment on lines +490 to +494

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve link activation for linked Markdown images

For linked-image syntax such as [![alt](image.png)](https://example.com), this listener stops the click before the existing .cm-inline-image-link activation path in livePreview.ts can call openUrl. As a result, a normal pointer click now opens only the image preview and the image's hyperlink is no longer reachable by clicking it; skip this handler when this.href is set or provide a separate preview affordance.

Useful? React with 👍 / 👎.

img.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
openImagePreview(this.src, this.alt);
});
if (this.title) img.title = this.title;
if (this.width) {
img.style.width = `${this.width}px`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,7 @@ export const livePreviewTheme = EditorView.baseTheme({
maxHeight: "500px",
borderRadius: "6px",
objectFit: "contain",
cursor: "zoom-in",
},
".cm-inline-image-fallback": {
color: "var(--text-secondary)",
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/features/editor/imagePreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const OPEN_IMAGE_PREVIEW_EVENT = "bifrostwrite:open-image-preview";

export interface OpenImagePreviewPayload {
src: string;
alt: string;
}

export function openImagePreview(src: string, alt = "") {
if (!src.trim()) return;

window.dispatchEvent(
new CustomEvent<OpenImagePreviewPayload>(OPEN_IMAGE_PREVIEW_EVENT, {
detail: { src, alt },
}),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createMermaidFullscreenButton,
openMermaidFullscreen,
} from "./mermaidFullscreen";
import { openImagePreview } from "../imagePreview";

const TEST_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100">
Expand Down Expand Up @@ -96,4 +97,54 @@ describe("MermaidFullscreenHost", () => {

expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("previews images with the same zoom overlay", () => {
renderComponent(<MermaidFullscreenHost />);

act(() => openImagePreview("https://example.com/photo.png", "Photo"));

expect(
screen.getByRole("dialog", { name: "Image preview" }),
).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Photo" })).toHaveAttribute(
"src",
"https://example.com/photo.png",
);

fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
expect(screen.getByRole("button", { name: "Reset zoom" })).toHaveTextContent(
"125%",
);
});

it("keeps the source Mermaid node mounted and preserves body layout styles", () => {
renderComponent(<MermaidFullscreenHost />);
const source = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
const sourceText = document.createElementNS(
"http://www.w3.org/2000/svg",
"text",
);
sourceText.textContent = "Source diagram";
source.appendChild(sourceText);
document.body.appendChild(source);
document.body.style.overflow = "auto";

act(() => openMermaidFullscreen(source));

expect(source.isConnected).toBe(true);
expect(source.parentElement).toBe(document.body);
expect(document.body.style.overflow).toBe("auto");
expect(screen.getAllByText("Source diagram")).toHaveLength(2);

fireEvent.click(screen.getByRole("button", { name: "Close" }));

expect(source.isConnected).toBe(true);
expect(source.parentElement).toBe(document.body);
expect(document.body.style.overflow).toBe("auto");
source.remove();
document.body.style.removeProperty("overflow");
});
});
Loading