feat(web-editor): add resizable media interactions

This commit is contained in:
2026-04-05 23:58:39 +08:00
parent fab72906c9
commit 8ef7c75948
7 changed files with 695 additions and 53 deletions
+1
View File
@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@tiptap/core": "^3.22.2",
"@tiptap/extension-image": "^3.22.2", "@tiptap/extension-image": "^3.22.2",
"@tiptap/extension-link": "^3.22.2", "@tiptap/extension-link": "^3.22.2",
"@tiptap/extension-youtube": "^3.22.2", "@tiptap/extension-youtube": "^3.22.2",
@@ -0,0 +1,283 @@
import { useEffect, useRef, useState } from "react";
import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
import { cn } from "@/lib/utils";
type MediaAlign = "left" | "center" | "right";
type MediaKind = "image" | "video" | "youtube";
type ResizeSide = "left" | "right";
type ResizableMediaNodeViewProps = NodeViewProps & {
mediaKind: MediaKind;
};
type HandleDescriptor = {
key: string;
side: ResizeSide;
className: string;
};
const HANDLE_DESCRIPTORS: HandleDescriptor[] = [
{
key: "top-left",
side: "left",
className: "-left-1.5 -top-1.5 cursor-ew-resize"
},
{
key: "bottom-left",
side: "left",
className: "-bottom-1.5 -left-1.5 cursor-ew-resize"
},
{
key: "top-right",
side: "right",
className: "-right-1.5 -top-1.5 cursor-ew-resize"
},
{
key: "bottom-right",
side: "right",
className: "-bottom-1.5 -right-1.5 cursor-ew-resize"
}
];
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function readWidthPercent(value: unknown): number {
const numericValue = typeof value === "number" ? value : Number(value);
if (Number.isNaN(numericValue)) {
return 100;
}
return clamp(numericValue, 25, 100);
}
function readAlign(value: unknown): MediaAlign {
if (value === "left" || value === "right" || value === "center") {
return value;
}
return "center";
}
function resolveAlignClass(align: MediaAlign): string {
if (align === "left") {
return "mr-auto";
}
if (align === "right") {
return "ml-auto";
}
return "mx-auto";
}
function isStringValue(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export function ResizableMediaNodeView({
editor,
getPos,
mediaKind,
node,
selected,
updateAttributes
}: ResizableMediaNodeViewProps) {
const [isResizing, setIsResizing] = useState(false);
const mediaFrameRef = useRef<HTMLDivElement | null>(null);
const cleanupResizeRef = useRef<(() => void) | null>(null);
const widthPercent = readWidthPercent(node.attrs.widthPercent);
const align = readAlign(node.attrs.align);
const src = isStringValue(node.attrs.src) ? node.attrs.src : "";
const alt = isStringValue(node.attrs.alt) ? node.attrs.alt : "";
const title = isStringValue(node.attrs.title) ? node.attrs.title : "";
const showControls = selected || isResizing;
useEffect(() => {
return () => {
cleanupResizeRef.current?.();
};
}, []);
function selectCurrentNode(): void {
const position = getPos();
if (typeof position !== "number") {
return;
}
editor.chain().focus().setNodeSelection(position).run();
}
function applyAlign(nextAlign: MediaAlign): void {
selectCurrentNode();
updateAttributes({ align: nextAlign });
}
function startResize(side: ResizeSide) {
return (event: React.PointerEvent<HTMLButtonElement>): void => {
event.preventDefault();
event.stopPropagation();
selectCurrentNode();
const mediaFrame = mediaFrameRef.current;
const editorRoot = mediaFrame?.closest(".ProseMirror") as HTMLElement | null;
if (!mediaFrame || !editorRoot) {
return;
}
const startX = event.clientX;
const startWidth = mediaFrame.getBoundingClientRect().width;
const maxWidth = Math.max(editorRoot.clientWidth - 24, 240);
const handlePointerMove = (moveEvent: PointerEvent): void => {
const delta = moveEvent.clientX - startX;
const resizedWidth = side === "right" ? startWidth + delta : startWidth - delta;
const nextWidth = clamp(resizedWidth, 180, maxWidth);
const nextWidthPercent = clamp((nextWidth / maxWidth) * 100, 25, 100);
updateAttributes({
widthPercent: Math.round(nextWidthPercent)
});
};
const handlePointerUp = (): void => {
cleanupResizeRef.current?.();
cleanupResizeRef.current = null;
setIsResizing(false);
};
cleanupResizeRef.current = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
setIsResizing(true);
};
}
function renderMediaContent() {
if (mediaKind === "image") {
return (
<img
src={src}
alt={alt}
title={title}
draggable={false}
className="block h-auto w-full rounded-xl object-contain"
/>
);
}
if (mediaKind === "youtube") {
return (
<div className="aspect-video w-full overflow-hidden rounded-xl bg-black">
<iframe
src={src}
title={title || "????"}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="h-full w-full border-0"
/>
</div>
);
}
return (
<video src={src} title={title} controls className="block h-auto w-full rounded-xl bg-black" />
);
}
return (
<NodeViewWrapper className="my-4" contentEditable={false}>
<div
ref={mediaFrameRef}
className={cn("relative transition-[width] duration-150", resolveAlignClass(align))}
style={{ width: `${widthPercent}%` }}
onMouseDown={selectCurrentNode}
>
{showControls ? (
<div className="absolute left-0 top-0 z-20 flex -translate-y-[calc(100%+8px)] items-center gap-1 rounded-lg border border-border bg-card/95 px-2 py-1 shadow-sm backdrop-blur">
<button
type="button"
className={cn(
"rounded px-1.5 py-0.5 text-[11px] transition-colors",
align === "left"
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
onClick={() => applyAlign("left")}
>
?
</button>
<button
type="button"
className={cn(
"rounded px-1.5 py-0.5 text-[11px] transition-colors",
align === "center"
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
onClick={() => applyAlign("center")}
>
?
</button>
<button
type="button"
className={cn(
"rounded px-1.5 py-0.5 text-[11px] transition-colors",
align === "right"
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
onClick={() => applyAlign("right")}
>
?
</button>
<span className="pl-1 text-[11px] text-muted-foreground">{widthPercent}%</span>
</div>
) : null}
<div
className={cn(
"relative rounded-xl border bg-muted/20 transition-colors",
showControls ? "border-primary/40 ring-2 ring-primary/20" : "border-border/70"
)}
>
{(mediaKind === "video" || mediaKind === "youtube") && !showControls ? (
<button
type="button"
aria-label="????"
className="absolute inset-0 z-10 rounded-xl"
onClick={selectCurrentNode}
/>
) : null}
{renderMediaContent()}
</div>
{showControls
? HANDLE_DESCRIPTORS.map((handle) => (
<button
key={handle.key}
type="button"
aria-label="??????"
className={cn(
"absolute z-20 h-3 w-3 rounded-full border border-background bg-primary shadow-sm",
handle.className
)}
onPointerDown={startResize(handle.side)}
/>
))
: null}
</div>
</NodeViewWrapper>
);
}
+235 -53
View File
@@ -1,10 +1,12 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { useEffect, useRef, useState, type ChangeEvent } from "react";
import imageCompression from "browser-image-compression"; import imageCompression from "browser-image-compression";
import Image from "@tiptap/extension-image"; import type { Editor as TiptapEditor } from "@tiptap/core";
import Link from "@tiptap/extension-link"; import Link from "@tiptap/extension-link";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Youtube from "@tiptap/extension-youtube";
import { EditorContent, type JSONContent, useEditor } from "@tiptap/react"; import { EditorContent, type JSONContent, useEditor } from "@tiptap/react";
import { ResizableImage } from "@/extensions/resizable-image";
import { ResizableVideo } from "@/extensions/resizable-video";
import { ResizableYoutube } from "@/extensions/resizable-youtube";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024; const MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024;
@@ -57,6 +59,14 @@ function resolveEditorContent(
return textFallback; return textFallback;
} }
function parseEditorJson(valueJson: string): JSONContent | null {
try {
return JSON.parse(valueJson) as JSONContent;
} catch {
return null;
}
}
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes < 1024) { if (bytes < 1024) {
return `${bytes} B`; return `${bytes} B`;
@@ -69,16 +79,87 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
} }
function isYoutubeUrl(url: string): boolean {
return /(youtube\.com|youtu\.be)/i.test(url);
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
return;
}
reject(new Error("读取文件失败"));
};
reader.onerror = () => {
reject(new Error("读取文件失败"));
};
reader.readAsDataURL(file);
});
}
function createUploadToken(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `upload-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function replaceMediaSourceByUploadToken(
editor: TiptapEditor,
uploadToken: string,
attributes: Record<string, string | number | null>
): boolean {
return editor.commands.command(({ tr, state }) => {
let updated = false;
state.doc.descendants((node, position) => {
if (node.attrs.uploadToken !== uploadToken) {
return true;
}
tr.setNodeMarkup(position, undefined, {
...node.attrs,
...attributes
});
updated = true;
return false;
});
return updated;
});
}
function removeMediaByUploadToken(editor: TiptapEditor, uploadToken: string): boolean {
return editor.commands.command(({ tr, state }) => {
let removed = false;
state.doc.descendants((node, position) => {
if (node.attrs.uploadToken !== uploadToken) {
return true;
}
tr.delete(position, position + node.nodeSize);
removed = true;
return false;
});
return removed;
});
}
export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEditorProps) { export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEditorProps) {
const [mediaHint, setMediaHint] = useState<string | null>(null); const [mediaHint, setMediaHint] = useState<string | null>(null);
const imageInputRef = useRef<HTMLInputElement | null>(null); const imageInputRef = useRef<HTMLInputElement | null>(null);
const videoInputRef = useRef<HTMLInputElement | null>(null); const videoInputRef = useRef<HTMLInputElement | null>(null);
const content = useMemo(
() => resolveEditorContent(valueJson, textFallback),
[valueJson, textFallback]
);
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit,
@@ -91,12 +172,13 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
target: "_blank" target: "_blank"
} }
}), }),
Image, ResizableImage,
Youtube.configure({ ResizableVideo,
ResizableYoutube.configure({
controls: true controls: true
}) })
], ],
content, content: resolveEditorContent(valueJson, textFallback),
editorProps: { editorProps: {
attributes: { attributes: {
class: class:
@@ -115,8 +197,30 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
return; return;
} }
editor.commands.setContent(content, { emitUpdate: false }); if (valueJson) {
}, [content, editor]); const nextJson = parseEditorJson(valueJson);
if (!nextJson) {
if (editor.getText() !== textFallback) {
editor.commands.setContent(textFallback, { emitUpdate: false });
}
return;
}
if (JSON.stringify(editor.getJSON()) === JSON.stringify(nextJson)) {
return;
}
editor.commands.setContent(nextJson, { emitUpdate: false });
return;
}
if (editor.getText() === textFallback) {
return;
}
editor.commands.setContent(textFallback, { emitUpdate: false });
}, [editor, textFallback, valueJson]);
async function handleImageFileChange(event: ChangeEvent<HTMLInputElement>): Promise<void> { async function handleImageFileChange(event: ChangeEvent<HTMLInputElement>): Promise<void> {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@@ -131,6 +235,25 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
return; return;
} }
const uploadToken = createUploadToken();
const previewUrl = URL.createObjectURL(file);
editor
.chain()
.focus()
.insertContent({
type: "image",
attrs: {
src: previewUrl,
alt: file.name,
title: file.name,
widthPercent: 100,
align: "center",
uploadToken
}
})
.run();
try { try {
const compressedImage = await imageCompression(file, { const compressedImage = await imageCompression(file, {
maxSizeMB: 1, maxSizeMB: 1,
@@ -138,19 +261,24 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
useWebWorker: true, useWebWorker: true,
initialQuality: 0.8 initialQuality: 0.8
}); });
const imageSource = await imageCompression.getDataUrlFromFile(compressedImage); const imageSource = await imageCompression.getDataUrlFromFile(compressedImage);
editor.chain().focus().setImage({ src: imageSource, alt: file.name }).run();
setMediaHint( replaceMediaSourceByUploadToken(editor, uploadToken, {
`图片已插入:${formatBytes(file.size)} -> ${formatBytes(compressedImage.size)}` src: imageSource,
); alt: file.name,
title: file.name,
uploadToken: null
});
setMediaHint(null);
} catch { } catch {
removeMediaByUploadToken(editor, uploadToken);
setMediaHint("图片处理失败,请重试。"); setMediaHint("图片处理失败,请重试。");
} finally {
URL.revokeObjectURL(previewUrl);
} }
} }
function handleVideoFileChange(event: ChangeEvent<HTMLInputElement>): void { async function handleVideoFileChange(event: ChangeEvent<HTMLInputElement>): Promise<void> {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ""; event.target.value = "";
@@ -163,12 +291,95 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
return; return;
} }
const uploadToken = createUploadToken();
const previewUrl = URL.createObjectURL(file);
editor editor
.chain() .chain()
.focus() .focus()
.insertContent(`\n[视频待上传] ${file.name}${formatBytes(file.size)}\n`) .insertContent({
type: "video",
attrs: {
src: previewUrl,
title: file.name,
widthPercent: 100,
align: "center",
uploadToken
}
})
.run(); .run();
setMediaHint("视频已通过大小校验并插入占位文本,正式上传接口将在后续接入。");
try {
const videoSource = await readFileAsDataUrl(file);
replaceMediaSourceByUploadToken(editor, uploadToken, {
src: videoSource,
title: file.name,
uploadToken: null
});
setMediaHint(null);
} catch {
removeMediaByUploadToken(editor, uploadToken);
setMediaHint("视频处理失败,请重试。");
} finally {
URL.revokeObjectURL(previewUrl);
}
}
function handleInsertImageUrl(): void {
if (!editor) {
return;
}
const url = window.prompt("请输入图片 URL");
if (!url) {
return;
}
editor
.chain()
.focus()
.setImage({
src: url
})
.run();
setMediaHint(null);
}
function handleInsertVideoUrl(): void {
if (!editor) {
return;
}
const url = window.prompt("请输入视频 URL");
if (!url) {
return;
}
if (isYoutubeUrl(url)) {
editor
.chain()
.focus()
.setYoutubeVideo({
src: url,
width: 640,
height: 360
})
.run();
setMediaHint(null);
return;
}
editor
.chain()
.focus()
.setVideo({
src: url
})
.run();
setMediaHint(null);
} }
return ( return (
@@ -223,6 +434,7 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
} }
const url = window.prompt("请输入链接地址"); const url = window.prompt("请输入链接地址");
if (!url) { if (!url) {
return; return;
} }
@@ -230,43 +442,13 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
editor.chain().focus().setLink({ href: url }).run(); editor.chain().focus().setLink({ href: url }).run();
}} }}
/> />
<ToolbarButton <ToolbarButton label="图片 URL" disabled={!editor} onClick={handleInsertImageUrl} />
label="图片URL"
disabled={!editor}
onClick={() => {
if (!editor) {
return;
}
const url = window.prompt("请输入图片 URL");
if (!url) {
return;
}
editor.chain().focus().setImage({ src: url }).run();
}}
/>
<ToolbarButton <ToolbarButton
label="上传图片" label="上传图片"
disabled={!editor} disabled={!editor}
onClick={() => imageInputRef.current?.click()} onClick={() => imageInputRef.current?.click()}
/> />
<ToolbarButton <ToolbarButton label="视频 URL" disabled={!editor} onClick={handleInsertVideoUrl} />
label="视频URL"
disabled={!editor}
onClick={() => {
if (!editor) {
return;
}
const url = window.prompt("请输入视频 URL(当前支持 YouTube");
if (!url) {
return;
}
editor.chain().focus().setYoutubeVideo({ src: url }).run();
}}
/>
<ToolbarButton <ToolbarButton
label="上传视频" label="上传视频"
disabled={!editor} disabled={!editor}
@@ -0,0 +1,42 @@
import Image from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { ResizableMediaNodeView } from "@/components/editor/resizable-media-node-view";
export const ResizableImage = Image.extend({
addAttributes() {
return {
...(this.parent?.() ?? {}),
widthPercent: {
default: 100,
parseHTML: (element: HTMLElement) =>
Number(element.getAttribute("data-width-percent") ?? 100),
renderHTML: (attributes: { widthPercent?: number }) => ({
"data-width-percent": attributes.widthPercent ?? 100
})
},
align: {
default: "center",
parseHTML: (element: HTMLElement) => element.getAttribute("data-align") ?? "center",
renderHTML: (attributes: { align?: string }) => ({
"data-align": attributes.align ?? "center"
})
},
uploadToken: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute("data-upload-token"),
renderHTML: (attributes: { uploadToken?: string | null }) =>
attributes.uploadToken
? {
"data-upload-token": attributes.uploadToken
}
: {}
}
};
},
addNodeView() {
return ReactNodeViewRenderer((props) => (
<ResizableMediaNodeView {...props} mediaKind="image" />
));
}
});
@@ -0,0 +1,99 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { ResizableMediaNodeView } from "@/components/editor/resizable-media-node-view";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
video: {
setVideo: (attributes: {
src: string;
title?: string | null;
widthPercent?: number;
align?: "left" | "center" | "right";
}) => ReturnType;
};
}
}
export const ResizableVideo = Node.create({
name: "video",
group: "block",
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
src: {
default: null
},
title: {
default: null
},
widthPercent: {
default: 100,
parseHTML: (element: HTMLElement) =>
Number(element.getAttribute("data-width-percent") ?? 100),
renderHTML: (attributes: { widthPercent?: number }) => ({
"data-width-percent": attributes.widthPercent ?? 100
})
},
align: {
default: "center",
parseHTML: (element: HTMLElement) => element.getAttribute("data-align") ?? "center",
renderHTML: (attributes: { align?: string }) => ({
"data-align": attributes.align ?? "center"
})
},
uploadToken: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute("data-upload-token"),
renderHTML: (attributes: { uploadToken?: string | null }) =>
attributes.uploadToken
? {
"data-upload-token": attributes.uploadToken
}
: {}
}
};
},
parseHTML() {
return [
{
tag: "video[src]"
}
];
},
renderHTML({ HTMLAttributes }) {
return [
"video",
mergeAttributes(HTMLAttributes, {
controls: "true"
})
];
},
addCommands() {
return {
setVideo:
(attributes) =>
({ commands }) =>
commands.insertContent({
type: this.name,
attrs: {
align: "center",
widthPercent: 100,
...attributes
}
})
};
},
addNodeView() {
return ReactNodeViewRenderer((props) => (
<ResizableMediaNodeView {...props} mediaKind="video" />
));
}
});
@@ -0,0 +1,32 @@
import Youtube from "@tiptap/extension-youtube";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { ResizableMediaNodeView } from "@/components/editor/resizable-media-node-view";
export const ResizableYoutube = Youtube.extend({
addAttributes() {
return {
...(this.parent?.() ?? {}),
widthPercent: {
default: 100,
parseHTML: (element: HTMLElement) =>
Number(element.getAttribute("data-width-percent") ?? 100),
renderHTML: (attributes: { widthPercent?: number }) => ({
"data-width-percent": attributes.widthPercent ?? 100
})
},
align: {
default: "center",
parseHTML: (element: HTMLElement) => element.getAttribute("data-align") ?? "center",
renderHTML: (attributes: { align?: string }) => ({
"data-align": attributes.align ?? "center"
})
}
};
},
addNodeView() {
return ReactNodeViewRenderer((props) => (
<ResizableMediaNodeView {...props} mediaKind="youtube" />
));
}
});
+3
View File
@@ -149,6 +149,9 @@ importers:
"@fontsource-variable/geist": "@fontsource-variable/geist":
specifier: ^5.2.8 specifier: ^5.2.8
version: 5.2.8 version: 5.2.8
"@tiptap/core":
specifier: ^3.22.2
version: 3.22.2(@tiptap/pm@3.22.2)
"@tiptap/extension-image": "@tiptap/extension-image":
specifier: ^3.22.2 specifier: ^3.22.2
version: 3.22.2(@tiptap/core@3.22.2(@tiptap/pm@3.22.2)) version: 3.22.2(@tiptap/core@3.22.2(@tiptap/pm@3.22.2))