tetrons 2.3.22 → 2.3.24

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.
Files changed (41) hide show
  1. package/dist/app/page.d.ts +2 -0
  2. package/dist/app/page.jsx +51 -0
  3. package/dist/components/UI/Button.tsx +0 -0
  4. package/dist/components/UI/Dropdown.tsx +0 -0
  5. package/dist/components/components/tetrons/EditorContent.tsx +60 -62
  6. package/dist/components/tetrons/EditorContent.d.ts +6 -0
  7. package/dist/components/tetrons/EditorContent.tsx +280 -0
  8. package/dist/components/tetrons/ResizableImage.ts +39 -0
  9. package/dist/components/tetrons/ResizableImageComponent.tsx +77 -0
  10. package/dist/components/tetrons/ResizableVideo.ts +66 -0
  11. package/dist/components/tetrons/ResizableVideoComponent.tsx +56 -0
  12. package/dist/components/tetrons/extensions/Spellcheck.ts +50 -0
  13. package/dist/components/tetrons/helpers.ts +0 -0
  14. package/dist/components/tetrons/toolbar/ActionGroup.tsx +218 -0
  15. package/dist/components/tetrons/toolbar/ClipboardGroup.tsx +58 -0
  16. package/dist/components/tetrons/toolbar/FileGroup.tsx +66 -0
  17. package/dist/components/tetrons/toolbar/FontStyleGroup.tsx +194 -0
  18. package/dist/components/tetrons/toolbar/InsertGroup.tsx +267 -0
  19. package/dist/components/tetrons/toolbar/ListAlignGroup.tsx +69 -0
  20. package/dist/components/tetrons/toolbar/MiscGroup.d.ts +7 -0
  21. package/dist/components/tetrons/toolbar/MiscGroup.jsx +55 -0
  22. package/dist/components/tetrons/toolbar/MiscGroup.tsx +104 -0
  23. package/dist/components/tetrons/toolbar/TableContextMenu.tsx +91 -0
  24. package/dist/components/tetrons/toolbar/TetronsToolbar.d.ts +6 -0
  25. package/dist/components/tetrons/toolbar/TetronsToolbar.tsx +71 -0
  26. package/dist/components/tetrons/toolbar/ToolbarButton.tsx +36 -0
  27. package/dist/components/tetrons/toolbar/extensions/Comment.ts +72 -0
  28. package/dist/components/tetrons/toolbar/extensions/Embed.ts +113 -0
  29. package/dist/components/tetrons/toolbar/extensions/FontFamily.ts +43 -0
  30. package/dist/components/tetrons/toolbar/extensions/FontSize.ts +43 -0
  31. package/dist/components/tetrons/toolbar/extensions/ResizableTable.ts +16 -0
  32. package/dist/components/tetrons/toolbar/marks/Subscript.ts +45 -0
  33. package/dist/components/tetrons/toolbar/marks/Superscript.ts +45 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +17366 -22
  36. package/dist/index.mjs +4705 -4592
  37. package/dist/styles/styles/tetrons.css +1 -1
  38. package/dist/styles/tetrons.css +1 -1
  39. package/dist/utils/checkGrammar.d.ts +25 -0
  40. package/dist/utils/checkGrammar.js +17 -0
  41. package/package.json +9 -9
