tetrons 2.1.7 → 2.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/api/export/route.d.ts +1 -0
- package/dist/app/api/export/route.js +4 -0
- package/dist/app/api/save/route.d.ts +6 -0
- package/dist/app/api/save/route.js +15 -0
- package/dist/app/layout.d.ts +6 -0
- package/dist/app/layout.jsx +30 -0
- package/dist/app/page.d.ts +1 -0
- package/dist/app/page.jsx +8 -0
- package/dist/components/UI/Button.d.ts +0 -0
- package/dist/components/UI/Button.jsx +1 -0
- package/dist/components/UI/Dropdown.d.ts +0 -0
- package/dist/components/UI/Dropdown.jsx +1 -0
- package/dist/components/tetrons/EditorContent.d.ts +2 -0
- package/dist/components/tetrons/EditorContent.jsx +160 -0
- package/dist/components/tetrons/ResizableImage.d.ts +1 -0
- package/dist/components/tetrons/ResizableImage.js +40 -0
- package/dist/components/tetrons/ResizableImageComponent.d.ts +11 -0
- package/dist/components/tetrons/ResizableImageComponent.jsx +37 -0
- package/dist/components/tetrons/ResizableVideo.d.ts +12 -0
- package/dist/components/tetrons/ResizableVideo.js +61 -0
- package/dist/components/tetrons/ResizableVideoComponent.d.ts +4 -0
- package/dist/components/tetrons/ResizableVideoComponent.jsx +32 -0
- package/dist/components/tetrons/helpers.d.ts +0 -0
- package/dist/components/tetrons/helpers.js +1 -0
- package/dist/components/tetrons/toolbar/ActionGroup.d.ts +7 -0
- package/dist/components/tetrons/toolbar/ActionGroup.jsx +165 -0
- package/dist/components/tetrons/toolbar/ClipboardGroup.d.ts +5 -0
- package/dist/components/tetrons/toolbar/ClipboardGroup.jsx +36 -0
- package/dist/components/tetrons/toolbar/FileGroup.d.ts +7 -0
- package/dist/components/tetrons/toolbar/FileGroup.jsx +40 -0
- package/dist/components/tetrons/toolbar/FontStyleGroup.d.ts +7 -0
- package/dist/components/tetrons/toolbar/FontStyleGroup.jsx +104 -0
- package/dist/components/tetrons/toolbar/InsertGroup.d.ts +5 -0
- package/dist/components/tetrons/toolbar/InsertGroup.jsx +162 -0
- package/dist/components/tetrons/toolbar/ListAlignGroup.d.ts +5 -0
- package/dist/components/tetrons/toolbar/ListAlignGroup.jsx +16 -0
- package/dist/components/tetrons/toolbar/MiscGroup.d.ts +7 -0
- package/dist/components/tetrons/toolbar/MiscGroup.jsx +31 -0
- package/dist/components/tetrons/toolbar/TableContextMenu.d.ts +7 -0
- package/dist/components/tetrons/toolbar/TableContextMenu.jsx +52 -0
- package/dist/components/tetrons/toolbar/TetronsToolbar.d.ts +5 -0
- package/dist/components/tetrons/toolbar/TetronsToolbar.jsx +46 -0
- package/dist/components/tetrons/toolbar/ToolbarButton.d.ts +12 -0
- package/dist/components/tetrons/toolbar/ToolbarButton.jsx +8 -0
- package/dist/components/tetrons/toolbar/extensions/Comment.d.ts +17 -0
- package/dist/components/tetrons/toolbar/extensions/Comment.js +45 -0
- package/dist/components/tetrons/toolbar/extensions/Embed.d.ts +2 -0
- package/dist/components/tetrons/toolbar/extensions/Embed.js +90 -0
- package/dist/components/tetrons/toolbar/extensions/FontFamily.d.ts +9 -0
- package/dist/components/tetrons/toolbar/extensions/FontFamily.js +28 -0
- package/dist/components/tetrons/toolbar/extensions/FontSize.d.ts +9 -0
- package/dist/components/tetrons/toolbar/extensions/FontSize.js +28 -0
- package/dist/components/tetrons/toolbar/extensions/ResizableTable.d.ts +1 -0
- package/dist/components/tetrons/toolbar/extensions/ResizableTable.js +11 -0
- package/dist/components/tetrons/toolbar/marks/Subscript.d.ts +2 -0
- package/dist/components/tetrons/toolbar/marks/Subscript.js +35 -0
- package/dist/components/tetrons/toolbar/marks/Superscript.d.ts +2 -0
- package/dist/components/tetrons/toolbar/marks/Superscript.js +35 -0
- package/dist/index.d.ts +3 -5
- package/dist/index.js +3 -17204
- package/dist/lib/export.d.ts +0 -0
- package/dist/lib/export.js +1 -0
- package/dist/lib/tiptap-extensions.d.ts +0 -0
- package/dist/lib/tiptap-extensions.js +1 -0
- package/dist/utils/loadEmojiPicker.d.ts +1 -0
- package/dist/utils/loadEmojiPicker.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function GET(req: Request): Response;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
export async function POST(request) {
|
|
5
|
+
try {
|
|
6
|
+
const json = await request.json();
|
|
7
|
+
const publicDir = path.join(process.cwd(), "public");
|
|
8
|
+
const filePath = path.join(publicDir, "editor-content.json");
|
|
9
|
+
await fs.writeFile(filePath, JSON.stringify(json, null, 2), "utf-8");
|
|
10
|
+
return NextResponse.json({ message: "File saved successfully" });
|
|
11
|
+
}
|
|
12
|
+
catch (_a) {
|
|
13
|
+
return NextResponse.json({ error: "Failed to save file" }, { status: 500 });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Geist, Geist_Mono } from "next/font/google";
|
|
2
|
+
import "./globals.css";
|
|
3
|
+
const geistSans = Geist({
|
|
4
|
+
variable: "--font-geist-sans",
|
|
5
|
+
subsets: ["latin"],
|
|
6
|
+
});
|
|
7
|
+
const geistMono = Geist_Mono({
|
|
8
|
+
variable: "--font-geist-mono",
|
|
9
|
+
subsets: ["latin"],
|
|
10
|
+
});
|
|
11
|
+
export const metadata = {
|
|
12
|
+
title: "Tetrons",
|
|
13
|
+
description: "A modern Word-style rich text editor built with Next.js",
|
|
14
|
+
icons: {
|
|
15
|
+
icon: [
|
|
16
|
+
{ url: "/favicon.ico", type: "image/x-icon" },
|
|
17
|
+
{ url: "/favicon-32x32.png", type: "image/png", sizes: "32x32" },
|
|
18
|
+
{ url: "/favicon-16x16.png", type: "image/png", sizes: "16x16" },
|
|
19
|
+
],
|
|
20
|
+
apple: "/apple-touch-icon.png",
|
|
21
|
+
},
|
|
22
|
+
manifest: "/site.webmanifest",
|
|
23
|
+
};
|
|
24
|
+
export default function RootLayout({ children, }) {
|
|
25
|
+
return (<html lang="en">
|
|
26
|
+
<body suppressHydrationWarning className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased bg-gray-50 text-gray-900`}>
|
|
27
|
+
<main className="max-w-screen-xl mx-auto p-4">{children}</main>
|
|
28
|
+
</body>
|
|
29
|
+
</html>);
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function Home(): import("react").JSX.Element;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import EditorContent from "../components/tetrons/EditorContent";
|
|
2
|
+
export default function Home() {
|
|
3
|
+
return (<main className="flex flex-col h-screen overflow-hidden">
|
|
4
|
+
<div className="flex-1 overflow-auto flex flex-col">
|
|
5
|
+
<EditorContent />
|
|
6
|
+
</div>
|
|
7
|
+
</main>);
|
|
8
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Comment } from "./toolbar/extensions/Comment";
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
5
|
+
import { useEditor, EditorContent as TiptapEditorContent, } from "@tiptap/react";
|
|
6
|
+
import Document from "@tiptap/extension-document";
|
|
7
|
+
import Paragraph from "@tiptap/extension-paragraph";
|
|
8
|
+
import Text from "@tiptap/extension-text";
|
|
9
|
+
import History from "@tiptap/extension-history";
|
|
10
|
+
import Bold from "@tiptap/extension-bold";
|
|
11
|
+
import Italic from "@tiptap/extension-italic";
|
|
12
|
+
import Underline from "@tiptap/extension-underline";
|
|
13
|
+
import Strike from "@tiptap/extension-strike";
|
|
14
|
+
import Code from "@tiptap/extension-code";
|
|
15
|
+
import Blockquote from "@tiptap/extension-blockquote";
|
|
16
|
+
import HardBreak from "@tiptap/extension-hard-break";
|
|
17
|
+
import Heading from "@tiptap/extension-heading";
|
|
18
|
+
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
|
19
|
+
import TextAlign from "@tiptap/extension-text-align";
|
|
20
|
+
import Color from "@tiptap/extension-color";
|
|
21
|
+
import Highlight from "@tiptap/extension-highlight";
|
|
22
|
+
import Image from "@tiptap/extension-image";
|
|
23
|
+
import Link from "@tiptap/extension-link";
|
|
24
|
+
import TextStyle from "@tiptap/extension-text-style";
|
|
25
|
+
import ListItem from "@tiptap/extension-list-item";
|
|
26
|
+
import BulletList from "@tiptap/extension-bullet-list";
|
|
27
|
+
import OrderedList from "@tiptap/extension-ordered-list";
|
|
28
|
+
import { Subscript } from "./toolbar/marks/Subscript";
|
|
29
|
+
import { Superscript } from "./toolbar/marks/Superscript";
|
|
30
|
+
import { ResizableTable } from "./toolbar/extensions/ResizableTable";
|
|
31
|
+
import { Embed } from "./toolbar/extensions/Embed";
|
|
32
|
+
import TableRow from "@tiptap/extension-table-row";
|
|
33
|
+
import TableCell from "@tiptap/extension-table-cell";
|
|
34
|
+
import TableHeader from "@tiptap/extension-table-header";
|
|
35
|
+
import { FontFamily } from "./toolbar/extensions/FontFamily";
|
|
36
|
+
import { FontSize } from "./toolbar/extensions/FontSize";
|
|
37
|
+
import TetronsToolbar from "./toolbar/TetronsToolbar";
|
|
38
|
+
import { ResizableImage } from "./ResizableImage";
|
|
39
|
+
import { ResizableVideo } from "./ResizableVideo";
|
|
40
|
+
import TableContextMenu from "./toolbar/TableContextMenu";
|
|
41
|
+
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
|
42
|
+
import { createLowlight } from "lowlight";
|
|
43
|
+
import js from "highlight.js/lib/languages/javascript";
|
|
44
|
+
import ts from "highlight.js/lib/languages/typescript";
|
|
45
|
+
const lowlight = createLowlight();
|
|
46
|
+
lowlight.register("js", js);
|
|
47
|
+
lowlight.register("ts", ts);
|
|
48
|
+
export default function EditorContent() {
|
|
49
|
+
const [versions, setVersions] = React.useState([]);
|
|
50
|
+
const [currentVersionIndex, setCurrentVersionIndex] = React.useState(null);
|
|
51
|
+
const editor = useEditor({
|
|
52
|
+
extensions: [
|
|
53
|
+
Document,
|
|
54
|
+
Paragraph,
|
|
55
|
+
Text,
|
|
56
|
+
History,
|
|
57
|
+
Bold,
|
|
58
|
+
Italic,
|
|
59
|
+
Underline,
|
|
60
|
+
Strike,
|
|
61
|
+
Code,
|
|
62
|
+
Blockquote,
|
|
63
|
+
HardBreak,
|
|
64
|
+
Heading.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
65
|
+
HorizontalRule,
|
|
66
|
+
TextStyle,
|
|
67
|
+
Color,
|
|
68
|
+
Highlight.configure({ multicolor: true }),
|
|
69
|
+
FontFamily,
|
|
70
|
+
FontSize,
|
|
71
|
+
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
|
72
|
+
ListItem,
|
|
73
|
+
BulletList,
|
|
74
|
+
OrderedList,
|
|
75
|
+
Subscript,
|
|
76
|
+
Superscript,
|
|
77
|
+
Image,
|
|
78
|
+
Link.configure({
|
|
79
|
+
openOnClick: false,
|
|
80
|
+
autolink: true,
|
|
81
|
+
linkOnPaste: true,
|
|
82
|
+
}),
|
|
83
|
+
ResizableTable.configure({
|
|
84
|
+
resizable: true,
|
|
85
|
+
}),
|
|
86
|
+
TableRow,
|
|
87
|
+
TableCell,
|
|
88
|
+
TableHeader,
|
|
89
|
+
Embed,
|
|
90
|
+
ResizableImage,
|
|
91
|
+
ResizableVideo,
|
|
92
|
+
Comment,
|
|
93
|
+
CodeBlockLowlight.configure({
|
|
94
|
+
lowlight,
|
|
95
|
+
HTMLAttributes: {
|
|
96
|
+
class: "bg-gray-100 p-2 rounded font-mono text-sm overflow-auto",
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
],
|
|
100
|
+
content: "",
|
|
101
|
+
editorProps: {
|
|
102
|
+
attributes: {
|
|
103
|
+
class: "min-h-full focus:outline-none p-0",
|
|
104
|
+
"data-placeholder": "Start typing here...",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
immediatelyRender: false,
|
|
108
|
+
});
|
|
109
|
+
const wrapperRef = useRef(null);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
return () => {
|
|
112
|
+
editor === null || editor === void 0 ? void 0 : editor.destroy();
|
|
113
|
+
};
|
|
114
|
+
}, [editor]);
|
|
115
|
+
const handleEditorClick = () => {
|
|
116
|
+
if (editor && !editor.isFocused) {
|
|
117
|
+
editor.commands.focus();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const saveVersion = () => {
|
|
121
|
+
if (!editor)
|
|
122
|
+
return;
|
|
123
|
+
const content = editor.getJSON();
|
|
124
|
+
setVersions((prev) => [...prev, JSON.stringify(content)]);
|
|
125
|
+
setCurrentVersionIndex(versions.length);
|
|
126
|
+
};
|
|
127
|
+
const restoreVersion = (index) => {
|
|
128
|
+
if (!editor)
|
|
129
|
+
return;
|
|
130
|
+
const versionContent = versions[index];
|
|
131
|
+
if (versionContent) {
|
|
132
|
+
editor.commands.setContent(JSON.parse(versionContent));
|
|
133
|
+
setCurrentVersionIndex(index);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
return (<div className="flex flex-col h-full">
|
|
137
|
+
<div className="p-2 border-b border-gray-300 bg-gray-50 flex flex-wrap items-center gap-3">
|
|
138
|
+
<button onClick={saveVersion} disabled={!editor} className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">
|
|
139
|
+
Save Version
|
|
140
|
+
</button>
|
|
141
|
+
|
|
142
|
+
<div className="flex items-center gap-2 overflow-x-auto max-w-full">
|
|
143
|
+
{versions.length === 0 && (<span className="text-gray-500 text-sm">No saved versions</span>)}
|
|
144
|
+
{versions.map((_, idx) => (<button key={idx} onClick={() => restoreVersion(idx)} className={`px-2 py-1 rounded border ${idx === currentVersionIndex
|
|
145
|
+
? "border-blue-600 font-semibold text-blue-600"
|
|
146
|
+
: "border-gray-300 text-gray-700 hover:border-gray-600"}`} title={`Restore Version ${idx + 1}`}>
|
|
147
|
+
{`V${idx + 1}`}
|
|
148
|
+
</button>))}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{editor && <TetronsToolbar editor={editor}/>}
|
|
153
|
+
<div ref={wrapperRef} className="flex-grow p-4 md:p-6 bg-white border border-gray-300 rounded shadow-sm overflow-auto min-h-0 prose relative" onClick={handleEditorClick}>
|
|
154
|
+
{editor ? (<>
|
|
155
|
+
<TiptapEditorContent editor={editor}/>
|
|
156
|
+
{editor && <TableContextMenu editor={editor}/>}
|
|
157
|
+
</>) : (<div className="text-gray-500">Loading editor...</div>)}
|
|
158
|
+
</div>
|
|
159
|
+
</div>);
|
|
160
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const ResizableImage: import("@tiptap/react").Node<import("@tiptap/extension-image").ImageOptions, any>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
2
|
+
var t = {};
|
|
3
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
4
|
+
t[p] = s[p];
|
|
5
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
6
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
7
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
8
|
+
t[p[i]] = s[p[i]];
|
|
9
|
+
}
|
|
10
|
+
return t;
|
|
11
|
+
};
|
|
12
|
+
import Image from "@tiptap/extension-image";
|
|
13
|
+
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
14
|
+
import ResizableImageComponent from "./ResizableImageComponent";
|
|
15
|
+
export const ResizableImage = Image.extend({
|
|
16
|
+
name: "resizableImage",
|
|
17
|
+
addAttributes() {
|
|
18
|
+
var _a;
|
|
19
|
+
return Object.assign(Object.assign({}, (_a = this.parent) === null || _a === void 0 ? void 0 : _a.call(this)), { width: {
|
|
20
|
+
default: null,
|
|
21
|
+
}, height: {
|
|
22
|
+
default: null,
|
|
23
|
+
} });
|
|
24
|
+
},
|
|
25
|
+
renderHTML({ HTMLAttributes }) {
|
|
26
|
+
const { width, height } = HTMLAttributes, rest = __rest(HTMLAttributes, ["width", "height"]);
|
|
27
|
+
const style = [];
|
|
28
|
+
if (width)
|
|
29
|
+
style.push(`width: ${width}px`);
|
|
30
|
+
if (height)
|
|
31
|
+
style.push(`height: ${height}px`);
|
|
32
|
+
return [
|
|
33
|
+
"img",
|
|
34
|
+
Object.assign(Object.assign({}, rest), { style: style.join("; ") }),
|
|
35
|
+
];
|
|
36
|
+
},
|
|
37
|
+
addNodeView() {
|
|
38
|
+
return ReactNodeViewRenderer(ResizableImageComponent);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { NodeViewRendererProps } from "@tiptap/react";
|
|
3
|
+
interface ResizableImageProps extends NodeViewRendererProps {
|
|
4
|
+
updateAttributes: (attrs: {
|
|
5
|
+
width?: number | null;
|
|
6
|
+
height?: number | null;
|
|
7
|
+
}) => void;
|
|
8
|
+
selected?: boolean;
|
|
9
|
+
}
|
|
10
|
+
declare const ResizableImageComponent: React.FC<ResizableImageProps>;
|
|
11
|
+
export default ResizableImageComponent;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React, { useRef, useEffect } from "react";
|
|
2
|
+
import { NodeViewWrapper } from "@tiptap/react";
|
|
3
|
+
const ResizableImageComponent = ({ node, updateAttributes, selected, }) => {
|
|
4
|
+
const { src, alt, title, width, height } = node.attrs;
|
|
5
|
+
const wrapperRef = useRef(null);
|
|
6
|
+
const imgRef = useRef(null);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const img = imgRef.current;
|
|
9
|
+
if (!img)
|
|
10
|
+
return;
|
|
11
|
+
const observer = new ResizeObserver(() => {
|
|
12
|
+
const w = Math.round(img.offsetWidth);
|
|
13
|
+
const h = Math.round(img.offsetHeight);
|
|
14
|
+
updateAttributes({ width: w, height: h });
|
|
15
|
+
});
|
|
16
|
+
observer.observe(img);
|
|
17
|
+
return () => observer.disconnect();
|
|
18
|
+
}, [updateAttributes]);
|
|
19
|
+
return (<NodeViewWrapper ref={wrapperRef} contentEditable={false} className={`resizable-image-wrapper ${selected ? "ProseMirror-selectednode" : ""}`} style={{
|
|
20
|
+
resize: "both",
|
|
21
|
+
overflow: "auto",
|
|
22
|
+
border: "1px solid #ccc",
|
|
23
|
+
padding: 2,
|
|
24
|
+
display: "inline-block",
|
|
25
|
+
maxWidth: "100%",
|
|
26
|
+
}}>
|
|
27
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
28
|
+
<img ref={imgRef} src={src} alt={alt !== null && alt !== void 0 ? alt : ""} title={title !== null && title !== void 0 ? title : ""} loading="lazy" style={{
|
|
29
|
+
width: width ? `${width}px` : "auto",
|
|
30
|
+
height: height ? `${height}px` : "auto",
|
|
31
|
+
display: "block",
|
|
32
|
+
userSelect: "none",
|
|
33
|
+
pointerEvents: "auto",
|
|
34
|
+
}} draggable={false}/>
|
|
35
|
+
</NodeViewWrapper>);
|
|
36
|
+
};
|
|
37
|
+
export default ResizableImageComponent;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Node } from "@tiptap/core";
|
|
2
|
+
declare module "@tiptap/core" {
|
|
3
|
+
interface Commands<ReturnType> {
|
|
4
|
+
video: {
|
|
5
|
+
setVideo: (options: {
|
|
6
|
+
src: string;
|
|
7
|
+
controls?: boolean;
|
|
8
|
+
}) => ReturnType;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export declare const ResizableVideo: Node<any, any>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
2
|
+
var t = {};
|
|
3
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
4
|
+
t[p] = s[p];
|
|
5
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
6
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
7
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
8
|
+
t[p[i]] = s[p[i]];
|
|
9
|
+
}
|
|
10
|
+
return t;
|
|
11
|
+
};
|
|
12
|
+
import { Node } from "@tiptap/core";
|
|
13
|
+
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
14
|
+
import ResizableVideoComponent from "./ResizableVideoComponent";
|
|
15
|
+
export const ResizableVideo = Node.create({
|
|
16
|
+
name: "video",
|
|
17
|
+
group: "block",
|
|
18
|
+
draggable: true,
|
|
19
|
+
atom: true,
|
|
20
|
+
addAttributes() {
|
|
21
|
+
return {
|
|
22
|
+
src: { default: null },
|
|
23
|
+
controls: {
|
|
24
|
+
default: true,
|
|
25
|
+
parseHTML: (element) => element.hasAttribute("controls"),
|
|
26
|
+
renderHTML: (attributes) => attributes.controls ? { controls: "controls" } : {},
|
|
27
|
+
},
|
|
28
|
+
width: {
|
|
29
|
+
default: null,
|
|
30
|
+
},
|
|
31
|
+
height: {
|
|
32
|
+
default: null,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
parseHTML() {
|
|
37
|
+
return [{ tag: "video[src]" }];
|
|
38
|
+
},
|
|
39
|
+
renderHTML({ HTMLAttributes }) {
|
|
40
|
+
const { width, height } = HTMLAttributes, rest = __rest(HTMLAttributes, ["width", "height"]);
|
|
41
|
+
const style = [];
|
|
42
|
+
if (width)
|
|
43
|
+
style.push(`width: ${width}px`);
|
|
44
|
+
if (height)
|
|
45
|
+
style.push(`height: ${height}px`);
|
|
46
|
+
return ["video", Object.assign(Object.assign({}, rest), { style: style.join("; ") })];
|
|
47
|
+
},
|
|
48
|
+
addCommands() {
|
|
49
|
+
return {
|
|
50
|
+
setVideo: (attributes) => ({ commands }) => {
|
|
51
|
+
return commands.insertContent({
|
|
52
|
+
type: this.name,
|
|
53
|
+
attrs: attributes,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
addNodeView() {
|
|
59
|
+
return ReactNodeViewRenderer(ResizableVideoComponent);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React, { useRef, useEffect } from "react";
|
|
2
|
+
import { NodeViewWrapper } from "@tiptap/react";
|
|
3
|
+
const ResizableVideoComponent = ({ node, updateAttributes, selected, }) => {
|
|
4
|
+
const { src, controls, width, height } = node.attrs;
|
|
5
|
+
const wrapperRef = useRef(null);
|
|
6
|
+
const videoRef = useRef(null);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const video = videoRef.current;
|
|
9
|
+
if (!video)
|
|
10
|
+
return;
|
|
11
|
+
const observer = new ResizeObserver(() => {
|
|
12
|
+
const w = Math.round(video.offsetWidth);
|
|
13
|
+
const h = Math.round(video.offsetHeight);
|
|
14
|
+
updateAttributes({ width: w, height: h });
|
|
15
|
+
});
|
|
16
|
+
observer.observe(video);
|
|
17
|
+
return () => observer.disconnect();
|
|
18
|
+
}, [updateAttributes]);
|
|
19
|
+
return (<NodeViewWrapper ref={wrapperRef} contentEditable={false} className={`resizable-video-wrapper ${selected ? "ProseMirror-selectednode" : ""}`} style={{
|
|
20
|
+
resize: "both",
|
|
21
|
+
overflow: "auto",
|
|
22
|
+
border: "1px solid #ccc",
|
|
23
|
+
padding: "2px",
|
|
24
|
+
display: "inline-block",
|
|
25
|
+
}}>
|
|
26
|
+
<video ref={videoRef} src={src} controls={controls} style={{
|
|
27
|
+
width: width ? `${width}px` : "auto",
|
|
28
|
+
height: height ? `${height}px` : "auto",
|
|
29
|
+
}}/>
|
|
30
|
+
</NodeViewWrapper>);
|
|
31
|
+
};
|
|
32
|
+
export default ResizableVideoComponent;
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { MdZoomIn, MdZoomOut, MdPrint, MdSave, MdDownload, } from "react-icons/md";
|
|
4
|
+
import ToolbarButton from "./ToolbarButton";
|
|
5
|
+
export default function ActionGroup({ editor }) {
|
|
6
|
+
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
7
|
+
const dropdownRef = useRef(null);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const handleClickOutside = (event) => {
|
|
10
|
+
if (dropdownRef.current &&
|
|
11
|
+
!dropdownRef.current.contains(event.target)) {
|
|
12
|
+
setDropdownOpen(false);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
if (dropdownOpen) {
|
|
16
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
20
|
+
}
|
|
21
|
+
return () => {
|
|
22
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
23
|
+
};
|
|
24
|
+
}, [dropdownOpen]);
|
|
25
|
+
const zoomIn = () => {
|
|
26
|
+
const element = document.querySelector(".ProseMirror");
|
|
27
|
+
if (element) {
|
|
28
|
+
const currentZoom = parseFloat(element.style.zoom || "1");
|
|
29
|
+
const next = Math.min(currentZoom + 0.1, 2);
|
|
30
|
+
element.style.zoom = next.toString();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const zoomOut = () => {
|
|
34
|
+
const element = document.querySelector(".ProseMirror");
|
|
35
|
+
if (element) {
|
|
36
|
+
const currentZoom = parseFloat(element.style.zoom || "1");
|
|
37
|
+
const next = Math.max(currentZoom - 0.1, 0.5);
|
|
38
|
+
element.style.zoom = next.toString();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const handlePrint = () => {
|
|
42
|
+
const html = editor.getHTML();
|
|
43
|
+
const printWindow = window.open("", "_blank");
|
|
44
|
+
if (printWindow) {
|
|
45
|
+
printWindow.document.write(`
|
|
46
|
+
<html>
|
|
47
|
+
<head>
|
|
48
|
+
<title>Print</title>
|
|
49
|
+
</head>
|
|
50
|
+
<body>${html}</body>
|
|
51
|
+
</html>
|
|
52
|
+
`);
|
|
53
|
+
printWindow.document.close();
|
|
54
|
+
printWindow.focus();
|
|
55
|
+
printWindow.print();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const handleSave = async () => {
|
|
59
|
+
const content = editor.getJSON();
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch("/api/save", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify(content),
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok)
|
|
67
|
+
throw new Error("Failed to save file");
|
|
68
|
+
const result = await response.json();
|
|
69
|
+
console.log(result.message);
|
|
70
|
+
alert("Content saved successfully!");
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
console.error("Error saving content:", error);
|
|
74
|
+
alert("Failed to save content.");
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const handleDownloadPDF = async () => {
|
|
78
|
+
const html = editor.getHTML();
|
|
79
|
+
const container = document.createElement("div");
|
|
80
|
+
container.innerHTML = html;
|
|
81
|
+
container.classList.add("p-4", "prose");
|
|
82
|
+
document.body.appendChild(container);
|
|
83
|
+
try {
|
|
84
|
+
const domToPdf = (await import("dom-to-pdf")).default;
|
|
85
|
+
const options = {
|
|
86
|
+
filename: "document.pdf",
|
|
87
|
+
overrideWidth: 800,
|
|
88
|
+
overrideHeight: 1120,
|
|
89
|
+
};
|
|
90
|
+
domToPdf(container, options, () => {
|
|
91
|
+
console.log("PDF downloaded!");
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
console.error("PDF download failed", error);
|
|
96
|
+
alert("Failed to download PDF.");
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
document.body.removeChild(container);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const handleDownloadHTML = () => {
|
|
103
|
+
const html = editor.getHTML();
|
|
104
|
+
const blob = new Blob([html], { type: "text/html" });
|
|
105
|
+
const link = document.createElement("a");
|
|
106
|
+
link.href = URL.createObjectURL(blob);
|
|
107
|
+
link.download = "document.html";
|
|
108
|
+
document.body.appendChild(link);
|
|
109
|
+
link.click();
|
|
110
|
+
document.body.removeChild(link);
|
|
111
|
+
};
|
|
112
|
+
const handleDownloadDOCX = async () => {
|
|
113
|
+
const { Document, Packer, Paragraph } = await import("docx");
|
|
114
|
+
const text = editor.getText();
|
|
115
|
+
const doc = new Document({
|
|
116
|
+
sections: [
|
|
117
|
+
{
|
|
118
|
+
properties: {},
|
|
119
|
+
children: [new Paragraph(text)],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
const blob = await Packer.toBlob(doc);
|
|
124
|
+
const link = document.createElement("a");
|
|
125
|
+
link.href = URL.createObjectURL(blob);
|
|
126
|
+
link.download = "document.docx";
|
|
127
|
+
document.body.appendChild(link);
|
|
128
|
+
link.click();
|
|
129
|
+
document.body.removeChild(link);
|
|
130
|
+
};
|
|
131
|
+
return (<div className="relative flex items-center gap-1" role="group" aria-label="Editor actions">
|
|
132
|
+
<ToolbarButton icon={MdZoomIn} onClick={zoomIn} title="Zoom In"/>
|
|
133
|
+
<ToolbarButton icon={MdZoomOut} onClick={zoomOut} title="Zoom Out"/>
|
|
134
|
+
<ToolbarButton icon={MdPrint} onClick={handlePrint} title="Print"/>
|
|
135
|
+
<ToolbarButton icon={MdSave} onClick={handleSave} title="Save"/>
|
|
136
|
+
|
|
137
|
+
<div className="relative" ref={dropdownRef}>
|
|
138
|
+
<button type="button" onClick={() => setDropdownOpen((open) => !open)} aria-haspopup="menu" aria-expanded={dropdownOpen ? "true" : "false"} className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 focus:outline-none" title="Export">
|
|
139
|
+
<MdDownload />
|
|
140
|
+
<span className="text-sm"></span>
|
|
141
|
+
</button>
|
|
142
|
+
|
|
143
|
+
{dropdownOpen && (<div className="absolute z-10 mt-2 w-40 bg-white border rounded shadow-md">
|
|
144
|
+
<button type="button" onClick={() => {
|
|
145
|
+
setDropdownOpen(false);
|
|
146
|
+
handleDownloadPDF();
|
|
147
|
+
}} className="w-full text-left px-4 py-2 hover:bg-gray-100">
|
|
148
|
+
Export as PDF
|
|
149
|
+
</button>
|
|
150
|
+
<button type="button" onClick={() => {
|
|
151
|
+
setDropdownOpen(false);
|
|
152
|
+
handleDownloadHTML();
|
|
153
|
+
}} className="w-full text-left px-4 py-2 hover:bg-gray-100">
|
|
154
|
+
Export as HTML
|
|
155
|
+
</button>
|
|
156
|
+
<button type="button" onClick={() => {
|
|
157
|
+
setDropdownOpen(false);
|
|
158
|
+
handleDownloadDOCX();
|
|
159
|
+
}} className="w-full text-left px-4 py-2 hover:bg-gray-100">
|
|
160
|
+
Export as DOCX
|
|
161
|
+
</button>
|
|
162
|
+
</div>)}
|
|
163
|
+
</div>
|
|
164
|
+
</div>);
|
|
165
|
+
}
|