feat(web): add fullscreen image preview

Closes #271
This commit is contained in:
tianma-if committed 2026-08-18 11:54:45 +08:00
1 parent 6911169e6b
commit c175cba97b
7 files changed
+244

No files matched your search

+29
View File
@@ -164,9 +164,12 @@ import { getAttachmentFilenameFromLabel, getAttachmentResourceId } from "@/lib/a
import {
IMAGE_MENU_HIDE_EVENT,
IMAGE_MENU_SHOW_EVENT,
IMAGE_PREVIEW_SHOW_EVENT,
ResizableImage,
type ImageMenuRequestDetail,
type ImagePreviewRequestDetail,
} from "./editor/ResizableImage";
import { ImageViewer } from "./editor/ImageViewer";
import {
createNoteSearchHighlightPlugin,
formatNoteSearchMatchLabel,
@@ -720,6 +723,7 @@ const RichEditorPane = ({
const [editorStateVersion, setEditorStateVersion] = useState(0);
const [editorContentVersion, setEditorContentVersion] = useState(0);
const [imageUploadState, setImageUploadState] = useState<"idle" | "compressing" | "uploading" | "error">("idle");
const [imagePreview, setImagePreview] = useState<ImagePreviewRequestDetail | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [aiAssistantOpen, setAiAssistantOpen] = useState(false);
@@ -1405,6 +1409,20 @@ const RichEditorPane = ({
};
}, [cancelResourceMenuHide, isMobileViewport, scheduleResourceMenuHide, showResourceMenu]);
useEffect(() => {
const showImagePreview = (event: Event) => {
const detail = (event as CustomEvent<ImagePreviewRequestDetail>).detail;
if (!detail?.url) return;
setImagePreview(detail);
};
window.addEventListener(IMAGE_PREVIEW_SHOW_EVENT, showImagePreview);
return () => window.removeEventListener(IMAGE_PREVIEW_SHOW_EVENT, showImagePreview);
}, []);
useEffect(() => {
setImagePreview(null);
}, [memo?.id]);
const showAttachmentMenu = useCallback((target: EventTarget | null) => {
if (isMobileViewport) return false;
const link = getAttachmentLinkFromEventTarget(target);
@@ -4225,6 +4243,17 @@ const RichEditorPane = ({
/>
)}
<ImageViewer
alt={imagePreview?.alt ?? ""}
closeLabel={t("editor.closeImagePreview")}
open={Boolean(imagePreview)}
src={imagePreview?.url ?? ""}
viewerLabel={t("editor.imageViewer")}
zoomInLabel={t("editor.imageZoomIn")}
zoomOutLabel={t("editor.imageZoomOut")}
onClose={() => setImagePreview(null)}
/>
{resourceMenuTarget && (
<ResourceActionMenu
target={resourceMenuTarget}
@@ -0,0 +1,63 @@
import Lightbox from "yet-another-react-lightbox";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import "yet-another-react-lightbox/styles.css";
type ImageViewerProps = {
alt: string;
closeLabel: string;
open: boolean;
src: string;
viewerLabel: string;
zoomInLabel: string;
zoomOutLabel: string;
onClose: () => void;
};
export const ImageViewer = ({
alt,
closeLabel,
open,
src,
viewerLabel,
zoomInLabel,
zoomOutLabel,
onClose,
}: ImageViewerProps) => {
if (!open || !src) return null;
return (
<Lightbox
open
close={onClose}
index={0}
slides={[{ src, alt: alt || viewerLabel, imageFit: "contain" }]}
plugins={[Zoom]}
labels={{
Close: closeLabel,
"Photo gallery": viewerLabel,
"Zoom in": zoomInLabel,
"Zoom out": zoomOutLabel,
}}
toolbar={{ buttons: ["zoom", "close"] }}
carousel={{ finite: true, padding: 24, imageFit: "contain" }}
controller={{
closeOnBackdropClick: true,
closeOnPullDown: false,
closeOnPullUp: false,
disableSwipeNavigation: true,
}}
zoom={{
maxZoomPixelRatio: 6,
zoomInMultiplier: 1.5,
keyboardMoveDistance: 80,
pinchZoomV4: true,
scrollToZoom: true,
}}
animation={{ fade: 160, zoom: 180 }}
render={{
buttonPrev: () => null,
buttonNext: () => null,
}}
/>
);
};
@@ -2,6 +2,7 @@ import { useCallback, useRef, useState, type MouseEvent, type PointerEvent as Re
import { mergeAttributes } from "@tiptap/core";
import Image from "@tiptap/extension-image";
import { NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from "@tiptap/react";
import { Maximize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getImageReferrerPolicy } from "@edgeever/shared";
import {
@@ -13,6 +14,7 @@ import {
import { getAttachmentResourceId } from "@/lib/attachment-links";
import { createMarkdownImagePasteRule } from "@/lib/markdown-image-paste";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export type ImageMenuRequestDetail = {
element: HTMLElement;
@@ -23,8 +25,14 @@ export type ImageMenuRequestDetail = {
deleteNode: () => void;
};
export type ImagePreviewRequestDetail = {
url: string;
alt: string;
};
export const IMAGE_MENU_SHOW_EVENT = "edgeever:image-menu-show";
export const IMAGE_MENU_HIDE_EVENT = "edgeever:image-menu-hide";
export const IMAGE_PREVIEW_SHOW_EVENT = "edgeever:image-preview-show";
const ResizableImageNodeView = ({
editor,
@@ -62,6 +70,17 @@ const ResizableImageNodeView = ({
window.dispatchEvent(new CustomEvent(IMAGE_MENU_HIDE_EVENT));
}, []);
const requestImagePreview = useCallback(() => {
if (!src) return;
hideImageMenu();
window.dispatchEvent(new CustomEvent<ImagePreviewRequestDetail>(IMAGE_PREVIEW_SHOW_EVENT, {
detail: {
url: src,
alt: title || alt,
},
}));
}, [alt, hideImageMenu, src, title]);
const updateWidth = useCallback((nextWidth: number) => {
updateAttributes({ width: clampImageWidth(nextWidth) });
}, [updateAttributes]);
@@ -101,6 +120,28 @@ const ResizableImageNodeView = ({
previewFromPointer(event.clientX);
}, [editable, nodeWidth, updateWidth]);
const previewButton = (
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-image-preview-button"
aria-label={t("editor.previewImage")}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation();
requestImagePreview();
}}
>
<Maximize2 aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{t("editor.previewImage")}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
return (
<NodeViewWrapper
ref={wrapperRef}
@@ -121,9 +162,15 @@ const ResizableImageNodeView = ({
title={title || undefined}
draggable={false}
referrerPolicy={getImageReferrerPolicy(src)}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
requestImagePreview();
}}
/>
{editable && selected && (
<div className="edgeever-image-controls" contentEditable={false}>
{previewButton}
<div className="edgeever-image-presets" aria-label={t("editor.imageScale")}>
{IMAGE_WIDTH_PRESETS.map((preset) => (
<button
@@ -147,6 +194,11 @@ const ResizableImageNodeView = ({
/>
</div>
)}
{(!editable || !selected) && (
<div className="edgeever-image-preview-control" contentEditable={false}>
{previewButton}
</div>
)}
</NodeViewWrapper>
);
};
+55
View File
@@ -897,6 +897,61 @@ textarea {
line-height: 1;
}
.ProseMirror .edgeever-image-preview-control {
position: absolute;
right: 0.5rem;
top: 0.5rem;
z-index: 2;
border: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
border-radius: 8px;
background: rgba(255, 255, 255, 0.94);
padding: 0.3rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16);
line-height: 1;
opacity: 0;
pointer-events: none;
transition: opacity 160ms ease;
}
.ProseMirror .edgeever-image-node:hover .edgeever-image-preview-control,
.ProseMirror .edgeever-image-preview-control:focus-within {
opacity: 1;
pointer-events: auto;
}
.ProseMirror .edgeever-image-preview-button {
display: inline-flex;
width: 2rem;
height: 2rem;
align-items: center;
justify-content: center;
border-radius: 6px;
color: #475569;
}
.ProseMirror .edgeever-image-preview-button:hover,
.ProseMirror .edgeever-image-preview-button:focus-visible {
background: var(--brand-green-muted);
color: var(--brand-green-text);
}
.ProseMirror .edgeever-image-preview-button:focus-visible {
outline: 2px solid var(--brand-green);
outline-offset: 2px;
}
.ProseMirror .edgeever-image-preview-button svg {
width: 1rem;
height: 1rem;
}
@media (hover: none) {
.ProseMirror .edgeever-image-preview-control {
opacity: 1;
pointer-events: auto;
}
}
.ProseMirror .edgeever-image-presets {
display: flex;
align-items: center;
+5
View File
@@ -870,6 +870,11 @@ export const enUS = {
imageSizeLarge: "Large",
imageSizeFull: "Full",
resizeImage: "Drag to resize image width",
previewImage: "Preview image",
imageViewer: "Image viewer",
imageZoomIn: "Zoom in image",
imageZoomOut: "Zoom out image",
closeImagePreview: "Close image viewer",
resourceActions: {
download: "Download",
saveAs: "Export",
+5
View File
@@ -870,6 +870,11 @@ export const zhCN = {
imageSizeLarge: "较大",
imageSizeFull: "铺满",
resizeImage: "拖拽调整图片宽度",
previewImage: "预览大图",
imageViewer: "图片查看器",
imageZoomIn: "放大图片",
imageZoomOut: "缩小图片",
closeImagePreview: "关闭图片查看器",
resourceActions: {
download: "下载",
saveAs: "导出",
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
const readSource = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const imageNodeSource = readSource("../apps/web/src/components/editor/ResizableImage.tsx");
const imageViewerSource = readSource("../apps/web/src/components/editor/ImageViewer.tsx");
const editorSource = readSource("../apps/web/src/components/EditorPane.tsx");
const englishSource = readSource("../packages/shared/src/i18n/en-US.ts");
const chineseSource = readSource("../packages/shared/src/i18n/zh-CN.ts");
describe("web note image preview", () => {
test("opens the same preview flow from double-click and an accessible button", () => {
expect(imageNodeSource).toContain("onDoubleClick={(event) => {");
expect(imageNodeSource.match(/requestImagePreview\(\);/g)?.length).toBeGreaterThanOrEqual(2);
expect(imageNodeSource).toContain('aria-label={t("editor.previewImage")}');
expect(imageNodeSource).toContain("<TooltipContent");
});
test("hosts one zoomable lightbox at the editor level", () => {
expect(editorSource).toContain("IMAGE_PREVIEW_SHOW_EVENT");
expect(editorSource).toContain("<ImageViewer");
expect(imageViewerSource).toContain("plugins={[Zoom]}");
expect(imageViewerSource).toContain("closeOnBackdropClick: true");
expect(imageViewerSource).toContain('toolbar={{ buttons: ["zoom", "close"] }}');
});
test("keeps viewer controls bilingual", () => {
for (const source of [englishSource, chineseSource]) {
expect(source).toContain("previewImage:");
expect(source).toContain("imageZoomIn:");
expect(source).toContain("closeImagePreview:");
}
});
});