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.
Files changed (63) hide show
  1. package/.hintrc +13 -0
  2. package/README.md +36 -0
  3. package/eslint.config.mjs +16 -0
  4. package/next.config.ts +7 -0
  5. package/package.json +61 -0
  6. package/postcss.config.mjs +5 -0
  7. package/public/editor-content.json +27 -0
  8. package/public/favicon-16x16.png +0 -0
  9. package/public/favicon-32x32.png +0 -0
  10. package/public/favicon-64x64.png +0 -0
  11. package/public/favicon-768x768.png +0 -0
  12. package/public/file.svg +1 -0
  13. package/public/globe.svg +1 -0
  14. package/public/next.svg +1 -0
  15. package/public/site.webmanifest +20 -0
  16. package/public/vercel.svg +1 -0
  17. package/public/window.svg +1 -0
  18. package/src/app/api/export/route.ts +0 -0
  19. package/src/app/api/save/route.ts +18 -0
  20. package/src/app/favicon-16x16.png +0 -0
  21. package/src/app/favicon-32x32.png +0 -0
  22. package/src/app/favicon-64x64.png +0 -0
  23. package/src/app/favicon-768x768.png +0 -0
  24. package/src/app/favicon.ico +0 -0
  25. package/src/app/globals.css +207 -0
  26. package/src/app/layout.tsx +47 -0
  27. package/src/app/page.tsx +11 -0
  28. package/src/components/UI/Button.tsx +0 -0
  29. package/src/components/UI/Dropdown.tsx +0 -0
  30. package/src/components/tetrons/EditorContent.tsx +210 -0
  31. package/src/components/tetrons/ResizableImage.ts +39 -0
  32. package/src/components/tetrons/ResizableImageComponent.tsx +77 -0
  33. package/src/components/tetrons/ResizableVideo.ts +66 -0
  34. package/src/components/tetrons/ResizableVideoComponent.tsx +56 -0
  35. package/src/components/tetrons/helpers.ts +0 -0
  36. package/src/components/tetrons/toolbar/ActionGroup.tsx +222 -0
  37. package/src/components/tetrons/toolbar/ClipboardGroup.tsx +57 -0
  38. package/src/components/tetrons/toolbar/FileGroup.tsx +70 -0
  39. package/src/components/tetrons/toolbar/FontStyleGroup.tsx +198 -0
  40. package/src/components/tetrons/toolbar/InsertGroup.tsx +268 -0
  41. package/src/components/tetrons/toolbar/ListAlignGroup.tsx +69 -0
  42. package/src/components/tetrons/toolbar/MiscGroup.tsx +70 -0
  43. package/src/components/tetrons/toolbar/TableContextMenu.tsx +91 -0
  44. package/src/components/tetrons/toolbar/TetronsToolbar.tsx +60 -0
  45. package/src/components/tetrons/toolbar/ToolbarButton.tsx +38 -0
  46. package/src/components/tetrons/toolbar/extensions/Comment.ts +72 -0
  47. package/src/components/tetrons/toolbar/extensions/Embed.ts +113 -0
  48. package/src/components/tetrons/toolbar/extensions/FontFamily.ts +43 -0
  49. package/src/components/tetrons/toolbar/extensions/FontSize.ts +43 -0
  50. package/src/components/tetrons/toolbar/extensions/ResizableTable.ts +16 -0
  51. package/src/components/tetrons/toolbar/marks/Subscript.ts +45 -0
  52. package/src/components/tetrons/toolbar/marks/Superscript.ts +45 -0
  53. package/src/index.ts +3 -0
  54. package/src/lib/export.ts +0 -0
  55. package/src/lib/tiptap-extensions.ts +0 -0
  56. package/src/types/dom-to-pdf.d.ts +13 -0
  57. package/src/types/editor.d.ts +0 -0
  58. package/src/types/emoji-picker.d.ts +18 -0
  59. package/src/types/global.d.ts +6 -0
  60. package/src/types/html2pdf.d.ts +1 -0
  61. package/src/types/tiptap-extensions.d.ts +23 -0
  62. package/src/utils/loadEmojiPicker.ts +12 -0
  63. 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
+ }