Add in-content video embed and image replace/delete controls
- KinescopeVideo TipTap node: insert video anywhere in lesson content via toolbar button; editor shows ID preview, student view renders KinescopePlayer - ImageWithControls NodeView: hover over any image in editor to reveal Replace and Delete buttons Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { useEditor, EditorContent, NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
@@ -10,6 +11,153 @@ import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Save, Eye, FileUp, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// ── Image NodeView: hover controls (replace / delete) ──────────────────────
|
||||
|
||||
function ImageWithControls({ node, updateAttributes, deleteNode }: {
|
||||
node: { attrs: Record<string, unknown> };
|
||||
updateAttributes: (attrs: Record<string, unknown>) => void;
|
||||
deleteNode: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
async function handleReplace() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/admin/upload", { method: "POST", body: fd });
|
||||
const data = await res.json();
|
||||
if (data.url) updateAttributes({ src: data.url });
|
||||
setUploading(false);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
background: "var(--background)",
|
||||
border: "2px solid var(--foreground)",
|
||||
color: "var(--foreground)",
|
||||
padding: "3px 10px",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
style={{ display: "block", position: "relative" }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<img
|
||||
src={node.attrs.src as string}
|
||||
alt={(node.attrs.alt as string) ?? ""}
|
||||
style={{ maxWidth: "100%", display: "block" }}
|
||||
/>
|
||||
{hovered && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "8px",
|
||||
right: "8px",
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
<button type="button" onClick={handleReplace} disabled={uploading} style={btnStyle}>
|
||||
{uploading ? "Загрузка..." : "Заменить"}
|
||||
</button>
|
||||
<button type="button" onClick={() => deleteNode()} style={{ ...btnStyle, borderColor: "oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const ImageWithControlsExtension = Image.extend({
|
||||
addNodeView() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return ReactNodeViewRenderer(ImageWithControls as any);
|
||||
},
|
||||
});
|
||||
|
||||
// ── KinescopeVideo TipTap node (editor preview) ────────────────────────────
|
||||
|
||||
function KinescopeVideoEditorView({ node, updateAttributes, deleteNode }: {
|
||||
node: { attrs: Record<string, unknown> };
|
||||
updateAttributes: (attrs: Record<string, unknown>) => void;
|
||||
deleteNode: () => void;
|
||||
}) {
|
||||
const videoId = node.attrs.videoId as string;
|
||||
|
||||
function changeVideoId() {
|
||||
const newId = window.prompt("Kinescope Video ID:", videoId ?? "");
|
||||
if (newId === null) return;
|
||||
if (!newId.trim()) { deleteNode(); return; }
|
||||
updateAttributes({ videoId: newId.trim() });
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div
|
||||
style={{
|
||||
border: "2px solid var(--border)",
|
||||
padding: "0.6rem 1rem",
|
||||
background: "var(--color-surface)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
fontSize: "13px",
|
||||
margin: "0.5rem 0",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--muted-foreground)" }}>▶ Kinescope:</span>
|
||||
<code style={{ flex: 1, fontFamily: "var(--font-mono)", fontSize: "12px" }}>
|
||||
{videoId || <span style={{ opacity: 0.4 }}>не задан</span>}
|
||||
</code>
|
||||
<button type="button" onClick={changeVideoId} className="btn-aubade px-2 py-1 text-xs">
|
||||
Изменить
|
||||
</button>
|
||||
<button type="button" onClick={() => deleteNode()} className="btn-aubade px-2 py-1 text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const KinescopeVideoExtension = Node.create({
|
||||
name: "kinescopeVideo",
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return { videoId: { default: "" } };
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "div[data-kinescope-video]" }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["div", mergeAttributes({ "data-kinescope-video": "" }, HTMLAttributes)];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return ReactNodeViewRenderer(KinescopeVideoEditorView as any);
|
||||
},
|
||||
});
|
||||
|
||||
interface LessonData {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -62,9 +210,10 @@ export function LessonEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Image.configure({ inline: false }),
|
||||
ImageWithControlsExtension.configure({ inline: false }),
|
||||
Link.configure({ openOnClick: false }),
|
||||
Placeholder.configure({ placeholder: "Начните писать текст урока..." }),
|
||||
KinescopeVideoExtension,
|
||||
],
|
||||
content: Object.keys(lesson.content).length ? lesson.content : undefined,
|
||||
editorProps: {
|
||||
@@ -120,6 +269,13 @@ export function LessonEditor({
|
||||
input.click();
|
||||
}, [editor]);
|
||||
|
||||
const insertKinescopeVideo = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const videoId = window.prompt("Kinescope Video ID:");
|
||||
if (!videoId?.trim()) return;
|
||||
editor.chain().focus().insertContent({ type: "kinescopeVideo", attrs: { videoId: videoId.trim() } }).run();
|
||||
}, [editor]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes("link").href as string | undefined;
|
||||
@@ -355,6 +511,7 @@ export function LessonEditor({
|
||||
{/* Link & image */}
|
||||
<ToolBtn onClick={addLink} active={editor?.isActive("link")}>🔗 Ссылка</ToolBtn>
|
||||
<ToolBtn onClick={uploadImage} disabled={uploading}>{uploading ? "Загрузка..." : "🖼 Фото"}</ToolBtn>
|
||||
<ToolBtn onClick={insertKinescopeVideo}>▶ Видео</ToolBtn>
|
||||
|
||||
<Sep />
|
||||
|
||||
|
||||
@@ -1,16 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { useEditor, EditorContent, NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import { KinescopePlayer } from "@/components/player/kinescope-player";
|
||||
|
||||
function KinescopeVideoStudentView({ node }: { node: { attrs: Record<string, unknown> } }) {
|
||||
const videoId = node.attrs.videoId as string;
|
||||
if (!videoId) return null;
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className="my-6">
|
||||
<KinescopePlayer videoId={videoId} />
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const KinescopeVideoExtension = Node.create({
|
||||
name: "kinescopeVideo",
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return { videoId: { default: "" } };
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "div[data-kinescope-video]" }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["div", mergeAttributes({ "data-kinescope-video": "" }, HTMLAttributes)];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return ReactNodeViewRenderer(KinescopeVideoStudentView as any);
|
||||
},
|
||||
});
|
||||
|
||||
export function LessonContent({ content }: { content: object }) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Image.configure({ inline: false }),
|
||||
Link.configure({ openOnClick: true }),
|
||||
KinescopeVideoExtension,
|
||||
],
|
||||
content,
|
||||
editable: false,
|
||||
|
||||
Reference in New Issue
Block a user