127 lines
3.9 KiB
TypeScript
127 lines
3.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useEditor, EditorContent, NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from "@tiptap/react";
|
|
import { Node, mergeAttributes } from "@tiptap/core";
|
|
import StarterKit from "@tiptap/starter-kit";
|
|
import CodeBlock from "@tiptap/extension-code-block";
|
|
import Image from "@tiptap/extension-image";
|
|
import Link from "@tiptap/extension-link";
|
|
import Underline from "@tiptap/extension-underline";
|
|
import { Copy, Check } from "lucide-react";
|
|
import { KinescopePlayer } from "@/components/player/kinescope-player";
|
|
|
|
function CodeBlockStudentView({ node }: { node: { textContent: string } }) {
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const handleCopy = async () => {
|
|
// WYSIWYG: копируем содержимое блока ровно как показано. Для Obsidian-сниппетов
|
|
// (dataview/query) забор ```...``` хранится прямо в содержимом → копируется целиком.
|
|
try {
|
|
await navigator.clipboard.writeText(node.textContent);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
} catch {
|
|
// clipboard недоступен (нет https / нет прав) — тихо игнорируем
|
|
}
|
|
};
|
|
|
|
return (
|
|
<NodeViewWrapper className="relative group my-4">
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
contentEditable={false}
|
|
aria-label="Скопировать код"
|
|
className="absolute top-2 right-2 z-10 flex items-center gap-1 px-2 py-1 text-xs select-none transition-opacity"
|
|
style={{
|
|
border: "1.5px solid var(--border)",
|
|
background: "var(--background)",
|
|
color: copied ? "var(--foreground)" : "var(--muted-foreground)",
|
|
fontFamily: "var(--font-mono, monospace)",
|
|
}}
|
|
>
|
|
{copied ? <Check size={13} strokeWidth={3} /> : <Copy size={13} />}
|
|
{copied ? "Скопировано" : "Копировать"}
|
|
</button>
|
|
<pre>
|
|
<NodeViewContent<"code"> as="code" />
|
|
</pre>
|
|
</NodeViewWrapper>
|
|
);
|
|
}
|
|
|
|
const CodeBlockWithCopy = CodeBlock.extend({
|
|
addNodeView() {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return ReactNodeViewRenderer(CodeBlockStudentView as any);
|
|
},
|
|
});
|
|
|
|
function KinescopeVideoStudentView({
|
|
node,
|
|
extension,
|
|
}: {
|
|
node: { attrs: Record<string, unknown> };
|
|
extension: { options: { poster?: string } };
|
|
}) {
|
|
const videoId = node.attrs.videoId as string;
|
|
if (!videoId) return null;
|
|
return (
|
|
<NodeViewWrapper>
|
|
<div className="my-6">
|
|
<KinescopePlayer videoId={videoId} poster={extension.options.poster} />
|
|
</div>
|
|
</NodeViewWrapper>
|
|
);
|
|
}
|
|
|
|
const KinescopeVideoExtension = Node.create<{ poster?: string }>({
|
|
name: "kinescopeVideo",
|
|
group: "block",
|
|
atom: true,
|
|
|
|
addOptions() {
|
|
return { poster: undefined };
|
|
},
|
|
|
|
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, coverImage }: { content: object; coverImage?: string | null }) {
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit.configure({ codeBlock: false }),
|
|
CodeBlockWithCopy,
|
|
Underline,
|
|
Image.configure({ inline: false }),
|
|
Link.configure({ openOnClick: true }),
|
|
KinescopeVideoExtension.configure({ poster: coverImage ?? undefined }),
|
|
],
|
|
content,
|
|
editable: false,
|
|
editorProps: {
|
|
attributes: {
|
|
class: "prose prose-slate max-w-none focus:outline-none",
|
|
},
|
|
},
|
|
});
|
|
|
|
return <EditorContent editor={editor} />;
|
|
}
|