@cyberyozh/text-editor 0.1.1

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 (209) hide show
  1. package/README.md +102 -0
  2. package/dist/editor.css +2069 -0
  3. package/dist/editor.css.map +1 -0
  4. package/dist/editor.d.mts +45 -0
  5. package/dist/editor.d.ts +45 -0
  6. package/dist/editor.js +6155 -0
  7. package/dist/editor.js.map +1 -0
  8. package/dist/editor.mjs +6118 -0
  9. package/dist/editor.mjs.map +1 -0
  10. package/dist/render/client.d.mts +24 -0
  11. package/dist/render/client.d.ts +24 -0
  12. package/dist/render/client.js +98 -0
  13. package/dist/render/client.js.map +1 -0
  14. package/dist/render/client.mjs +74 -0
  15. package/dist/render/client.mjs.map +1 -0
  16. package/dist/render.d.mts +58 -0
  17. package/dist/render.d.ts +58 -0
  18. package/dist/render.js +2521 -0
  19. package/dist/render.js.map +1 -0
  20. package/dist/render.mjs +2484 -0
  21. package/dist/render.mjs.map +1 -0
  22. package/dist/styles/index.css +2068 -0
  23. package/package.json +92 -0
  24. package/src/editor/commands/items.ts +374 -0
  25. package/src/editor/commands/suggestion.tsx +146 -0
  26. package/src/editor/components/button/button.css +56 -0
  27. package/src/editor/components/button/button.tsx +35 -0
  28. package/src/editor/components/button/index.ts +1 -0
  29. package/src/editor/components/common-modal/common-modal.css +85 -0
  30. package/src/editor/components/common-modal/common-modal.tsx +58 -0
  31. package/src/editor/components/common-modal/index.ts +1 -0
  32. package/src/editor/components/index.ts +3 -0
  33. package/src/editor/components/input/index.ts +1 -0
  34. package/src/editor/components/input/input.css +43 -0
  35. package/src/editor/components/input/input.tsx +22 -0
  36. package/src/editor/context/dialog-command.tsx +47 -0
  37. package/src/editor/context/index.ts +2 -0
  38. package/src/editor/context/translations-context.tsx +28 -0
  39. package/src/editor/controls/bubble-menu-content.tsx +394 -0
  40. package/src/editor/controls/callout-menu.tsx +169 -0
  41. package/src/editor/controls/character-count.tsx +36 -0
  42. package/src/editor/controls/commands-list.tsx +117 -0
  43. package/src/editor/controls/drag-handle.tsx +98 -0
  44. package/src/editor/controls/emoji-picker.tsx +62 -0
  45. package/src/editor/controls/index.ts +5 -0
  46. package/src/editor/controls/table-menu.tsx +322 -0
  47. package/src/editor/dialogs/add-katex-dialog/add-katex-dialog.css +115 -0
  48. package/src/editor/dialogs/add-katex-dialog/add-katex-dialog.tsx +116 -0
  49. package/src/editor/dialogs/add-katex-dialog/index.ts +1 -0
  50. package/src/editor/dialogs/add-link-dialog/add-link-dialog.css +105 -0
  51. package/src/editor/dialogs/add-link-dialog/add-link-dialog.tsx +177 -0
  52. package/src/editor/dialogs/add-link-dialog/index.ts +1 -0
  53. package/src/editor/dialogs/add-video-dialog/add-video-dialog.css +146 -0
  54. package/src/editor/dialogs/add-video-dialog/add-video-dialog.tsx +157 -0
  55. package/src/editor/dialogs/add-video-dialog/index.ts +1 -0
  56. package/src/editor/dialogs/image-dialog/image-dialog.css +101 -0
  57. package/src/editor/dialogs/image-dialog/image-dialog.tsx +276 -0
  58. package/src/editor/dialogs/image-dialog/index.ts +1 -0
  59. package/src/editor/dialogs/index.ts +4 -0
  60. package/src/editor/editor.tsx +247 -0
  61. package/src/editor/handlers/create-image-upload-handler.ts +24 -0
  62. package/src/editor/handlers/dialog-handlers.ts +143 -0
  63. package/src/editor/handlers/index.ts +8 -0
  64. package/src/editor/index.ts +3 -0
  65. package/src/extensions/base/callout.ts +138 -0
  66. package/src/extensions/base/code-block.ts +24 -0
  67. package/src/extensions/base/compatibility.ts +493 -0
  68. package/src/extensions/base/copyable-text.ts +56 -0
  69. package/src/extensions/base/custom-quote.ts +174 -0
  70. package/src/extensions/base/figure.ts +141 -0
  71. package/src/extensions/base/index.ts +19 -0
  72. package/src/extensions/base/link.ts +18 -0
  73. package/src/extensions/base/myth-reality.ts +45 -0
  74. package/src/extensions/base/prompt.ts +109 -0
  75. package/src/extensions/base/video.ts +29 -0
  76. package/src/extensions/editor/callout.ts +43 -0
  77. package/src/extensions/editor/code-block.ts +9 -0
  78. package/src/extensions/editor/commands.ts +43 -0
  79. package/src/extensions/editor/copyable-text.ts +16 -0
  80. package/src/extensions/editor/custom-quote.ts +54 -0
  81. package/src/extensions/editor/figure.ts +20 -0
  82. package/src/extensions/editor/get-editor-extensions.ts +113 -0
  83. package/src/extensions/editor/index.ts +25 -0
  84. package/src/extensions/editor/prompt.ts +43 -0
  85. package/src/extensions/editor/video.ts +23 -0
  86. package/src/extensions/render/get-render-extensions.ts +74 -0
  87. package/src/extensions/render/index.ts +6 -0
  88. package/src/extensions/views/components/callout.tsx +70 -0
  89. package/src/extensions/views/components/code-block.tsx +73 -0
  90. package/src/extensions/views/components/copy-button.tsx +52 -0
  91. package/src/extensions/views/components/copyable-text.tsx +11 -0
  92. package/src/extensions/views/components/custom-quote.tsx +64 -0
  93. package/src/extensions/views/components/figure.tsx +63 -0
  94. package/src/extensions/views/components/index.ts +7 -0
  95. package/src/extensions/views/components/prompt.tsx +43 -0
  96. package/src/extensions/views/components/video.tsx +40 -0
  97. package/src/extensions/views/node-views/callout.tsx +51 -0
  98. package/src/extensions/views/node-views/code-block.tsx +30 -0
  99. package/src/extensions/views/node-views/copyable-text.tsx +10 -0
  100. package/src/extensions/views/node-views/custom-quote.tsx +66 -0
  101. package/src/extensions/views/node-views/figure.tsx +38 -0
  102. package/src/extensions/views/node-views/index.ts +7 -0
  103. package/src/extensions/views/node-views/prompt.tsx +35 -0
  104. package/src/extensions/views/node-views/video.tsx +19 -0
  105. package/src/icons/Check.tsx +12 -0
  106. package/src/icons/Copy.tsx +15 -0
  107. package/src/icons/DragIcon.tsx +10 -0
  108. package/src/icons/Edit.tsx +12 -0
  109. package/src/icons/Quote.tsx +10 -0
  110. package/src/icons/X.tsx +13 -0
  111. package/src/icons/color/TiptapCalloutError.tsx +25 -0
  112. package/src/icons/color/TiptapCalloutInfo.tsx +18 -0
  113. package/src/icons/color/TiptapCalloutSuccess.tsx +49 -0
  114. package/src/icons/color/TiptapCalloutWarning.tsx +28 -0
  115. package/src/icons/color/index.ts +4 -0
  116. package/src/icons/index.ts +60 -0
  117. package/src/icons/menu/AddImage.tsx +10 -0
  118. package/src/icons/menu/AlignCenter.tsx +10 -0
  119. package/src/icons/menu/AlignLeft.tsx +10 -0
  120. package/src/icons/menu/AlignRight.tsx +10 -0
  121. package/src/icons/menu/ArrowDown.tsx +13 -0
  122. package/src/icons/menu/ArrowLeft.tsx +13 -0
  123. package/src/icons/menu/ArrowRight.tsx +13 -0
  124. package/src/icons/menu/ArrowUp.tsx +13 -0
  125. package/src/icons/menu/Blockquote.tsx +7 -0
  126. package/src/icons/menu/Bold.tsx +10 -0
  127. package/src/icons/menu/BulletList.tsx +10 -0
  128. package/src/icons/menu/CalloutError.tsx +10 -0
  129. package/src/icons/menu/CalloutInfo.tsx +12 -0
  130. package/src/icons/menu/CalloutSuccess.tsx +10 -0
  131. package/src/icons/menu/CalloutWarning.tsx +12 -0
  132. package/src/icons/menu/ClearFormatting.tsx +10 -0
  133. package/src/icons/menu/Code.tsx +10 -0
  134. package/src/icons/menu/CodeBlock.tsx +10 -0
  135. package/src/icons/menu/Copyable.tsx +10 -0
  136. package/src/icons/menu/CustomQuote.tsx +17 -0
  137. package/src/icons/menu/DeleteColumn.tsx +17 -0
  138. package/src/icons/menu/DeleteRow.tsx +17 -0
  139. package/src/icons/menu/Details.tsx +10 -0
  140. package/src/icons/menu/H2.tsx +10 -0
  141. package/src/icons/menu/H3.tsx +10 -0
  142. package/src/icons/menu/H4.tsx +10 -0
  143. package/src/icons/menu/H5.tsx +10 -0
  144. package/src/icons/menu/H6.tsx +10 -0
  145. package/src/icons/menu/Highlight.tsx +13 -0
  146. package/src/icons/menu/HorizontalRule.tsx +7 -0
  147. package/src/icons/menu/Italic.tsx +10 -0
  148. package/src/icons/menu/Link.tsx +10 -0
  149. package/src/icons/menu/LinkOff.tsx +10 -0
  150. package/src/icons/menu/Math.tsx +10 -0
  151. package/src/icons/menu/MergeCells.tsx +10 -0
  152. package/src/icons/menu/MythReality.tsx +10 -0
  153. package/src/icons/menu/OrderedList.tsx +10 -0
  154. package/src/icons/menu/Prompt.tsx +18 -0
  155. package/src/icons/menu/Redo.tsx +14 -0
  156. package/src/icons/menu/SplitCells.tsx +7 -0
  157. package/src/icons/menu/Strikethrough.tsx +10 -0
  158. package/src/icons/menu/Subscript.tsx +10 -0
  159. package/src/icons/menu/Superscript.tsx +10 -0
  160. package/src/icons/menu/Table.tsx +14 -0
  161. package/src/icons/menu/TableColumnHeading.tsx +7 -0
  162. package/src/icons/menu/TableRowHeading.tsx +10 -0
  163. package/src/icons/menu/TextColor.tsx +13 -0
  164. package/src/icons/menu/Underline.tsx +10 -0
  165. package/src/icons/menu/Undo.tsx +14 -0
  166. package/src/icons/menu/Video.tsx +14 -0
  167. package/src/icons/menu/index.ts +49 -0
  168. package/src/render/index.ts +22 -0
  169. package/src/render/static-renderer-config.tsx +206 -0
  170. package/src/render/tiptap-interactivity-provider.tsx +78 -0
  171. package/src/render/tiptap-render.tsx +102 -0
  172. package/src/styles/components.css +10 -0
  173. package/src/styles/editor/base.css +39 -0
  174. package/src/styles/editor/callout-menu.css +81 -0
  175. package/src/styles/editor/controls.css +57 -0
  176. package/src/styles/editor/emoji-picker.css +113 -0
  177. package/src/styles/editor/index.css +9 -0
  178. package/src/styles/editor/menus.css +16 -0
  179. package/src/styles/editor/states.css +43 -0
  180. package/src/styles/editor/typography.css +165 -0
  181. package/src/styles/editor/utilities.css +71 -0
  182. package/src/styles/editor/variables.css +70 -0
  183. package/src/styles/extensions/callout.css +155 -0
  184. package/src/styles/extensions/code-block.css +109 -0
  185. package/src/styles/extensions/commands-list.css +83 -0
  186. package/src/styles/extensions/custom-quote.css +103 -0
  187. package/src/styles/extensions/details.css +87 -0
  188. package/src/styles/extensions/figure.css +66 -0
  189. package/src/styles/extensions/index.css +11 -0
  190. package/src/styles/extensions/math.css +32 -0
  191. package/src/styles/extensions/myth-reality.css +59 -0
  192. package/src/styles/extensions/prompt.css +75 -0
  193. package/src/styles/extensions/table.css +253 -0
  194. package/src/styles/extensions/video.css +53 -0
  195. package/src/styles/styles.css +3 -0
  196. package/src/translations/en.json +158 -0
  197. package/src/translations/ru.json +158 -0
  198. package/src/types/image-handler.ts +33 -0
  199. package/src/types/index.ts +1 -0
  200. package/src/utils/copy-to-clipboard.ts +23 -0
  201. package/src/utils/custom-emoji.ts +174 -0
  202. package/src/utils/detect-video-hosting.ts +106 -0
  203. package/src/utils/get-shared-extensions.ts +45 -0
  204. package/src/utils/get-translations.ts +25 -0
  205. package/src/utils/handle-exit-node.ts +61 -0
  206. package/src/utils/image-src-resolver.ts +74 -0
  207. package/src/utils/index.ts +2 -0
  208. package/src/utils/migrate-legacy-callouts.ts +59 -0
  209. package/src/utils/pluralize.ts +38 -0
