feat(web-editor): add resizable media interactions
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+3
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user