1 parent
6911169e6b
commit
c175cba97b
7 files changed
+244
No files matched your search
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -870,6 +870,11 @@ export const zhCN = {
|
||||
imageSizeLarge: "较大",
|
||||
imageSizeFull: "铺满",
|
||||
resizeImage: "拖拽调整图片宽度",
|
||||
previewImage: "预览大图",
|
||||
imageViewer: "图片查看器",
|
||||
imageZoomIn: "放大图片",
|
||||
imageZoomOut: "缩小图片",
|
||||
closeImagePreview: "关闭图片查看器",
|
||||
resourceActions: {
|
||||
download: "下载",
|
||||
saveAs: "导出",
|
||||
|
||||
@@ -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:");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user