diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 9e32e657..d0eb4df0 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -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(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).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 = ({ /> )} + setImagePreview(null)} + /> + {resourceMenuTarget && ( void; +}; + +export const ImageViewer = ({ + alt, + closeLabel, + open, + src, + viewerLabel, + zoomInLabel, + zoomOutLabel, + onClose, +}: ImageViewerProps) => { + if (!open || !src) return null; + + return ( + null, + buttonNext: () => null, + }} + /> + ); +}; diff --git a/apps/web/src/components/editor/ResizableImage.tsx b/apps/web/src/components/editor/ResizableImage.tsx index b3447add..2303c572 100644 --- a/apps/web/src/components/editor/ResizableImage.tsx +++ b/apps/web/src/components/editor/ResizableImage.tsx @@ -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(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 = ( + + + + + + {t("editor.previewImage")} + + + ); + return ( { + event.preventDefault(); + event.stopPropagation(); + requestImagePreview(); + }} /> {editable && selected && (
+ {previewButton}
{IMAGE_WIDTH_PRESETS.map((preset) => (
)} + {(!editable || !selected) && ( +
+ {previewButton} +
+ )} ); }; diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 022e4381..9e9fabe5 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index ec90d6d6..1008aca1 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 1802b17c..559502c0 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -870,6 +870,11 @@ export const zhCN = { imageSizeLarge: "较大", imageSizeFull: "铺满", resizeImage: "拖拽调整图片宽度", + previewImage: "预览大图", + imageViewer: "图片查看器", + imageZoomIn: "放大图片", + imageZoomOut: "缩小图片", + closeImagePreview: "关闭图片查看器", resourceActions: { download: "下载", saveAs: "导出", diff --git a/tests/web-image-preview.test.ts b/tests/web-image-preview.test.ts new file mode 100644 index 00000000..b29d2f0c --- /dev/null +++ b/tests/web-image-preview.test.ts @@ -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(" { + expect(editorSource).toContain("IMAGE_PREVIEW_SHOW_EVENT"); + expect(editorSource).toContain(" { + for (const source of [englishSource, chineseSource]) { + expect(source).toContain("previewImage:"); + expect(source).toContain("imageZoomIn:"); + expect(source).toContain("closeImagePreview:"); + } + }); +});