@cyberyozh/text-editor 0.1.17 → 0.2.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.
@@ -497,6 +497,7 @@
497
497
  gap: 12px;
498
498
  }
499
499
  .image-dialog-preview {
500
+ width: auto;
500
501
  max-width: 100%;
501
502
  height: auto;
502
503
  margin-bottom: 12px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyberyozh/text-editor",
3
- "version": "0.1.17",
3
+ "version": "0.2.1",
4
4
  "description": "Rich text editor library based on Tiptap for CyberYozh",
5
5
  "author": "CyberYozh",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "scripts": {
35
35
  "build": "rm -rf dist && tsup",
36
- "dev": "tsup --watch",
36
+ "dev": "cross-env NODE_ENV=development tsup --watch",
37
37
  "dev:link": "npm run build && npm link && npm run dev",
38
38
  "type-check": "tsc --noEmit",
39
39
  "lint": "eslint src --ext .ts,.tsx",
@@ -47,47 +47,48 @@
47
47
  "react-dom": "^18.0.0 || ^19.0.0"
48
48
  },
49
49
  "dependencies": {
50
- "@floating-ui/dom": "^1.7.6",
51
- "@tiptap/core": "^3.20.0",
52
- "@tiptap/extension-blockquote": "^3.20.0",
53
- "@tiptap/extension-bubble-menu": "^3.20.0",
54
- "@tiptap/extension-code": "^3.20.0",
55
- "@tiptap/extension-code-block-lowlight": "^3.20.0",
56
- "@tiptap/extension-collaboration": "^3.20.0",
57
- "@tiptap/extension-details": "^3.20.0",
58
- "@tiptap/extension-drag-handle": "^3.20.0",
59
- "@tiptap/extension-drag-handle-react": "^3.20.0",
60
- "@tiptap/extension-floating-menu": "^3.20.0",
61
- "@tiptap/extension-hard-break": "^3.20.0",
62
- "@tiptap/extension-highlight": "^3.20.0",
63
- "@tiptap/extension-horizontal-rule": "^3.20.0",
64
- "@tiptap/extension-image": "^3.20.0",
65
- "@tiptap/extension-link": "^3.20.0",
66
- "@tiptap/extension-list": "^3.20.0",
67
- "@tiptap/extension-mathematics": "^3.20.0",
68
- "@tiptap/extension-node-range": "^3.20.0",
69
- "@tiptap/extension-subscript": "^3.20.0",
70
- "@tiptap/extension-superscript": "^3.20.0",
71
- "@tiptap/extension-table": "^3.20.0",
72
- "@tiptap/extension-text-align": "^3.20.0",
73
- "@tiptap/extensions": "^3.20.0",
74
- "@tiptap/html": "^3.20.0",
75
- "@tiptap/pm": "^3.20.0",
76
- "@tiptap/react": "^3.17.1",
77
- "@tiptap/starter-kit": "^3.20.0",
78
- "@tiptap/static-renderer": "^3.20.0",
79
- "@tiptap/suggestion": "^3.20.0",
80
- "@tiptap/y-tiptap": "^3.0.2",
81
- "emoji-picker-react": "^4.18.0",
82
- "highlight.js": "^11.11.1",
83
- "katex": "^0.16.35",
84
- "lowlight": "^3.3.0",
85
- "y-protocols": "^1.0.7",
86
- "yjs": "^13.6.29"
50
+ "@floating-ui/dom": "1.7.6",
51
+ "@tiptap/core": "3.23.6",
52
+ "@tiptap/extension-blockquote": "3.23.6",
53
+ "@tiptap/extension-bubble-menu": "3.23.6",
54
+ "@tiptap/extension-code": "3.23.6",
55
+ "@tiptap/extension-code-block-lowlight": "3.23.6",
56
+ "@tiptap/extension-collaboration": "3.23.6",
57
+ "@tiptap/extension-details": "3.23.6",
58
+ "@tiptap/extension-drag-handle": "3.23.6",
59
+ "@tiptap/extension-drag-handle-react": "3.23.6",
60
+ "@tiptap/extension-floating-menu": "3.23.6",
61
+ "@tiptap/extension-hard-break": "3.23.6",
62
+ "@tiptap/extension-highlight": "3.23.6",
63
+ "@tiptap/extension-horizontal-rule": "3.23.6",
64
+ "@tiptap/extension-image": "3.23.6",
65
+ "@tiptap/extension-link": "3.23.6",
66
+ "@tiptap/extension-list": "3.23.6",
67
+ "@tiptap/extension-mathematics": "3.23.6",
68
+ "@tiptap/extension-node-range": "3.23.6",
69
+ "@tiptap/extension-subscript": "3.23.6",
70
+ "@tiptap/extension-superscript": "3.23.6",
71
+ "@tiptap/extension-table": "3.23.6",
72
+ "@tiptap/extension-text-align": "3.23.6",
73
+ "@tiptap/extensions": "3.23.6",
74
+ "@tiptap/html": "3.23.6",
75
+ "@tiptap/pm": "3.23.6",
76
+ "@tiptap/react": "3.23.6",
77
+ "@tiptap/starter-kit": "3.23.6",
78
+ "@tiptap/static-renderer": "3.23.6",
79
+ "@tiptap/suggestion": "3.23.6",
80
+ "@tiptap/y-tiptap": "3.0.3",
81
+ "emoji-picker-react": "4.19.1",
82
+ "highlight.js": "11.11.1",
83
+ "katex": "0.16.35",
84
+ "lowlight": "3.3.0",
85
+ "y-protocols": "1.0.7",
86
+ "yjs": "13.6.30"
87
87
  },
88
88
  "devDependencies": {
89
89
  "@changesets/cli": "^2.30.0",
90
90
  "@types/node": "^25.3.3",
91
+ "cross-env": "^10.1.0",
91
92
  "eslint": "^10.0.2",
92
93
  "tsup": "^8.5.1",
93
94
  "typescript": "^5.9.3"
@@ -69,10 +69,19 @@ export const BubbleMenuContent = ({ editor, onLinkClick }: Props) => {
69
69
  } = useEditorState({
70
70
  editor,
71
71
  selector: (ctx) => {
72
+ let canAvalable = false;
73
+
74
+ try {
75
+ ctx.editor.can().undo();
76
+ canAvalable = true;
77
+ } catch {
78
+ // When `lang` changes, the editor reloads and there is a brief period where parts of its internal state are inaccessible, causing certain methods to throw exceptions
79
+ }
80
+
72
81
  return {
73
- canUndo: ctx.editor.can().undo(),
74
- canRedo: ctx.editor.can().redo(),
75
- canClearMarks: ctx.editor.can().unsetAllMarks(),
82
+ canUndo: canAvalable ? ctx.editor.can().undo() : false,
83
+ canRedo: canAvalable ? ctx.editor.can().redo() : false,
84
+ canClearMarks: canAvalable ? ctx.editor.can().unsetAllMarks() : false,
76
85
  isHeading2: ctx.editor.isActive("heading", { level: 2 }),
77
86
  isHeading3: ctx.editor.isActive("heading", { level: 3 }),
78
87
  isHeading4: ctx.editor.isActive("heading", { level: 4 }),
@@ -11,10 +11,13 @@ export const CharacterCount = ({ editor }: Props) => {
11
11
  const translations = useTiptapTranslations();
12
12
  const { charactersCount, wordsCount } = useEditorState({
13
13
  editor,
14
- selector: (context) => ({
15
- charactersCount: context.editor.storage.characterCount.characters(),
16
- wordsCount: context.editor.storage.characterCount.words(),
17
- }),
14
+ selector: (context) => {
15
+ return {
16
+ charactersCount:
17
+ context.editor.storage.characterCount?.characters() || 0,
18
+ wordsCount: context.editor.storage.characterCount?.words() || 0,
19
+ };
20
+ },
18
21
  });
19
22
 
20
23
  return (
@@ -25,6 +25,7 @@
25
25
  }
26
26
 
27
27
  .image-dialog-preview {
28
+ width: auto;
28
29
  max-width: 100%;
29
30
  height: auto;
30
31
  margin-bottom: 12px;
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import React, { useRef, useState } from "react";
3
+ import React, { useEffect, useRef, useState } from "react";
4
4
 
5
5
  import { TiptapX } from "../../../icons";
6
6
 
@@ -12,6 +12,7 @@ import { useTiptapTranslations } from "../../context";
12
12
  interface ImageDialogProps {
13
13
  open: boolean;
14
14
  onClose: () => void;
15
+ defaultFile?: File | null;
15
16
  onImageAdd?: (attrs: TiptapImageAttrs) => void;
16
17
  onImageEdit?: (attrs: Partial<FigureAttrs>) => void;
17
18
  uploadHandler?: ImageUploadHandler;
@@ -33,6 +34,7 @@ interface ImageFormState {
33
34
  export const ImageDialog = ({
34
35
  open,
35
36
  onClose,
37
+ defaultFile,
36
38
  onImageAdd,
37
39
  onImageEdit,
38
40
  uploadHandler,
@@ -131,6 +133,12 @@ export const ImageDialog = ({
131
133
  };
132
134
  };
133
135
 
136
+ useEffect(() => {
137
+ if (defaultFile) {
138
+ handleSelectFile(defaultFile)
139
+ }
140
+ }, []);
141
+
134
142
  const handleAreaClick = () => {
135
143
  inputRef.current?.click();
136
144
  };
@@ -31,6 +31,7 @@ import {
31
31
  createHandleKatexAdd,
32
32
  createHandleLinkAdd,
33
33
  createHandleVideoAdd,
34
+ useInsertionHandlers,
34
35
  } from "./handlers";
35
36
 
36
37
  import "../styles/styles.css";
@@ -38,7 +39,7 @@ import { createSuggestions } from "./commands/suggestion";
38
39
  import { getTranslations, type SupportedLanguage } from "../utils/get-translations";
39
40
  import { LinkDialogContextProps } from "@/types/link";
40
41
 
41
- interface TiptapProps {
42
+ export interface TiptapProps {
42
43
  isEditable?: boolean;
43
44
  /**
44
45
  * When true, makes the editor completely disabled (non-editable with disabled styling)
@@ -61,7 +62,7 @@ interface TiptapProps {
61
62
  error?: string;
62
63
  }
63
64
 
64
- interface KatexDialogContextProps {
65
+ export interface KatexDialogContextProps {
65
66
  latex?: string;
66
67
  pos?: number;
67
68
  }
@@ -87,6 +88,9 @@ function TiptapContent({
87
88
  const { activeDialog, closeDialogCommand, openDialogCommand } = useDialogCommand();
88
89
  const translations = useTiptapTranslations();
89
90
 
91
+ const [isDisabledInternal, setIsDisabledInternal] = useState(isDisabled);
92
+ const disabled = isDisabled || isDisabledInternal;
93
+
90
94
  let safeContent: JSONContent;
91
95
 
92
96
  try {
@@ -95,13 +99,20 @@ function TiptapContent({
95
99
  safeContent = { type: "doc", content: [{ type: "paragraph" }] };
96
100
  }
97
101
 
102
+ const {
103
+ insertedImage,
104
+ onPaste,
105
+ onDrop,
106
+ onMarkupInsertion,
107
+ } = useInsertionHandlers();
108
+
98
109
  const editor = useEditor(
99
110
  {
100
- editable: isEditable && !isDisabled,
111
+ editable: isEditable && !disabled,
101
112
  extensions: getEditorExtensions({
102
113
  translations,
103
114
  suggestion: createSuggestions(translations, !!imageHandlers),
104
- isEditable: isEditable && !isDisabled,
115
+ isEditable: isEditable && !disabled,
105
116
  getImageById: imageHandlers?.getImage,
106
117
  onKatexClick: (latex, pos) => {
107
118
  setKatexDialogContext({ latex, pos });
@@ -115,7 +126,38 @@ function TiptapContent({
115
126
  content: safeContent,
116
127
  immediatelyRender: false,
117
128
  editorProps: {
118
- handlePaste: (view, event) => {
129
+ handleDOMEvents: {
130
+ paste: onPaste,
131
+ drop: onDrop,
132
+ },
133
+ handleDrop: (view, event, slice) => {
134
+ // Ignoring events from dragging within the editor itself
135
+ if (view.dragging) return false;
136
+
137
+
138
+ const toReturn = onMarkupInsertion(
139
+ view,
140
+ event,
141
+ slice,
142
+ setIsDisabledInternal,
143
+ imageHandlers?.uploadMultipleByURL
144
+ );
145
+
146
+ if (typeof toReturn === 'boolean') return toReturn;
147
+
148
+ return false;
149
+ },
150
+ handlePaste: (view, event, slice) => {
151
+ const toReturn = onMarkupInsertion(
152
+ view,
153
+ event,
154
+ slice,
155
+ setIsDisabledInternal,
156
+ imageHandlers?.uploadMultipleByURL
157
+ );
158
+
159
+ if (typeof toReturn === 'boolean') return toReturn;
160
+
119
161
  try {
120
162
  const text = event.clipboardData?.getData("text/plain");
121
163
  if (!text) return false;
@@ -203,8 +245,8 @@ function TiptapContent({
203
245
  return (
204
246
  <>
205
247
  {<DragHandle editor={editor} />}
206
- {isEditable && !isDisabled && <TableMenu editor={editor} />}
207
- {isEditable && !isDisabled && (
248
+ {isEditable && !disabled && <TableMenu editor={editor} />}
249
+ {isEditable && !disabled && (
208
250
  <BubbleMenu
209
251
  shouldShow={shouldBubbleMenuShow}
210
252
  editor={editor}
@@ -219,11 +261,11 @@ function TiptapContent({
219
261
 
220
262
  <div
221
263
  className={`tiptap-content-wrapper ${!!error ? "has-error" : ""} ${
222
- isDisabled ? "is-disabled" : ""
264
+ disabled ? "is-disabled" : ""
223
265
  }`}
224
266
  >
225
267
  <EditorContent editor={editor} />
226
- {isEditable && !isDisabled && <CharacterCountComponent editor={editor} />}
268
+ {isEditable && !disabled && <CharacterCountComponent editor={editor} />}
227
269
  </div>
228
270
  {!!error && <p className="tiptap-error-message">{error}</p>}
229
271
 
@@ -233,6 +275,7 @@ function TiptapContent({
233
275
  <ImageDialog
234
276
  open={true}
235
277
  onClose={handleCloseDialog}
278
+ defaultFile={insertedImage}
236
279
  onImageAdd={handleImageAdd}
237
280
  onImageEdit={handleImageEdit}
238
281
  uploadHandler={imageHandlers?.upload}
@@ -5,3 +5,7 @@ export {
5
5
  createHandleVideoAdd,
6
6
  createHandleImageEdit,
7
7
  } from "./dialog-handlers";
8
+
9
+ export {
10
+ useInsertionHandlers
11
+ } from './insertion-handlers'
@@ -0,0 +1,113 @@
1
+ import { Dispatch, SetStateAction, useEffect, useState } from "react";
2
+ import { useDialogCommand } from "../context";
3
+ import { EditorView } from "@tiptap/pm/view";
4
+ import { Node, Slice } from "@tiptap/pm/model";
5
+ import { ImageUploadMultipleByURLHandler } from "@/types";
6
+ import { prosemirrorIterator } from "@/utils/prosemirror-iterator";
7
+
8
+ export function useInsertionHandlers() {
9
+ const [insertedImage, setInsertedImage] = useState<File | null>(null);
10
+
11
+ const { activeDialog, openDialogCommand } = useDialogCommand();
12
+
13
+ useEffect(() => {
14
+ if (insertedImage && !activeDialog) {
15
+ setInsertedImage(null);
16
+ }
17
+ }, [insertedImage, activeDialog]);
18
+
19
+ function genericInsertionHandler(file: File | undefined, event: Event) {
20
+ if (!file || !file.type.startsWith("image/")) return;
21
+
22
+ event.preventDefault();
23
+ event.stopPropagation();
24
+
25
+ setInsertedImage(file);
26
+ openDialogCommand("image");
27
+ }
28
+
29
+ function onPaste(_: unknown, event: ClipboardEvent) {
30
+ const file = event.clipboardData?.files?.[0];
31
+ genericInsertionHandler(file, event);
32
+ }
33
+
34
+ function onDrop(_: unknown, event: DragEvent) {
35
+ const file = event.dataTransfer?.files?.[0];
36
+ genericInsertionHandler(file, event);
37
+ }
38
+
39
+ function onMarkupInsertion(
40
+ view: EditorView,
41
+ event: ClipboardEvent | DragEvent,
42
+ slice: Slice,
43
+ setDisabled: Dispatch<SetStateAction<boolean>>,
44
+ uploadMultipleByURL?: ImageUploadMultipleByURLHandler
45
+ ): boolean | void {
46
+ if (!uploadMultipleByURL) return;
47
+
48
+ let hasMarkupInsertion = false;
49
+
50
+ if (
51
+ "clipboardData" in event &&
52
+ Array.from(event.clipboardData?.items ?? []).some(
53
+ (item) => item.type === "text/html"
54
+ )
55
+ ) {
56
+ hasMarkupInsertion = true;
57
+ }
58
+
59
+ if (
60
+ "dataTransfer" in event &&
61
+ Array.from(event.dataTransfer?.items ?? []).some(
62
+ (item) => item.type === "text/html"
63
+ )
64
+ ) {
65
+ hasMarkupInsertion = true;
66
+ }
67
+
68
+ if (hasMarkupInsertion) {
69
+ const imagesToNodes: [string, Node][] = [];
70
+
71
+ setDisabled(true);
72
+
73
+ prosemirrorIterator(slice, (node) => {
74
+ if (node.type.name === "image" && !node.attrs.fileId) {
75
+ try {
76
+ const url = new URL(node.attrs.src).toString();
77
+ imagesToNodes.push([url, node]);
78
+ } catch {}
79
+ }
80
+ });
81
+
82
+ uploadMultipleByURL(imagesToNodes.map(([url]) => url))
83
+ .then((results) => {
84
+ results.forEach((result, index) => {
85
+ const node = imagesToNodes[index][1];
86
+
87
+ // Ignoring `readonly` should be safe in this place because we are inserting slice to the Prosemirror tree only after manipilation.
88
+
89
+ // @ts-expect-error ignore `readonly`
90
+ node.attrs.src = null;
91
+ // @ts-expect-error ignore `readonly`
92
+ node.attrs.fileId = result?.fileId;
93
+ // @ts-expect-error ignore `readonly`
94
+ node.attrs.storageKey = result?.storageKey;
95
+ });
96
+
97
+ view.dispatch(view.state.tr.replaceSelection(slice));
98
+ })
99
+ .finally(() => {
100
+ setDisabled(false);
101
+ });
102
+
103
+ return true;
104
+ }
105
+ }
106
+
107
+ return {
108
+ insertedImage,
109
+ onPaste,
110
+ onDrop,
111
+ onMarkupInsertion,
112
+ };
113
+ }
@@ -1,4 +1,4 @@
1
- export { Tiptap } from "./editor";
1
+ export { Tiptap, type TiptapProps, type KatexDialogContextProps } from "./editor";
2
2
  export type { SupportedLanguage as TiptapSupportedLanguages } from "../utils/get-translations";
3
3
  export { type JSONContent } from "@tiptap/react";
4
4
  export { htmlToTiptapContent } from "../utils/html-to-tiptap-content";
@@ -8,12 +8,17 @@ export interface ImageUploadHandler {
8
8
  (file: File): Promise<ImageUploadResult>;
9
9
  }
10
10
 
11
+ export interface ImageUploadMultipleByURLHandler {
12
+ (files: string[]): Promise<(ImageUploadResult | null)[]>;
13
+ }
14
+
11
15
  export interface ImageGetHandler {
12
16
  (fileId: string): string | null;
13
17
  }
14
18
 
15
19
  export interface ImageHandlers {
16
20
  upload: ImageUploadHandler;
21
+ uploadMultipleByURL?: ImageUploadMultipleByURLHandler;
17
22
  getImage?: ImageGetHandler;
18
23
  }
19
24
 
@@ -0,0 +1,38 @@
1
+ import { Fragment, Node, Slice } from "@tiptap/pm/model";
2
+
3
+ export type ProsemirrorIteratorCallback = (node: Node) => boolean | void;
4
+
5
+ /**
6
+ * Traverses a ProseMirror document tree depth-first, invoking `cb` for each node.
7
+ *
8
+ * Iteration stops early when `cb` returns `true`.
9
+ */
10
+ export function prosemirrorIterator(
11
+ slice: Slice | Fragment | Node,
12
+ cb: ProsemirrorIteratorCallback,
13
+ ) {
14
+ const stack: Node[] = [];
15
+
16
+ if (slice instanceof Slice) {
17
+ collectFragmentChildren(slice.content, stack);
18
+ } else if (slice instanceof Fragment) {
19
+ collectFragmentChildren(slice, stack);
20
+ } else {
21
+ stack.push(slice);
22
+ }
23
+
24
+ while (stack.length > 0) {
25
+ const node = stack.pop()!;
26
+ const shouldStop = cb(node);
27
+ if (shouldStop === true) break;
28
+ if (node.content.size > 0) {
29
+ collectFragmentChildren(node.content, stack);
30
+ }
31
+ }
32
+ }
33
+
34
+ function collectFragmentChildren(fragment: Fragment, stack: Node[]) {
35
+ for (let i = fragment.childCount - 1; i >= 0; i--) {
36
+ stack.push(fragment.child(i));
37
+ }
38
+ }