tetrons 0.1.0
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/.hintrc +13 -0
- package/README.md +36 -0
- package/eslint.config.mjs +16 -0
- package/next.config.ts +7 -0
- package/package.json +61 -0
- package/postcss.config.mjs +5 -0
- package/public/editor-content.json +27 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon-64x64.png +0 -0
- package/public/favicon-768x768.png +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/site.webmanifest +20 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/export/route.ts +0 -0
- package/src/app/api/save/route.ts +18 -0
- package/src/app/favicon-16x16.png +0 -0
- package/src/app/favicon-32x32.png +0 -0
- package/src/app/favicon-64x64.png +0 -0
- package/src/app/favicon-768x768.png +0 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +207 -0
- package/src/app/layout.tsx +47 -0
- package/src/app/page.tsx +11 -0
- package/src/components/UI/Button.tsx +0 -0
- package/src/components/UI/Dropdown.tsx +0 -0
- package/src/components/tetrons/EditorContent.tsx +210 -0
- package/src/components/tetrons/ResizableImage.ts +39 -0
- package/src/components/tetrons/ResizableImageComponent.tsx +77 -0
- package/src/components/tetrons/ResizableVideo.ts +66 -0
- package/src/components/tetrons/ResizableVideoComponent.tsx +56 -0
- package/src/components/tetrons/helpers.ts +0 -0
- package/src/components/tetrons/toolbar/ActionGroup.tsx +222 -0
- package/src/components/tetrons/toolbar/ClipboardGroup.tsx +57 -0
- package/src/components/tetrons/toolbar/FileGroup.tsx +70 -0
- package/src/components/tetrons/toolbar/FontStyleGroup.tsx +198 -0
- package/src/components/tetrons/toolbar/InsertGroup.tsx +268 -0
- package/src/components/tetrons/toolbar/ListAlignGroup.tsx +69 -0
- package/src/components/tetrons/toolbar/MiscGroup.tsx +70 -0
- package/src/components/tetrons/toolbar/TableContextMenu.tsx +91 -0
- package/src/components/tetrons/toolbar/TetronsToolbar.tsx +60 -0
- package/src/components/tetrons/toolbar/ToolbarButton.tsx +38 -0
- package/src/components/tetrons/toolbar/extensions/Comment.ts +72 -0
- package/src/components/tetrons/toolbar/extensions/Embed.ts +113 -0
- package/src/components/tetrons/toolbar/extensions/FontFamily.ts +43 -0
- package/src/components/tetrons/toolbar/extensions/FontSize.ts +43 -0
- package/src/components/tetrons/toolbar/extensions/ResizableTable.ts +16 -0
- package/src/components/tetrons/toolbar/marks/Subscript.ts +45 -0
- package/src/components/tetrons/toolbar/marks/Superscript.ts +45 -0
- package/src/index.ts +3 -0
- package/src/lib/export.ts +0 -0
- package/src/lib/tiptap-extensions.ts +0 -0
- package/src/types/dom-to-pdf.d.ts +13 -0
- package/src/types/editor.d.ts +0 -0
- package/src/types/emoji-picker.d.ts +18 -0
- package/src/types/global.d.ts +6 -0
- package/src/types/html2pdf.d.ts +1 -0
- package/src/types/tiptap-extensions.d.ts +23 -0
- package/src/utils/loadEmojiPicker.ts +12 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Comment } from "./toolbar/extensions/Comment";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import {
|
|
6
|
+
useEditor,
|
|
7
|
+
EditorContent as TiptapEditorContent,
|
|
8
|
+
Editor,
|
|
9
|
+
} from "@tiptap/react";
|
|
10
|
+
|
|
11
|
+
import Document from "@tiptap/extension-document";
|
|
12
|
+
import Paragraph from "@tiptap/extension-paragraph";
|
|
13
|
+
import Text from "@tiptap/extension-text";
|
|
14
|
+
import History from "@tiptap/extension-history";
|
|
15
|
+
import Bold from "@tiptap/extension-bold";
|
|
16
|
+
import Italic from "@tiptap/extension-italic";
|
|
17
|
+
import Underline from "@tiptap/extension-underline";
|
|
18
|
+
import Strike from "@tiptap/extension-strike";
|
|
19
|
+
import Code from "@tiptap/extension-code";
|
|
20
|
+
import Blockquote from "@tiptap/extension-blockquote";
|
|
21
|
+
import HardBreak from "@tiptap/extension-hard-break";
|
|
22
|
+
import Heading from "@tiptap/extension-heading";
|
|
23
|
+
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
|
24
|
+
|
|
25
|
+
import TextAlign from "@tiptap/extension-text-align";
|
|
26
|
+
import Color from "@tiptap/extension-color";
|
|
27
|
+
import Highlight from "@tiptap/extension-highlight";
|
|
28
|
+
import Image from "@tiptap/extension-image";
|
|
29
|
+
import Link from "@tiptap/extension-link";
|
|
30
|
+
import TextStyle from "@tiptap/extension-text-style";
|
|
31
|
+
|
|
32
|
+
import ListItem from "@tiptap/extension-list-item";
|
|
33
|
+
import BulletList from "@tiptap/extension-bullet-list";
|
|
34
|
+
import OrderedList from "@tiptap/extension-ordered-list";
|
|
35
|
+
import { Subscript } from "./toolbar/marks/Subscript";
|
|
36
|
+
import { Superscript } from "./toolbar/marks/Superscript";
|
|
37
|
+
|
|
38
|
+
import { ResizableTable } from "./toolbar/extensions/ResizableTable";
|
|
39
|
+
import { Embed } from "./toolbar/extensions/Embed";
|
|
40
|
+
import TableRow from "@tiptap/extension-table-row";
|
|
41
|
+
import TableCell from "@tiptap/extension-table-cell";
|
|
42
|
+
import TableHeader from "@tiptap/extension-table-header";
|
|
43
|
+
|
|
44
|
+
import { FontFamily } from "./toolbar/extensions/FontFamily";
|
|
45
|
+
import { FontSize } from "./toolbar/extensions/FontSize";
|
|
46
|
+
import TetronsToolbar from "./toolbar/TetronsToolbar";
|
|
47
|
+
|
|
48
|
+
import { ResizableImage } from "./ResizableImage";
|
|
49
|
+
import { ResizableVideo } from "./ResizableVideo";
|
|
50
|
+
import TableContextMenu from "./toolbar/TableContextMenu";
|
|
51
|
+
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
|
52
|
+
import { createLowlight } from "lowlight";
|
|
53
|
+
|
|
54
|
+
import js from "highlight.js/lib/languages/javascript";
|
|
55
|
+
import ts from "highlight.js/lib/languages/typescript";
|
|
56
|
+
|
|
57
|
+
const lowlight = createLowlight();
|
|
58
|
+
|
|
59
|
+
lowlight.register("js", js);
|
|
60
|
+
lowlight.register("ts", ts);
|
|
61
|
+
|
|
62
|
+
export default function EditorContent() {
|
|
63
|
+
const [versions, setVersions] = useState<string[]>([]);
|
|
64
|
+
const [currentVersionIndex, setCurrentVersionIndex] = useState<number | null>(
|
|
65
|
+
null
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const editor: Editor | null = useEditor({
|
|
69
|
+
extensions: [
|
|
70
|
+
Document,
|
|
71
|
+
Paragraph,
|
|
72
|
+
Text,
|
|
73
|
+
History,
|
|
74
|
+
Bold,
|
|
75
|
+
Italic,
|
|
76
|
+
Underline,
|
|
77
|
+
Strike,
|
|
78
|
+
Code,
|
|
79
|
+
Blockquote,
|
|
80
|
+
HardBreak,
|
|
81
|
+
Heading.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
82
|
+
HorizontalRule,
|
|
83
|
+
|
|
84
|
+
TextStyle,
|
|
85
|
+
Color,
|
|
86
|
+
Highlight.configure({ multicolor: true }),
|
|
87
|
+
FontFamily,
|
|
88
|
+
FontSize,
|
|
89
|
+
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
|
90
|
+
|
|
91
|
+
ListItem,
|
|
92
|
+
BulletList,
|
|
93
|
+
OrderedList,
|
|
94
|
+
Subscript,
|
|
95
|
+
Superscript,
|
|
96
|
+
|
|
97
|
+
Image,
|
|
98
|
+
Link.configure({
|
|
99
|
+
openOnClick: false,
|
|
100
|
+
autolink: true,
|
|
101
|
+
linkOnPaste: true,
|
|
102
|
+
}),
|
|
103
|
+
|
|
104
|
+
ResizableTable.configure({
|
|
105
|
+
resizable: true,
|
|
106
|
+
}),
|
|
107
|
+
TableRow,
|
|
108
|
+
TableCell,
|
|
109
|
+
TableHeader,
|
|
110
|
+
Embed,
|
|
111
|
+
|
|
112
|
+
ResizableImage,
|
|
113
|
+
ResizableVideo,
|
|
114
|
+
Comment,
|
|
115
|
+
CodeBlockLowlight.configure({
|
|
116
|
+
lowlight,
|
|
117
|
+
HTMLAttributes: {
|
|
118
|
+
class: "bg-gray-100 p-2 rounded font-mono text-sm overflow-auto",
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
],
|
|
122
|
+
content: "",
|
|
123
|
+
editorProps: {
|
|
124
|
+
attributes: {
|
|
125
|
+
class: "min-h-full focus:outline-none p-0",
|
|
126
|
+
"data-placeholder": "Start typing here...",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
return () => {
|
|
135
|
+
editor?.destroy();
|
|
136
|
+
};
|
|
137
|
+
}, [editor]);
|
|
138
|
+
|
|
139
|
+
const handleEditorClick = () => {
|
|
140
|
+
if (editor && !editor.isFocused) {
|
|
141
|
+
editor.commands.focus();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const saveVersion = () => {
|
|
146
|
+
if (!editor) return;
|
|
147
|
+
const content = editor.getJSON();
|
|
148
|
+
setVersions((prev) => [...prev, JSON.stringify(content)]);
|
|
149
|
+
setCurrentVersionIndex(versions.length);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const restoreVersion = (index: number) => {
|
|
153
|
+
if (!editor) return;
|
|
154
|
+
const versionContent = versions[index];
|
|
155
|
+
if (versionContent) {
|
|
156
|
+
editor.commands.setContent(JSON.parse(versionContent));
|
|
157
|
+
setCurrentVersionIndex(index);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex flex-col h-full">
|
|
163
|
+
<div className="p-2 border-b border-gray-300 bg-gray-50 flex flex-wrap items-center gap-3">
|
|
164
|
+
<button
|
|
165
|
+
onClick={saveVersion}
|
|
166
|
+
disabled={!editor}
|
|
167
|
+
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
|
168
|
+
>
|
|
169
|
+
Save Version
|
|
170
|
+
</button>
|
|
171
|
+
|
|
172
|
+
<div className="flex items-center gap-2 overflow-x-auto max-w-full">
|
|
173
|
+
{versions.length === 0 && (
|
|
174
|
+
<span className="text-gray-500 text-sm">No saved versions</span>
|
|
175
|
+
)}
|
|
176
|
+
{versions.map((_, idx) => (
|
|
177
|
+
<button
|
|
178
|
+
key={idx}
|
|
179
|
+
onClick={() => restoreVersion(idx)}
|
|
180
|
+
className={`px-2 py-1 rounded border ${
|
|
181
|
+
idx === currentVersionIndex
|
|
182
|
+
? "border-blue-600 font-semibold text-blue-600"
|
|
183
|
+
: "border-gray-300 text-gray-700 hover:border-gray-600"
|
|
184
|
+
}`}
|
|
185
|
+
title={`Restore Version ${idx + 1}`}
|
|
186
|
+
>
|
|
187
|
+
{`V${idx + 1}`}
|
|
188
|
+
</button>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{editor && <TetronsToolbar editor={editor} />}
|
|
194
|
+
<div
|
|
195
|
+
ref={wrapperRef}
|
|
196
|
+
className="flex-grow p-4 md:p-6 bg-white border border-gray-300 rounded shadow-sm overflow-auto min-h-0 prose relative"
|
|
197
|
+
onClick={handleEditorClick}
|
|
198
|
+
>
|
|
199
|
+
{editor ? (
|
|
200
|
+
<>
|
|
201
|
+
<TiptapEditorContent editor={editor} />
|
|
202
|
+
{editor && <TableContextMenu editor={editor} />}
|
|
203
|
+
</>
|
|
204
|
+
) : (
|
|
205
|
+
<div className="text-gray-500">Loading editor...</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Image from "@tiptap/extension-image";
|
|
2
|
+
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
3
|
+
import ResizableImageComponent from "./ResizableImageComponent";
|
|
4
|
+
|
|
5
|
+
export const ResizableImage = Image.extend({
|
|
6
|
+
name: "resizableImage",
|
|
7
|
+
|
|
8
|
+
addAttributes() {
|
|
9
|
+
return {
|
|
10
|
+
...this.parent?.(),
|
|
11
|
+
width: {
|
|
12
|
+
default: null,
|
|
13
|
+
},
|
|
14
|
+
height: {
|
|
15
|
+
default: null,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
renderHTML({ HTMLAttributes }) {
|
|
21
|
+
const { width, height, ...rest } = HTMLAttributes;
|
|
22
|
+
const style = [];
|
|
23
|
+
|
|
24
|
+
if (width) style.push(`width: ${width}px`);
|
|
25
|
+
if (height) style.push(`height: ${height}px`);
|
|
26
|
+
|
|
27
|
+
return [
|
|
28
|
+
"img",
|
|
29
|
+
{
|
|
30
|
+
...rest,
|
|
31
|
+
style: style.join("; "),
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
addNodeView() {
|
|
37
|
+
return ReactNodeViewRenderer(ResizableImageComponent);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React, { useRef, useEffect } from "react";
|
|
2
|
+
import { NodeViewWrapper, NodeViewRendererProps } from "@tiptap/react";
|
|
3
|
+
|
|
4
|
+
interface ResizableImageProps extends NodeViewRendererProps {
|
|
5
|
+
updateAttributes: (attrs: {
|
|
6
|
+
width?: number | null;
|
|
7
|
+
height?: number | null;
|
|
8
|
+
}) => void;
|
|
9
|
+
selected?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ResizableImageComponent: React.FC<ResizableImageProps> = ({
|
|
13
|
+
node,
|
|
14
|
+
updateAttributes,
|
|
15
|
+
selected,
|
|
16
|
+
}) => {
|
|
17
|
+
const { src, alt, title, width, height } = node.attrs as {
|
|
18
|
+
src: string;
|
|
19
|
+
alt?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
width?: number | null;
|
|
22
|
+
height?: number | null;
|
|
23
|
+
};
|
|
24
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
const imgRef = useRef<HTMLImageElement>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const img = imgRef.current;
|
|
29
|
+
if (!img) return;
|
|
30
|
+
|
|
31
|
+
const observer = new ResizeObserver(() => {
|
|
32
|
+
const w = Math.round(img.offsetWidth);
|
|
33
|
+
const h = Math.round(img.offsetHeight);
|
|
34
|
+
updateAttributes({ width: w, height: h });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
observer.observe(img);
|
|
38
|
+
return () => observer.disconnect();
|
|
39
|
+
}, [updateAttributes]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<NodeViewWrapper
|
|
43
|
+
ref={wrapperRef}
|
|
44
|
+
contentEditable={false}
|
|
45
|
+
className={`resizable-image-wrapper ${
|
|
46
|
+
selected ? "ProseMirror-selectednode" : ""
|
|
47
|
+
}`}
|
|
48
|
+
style={{
|
|
49
|
+
resize: "both",
|
|
50
|
+
overflow: "auto",
|
|
51
|
+
border: "1px solid #ccc",
|
|
52
|
+
padding: 2,
|
|
53
|
+
display: "inline-block",
|
|
54
|
+
maxWidth: "100%",
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
58
|
+
<img
|
|
59
|
+
ref={imgRef}
|
|
60
|
+
src={src}
|
|
61
|
+
alt={alt ?? ""}
|
|
62
|
+
title={title ?? ""}
|
|
63
|
+
loading="lazy"
|
|
64
|
+
style={{
|
|
65
|
+
width: width ? `${width}px` : "auto",
|
|
66
|
+
height: height ? `${height}px` : "auto",
|
|
67
|
+
display: "block",
|
|
68
|
+
userSelect: "none",
|
|
69
|
+
pointerEvents: "auto",
|
|
70
|
+
}}
|
|
71
|
+
draggable={false}
|
|
72
|
+
/>
|
|
73
|
+
</NodeViewWrapper>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export default ResizableImageComponent;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Node } from "@tiptap/core";
|
|
2
|
+
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
3
|
+
import ResizableVideoComponent from "./ResizableVideoComponent";
|
|
4
|
+
|
|
5
|
+
declare module "@tiptap/core" {
|
|
6
|
+
interface Commands<ReturnType> {
|
|
7
|
+
video: {
|
|
8
|
+
setVideo: (options: { src: string; controls?: boolean }) => ReturnType;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const ResizableVideo = Node.create({
|
|
14
|
+
name: "video",
|
|
15
|
+
group: "block",
|
|
16
|
+
draggable: true,
|
|
17
|
+
atom: true,
|
|
18
|
+
|
|
19
|
+
addAttributes() {
|
|
20
|
+
return {
|
|
21
|
+
src: { default: null },
|
|
22
|
+
controls: {
|
|
23
|
+
default: true,
|
|
24
|
+
parseHTML: (element) => element.hasAttribute("controls"),
|
|
25
|
+
renderHTML: (attributes) =>
|
|
26
|
+
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
|
+
|
|
40
|
+
renderHTML({ HTMLAttributes }) {
|
|
41
|
+
const { width, height, ...rest } = HTMLAttributes;
|
|
42
|
+
const style = [];
|
|
43
|
+
|
|
44
|
+
if (width) style.push(`width: ${width}px`);
|
|
45
|
+
if (height) style.push(`height: ${height}px`);
|
|
46
|
+
|
|
47
|
+
return ["video", { ...rest, style: style.join("; ") }];
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
addCommands() {
|
|
51
|
+
return {
|
|
52
|
+
setVideo:
|
|
53
|
+
(attributes) =>
|
|
54
|
+
({ commands }) => {
|
|
55
|
+
return commands.insertContent({
|
|
56
|
+
type: this.name,
|
|
57
|
+
attrs: attributes,
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
addNodeView() {
|
|
64
|
+
return ReactNodeViewRenderer(ResizableVideoComponent);
|
|
65
|
+
},
|
|
66
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React, { useRef, useEffect } from "react";
|
|
2
|
+
import { NodeViewWrapper } from "@tiptap/react";
|
|
3
|
+
import type { NodeViewProps } from "@tiptap/core";
|
|
4
|
+
|
|
5
|
+
const ResizableVideoComponent: React.FC<NodeViewProps> = ({
|
|
6
|
+
node,
|
|
7
|
+
updateAttributes,
|
|
8
|
+
selected,
|
|
9
|
+
}) => {
|
|
10
|
+
const { src, controls, width, height } = node.attrs;
|
|
11
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
12
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const video = videoRef.current;
|
|
16
|
+
if (!video) return;
|
|
17
|
+
|
|
18
|
+
const observer = new ResizeObserver(() => {
|
|
19
|
+
const w = Math.round(video.offsetWidth);
|
|
20
|
+
const h = Math.round(video.offsetHeight);
|
|
21
|
+
updateAttributes({ width: w, height: h });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
observer.observe(video);
|
|
25
|
+
return () => observer.disconnect();
|
|
26
|
+
}, [updateAttributes]);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<NodeViewWrapper
|
|
30
|
+
ref={wrapperRef}
|
|
31
|
+
contentEditable={false}
|
|
32
|
+
className={`resizable-video-wrapper ${
|
|
33
|
+
selected ? "ProseMirror-selectednode" : ""
|
|
34
|
+
}`}
|
|
35
|
+
style={{
|
|
36
|
+
resize: "both",
|
|
37
|
+
overflow: "auto",
|
|
38
|
+
border: "1px solid #ccc",
|
|
39
|
+
padding: "2px",
|
|
40
|
+
display: "inline-block",
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
<video
|
|
44
|
+
ref={videoRef}
|
|
45
|
+
src={src}
|
|
46
|
+
controls={controls}
|
|
47
|
+
style={{
|
|
48
|
+
width: width ? `${width}px` : "auto",
|
|
49
|
+
height: height ? `${height}px` : "auto",
|
|
50
|
+
}}
|
|
51
|
+
/>
|
|
52
|
+
</NodeViewWrapper>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export default ResizableVideoComponent;
|
|
File without changes
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Editor } from "@tiptap/react";
|
|
5
|
+
import {
|
|
6
|
+
MdZoomIn,
|
|
7
|
+
MdZoomOut,
|
|
8
|
+
MdPrint,
|
|
9
|
+
MdSave,
|
|
10
|
+
MdDownload,
|
|
11
|
+
} from "react-icons/md";
|
|
12
|
+
import ToolbarButton from "./ToolbarButton";
|
|
13
|
+
|
|
14
|
+
type ActionGroupProps = {
|
|
15
|
+
editor: Editor;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default function ActionGroup({ editor }: ActionGroupProps) {
|
|
19
|
+
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
20
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
24
|
+
if (
|
|
25
|
+
dropdownRef.current &&
|
|
26
|
+
!dropdownRef.current.contains(event.target as Node)
|
|
27
|
+
) {
|
|
28
|
+
setDropdownOpen(false);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (dropdownOpen) {
|
|
33
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
34
|
+
} else {
|
|
35
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
40
|
+
};
|
|
41
|
+
}, [dropdownOpen]);
|
|
42
|
+
|
|
43
|
+
const zoomIn = () => {
|
|
44
|
+
const element = document.querySelector(
|
|
45
|
+
".ProseMirror"
|
|
46
|
+
) as HTMLElement | null;
|
|
47
|
+
if (element) {
|
|
48
|
+
const currentZoom = parseFloat(element.style.zoom || "1");
|
|
49
|
+
const next = Math.min(currentZoom + 0.1, 2);
|
|
50
|
+
element.style.zoom = next.toString();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const zoomOut = () => {
|
|
55
|
+
const element = document.querySelector(
|
|
56
|
+
".ProseMirror"
|
|
57
|
+
) as HTMLElement | null;
|
|
58
|
+
if (element) {
|
|
59
|
+
const currentZoom = parseFloat(element.style.zoom || "1");
|
|
60
|
+
const next = Math.max(currentZoom - 0.1, 0.5);
|
|
61
|
+
element.style.zoom = next.toString();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handlePrint = () => {
|
|
66
|
+
const html = editor.getHTML();
|
|
67
|
+
const printWindow = window.open("", "_blank");
|
|
68
|
+
if (printWindow) {
|
|
69
|
+
printWindow.document.write(`
|
|
70
|
+
<html>
|
|
71
|
+
<head>
|
|
72
|
+
<title>Print</title>
|
|
73
|
+
</head>
|
|
74
|
+
<body>${html}</body>
|
|
75
|
+
</html>
|
|
76
|
+
`);
|
|
77
|
+
printWindow.document.close();
|
|
78
|
+
printWindow.focus();
|
|
79
|
+
printWindow.print();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleSave = async () => {
|
|
84
|
+
const content = editor.getJSON();
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch("/api/save", {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
body: JSON.stringify(content),
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) throw new Error("Failed to save file");
|
|
92
|
+
|
|
93
|
+
const result = await response.json();
|
|
94
|
+
console.log(result.message);
|
|
95
|
+
alert("Content saved successfully!");
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error("Error saving content:", error);
|
|
98
|
+
alert("Failed to save content.");
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleDownloadPDF = async () => {
|
|
103
|
+
const html = editor.getHTML();
|
|
104
|
+
const container = document.createElement("div");
|
|
105
|
+
container.innerHTML = html;
|
|
106
|
+
container.classList.add("p-4", "prose");
|
|
107
|
+
document.body.appendChild(container);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const domToPdf = (await import("dom-to-pdf")).default;
|
|
111
|
+
const options = {
|
|
112
|
+
filename: "document.pdf",
|
|
113
|
+
overrideWidth: 800,
|
|
114
|
+
overrideHeight: 1120,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
domToPdf(container, options, () => {
|
|
118
|
+
console.log("PDF downloaded!");
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error("PDF download failed", error);
|
|
122
|
+
alert("Failed to download PDF.");
|
|
123
|
+
} finally {
|
|
124
|
+
document.body.removeChild(container);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const handleDownloadHTML = () => {
|
|
129
|
+
const html = editor.getHTML();
|
|
130
|
+
const blob = new Blob([html], { type: "text/html" });
|
|
131
|
+
const link = document.createElement("a");
|
|
132
|
+
link.href = URL.createObjectURL(blob);
|
|
133
|
+
link.download = "document.html";
|
|
134
|
+
document.body.appendChild(link);
|
|
135
|
+
link.click();
|
|
136
|
+
document.body.removeChild(link);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handleDownloadDOCX = async () => {
|
|
140
|
+
const { Document, Packer, Paragraph } = await import("docx");
|
|
141
|
+
const text = editor.getText();
|
|
142
|
+
|
|
143
|
+
const doc = new Document({
|
|
144
|
+
sections: [
|
|
145
|
+
{
|
|
146
|
+
properties: {},
|
|
147
|
+
children: [new Paragraph(text)],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const blob = await Packer.toBlob(doc);
|
|
153
|
+
const link = document.createElement("a");
|
|
154
|
+
link.href = URL.createObjectURL(blob);
|
|
155
|
+
link.download = "document.docx";
|
|
156
|
+
document.body.appendChild(link);
|
|
157
|
+
link.click();
|
|
158
|
+
document.body.removeChild(link);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div
|
|
163
|
+
className="relative flex items-center gap-1"
|
|
164
|
+
role="group"
|
|
165
|
+
aria-label="Editor actions"
|
|
166
|
+
>
|
|
167
|
+
<ToolbarButton icon={MdZoomIn} onClick={zoomIn} title="Zoom In" />
|
|
168
|
+
<ToolbarButton icon={MdZoomOut} onClick={zoomOut} title="Zoom Out" />
|
|
169
|
+
<ToolbarButton icon={MdPrint} onClick={handlePrint} title="Print" />
|
|
170
|
+
<ToolbarButton icon={MdSave} onClick={handleSave} title="Save" />
|
|
171
|
+
|
|
172
|
+
<div className="relative" ref={dropdownRef}>
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
onClick={() => setDropdownOpen((open) => !open)}
|
|
176
|
+
aria-haspopup="menu"
|
|
177
|
+
aria-expanded={dropdownOpen ? "true" : "false"}
|
|
178
|
+
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 focus:outline-none"
|
|
179
|
+
title="Export"
|
|
180
|
+
>
|
|
181
|
+
<MdDownload />
|
|
182
|
+
<span className="text-sm"></span>
|
|
183
|
+
</button>
|
|
184
|
+
|
|
185
|
+
{dropdownOpen && (
|
|
186
|
+
<div className="absolute z-10 mt-2 w-40 bg-white border rounded shadow-md">
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={() => {
|
|
190
|
+
setDropdownOpen(false);
|
|
191
|
+
handleDownloadPDF();
|
|
192
|
+
}}
|
|
193
|
+
className="w-full text-left px-4 py-2 hover:bg-gray-100"
|
|
194
|
+
>
|
|
195
|
+
Export as PDF
|
|
196
|
+
</button>
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
onClick={() => {
|
|
200
|
+
setDropdownOpen(false);
|
|
201
|
+
handleDownloadHTML();
|
|
202
|
+
}}
|
|
203
|
+
className="w-full text-left px-4 py-2 hover:bg-gray-100"
|
|
204
|
+
>
|
|
205
|
+
Export as HTML
|
|
206
|
+
</button>
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={() => {
|
|
210
|
+
setDropdownOpen(false);
|
|
211
|
+
handleDownloadDOCX();
|
|
212
|
+
}}
|
|
213
|
+
className="w-full text-left px-4 py-2 hover:bg-gray-100"
|
|
214
|
+
>
|
|
215
|
+
Export as DOCX
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|