From e829f9a8a5035ca8e87ac8bc7abe65d3d88e94d0 Mon Sep 17 00:00:00 2001 From: yoma Date: Fri, 3 Jul 2026 11:26:14 +0800 Subject: [PATCH] Add resizable note images --- apps/web/src/components/EditorPane.tsx | 141 ++++++++++++++++++++++++- apps/web/src/styles/globals.css | 75 +++++++++++++ 2 files changed, 213 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 916e4571..25881d5c 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -1,6 +1,6 @@ -import { useRef, useState, useEffect, useCallback, useMemo, type ReactNode } from "react"; +import { useRef, useState, useEffect, useCallback, useMemo, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { useQueryClient, useMutation } from "@tanstack/react-query"; -import { useEditor, EditorContent, type Editor } from "@tiptap/react"; +import { NodeViewWrapper, ReactNodeViewRenderer, useEditor, EditorContent, type Editor, type NodeViewProps } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Image from "@tiptap/extension-image"; import Placeholder from "@tiptap/extension-placeholder"; @@ -62,6 +62,10 @@ import { const SUPPORTED_PASTE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]); const MOBILE_EDITOR_QUERY = "(max-width: 639px)"; +const DEFAULT_IMAGE_WIDTH_PERCENT = 72; +const MIN_IMAGE_WIDTH_PERCENT = 25; +const MAX_IMAGE_WIDTH_PERCENT = 100; +const IMAGE_WIDTH_PRESETS = [35, 50, 72, 100]; type NoteSearchMatch = { from: number; @@ -129,6 +133,135 @@ const getImageFilesFromDataTransfer = (dataTransfer: DataTransfer | null) => { return files.filter((file) => SUPPORTED_PASTE_IMAGE_TYPES.has(file.type)); }; +const clampImageWidth = (width: number) => + Math.min(MAX_IMAGE_WIDTH_PERCENT, Math.max(MIN_IMAGE_WIDTH_PERCENT, Math.round(width))); + +const parseImageWidth = (value: unknown) => { + if (typeof value === "number" && Number.isFinite(value)) { + return clampImageWidth(value); + } + + if (typeof value !== "string") { + return null; + } + + const match = /(\d+(?:\.\d+)?)/.exec(value); + return match ? clampImageWidth(Number(match[1])) : null; +}; + +const ResizableImageNodeView = ({ editor, node, selected, updateAttributes }: NodeViewProps) => { + const wrapperRef = useRef(null); + const width = parseImageWidth(node.attrs.width) ?? DEFAULT_IMAGE_WIDTH_PERCENT; + const editable = editor.isEditable; + const alt = typeof node.attrs.alt === "string" ? node.attrs.alt : ""; + const title = typeof node.attrs.title === "string" ? node.attrs.title : ""; + const src = typeof node.attrs.src === "string" ? node.attrs.src : ""; + + const updateWidth = useCallback( + (nextWidth: number) => { + updateAttributes({ width: clampImageWidth(nextWidth) }); + }, + [updateAttributes] + ); + + const startResize = useCallback( + (event: ReactPointerEvent) => { + if (!editable) { + return; + } + + const wrapper = wrapperRef.current; + const parent = wrapper?.parentElement; + if (!wrapper || !parent) { + return; + } + + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + + const parentWidth = parent.getBoundingClientRect().width; + if (parentWidth <= 0) { + return; + } + + const updateFromPointer = (clientX: number) => { + const wrapperLeft = wrapper.getBoundingClientRect().left; + updateWidth(((clientX - wrapperLeft) / parentWidth) * 100); + }; + + const handlePointerMove = (moveEvent: PointerEvent) => updateFromPointer(moveEvent.clientX); + const stopResize = () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", stopResize); + window.removeEventListener("pointercancel", stopResize); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", stopResize); + window.addEventListener("pointercancel", stopResize); + updateFromPointer(event.clientX); + }, + [editable, updateWidth] + ); + + return ( + + {alt} + {editable && selected && ( +
+
+ {IMAGE_WIDTH_PRESETS.map((preset) => ( + + ))} +
+
+ )} +
+ ); +}; + +const ResizableImage = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: null, + parseHTML: (element) => + parseImageWidth(element.getAttribute("data-width") ?? element.getAttribute("width") ?? element.style.width), + renderHTML: (attributes) => { + const width = parseImageWidth(attributes.width); + return width ? { "data-width": String(width), style: `width: ${width}%` } : {}; + }, + }, + }; + }, + addNodeView() { + return ReactNodeViewRenderer(ResizableImageNodeView); + }, +}); + const syncStatusToSaveState = (status: "pending" | "syncing" | "conflict" | "error") => { if (status === "conflict") { return "conflict"; @@ -366,6 +499,7 @@ export const EditorPane = ({ src: resource.url, alt: file.name, title: file.name, + width: DEFAULT_IMAGE_WIDTH_PERCENT, }) .run(); } @@ -416,6 +550,7 @@ export const EditorPane = ({ src: resource.url, alt: file.name, title: file.name, + width: DEFAULT_IMAGE_WIDTH_PERCENT, }) .run(); } else { @@ -441,7 +576,7 @@ export const EditorPane = ({ const editor = useEditor({ extensions: [ StarterKit, - Image.configure({ + ResizableImage.configure({ allowBase64: false, inline: false, }), diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 943ab33b..9c3885b9 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -181,7 +181,9 @@ textarea { .ProseMirror img { display: block; max-width: 100%; + max-height: min(68vh, 720px); height: auto; + object-fit: contain; margin: 1rem 0; border: 1px solid var(--brand-green-border); border-radius: 8px; @@ -192,6 +194,79 @@ textarea { outline-offset: 3px; } +.ProseMirror .edgeever-image-node { + position: relative; + display: block; + max-width: 100%; + min-width: 9rem; + margin: 1rem 0; + line-height: 0; +} + +.ProseMirror .edgeever-image-node img { + width: 100%; + margin: 0; +} + +.ProseMirror .edgeever-image-node.is-selected img { + outline: 2px solid var(--brand-green); + outline-offset: 3px; +} + +.ProseMirror .edgeever-image-controls { + position: absolute; + right: 0.5rem; + bottom: 0.5rem; + z-index: 2; + display: flex; + align-items: center; + gap: 0.35rem; + 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; +} + +.ProseMirror .edgeever-image-presets { + display: flex; + align-items: center; + gap: 0.2rem; +} + +.ProseMirror .edgeever-image-preset { + display: inline-flex; + min-width: 2rem; + height: 1.75rem; + align-items: center; + justify-content: center; + border-radius: 6px; + color: #475569; + font-size: 0.75rem; + font-weight: 700; +} + +.ProseMirror .edgeever-image-preset:hover, +.ProseMirror .edgeever-image-preset.is-active { + background: var(--brand-green-muted); + color: var(--brand-green-text); +} + +.ProseMirror .edgeever-image-resize-handle { + width: 1.75rem; + height: 1.75rem; + border-radius: 6px; + background: + linear-gradient(135deg, transparent 46%, var(--brand-green) 48%, var(--brand-green) 54%, transparent 56%), + linear-gradient(135deg, transparent 62%, rgba(var(--brand-green-600-rgb), 0.8) 64%, rgba(var(--brand-green-600-rgb), 0.8) 70%, transparent 72%); + cursor: ew-resize; +} + +.ProseMirror .edgeever-image-resize-handle:hover { + background-color: var(--brand-green-muted); +} + .ProseMirror p.is-editor-empty:first-child::before { color: #94a3b8; content: attr(data-placeholder);