@@ -0,0 +1,276 @@
1
+ "use client";
2
+
3
+ import React, { useRef, useState } from "react";
4
+
5
+ import { TiptapX } from "../../../icons";
6
+
7
+ import { Button, CommonModal, Input } from "../../components";
8
+ import { ImageUploadHandler, TiptapImageAttrs, ImageGetHandler, FigureAttrs } from "../../../types";
9
+ import { resolveImageSrc } from "../../../utils/image-src-resolver";
10
+ import { useTiptapTranslations } from "../../context";
11
+
12
+ interface ImageDialogProps {
13
+ open: boolean;
14
+ onClose: () => void;
15
+ onImageAdd?: (attrs: TiptapImageAttrs) => void;
16
+ onImageEdit?: (attrs: Partial<FigureAttrs>) => void;
17
+ uploadHandler?: ImageUploadHandler;
18
+ getImageById?: ImageGetHandler;
19
+ initialAttrs?: FigureAttrs;
20
+ mode?: "add" | "edit";
21
+ }
22
+
23
+ interface ImageFormState {
24
+ file: File | null;
25
+ title: string;
26
+ alt: string;
27
+ caption: string;
28
+ fileUrl: string;
29
+ width: number;
30
+ height: number;
31
+ }
32
+
33
+ export const ImageDialog = ({
34
+ open,
35
+ onClose,
36
+ onImageAdd,
37
+ onImageEdit,
38
+ uploadHandler,
39
+ getImageById,
40
+ initialAttrs,
41
+ mode = "add",
42
+ }: ImageDialogProps) => {
43
+ const translations = useTiptapTranslations();
44
+ const [isUploading, setIsUploading] = useState(false);
45
+ const [dragActive, setDragActive] = useState(false);
46
+ const inputRef = useRef<HTMLInputElement | null>(null);
47
+
48
+ // Resolve the image URL from fileId or use src directly
49
+ const resolvedInitialUrl = initialAttrs
50
+ ? resolveImageSrc({ fileId: initialAttrs.fileId, src: initialAttrs.src }, { getImageById })
51
+ : "";
52
+
53
+ const [formState, setFormState] = useState<ImageFormState>({
54
+ file: null,
55
+ title: initialAttrs?.title || "",
56
+ alt: initialAttrs?.alt || "",
57
+ caption: initialAttrs?.caption || "",
58
+ fileUrl: resolvedInitialUrl,
59
+ width: initialAttrs?.width || 0,
60
+ height: initialAttrs?.height || 0,
61
+ });
62
+
63
+ const handleSelectFile = (file: File | undefined) => {
64
+ if (!file) return;
65
+
66
+ const imageUrl = URL.createObjectURL(file);
67
+ const img = new Image();
68
+ img.src = imageUrl;
69
+
70
+ img.onload = () => {
71
+ const { width, height } = img;
72
+ setFormState((prev) => ({
73
+ ...prev,
74
+ file,
75
+ fileUrl: imageUrl,
76
+ title: file.name,
77
+ alt: file.name,
78
+ width,
79
+ height,
80
+ }));
81
+ };
82
+ };
83
+
84
+ const handleAreaClick = () => {
85
+ inputRef.current?.click();
86
+ };
87
+
88
+ const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
89
+ e.preventDefault();
90
+ e.stopPropagation();
91
+ setDragActive(true);
92
+ };
93
+
94
+ const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
95
+ e.preventDefault();
96
+ e.stopPropagation();
97
+ setDragActive(true);
98
+ };
99
+
100
+ const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
101
+ e.preventDefault();
102
+ e.stopPropagation();
103
+ setDragActive(false);
104
+ };
105
+
106
+ const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
107
+ e.preventDefault();
108
+ e.stopPropagation();
109
+ setDragActive(false);
110
+
111
+ const file = e.dataTransfer.files?.[0];
112
+ if (file) {
113
+ handleSelectFile(file);
114
+ }
115
+ };
116
+
117
+ const clearImageFormFields = () => {
118
+ setFormState({
119
+ file: null,
120
+ title: "",
121
+ alt: "",
122
+ caption: "",
123
+ fileUrl: "",
124
+ width: 0,
125
+ height: 0,
126
+ });
127
+ };
128
+
129
+ const handleAddImage = async () => {
130
+ if (!uploadHandler || !onImageAdd) return;
131
+ if (!formState.file) return;
132
+
133
+ setIsUploading(true);
134
+ try {
135
+ const result = await uploadHandler(formState.file);
136
+
137
+ onImageAdd({
138
+ fileId: result.fileId,
139
+ src: result.fileUrl,
140
+ alt: formState.alt,
141
+ title: formState.title,
142
+ caption: formState.caption,
143
+ width: formState.width,
144
+ height: formState.height,
145
+ });
146
+
147
+ handleClose();
148
+ } finally {
149
+ setIsUploading(false);
150
+ }
151
+ };
152
+
153
+ const handleEditImage = () => {
154
+ if (!onImageEdit) return;
155
+
156
+ onImageEdit({
157
+ alt: formState.alt,
158
+ title: formState.title,
159
+ caption: formState.caption,
160
+ });
161
+
162
+ handleClose();
163
+ };
164
+
165
+ const handleSave = mode === "edit" ? handleEditImage : handleAddImage;
166
+
167
+ const handleClose = () => {
168
+ clearImageFormFields();
169
+ onClose();
170
+ };
171
+
172
+ const isAddMode = mode === "add";
173
+ const hasImage = Boolean(formState.fileUrl);
174
+ const dialogTitle = isAddMode
175
+ ? translations.imageDialog.addTitle
176
+ : translations.imageDialog.editTitle;
177
+ const saveButtonText = isAddMode ? translations.imageDialog.add : translations.imageDialog.save;
178
+ const dragPlaceholder = dragActive
179
+ ? translations.imageUpload.dragActive
180
+ : translations.imageUpload.dragInactive;
181
+
182
+ return (
183
+ <CommonModal
184
+ open={open}
185
+ handleClose={handleClose}
186
+ >
187
+ <p className="image-dialog-title">{dialogTitle}</p>
188
+ <hr className="image-dialog-divider" />
189
+ <div className="image-dialog-form-container">
190
+ <div className="image-dialog-form">
191
+ {hasImage ? (
192
+ <div className="image-dialog-preview-wrapper">
193
+ {isAddMode && (
194
+ <button
195
+ type="button"
196
+ className="image-dialog-delete-button"
197
+ onClick={clearImageFormFields}
198
+ aria-label={translations.media.deleteImage}
199
+ >
200
+ <TiptapX />
201
+ </button>
202
+ )}
203
+ <img
204
+ src={formState.fileUrl}
205
+ alt={formState.alt || translations.media.previewLabel}
206
+ width={formState.width ?? 696}
207
+ height={formState.height ?? 400}
208
+ className="image-dialog-preview"
209
+ />
210
+ </div>
211
+ ) : (
212
+ isAddMode && (
213
+ <div
214
+ className={`image-dialog-drag-area ${
215
+ dragActive ? "image-dialog-drag-area-active" : ""
216
+ }`}
217
+ onClick={handleAreaClick}
218
+ onDragOver={handleDragOver}
219
+ onDragEnter={handleDragEnter}
220
+ onDragLeave={handleDragLeave}
221
+ onDrop={handleDrop}
222
+ >
223
+ <input
224
+ type="file"
225
+ hidden
226
+ name="fileInput"
227
+ ref={inputRef}
228
+ onChange={(e) => handleSelectFile(e.target.files?.[0])}
229
+ accept="image/*"
230
+ />
231
+ <p style={{ textAlign: "center" }}>{dragPlaceholder}</p>
232
+ </div>
233
+ )
234
+ )}
235
+ {hasImage && (
236
+ <>
237
+ <Input
238
+ label={translations.media.imageTitle}
239
+ value={formState.title}
240
+ onChange={(e) => setFormState((prev) => ({ ...prev, title: e.target.value }))}
241
+ />
242
+ <Input
243
+ label={translations.media.imageAlt}
244
+ value={formState.alt}
245
+ onChange={(e) => setFormState((prev) => ({ ...prev, alt: e.target.value }))}
246
+ />
247
+ <Input
248
+ label={translations.imageDialog.captionLabel}
249
+ value={formState.caption}
250
+ onChange={(e) => setFormState((prev) => ({ ...prev, caption: e.target.value }))}
251
+ placeholder={translations.imageDialog.captionPlaceholder}
252
+ />
253
+ </>
254
+ )}
255
+ </div>
256
+ </div>
257
+ <div className="image-dialog-actions">
258
+ <Button
259
+ type="button"
260
+ onClick={handleClose}
261
+ variant="secondary"
262
+ className="image-dialog-cancel-button"
263
+ >
264
+ {translations.common.cancel}
265
+ </Button>
266
+ <Button
267
+ isLoading={isUploading}
268
+ type="button"
269
+ onClick={handleSave}
270
+ >
271
+ {saveButtonText}
272
+ </Button>
273
+ </div>
274
+ </CommonModal>
275
+ );
276
+ };
@@ -0,0 +1 @@
1
+ export { ImageDialog } from './image-dialog';
@@ -0,0 +1,4 @@
1
+ export { ImageDialog } from './image-dialog';
2
+ export { AddKatexDialog } from './add-katex-dialog';
3
+ export { AddLinkDialog } from './add-link-dialog';
4
+ export { AddVideoDialog } from './add-video-dialog';
@@ -0,0 +1,247 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+
5
+ import { EditorState, NodeSelection } from "@tiptap/pm/state";
6
+ import { Slice } from "@tiptap/pm/model";
7
+ import { useEditor, EditorContent, type JSONContent } from "@tiptap/react";
8
+ import { BubbleMenu } from "@tiptap/react/menus";
9
+ import "highlight.js/styles/github-dark.css";
10
+ import "katex/dist/katex.min.css";
11
+
12
+ import {
13
+ DialogCommandProvider,
14
+ useDialogCommand,
15
+ TiptapTranslationsProvider,
16
+ useTiptapTranslations,
17
+ } from "./context";
18
+ import {
19
+ BubbleMenuContent,
20
+ CharacterCount as CharacterCountComponent,
21
+ DragHandle,
22
+ TableMenu,
23
+ } from "./controls";
24
+ import { ImageDialog, AddKatexDialog, AddLinkDialog, AddVideoDialog } from "./dialogs";
25
+ import { getEditorExtensions } from "../extensions/editor";
26
+
27
+ import type { FigureAttrs, ImageHandlers } from "../types/image-handler";
28
+ import {
29
+ createHandleImageAdd,
30
+ createHandleImageEdit,
31
+ createHandleKatexAdd,
32
+ createHandleLinkAdd,
33
+ createHandleVideoAdd,
34
+ } from "./handlers";
35
+
36
+ import "../styles/styles.css";
37
+ import { createSuggestions } from "./commands/suggestion";
38
+ import { getTranslations, type SupportedLanguage } from "../utils/get-translations";
39
+
40
+ interface TiptapProps {
41
+ isEditable?: boolean;
42
+ content: string;
43
+ handleChange?: (value: JSONContent) => void;
44
+ /**
45
+ * Image handlers for upload and URL resolution
46
+ * - upload: Function to upload files, returns fileId
47
+ * - getImage: Optional function to resolve fileId to display URL
48
+ */
49
+ imageHandlers?: ImageHandlers;
50
+ /**
51
+ * Language for editor interface
52
+ * @default 'ru' - Russian
53
+ */
54
+ lang?: SupportedLanguage;
55
+ error?: string;
56
+ }
57
+
58
+ interface LinkDialogContextProps {
59
+ selectedText?: string;
60
+ url?: string;
61
+ }
62
+
63
+ interface KatexDialogContextProps {
64
+ latex?: string;
65
+ pos?: number;
66
+ }
67
+
68
+ function TiptapContent({
69
+ content,
70
+ handleChange,
71
+ imageHandlers,
72
+ isEditable = true,
73
+ lang,
74
+ error,
75
+ }: TiptapProps) {
76
+ const [linkDialogContext, setLinkDialogContext] = useState<LinkDialogContextProps | null>(null);
77
+ const [katexDialogContext, setKatexDialogContext] = useState<KatexDialogContextProps | null>(
78
+ null
79
+ );
80
+ const [editFigureContext, setEditFigureContext] = useState<{
81
+ attrs: FigureAttrs;
82
+ pos?: number;
83
+ } | null>(null);
84
+ const { activeDialog, closeDialogCommand, openDialogCommand } = useDialogCommand();
85
+ const translations = useTiptapTranslations();
86
+
87
+ let safeContent: JSONContent;
88
+
89
+ try {
90
+ safeContent = JSON.parse(content);
91
+ } catch {
92
+ safeContent = { type: "doc", content: [{ type: "paragraph" }] };
93
+ }
94
+
95
+ const editor = useEditor(
96
+ {
97
+ editable: isEditable,
98
+ extensions: getEditorExtensions({
99
+ translations,
100
+ suggestion: createSuggestions(translations, !!imageHandlers),
101
+ isEditable,
102
+ getImageById: imageHandlers?.getImage,
103
+ onKatexClick: (latex, pos) => {
104
+ setKatexDialogContext({ latex, pos });
105
+ openDialogCommand("katex");
106
+ },
107
+ onFigureClick: (attrs, pos) => {
108
+ setEditFigureContext({ attrs, pos });
109
+ openDialogCommand("image");
110
+ },
111
+ }),
112
+ content: safeContent,
113
+ immediatelyRender: false,
114
+ editorProps: {
115
+ handlePaste: (view, event) => {
116
+ try {
117
+ const text = event.clipboardData?.getData("text/plain");
118
+ if (!text) return false;
119
+
120
+ const json = JSON.parse(text);
121
+ if (json.type === "doc" && json.content) {
122
+ const node = view.state.schema.nodeFromJSON(json);
123
+ const newSlice = new Slice(node.content, 0, 0);
124
+ view.dispatch(view.state.tr.replaceSelection(newSlice));
125
+ return true;
126
+ }
127
+ } catch (e) {
128
+ console.log("Not JSON or invalid Tiptap format", e);
129
+ }
130
+ return false;
131
+ },
132
+ },
133
+ onBlur: ({ editor }) => handleChange?.(editor.getJSON()),
134
+ },
135
+ [lang]
136
+ );
137
+
138
+ const handleCloseDialog = () => {
139
+ setLinkDialogContext(null);
140
+ setKatexDialogContext(null);
141
+ setEditFigureContext(null);
142
+ closeDialogCommand();
143
+ };
144
+
145
+ const handleImageAdd = createHandleImageAdd(editor, handleCloseDialog);
146
+ const handleImageEdit = createHandleImageEdit(editor, handleCloseDialog, editFigureContext?.pos);
147
+ const handleVideoAdd = createHandleVideoAdd(editor, handleCloseDialog);
148
+ const handleLinkAdd = createHandleLinkAdd(editor, handleCloseDialog);
149
+ const handleKatexAdd = (latex: string) => {
150
+ const katexHandler = createHandleKatexAdd(editor, handleCloseDialog);
151
+ katexHandler(latex, katexDialogContext?.pos);
152
+ };
153
+
154
+ if (!editor) return null;
155
+
156
+ const shouldBubbleMenuShow = ({ state }: { state: EditorState }) => {
157
+ if (state.selection instanceof NodeSelection) {
158
+ return false;
159
+ }
160
+
161
+ if (state.selection.empty) {
162
+ return false;
163
+ }
164
+
165
+ if (state.selection.$from.parent.type.name === "codeBlock") {
166
+ return false;
167
+ }
168
+
169
+ return true;
170
+ };
171
+
172
+ return (
173
+ <>
174
+ <DragHandle editor={editor} />
175
+ {isEditable && <TableMenu editor={editor} />}
176
+ <BubbleMenu
177
+ shouldShow={shouldBubbleMenuShow}
178
+ editor={editor}
179
+ options={{ placement: "bottom", offset: 8, flip: true }}
180
+ >
181
+ <BubbleMenuContent
182
+ editor={editor}
183
+ onLinkClick={(selectedText, url) => setLinkDialogContext({ selectedText, url })}
184
+ />
185
+ </BubbleMenu>
186
+
187
+ <div className={`tiptap-content-wrapper ${!!error ? "has-error" : ""}`}>
188
+ <EditorContent editor={editor} />
189
+ {isEditable && <CharacterCountComponent editor={editor} />}
190
+ </div>
191
+ {!!error && <p className="tiptap-error-message">{error}</p>}
192
+
193
+ {activeDialog === "image" && isEditable && (
194
+ <ImageDialog
195
+ open={true}
196
+ onClose={handleCloseDialog}
197
+ onImageAdd={handleImageAdd}
198
+ onImageEdit={handleImageEdit}
199
+ uploadHandler={imageHandlers?.upload}
200
+ getImageById={imageHandlers?.getImage}
201
+ initialAttrs={editFigureContext?.attrs || undefined}
202
+ mode={editFigureContext ? "edit" : "add"}
203
+ />
204
+ )}
205
+
206
+ {activeDialog === "link" && (
207
+ <AddLinkDialog
208
+ open={true}
209
+ onClose={handleCloseDialog}
210
+ onLinkAdd={handleLinkAdd}
211
+ initialTitle={linkDialogContext?.selectedText || ""}
212
+ initialUrl={linkDialogContext?.url || ""}
213
+ />
214
+ )}
215
+
216
+ {activeDialog === "video" && (
217
+ <AddVideoDialog
218
+ open={true}
219
+ onClose={handleCloseDialog}
220
+ onVideoAdd={handleVideoAdd}
221
+ />
222
+ )}
223
+
224
+ {activeDialog === "katex" && (
225
+ <AddKatexDialog
226
+ open={true}
227
+ onClose={handleCloseDialog}
228
+ onKatexAdd={handleKatexAdd}
229
+ initialContent={katexDialogContext?.latex || ""}
230
+ />
231
+ )}
232
+ </>
233
+ );
234
+ }
235
+
236
+ export const Tiptap = (props: TiptapProps) => {
237
+ const { lang = "ru" } = props;
238
+ const translations = getTranslations(lang);
239
+
240
+ return (
241
+ <TiptapTranslationsProvider translations={translations}>
242
+ <DialogCommandProvider>
243
+ <TiptapContent {...props} />
244
+ </DialogCommandProvider>
245
+ </TiptapTranslationsProvider>
246
+ );
247
+ };
@@ -0,0 +1,24 @@
1
+ import { ImageUploadHandler } from "../../types";
2
+
3
+ /**
4
+ * Creates an image upload handler that wraps a custom upload action
5
+ * Supports two patterns:
6
+ * 1. Backend with fileId: { fileId: "file-123" }
7
+ * 2. Direct CDN: { fileUrl: "https://cdn.com/image.jpg" }
8
+ */
9
+ export function createImageUploadHandler(
10
+ uploadAction: (file: File) => Promise<{ data?: { fileId?: string; fileUrl?: string } }>
11
+ ): ImageUploadHandler {
12
+ return async (file: File) => {
13
+ const { data } = await uploadAction(file);
14
+
15
+ if (!data?.fileId && !data?.fileUrl) {
16
+ throw new Error("Failed to upload file: must return fileId or fileUrl");
17
+ }
18
+
19
+ return {
20
+ fileId: data.fileId,
21
+ fileUrl: data.fileUrl,
22
+ };
23
+ };
24
+ }
@@ -0,0 +1,143 @@
1
+ import type { Editor } from "@tiptap/react";
2
+
3
+ import type { FigureAttrs, TiptapImageAttrs } from "../../types";
4
+
5
+ export const createHandleImageAdd = (editor: Editor | null, closeDialogCommand: () => void) => {
6
+ return (attrs: TiptapImageAttrs) => {
7
+ if (!editor) return;
8
+
9
+ const { state } = editor;
10
+ const { $from } = state.selection;
11
+ const text = $from.parent.textContent;
12
+
13
+ if (text.lastIndexOf("/") !== -1) {
14
+ editor
15
+ .chain()
16
+ .focus()
17
+ .command(({ tr }) => {
18
+ const node = editor.schema.nodes.image.create({ ...attrs });
19
+ tr.replaceWith($from.before(), $from.after(), node);
20
+ return true;
21
+ })
22
+ .run();
23
+ } else {
24
+ editor?.chain().focus().setFigure(attrs).run();
25
+ }
26
+ closeDialogCommand();
27
+ };
28
+ };
29
+
30
+ export const createHandleImageEdit = (
31
+ editor: Editor | null,
32
+ closeDialogCommand: () => void,
33
+ pos?: number
34
+ ) => {
35
+ return (attrs: Partial<FigureAttrs>) => {
36
+ if (!editor || pos === undefined) return;
37
+
38
+ editor.chain().focus().setNodeSelection(pos).updateFigure(attrs).run();
39
+ closeDialogCommand();
40
+ };
41
+ };
42
+
43
+ export const createHandleVideoAdd = (editor: Editor | null, closeDialogCommand: () => void) => {
44
+ return (url: string) => {
45
+ if (!editor) return;
46
+
47
+ const { state } = editor;
48
+ const { $from } = state.selection;
49
+
50
+ editor
51
+ .chain()
52
+ .focus()
53
+ .command(({ tr }) => {
54
+ tr.replaceWith(
55
+ $from.before(),
56
+ $from.after(),
57
+ editor.schema.nodes.video.create({ src: url })
58
+ );
59
+ return true;
60
+ })
61
+ .run();
62
+ closeDialogCommand();
63
+ };
64
+ };
65
+
66
+ export const createHandleLinkAdd = (editor: Editor | null, closeDialogCommand: () => void) => {
67
+ return (title: string, url: string) => {
68
+ if (!editor) return;
69
+
70
+ const hasSelection = !editor.state.selection.empty;
71
+
72
+ if (hasSelection) {
73
+ editor
74
+ .chain()
75
+ .focus()
76
+ .command(({ tr, state }) => {
77
+ const { from, to } = state.selection;
78
+ tr.insertText(title, from, to);
79
+ tr.addMark(from, from + title.length, editor.schema.marks.link.create({ href: url }));
80
+ return true;
81
+ })
82
+ .run();
83
+ } else {
84
+ editor
85
+ .chain()
86
+ .focus()
87
+ .command(({ tr, state }) => {
88
+ const { $from } = state.selection;
89
+ const text = $from.parent.textContent;
90
+ const nodeStart = $from.start();
91
+ const beforeCursor = text.substring(0, $from.pos - nodeStart);
92
+ const slashIndex = beforeCursor.lastIndexOf("/");
93
+
94
+ if (slashIndex !== -1) {
95
+ tr.delete(nodeStart + slashIndex, $from.pos);
96
+ }
97
+
98
+ return true;
99
+ })
100
+ .extendMarkRange("link")
101
+ .setLink({ href: url })
102
+ .insertContent(title)
103
+ .run();
104
+ }
105
+ closeDialogCommand();
106
+ };
107
+ };
108
+
109
+ export const createHandleKatexAdd = (editor: Editor | null, closeDialogCommand: () => void) => {
110
+ return (latex: string, pos?: number) => {
111
+ if (!editor) return;
112
+
113
+ if (pos !== undefined) {
114
+ editor.chain().setNodeSelection(pos).updateInlineMath({ latex }).focus().run();
115
+ } else {
116
+ const { state } = editor;
117
+ const { $from } = state.selection;
118
+ const text = $from.parent.textContent;
119
+ const nodeStart = $from.start();
120
+ const beforeCursor = text.substring(0, $from.pos - nodeStart);
121
+ const slashIndex = beforeCursor.lastIndexOf("/");
122
+
123
+ if (slashIndex !== -1) {
124
+ const deleteFrom = nodeStart + slashIndex;
125
+ const deleteTo = $from.pos;
126
+
127
+ editor
128
+ .chain()
129
+ .focus()
130
+ .command(({ tr, state }) => {
131
+ const inlineMathNode = state.schema.nodes.inlineMath.create({ latex });
132
+ tr.replaceRangeWith(deleteFrom, deleteTo, inlineMathNode);
133
+ return true;
134
+ })
135
+ .run();
136
+ } else {
137
+ editor.chain().insertInlineMath({ latex }).focus().run();
138
+ }
139
+ }
140
+
141
+ closeDialogCommand();
142
+ };
143
+ };
@@ -0,0 +1,8 @@
1
+ export { createImageUploadHandler } from './create-image-upload-handler';
2
+ export {
3
+ createHandleImageAdd,
4
+ createHandleKatexAdd,
5
+ createHandleLinkAdd,
6
+ createHandleVideoAdd,
7
+ createHandleImageEdit,
8
+ } from './dialog-handlers';