tetrons 2.3.21 → 2.3.23
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/components/UI/Button.tsx +0 -0
- package/dist/components/UI/Dropdown.tsx +0 -0
- package/dist/components/tetrons/EditorContent.tsx +282 -0
- package/dist/components/tetrons/ResizableImage.ts +39 -0
- package/dist/components/tetrons/ResizableImageComponent.tsx +77 -0
- package/dist/components/tetrons/ResizableVideo.ts +66 -0
- package/dist/components/tetrons/ResizableVideoComponent.tsx +56 -0
- package/dist/components/tetrons/helpers.ts +0 -0
- package/dist/components/tetrons/toolbar/ActionGroup.tsx +218 -0
- package/dist/components/tetrons/toolbar/ClipboardGroup.tsx +58 -0
- package/dist/components/tetrons/toolbar/FileGroup.tsx +66 -0
- package/dist/components/tetrons/toolbar/FontStyleGroup.tsx +194 -0
- package/dist/components/tetrons/toolbar/InsertGroup.tsx +267 -0
- package/dist/components/tetrons/toolbar/ListAlignGroup.tsx +69 -0
- package/dist/components/tetrons/toolbar/MiscGroup.tsx +71 -0
- package/dist/components/tetrons/toolbar/TableContextMenu.tsx +91 -0
- package/dist/components/tetrons/toolbar/TetronsToolbar.tsx +71 -0
- package/dist/components/tetrons/toolbar/ToolbarButton.tsx +36 -0
- package/dist/components/tetrons/toolbar/extensions/Comment.ts +72 -0
- package/dist/components/tetrons/toolbar/extensions/Embed.ts +113 -0
- package/dist/components/tetrons/toolbar/extensions/FontFamily.ts +43 -0
- package/dist/components/tetrons/toolbar/extensions/FontSize.ts +43 -0
- package/dist/components/tetrons/toolbar/extensions/ResizableTable.ts +16 -0
- package/dist/components/tetrons/toolbar/marks/Subscript.ts +45 -0
- package/dist/components/tetrons/toolbar/marks/Superscript.ts +45 -0
- package/dist/index.js +0 -1
- package/dist/index.mjs +0 -1
- package/package.json +7 -7
- package/dist/tetrons-UCHWNATC.css +0 -372
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
MdTableChart,
|
|
6
|
+
MdInsertPhoto,
|
|
7
|
+
MdInsertLink,
|
|
8
|
+
MdInsertComment,
|
|
9
|
+
MdInsertEmoticon,
|
|
10
|
+
MdHorizontalRule,
|
|
11
|
+
MdVideoLibrary,
|
|
12
|
+
MdOutlineOndemandVideo,
|
|
13
|
+
} from "react-icons/md";
|
|
14
|
+
import { Editor } from "@tiptap/react";
|
|
15
|
+
import ToolbarButton from "./ToolbarButton";
|
|
16
|
+
import Picker from "@emoji-mart/react";
|
|
17
|
+
|
|
18
|
+
type Emoji = {
|
|
19
|
+
native: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default function InsertGroup({ editor }: { editor: Editor }) {
|
|
23
|
+
const [showTableGrid, setShowTableGrid] = useState(false);
|
|
24
|
+
const [selectedRows, setSelectedRows] = useState(1);
|
|
25
|
+
const [selectedCols, setSelectedCols] = useState(1);
|
|
26
|
+
|
|
27
|
+
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
28
|
+
const videoInputRef = useRef<HTMLInputElement>(null);
|
|
29
|
+
|
|
30
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
31
|
+
|
|
32
|
+
const addEmoji = (emoji: Emoji) => {
|
|
33
|
+
editor.chain().focus().insertContent(emoji.native).run();
|
|
34
|
+
setShowPicker(false);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handleTableCellHover = (row: number, col: number) => {
|
|
38
|
+
setSelectedRows(row);
|
|
39
|
+
setSelectedCols(col);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const handleTableInsert = (rows: number, cols: number) => {
|
|
43
|
+
editor
|
|
44
|
+
.chain()
|
|
45
|
+
.focus()
|
|
46
|
+
.insertTable({
|
|
47
|
+
rows: rows || 1,
|
|
48
|
+
cols: cols || 1,
|
|
49
|
+
withHeaderRow: true,
|
|
50
|
+
})
|
|
51
|
+
.run();
|
|
52
|
+
|
|
53
|
+
setShowTableGrid(false);
|
|
54
|
+
setSelectedRows(1);
|
|
55
|
+
setSelectedCols(1);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
59
|
+
const file = e.target.files?.[0];
|
|
60
|
+
if (file) {
|
|
61
|
+
const reader = new FileReader();
|
|
62
|
+
reader.onload = () => {
|
|
63
|
+
editor
|
|
64
|
+
.chain()
|
|
65
|
+
.focus()
|
|
66
|
+
.setImage({ src: reader.result as string })
|
|
67
|
+
.run();
|
|
68
|
+
};
|
|
69
|
+
reader.readAsDataURL(file);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
74
|
+
const file = e.target.files?.[0];
|
|
75
|
+
if (file) {
|
|
76
|
+
const reader = new FileReader();
|
|
77
|
+
reader.onload = () => {
|
|
78
|
+
editor
|
|
79
|
+
.chain()
|
|
80
|
+
.focus()
|
|
81
|
+
.setVideo({ src: reader.result as string, controls: true })
|
|
82
|
+
.run();
|
|
83
|
+
};
|
|
84
|
+
reader.readAsDataURL(file);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function normalizeEmbedUrl(url: string): string {
|
|
89
|
+
try {
|
|
90
|
+
const u = new URL(url);
|
|
91
|
+
const hostname = u.hostname.replace("www.", "").toLowerCase();
|
|
92
|
+
if (hostname === "youtube.com" || hostname === "youtu.be") {
|
|
93
|
+
let videoId = u.searchParams.get("v");
|
|
94
|
+
if (!videoId && hostname === "youtu.be") {
|
|
95
|
+
videoId = u.pathname.slice(1);
|
|
96
|
+
}
|
|
97
|
+
if (videoId) {
|
|
98
|
+
return `https://www.youtube.com/embed/${videoId}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (hostname === "vimeo.com") {
|
|
102
|
+
const videoId = u.pathname.split("/")[1];
|
|
103
|
+
if (videoId) {
|
|
104
|
+
return `https://player.vimeo.com/video/${videoId}`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
hostname === "google.com" ||
|
|
109
|
+
hostname === "maps.google.com" ||
|
|
110
|
+
hostname === "goo.gl"
|
|
111
|
+
) {
|
|
112
|
+
if (url.includes("/maps/embed")) {
|
|
113
|
+
return url;
|
|
114
|
+
}
|
|
115
|
+
return url.replace("/maps/", "/maps/embed/");
|
|
116
|
+
}
|
|
117
|
+
return url;
|
|
118
|
+
} catch {
|
|
119
|
+
return url;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="insert-group">
|
|
125
|
+
<input
|
|
126
|
+
type="file"
|
|
127
|
+
accept="image/*"
|
|
128
|
+
ref={imageInputRef}
|
|
129
|
+
className="hidden-input"
|
|
130
|
+
onChange={handleImageUpload}
|
|
131
|
+
aria-label="Upload Image"
|
|
132
|
+
title="Upload Image"
|
|
133
|
+
/>
|
|
134
|
+
<input
|
|
135
|
+
type="file"
|
|
136
|
+
accept="video/*"
|
|
137
|
+
ref={videoInputRef}
|
|
138
|
+
className="hidden-input"
|
|
139
|
+
onChange={handleVideoUpload}
|
|
140
|
+
aria-label="Upload Video"
|
|
141
|
+
title="Upload Video"
|
|
142
|
+
/>
|
|
143
|
+
|
|
144
|
+
<ToolbarButton
|
|
145
|
+
icon={MdTableChart}
|
|
146
|
+
label="Insert Table"
|
|
147
|
+
onClick={() => setShowTableGrid(!showTableGrid)}
|
|
148
|
+
/>
|
|
149
|
+
{showTableGrid && (
|
|
150
|
+
<div
|
|
151
|
+
className="table-grid-popup"
|
|
152
|
+
onMouseLeave={() => setShowTableGrid(false)}
|
|
153
|
+
>
|
|
154
|
+
<div className="table-grid">
|
|
155
|
+
{[...Array(10)].map((_, row) =>
|
|
156
|
+
[...Array(10)].map((_, col) => {
|
|
157
|
+
const isSelected = row < selectedRows && col < selectedCols;
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
key={`${row}-${col}`}
|
|
161
|
+
className={`table-grid-cell ${
|
|
162
|
+
isSelected ? "selected" : ""
|
|
163
|
+
}`}
|
|
164
|
+
onMouseEnter={() => handleTableCellHover(row + 1, col + 1)}
|
|
165
|
+
onClick={() => handleTableInsert(row + 1, col + 1)}
|
|
166
|
+
/>
|
|
167
|
+
);
|
|
168
|
+
})
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
<div className="table-grid-label">
|
|
172
|
+
{selectedRows} x {selectedCols}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
<ToolbarButton
|
|
178
|
+
icon={MdInsertPhoto}
|
|
179
|
+
label="Insert Image"
|
|
180
|
+
onClick={() => imageInputRef.current?.click()}
|
|
181
|
+
/>
|
|
182
|
+
<ToolbarButton
|
|
183
|
+
icon={MdVideoLibrary}
|
|
184
|
+
label="Insert Video"
|
|
185
|
+
onClick={() => videoInputRef.current?.click()}
|
|
186
|
+
/>
|
|
187
|
+
<ToolbarButton
|
|
188
|
+
icon={MdInsertLink}
|
|
189
|
+
label="Insert Link"
|
|
190
|
+
onClick={() => {
|
|
191
|
+
const url = prompt("Enter URL");
|
|
192
|
+
if (url) {
|
|
193
|
+
editor
|
|
194
|
+
.chain()
|
|
195
|
+
.focus()
|
|
196
|
+
.extendMarkRange("link")
|
|
197
|
+
.setLink({ href: url })
|
|
198
|
+
.run();
|
|
199
|
+
}
|
|
200
|
+
}}
|
|
201
|
+
/>
|
|
202
|
+
<ToolbarButton
|
|
203
|
+
icon={MdInsertComment}
|
|
204
|
+
label="Insert Comment"
|
|
205
|
+
onClick={() => {
|
|
206
|
+
const comment = prompt("Enter your comment");
|
|
207
|
+
if (
|
|
208
|
+
comment &&
|
|
209
|
+
editor.can().chain().focus().setComment(comment).run()
|
|
210
|
+
) {
|
|
211
|
+
editor.chain().focus().setComment(comment).run();
|
|
212
|
+
} else {
|
|
213
|
+
console.warn("Cannot apply comment — maybe no selection?");
|
|
214
|
+
}
|
|
215
|
+
}}
|
|
216
|
+
/>
|
|
217
|
+
<div className="relative">
|
|
218
|
+
<ToolbarButton
|
|
219
|
+
icon={MdInsertEmoticon}
|
|
220
|
+
label="Emoji"
|
|
221
|
+
onClick={() => setShowPicker(!showPicker)}
|
|
222
|
+
/>
|
|
223
|
+
{showPicker && (
|
|
224
|
+
<div className="emoji-picker">
|
|
225
|
+
<Picker
|
|
226
|
+
onEmojiSelect={addEmoji}
|
|
227
|
+
theme="auto"
|
|
228
|
+
emoji="point_up"
|
|
229
|
+
showPreview={false}
|
|
230
|
+
showSkinTones={true}
|
|
231
|
+
emojiTooltip={true}
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
<ToolbarButton
|
|
237
|
+
icon={MdHorizontalRule}
|
|
238
|
+
label="Horizontal Line"
|
|
239
|
+
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
|
240
|
+
/>
|
|
241
|
+
<ToolbarButton
|
|
242
|
+
icon={MdOutlineOndemandVideo}
|
|
243
|
+
label="Embed"
|
|
244
|
+
onClick={() => {
|
|
245
|
+
const url = prompt(
|
|
246
|
+
"Enter embed URL (YouTube, Vimeo, Google Maps, etc.)"
|
|
247
|
+
);
|
|
248
|
+
if (!url) return;
|
|
249
|
+
|
|
250
|
+
const embedUrl = normalizeEmbedUrl(url);
|
|
251
|
+
const width = prompt("Enter width in pixels", "560");
|
|
252
|
+
const height = prompt("Enter height in pixels", "315");
|
|
253
|
+
|
|
254
|
+
editor
|
|
255
|
+
.chain()
|
|
256
|
+
.focus()
|
|
257
|
+
.setEmbed({
|
|
258
|
+
src: embedUrl,
|
|
259
|
+
width: width ? parseInt(width) : 560,
|
|
260
|
+
height: height ? parseInt(height) : 315,
|
|
261
|
+
})
|
|
262
|
+
.run();
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import {
|
|
4
|
+
MdFormatListBulleted,
|
|
5
|
+
MdFormatListNumbered,
|
|
6
|
+
MdFormatIndentDecrease,
|
|
7
|
+
MdFormatIndentIncrease,
|
|
8
|
+
MdFormatAlignLeft,
|
|
9
|
+
MdFormatAlignCenter,
|
|
10
|
+
MdFormatAlignRight,
|
|
11
|
+
MdFormatAlignJustify,
|
|
12
|
+
} from "react-icons/md";
|
|
13
|
+
import { Editor } from "@tiptap/react";
|
|
14
|
+
import ToolbarButton from "./ToolbarButton";
|
|
15
|
+
|
|
16
|
+
export default function ListAlignGroup({ editor }: { editor: Editor }) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="list-align-group">
|
|
19
|
+
<ToolbarButton
|
|
20
|
+
icon={MdFormatListBulleted}
|
|
21
|
+
title="Bulleted List"
|
|
22
|
+
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
23
|
+
disabled={!editor.can().toggleBulletList()}
|
|
24
|
+
/>
|
|
25
|
+
<ToolbarButton
|
|
26
|
+
icon={MdFormatListNumbered}
|
|
27
|
+
title="Numbered List"
|
|
28
|
+
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
29
|
+
disabled={!editor.can().toggleOrderedList()}
|
|
30
|
+
/>
|
|
31
|
+
<ToolbarButton
|
|
32
|
+
icon={MdFormatIndentIncrease}
|
|
33
|
+
title="Increase Indent"
|
|
34
|
+
onClick={() => editor.chain().focus().sinkListItem("listItem").run()}
|
|
35
|
+
disabled={!editor.can().sinkListItem("listItem")}
|
|
36
|
+
/>
|
|
37
|
+
<ToolbarButton
|
|
38
|
+
icon={MdFormatIndentDecrease}
|
|
39
|
+
title="Decrease Indent"
|
|
40
|
+
onClick={() => editor.chain().focus().liftListItem("listItem").run()}
|
|
41
|
+
disabled={!editor.can().liftListItem("listItem")}
|
|
42
|
+
/>
|
|
43
|
+
<ToolbarButton
|
|
44
|
+
icon={MdFormatAlignLeft}
|
|
45
|
+
title="Align Left"
|
|
46
|
+
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
|
47
|
+
disabled={!editor.can().setTextAlign("left")}
|
|
48
|
+
/>
|
|
49
|
+
<ToolbarButton
|
|
50
|
+
icon={MdFormatAlignCenter}
|
|
51
|
+
title="Align Center"
|
|
52
|
+
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
|
53
|
+
disabled={!editor.can().setTextAlign("center")}
|
|
54
|
+
/>
|
|
55
|
+
<ToolbarButton
|
|
56
|
+
icon={MdFormatAlignRight}
|
|
57
|
+
title="Align Right"
|
|
58
|
+
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
|
59
|
+
disabled={!editor.can().setTextAlign("right")}
|
|
60
|
+
/>
|
|
61
|
+
<ToolbarButton
|
|
62
|
+
icon={MdFormatAlignJustify}
|
|
63
|
+
title="Justify"
|
|
64
|
+
onClick={() => editor.chain().focus().setTextAlign("justify").run()}
|
|
65
|
+
disabled={!editor.can().setTextAlign("justify")}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
MdUndo,
|
|
4
|
+
MdRedo,
|
|
5
|
+
MdRefresh,
|
|
6
|
+
MdVisibility,
|
|
7
|
+
MdCode,
|
|
8
|
+
} from "react-icons/md";
|
|
9
|
+
import { Editor } from "@tiptap/react";
|
|
10
|
+
import ToolbarButton from "./ToolbarButton";
|
|
11
|
+
|
|
12
|
+
interface MiscGroupProps {
|
|
13
|
+
editor: Editor;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function MiscGroup({ editor }: MiscGroupProps) {
|
|
17
|
+
const handlePreview = () => {
|
|
18
|
+
const html = editor.getHTML();
|
|
19
|
+
const previewWindow = window.open("", "_blank");
|
|
20
|
+
if (previewWindow) {
|
|
21
|
+
previewWindow.document.open();
|
|
22
|
+
previewWindow.document.write(`
|
|
23
|
+
<html>
|
|
24
|
+
<head>
|
|
25
|
+
<title>Preview</title>
|
|
26
|
+
<style>
|
|
27
|
+
body { font-family: sans-serif; padding: 2rem; line-height: 1.6; }
|
|
28
|
+
</style>
|
|
29
|
+
</head>
|
|
30
|
+
<body>${html}</body>
|
|
31
|
+
</html>
|
|
32
|
+
`);
|
|
33
|
+
previewWindow.document.close();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="misc-group">
|
|
39
|
+
<ToolbarButton
|
|
40
|
+
icon={MdUndo}
|
|
41
|
+
label="Undo"
|
|
42
|
+
onClick={() => editor.chain().focus().undo().run()}
|
|
43
|
+
disabled={!editor.can().undo()}
|
|
44
|
+
/>
|
|
45
|
+
<ToolbarButton
|
|
46
|
+
icon={MdRedo}
|
|
47
|
+
label="Redo"
|
|
48
|
+
onClick={() => editor.chain().focus().redo().run()}
|
|
49
|
+
disabled={!editor.can().redo()}
|
|
50
|
+
/>
|
|
51
|
+
<ToolbarButton
|
|
52
|
+
icon={MdRefresh}
|
|
53
|
+
label="Reset Formatting"
|
|
54
|
+
onClick={() =>
|
|
55
|
+
editor.chain().focus().unsetAllMarks().clearNodes().run()
|
|
56
|
+
}
|
|
57
|
+
/>
|
|
58
|
+
<ToolbarButton
|
|
59
|
+
icon={MdCode}
|
|
60
|
+
label="Toggle Code Block"
|
|
61
|
+
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
62
|
+
isActive={editor.isActive("codeBlock")}
|
|
63
|
+
/>
|
|
64
|
+
<ToolbarButton
|
|
65
|
+
icon={MdVisibility}
|
|
66
|
+
label="Preview"
|
|
67
|
+
onClick={handlePreview}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import { Editor } from "@tiptap/react";
|
|
5
|
+
|
|
6
|
+
interface ContextMenuProps {
|
|
7
|
+
editor: Editor;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function TableContextMenu({ editor }: ContextMenuProps) {
|
|
11
|
+
const [menuPosition, setMenuPosition] = useState<{
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
} | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const handleContextMenu = (event: MouseEvent) => {
|
|
18
|
+
const target = event.target as HTMLElement;
|
|
19
|
+
|
|
20
|
+
if (target.closest("td") || target.closest("th")) {
|
|
21
|
+
event.preventDefault();
|
|
22
|
+
setMenuPosition({ x: event.pageX, y: event.pageY });
|
|
23
|
+
} else {
|
|
24
|
+
setMenuPosition(null);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const handleClick = () => setMenuPosition(null);
|
|
29
|
+
|
|
30
|
+
document.addEventListener("contextmenu", handleContextMenu);
|
|
31
|
+
document.addEventListener("click", handleClick);
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
document.removeEventListener("contextmenu", handleContextMenu);
|
|
35
|
+
document.removeEventListener("click", handleClick);
|
|
36
|
+
};
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const insertRowAbove = () => editor.chain().focus().addRowBefore().run();
|
|
40
|
+
const insertRowBelow = () => editor.chain().focus().addRowAfter().run();
|
|
41
|
+
const insertColLeft = () => editor.chain().focus().addColumnBefore().run();
|
|
42
|
+
const insertColRight = () => editor.chain().focus().addColumnAfter().run();
|
|
43
|
+
const deleteRow = () => editor.chain().focus().deleteRow().run();
|
|
44
|
+
const deleteCol = () => editor.chain().focus().deleteColumn().run();
|
|
45
|
+
|
|
46
|
+
if (!menuPosition) return null;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<ul
|
|
50
|
+
className="absolute bg-white shadow border rounded text-sm z-50"
|
|
51
|
+
style={{ top: menuPosition.y, left: menuPosition.x }}
|
|
52
|
+
>
|
|
53
|
+
<li
|
|
54
|
+
className="px-3 py-1 hover:bg-gray-100 cursor-pointer"
|
|
55
|
+
onClick={insertRowAbove}
|
|
56
|
+
>
|
|
57
|
+
Insert Row Above
|
|
58
|
+
</li>
|
|
59
|
+
<li
|
|
60
|
+
className="px-3 py-1 hover:bg-gray-100 cursor-pointer"
|
|
61
|
+
onClick={insertRowBelow}
|
|
62
|
+
>
|
|
63
|
+
Insert Row Below
|
|
64
|
+
</li>
|
|
65
|
+
<li
|
|
66
|
+
className="px-3 py-1 hover:bg-gray-100 cursor-pointer"
|
|
67
|
+
onClick={insertColLeft}
|
|
68
|
+
>
|
|
69
|
+
Insert Column Left
|
|
70
|
+
</li>
|
|
71
|
+
<li
|
|
72
|
+
className="px-3 py-1 hover:bg-gray-100 cursor-pointer"
|
|
73
|
+
onClick={insertColRight}
|
|
74
|
+
>
|
|
75
|
+
Insert Column Right
|
|
76
|
+
</li>
|
|
77
|
+
<li
|
|
78
|
+
className="px-3 py-1 hover:bg-red-100 cursor-pointer"
|
|
79
|
+
onClick={deleteRow}
|
|
80
|
+
>
|
|
81
|
+
Delete Row
|
|
82
|
+
</li>
|
|
83
|
+
<li
|
|
84
|
+
className="px-3 py-1 hover:bg-red-100 cursor-pointer"
|
|
85
|
+
onClick={deleteCol}
|
|
86
|
+
>
|
|
87
|
+
Delete Column
|
|
88
|
+
</li>
|
|
89
|
+
</ul>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import type { Editor } from "@tiptap/react";
|
|
5
|
+
|
|
6
|
+
import ActionGroup from "./ActionGroup";
|
|
7
|
+
import ClipboardGroup from "./ClipboardGroup";
|
|
8
|
+
import FontStyleGroup from "./FontStyleGroup";
|
|
9
|
+
import InsertGroup from "./InsertGroup";
|
|
10
|
+
import ListAlignGroup from "./ListAlignGroup";
|
|
11
|
+
import MiscGroup from "./MiscGroup";
|
|
12
|
+
import FileGroup from "./FileGroup";
|
|
13
|
+
|
|
14
|
+
export default function TetronsToolbar({
|
|
15
|
+
editor,
|
|
16
|
+
version,
|
|
17
|
+
}: {
|
|
18
|
+
editor: Editor;
|
|
19
|
+
version: "free" | "pro" | "premium" | "platinum";
|
|
20
|
+
}) {
|
|
21
|
+
const [autoSave, setAutoSave] = useState(false);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!editor) return;
|
|
25
|
+
|
|
26
|
+
const handleUpdate = () => {
|
|
27
|
+
if (!autoSave) return;
|
|
28
|
+
const content = editor.getJSON();
|
|
29
|
+
fetch("/api/save", {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify(content),
|
|
33
|
+
}).catch((err) => console.error("Auto-save failed:", err));
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
editor.on("update", handleUpdate);
|
|
37
|
+
return () => {
|
|
38
|
+
editor.off("update", handleUpdate);
|
|
39
|
+
};
|
|
40
|
+
}, [autoSave, editor]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="tetrons-toolbar">
|
|
44
|
+
{version !== "free" && (
|
|
45
|
+
<div className="group">
|
|
46
|
+
<input
|
|
47
|
+
type="checkbox"
|
|
48
|
+
id="autoSave"
|
|
49
|
+
checked={autoSave}
|
|
50
|
+
onChange={(e) => setAutoSave(e.target.checked)}
|
|
51
|
+
/>
|
|
52
|
+
<label htmlFor="autoSave">Auto Save</label>
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
{["pro", "premium", "platinum"].includes(version) && (
|
|
57
|
+
<FileGroup editor={editor} />
|
|
58
|
+
)}
|
|
59
|
+
<ClipboardGroup editor={editor} />
|
|
60
|
+
<FontStyleGroup editor={editor} />
|
|
61
|
+
<ListAlignGroup editor={editor} />
|
|
62
|
+
{["premium", "platinum"].includes(version) && (
|
|
63
|
+
<>
|
|
64
|
+
<InsertGroup editor={editor} />
|
|
65
|
+
<ActionGroup editor={editor} />
|
|
66
|
+
</>
|
|
67
|
+
)}
|
|
68
|
+
{version === "platinum" && <MiscGroup editor={editor} />}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { IconType } from "react-icons";
|
|
3
|
+
|
|
4
|
+
export type ToolbarButtonProps = {
|
|
5
|
+
icon: IconType;
|
|
6
|
+
onClick: () => void;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
title?: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
isActive?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
|
|
14
|
+
(
|
|
15
|
+
{ icon: Icon, onClick, disabled = false, title, label, isActive = false },
|
|
16
|
+
ref
|
|
17
|
+
) => {
|
|
18
|
+
return (
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
ref={ref}
|
|
22
|
+
onClick={onClick}
|
|
23
|
+
disabled={disabled}
|
|
24
|
+
title={title ?? label}
|
|
25
|
+
aria-label={title ?? label}
|
|
26
|
+
className={`toolbar-button ${isActive ? "active" : ""}`}
|
|
27
|
+
>
|
|
28
|
+
<Icon size={20} />
|
|
29
|
+
</button>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
ToolbarButton.displayName = "ToolbarButton";
|
|
35
|
+
|
|
36
|
+
export default ToolbarButton;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Mark, mergeAttributes } from "@tiptap/core";
|
|
2
|
+
|
|
3
|
+
export interface CommentOptions {
|
|
4
|
+
HTMLAttributes: {
|
|
5
|
+
class?: string;
|
|
6
|
+
style?: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare module "@tiptap/core" {
|
|
12
|
+
interface Commands<ReturnType> {
|
|
13
|
+
comment: {
|
|
14
|
+
setComment: (comment: string) => ReturnType;
|
|
15
|
+
unsetComment: () => ReturnType;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const Comment = Mark.create<CommentOptions>({
|
|
21
|
+
name: "comment",
|
|
22
|
+
|
|
23
|
+
addOptions() {
|
|
24
|
+
return {
|
|
25
|
+
HTMLAttributes: {},
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
addAttributes() {
|
|
30
|
+
return {
|
|
31
|
+
comment: {
|
|
32
|
+
default: "",
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
parseHTML() {
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
tag: "span[data-comment]",
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
renderHTML({ HTMLAttributes }) {
|
|
46
|
+
return [
|
|
47
|
+
"span",
|
|
48
|
+
mergeAttributes(HTMLAttributes, {
|
|
49
|
+
"data-comment": HTMLAttributes.comment,
|
|
50
|
+
class: "comment-highlight",
|
|
51
|
+
title: HTMLAttributes.comment,
|
|
52
|
+
style: "background-color: rgba(255, 230, 0, 0.3);",
|
|
53
|
+
}),
|
|
54
|
+
0,
|
|
55
|
+
];
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
addCommands() {
|
|
59
|
+
return {
|
|
60
|
+
setComment:
|
|
61
|
+
(comment) =>
|
|
62
|
+
({ commands }) => {
|
|
63
|
+
return commands.setMark(this.name, { comment });
|
|
64
|
+
},
|
|
65
|
+
unsetComment:
|
|
66
|
+
() =>
|
|
67
|
+
({ commands }) => {
|
|
68
|
+
return commands.unsetMark(this.name);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
});
|