@@ -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;
@@ -0,0 +1,50 @@
1
+ import { Mark, markInputRule } from "@tiptap/core";
2
+
3
+ export interface SpellcheckOptions {
4
+ spellcheckFn: (word: string) => boolean;
5
+ }
6
+
7
+ const wordMatchRegex = () => /(?:^|\s)([a-zA-Z]{2,})(?=\s|$)/g;
8
+
9
+ export const Spellcheck = Mark.create<SpellcheckOptions>({
10
+ name: "spellcheck",
11
+
12
+ addOptions() {
13
+ return {
14
+ spellcheckFn: () => true,
15
+ };
16
+ },
17
+
18
+ addInputRules() {
19
+ return [
20
+ markInputRule({
21
+ find: wordMatchRegex(),
22
+ type: this.type,
23
+ getAttributes: (match) =>
24
+ this.options.spellcheckFn(match[1])
25
+ ? false
26
+ : { "data-spellcheck": "true" },
27
+ }),
28
+ ];
29
+ },
30
+
31
+ parseHTML() {
32
+ return [
33
+ {
34
+ tag: "span[data-spellcheck]",
35
+ },
36
+ ];
37
+ },
38
+
39
+ renderHTML({ HTMLAttributes }) {
40
+ return [
41
+ "span",
42
+ {
43
+ ...HTMLAttributes,
44
+ style: "text-decoration: red wavy underline;",
45
+ "data-spellcheck": "true",
46
+ },
47
+ 0,
48
+ ];
49
+ },
50
+ });
File without changes
@@ -0,0 +1,218 @@
1
+ "use client";
2
+
3
+ import React, { 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 style = element.style as ZoomableStyle;
49
+ const currentZoom = parseFloat(style.zoom || "1");
50
+ const next = Math.min(currentZoom + 0.1, 2);
51
+ style.zoom = next.toString();
52
+ }
53
+ };
54
+
55
+ const zoomOut = () => {
56
+ const element = document.querySelector(
57
+ ".ProseMirror"
58
+ ) as HTMLElement | null;
59
+ if (element) {
60
+ const style = element.style as ZoomableStyle;
61
+ const currentZoom = parseFloat(style.zoom || "1");
62
+ const next = Math.max(currentZoom - 0.1, 0.5);
63
+ style.zoom = next.toString();
64
+ }
65
+ };
66
+
67
+ const handlePrint = () => {
68
+ const html = editor.getHTML();
69
+ const printWindow = window.open("", "_blank");
70
+ if (printWindow) {
71
+ printWindow.document.write(`
72
+ <html>
73
+ <head>
74
+ <title>Print</title>
75
+ </head>
76
+ <body>${html}</body>
77
+ </html>
78
+ `);
79
+ printWindow.document.close();
80
+ printWindow.focus();
81
+ printWindow.print();
82
+ }
83
+ };
84
+
85
+ const handleSave = async () => {
86
+ const content = editor.getJSON();
87
+ try {
88
+ const response = await fetch("/api/save", {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify(content),
92
+ });
93
+ if (!response.ok) throw new Error("Failed to save file");
94
+
95
+ const result = await response.json();
96
+ console.log(result.message);
97
+ alert("Content saved successfully!");
98
+ } catch (error) {
99
+ console.error("Error saving content:", error);
100
+ alert("Failed to save content.");
101
+ }
102
+ };
103
+
104
+ const handleDownloadPDF = async () => {
105
+ const html = editor.getHTML();
106
+ const container = document.createElement("div");
107
+ container.innerHTML = html;
108
+ container.classList.add("p-4", "prose");
109
+ document.body.appendChild(container);
110
+
111
+ try {
112
+ const domToPdf = (await import("dom-to-pdf")).default;
113
+ const options = {
114
+ filename: "document.pdf",
115
+ overrideWidth: 800,
116
+ overrideHeight: 1120,
117
+ };
118
+
119
+ domToPdf(container, options, () => {
120
+ console.log("PDF downloaded!");
121
+ });
122
+ } catch (error) {
123
+ console.error("PDF download failed", error);
124
+ alert("Failed to download PDF.");
125
+ } finally {
126
+ document.body.removeChild(container);
127
+ }
128
+ };
129
+
130
+ const handleDownloadHTML = () => {
131
+ const html = editor.getHTML();
132
+ const blob = new Blob([html], { type: "text/html" });
133
+ const link = document.createElement("a");
134
+ link.href = URL.createObjectURL(blob);
135
+ link.download = "document.html";
136
+ document.body.appendChild(link);
137
+ link.click();
138
+ document.body.removeChild(link);
139
+ };
140
+
141
+ const handleDownloadDOCX = async () => {
142
+ const { Document, Packer, Paragraph } = await import("docx");
143
+ const text = editor.getText();
144
+
145
+ const doc = new Document({
146
+ sections: [
147
+ {
148
+ properties: {},
149
+ children: [new Paragraph(text)],
150
+ },
151
+ ],
152
+ });
153
+
154
+ const blob = await Packer.toBlob(doc);
155
+ const link = document.createElement("a");
156
+ link.href = URL.createObjectURL(blob);
157
+ link.download = "document.docx";
158
+ document.body.appendChild(link);
159
+ link.click();
160
+ document.body.removeChild(link);
161
+ };
162
+
163
+ return (
164
+ <div className="action-group" role="group" aria-label="Editor actions">
165
+ <ToolbarButton icon={MdZoomIn} onClick={zoomIn} title="Zoom In" />
166
+ <ToolbarButton icon={MdZoomOut} onClick={zoomOut} title="Zoom Out" />
167
+ <ToolbarButton icon={MdPrint} onClick={handlePrint} title="Print" />
168
+ <ToolbarButton icon={MdSave} onClick={handleSave} title="Save" />
169
+
170
+ <div className="relative" ref={dropdownRef}>
171
+ <button
172
+ type="button"
173
+ onClick={() => setDropdownOpen((open) => !open)}
174
+ aria-haspopup="menu"
175
+ aria-expanded={dropdownOpen ? "true" : "false"}
176
+ className="export-button"
177
+ title="Export"
178
+ >
179
+ <MdDownload />
180
+ <span className="text-sm"></span>
181
+ </button>
182
+
183
+ {dropdownOpen && (
184
+ <div className="export-dropdown">
185
+ <button
186
+ type="button"
187
+ onClick={() => {
188
+ setDropdownOpen(false);
189
+ handleDownloadPDF();
190
+ }}
191
+ >
192
+ Export as PDF
193
+ </button>
194
+ <button
195
+ type="button"
196
+ onClick={() => {
197
+ setDropdownOpen(false);
198
+ handleDownloadHTML();
199
+ }}
200
+ >
201
+ Export as HTML
202
+ </button>
203
+ <button
204
+ type="button"
205
+ onClick={() => {
206
+ setDropdownOpen(false);
207
+ handleDownloadDOCX();
208
+ }}
209
+ >
210
+ Export as DOCX
211
+ </button>
212
+ </div>
213
+ )}
214
+ </div>
215
+ </div>
216
+ );
217
+
218
+ }
@@ -0,0 +1,58 @@
1
+ import {
2
+ MdContentPaste,
3
+ MdContentCut,
4
+ MdContentCopy,
5
+ MdFormatPaint,
6
+ } from "react-icons/md";
7
+ import React from "react";
8
+ import { Editor } from "@tiptap/react";
9
+ import ToolbarButton from "./ToolbarButton";
10
+
11
+ export default function ClipboardGroup({ editor }: { editor: Editor }) {
12
+ return (
13
+ <div className="clipboard-group">
14
+ <ToolbarButton
15
+ icon={MdContentPaste}
16
+ title="Paste"
17
+ onClick={async () => {
18
+ try {
19
+ const text = await navigator.clipboard.readText();
20
+ editor.chain().focus().insertContent(text).run();
21
+ } catch (error) {
22
+ console.error("Failed to read clipboard contents:", error);
23
+ }
24
+ }}
25
+ />
26
+ <ToolbarButton
27
+ icon={MdContentCut}
28
+ title="Cut"
29
+ onClick={() => {
30
+ const { from, to } = editor.state.selection;
31
+ if (from === to) return;
32
+ const selectedText = editor.state.doc.textBetween(from, to);
33
+ navigator.clipboard.writeText(selectedText).then(() => {
34
+ editor.chain().focus().deleteRange({ from, to }).run();
35
+ });
36
+ }}
37
+ />
38
+ <ToolbarButton
39
+ icon={MdContentCopy}
40
+ title="Copy"
41
+ onClick={() => {
42
+ const { from, to } = editor.state.selection;
43
+ if (from === to) return;
44
+ const selectedText = editor.state.doc.textBetween(from, to);
45
+ navigator.clipboard.writeText(selectedText);
46
+ }}
47
+ />
48
+ <ToolbarButton
49
+ icon={MdFormatPaint}
50
+ title="Format Painter"
51
+ onClick={() => {
52
+ const currentMarks = editor.getAttributes("textStyle");
53
+ localStorage.setItem("formatPainter", JSON.stringify(currentMarks));
54
+ }}
55
+ />
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import { Editor } from "@tiptap/react";
4
+ import { FaRegFolderOpen } from "react-icons/fa";
5
+ import { VscNewFile } from "react-icons/vsc";
6
+ import ToolbarButton from "./ToolbarButton";
7
+ import React, { useRef } from "react";
8
+
9
+ type FileGroupProps = {
10
+ editor: Editor;
11
+ };
12
+
13
+ export default function FileGroup({ editor }: FileGroupProps) {
14
+ const fileInputRef = useRef<HTMLInputElement>(null);
15
+
16
+ const handleNew = () => {
17
+ if (
18
+ confirm(
19
+ "Are you sure you want to create a new document? Unsaved changes will be lost."
20
+ )
21
+ ) {
22
+ editor.commands.clearContent();
23
+ }
24
+ };
25
+
26
+ const handleOpen = () => {
27
+ fileInputRef.current?.click();
28
+ };
29
+
30
+ const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
31
+ const file = e.target.files?.[0];
32
+ if (!file) return;
33
+
34
+ try {
35
+ const text = await file.text();
36
+ const json = JSON.parse(text);
37
+ editor.commands.setContent(json);
38
+ } catch (error) {
39
+ console.error("Error reading file:", error);
40
+ alert(
41
+ "Failed to open or parse the file. Make sure it's a valid JSON file."
42
+ );
43
+ } finally {
44
+ e.target.value = "";
45
+ }
46
+ };
47
+
48
+ return (
49
+ <div className="file-group" role="group" aria-label="File actions">
50
+ <input
51
+ type="file"
52
+ accept=".json"
53
+ ref={fileInputRef}
54
+ onChange={handleFileChange}
55
+ className="hidden"
56
+ aria-label="Open JSON file"
57
+ />
58
+ <ToolbarButton icon={VscNewFile} onClick={handleNew} title="New" />
59
+ <ToolbarButton
60
+ icon={FaRegFolderOpen}
61
+ onClick={handleOpen}
62
+ title="Open File"
63
+ />
64
+ </div>
65
+ );
66
+ }