Add resizable note images
This commit is contained in:
1 parent
53df429d0d
commit
e829f9a8a5
2 files changed
+213
-3
No files matched your search
@@ -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<HTMLDivElement | null>(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<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<NodeViewWrapper
|
||||
ref={wrapperRef}
|
||||
as="figure"
|
||||
className={cn("edgeever-image-node", selected && "is-selected")}
|
||||
style={{ width: `${width}%` }}
|
||||
data-width={width}
|
||||
>
|
||||
<img src={src} alt={alt} title={title || undefined} draggable={false} />
|
||||
{editable && selected && (
|
||||
<div className="edgeever-image-controls" contentEditable={false}>
|
||||
<div className="edgeever-image-presets" aria-label="图片缩放">
|
||||
{IMAGE_WIDTH_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
className={cn("edgeever-image-preset", width === preset && "is-active")}
|
||||
title={`缩放到 ${preset}%`}
|
||||
aria-label={`缩放到 ${preset}%`}
|
||||
onClick={() => updateWidth(preset)}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="edgeever-image-resize-handle"
|
||||
title="拖拽调整图片宽度"
|
||||
aria-label="拖拽调整图片宽度"
|
||||
onPointerDown={startResize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user