@cyberyozh/text-editor 0.2.1 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyberyozh/text-editor",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Rich text editor library based on Tiptap for CyberYozh",
5
5
  "author": "CyberYozh",
6
6
  "license": "MIT",
@@ -36,6 +36,8 @@
36
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
+ "test": "vitest run",
40
+ "test:watch": "vitest",
39
41
  "lint": "eslint src --ext .ts,.tsx",
40
42
  "changeset": "changeset",
41
43
  "version": "changeset version",
@@ -91,7 +93,8 @@
91
93
  "cross-env": "^10.1.0",
92
94
  "eslint": "^10.0.2",
93
95
  "tsup": "^8.5.1",
94
- "typescript": "^5.9.3"
96
+ "typescript": "^5.9.3",
97
+ "vitest": "^3.2.6"
95
98
  },
96
99
  "overrides": {
97
100
  "glob": "^13.0.6"
@@ -191,7 +191,6 @@ export const ImageDialog = ({
191
191
  setIsUploading(true);
192
192
  try {
193
193
  const result = await uploadHandler(formState.file);
194
- console.log("👀 ~ handleAddImage ~ result:", result);
195
194
 
196
195
  onImageAdd({
197
196
  fileId: result.fileId,
@@ -85,6 +85,9 @@ function TiptapContent({
85
85
  pos?: number;
86
86
  } | null>(null);
87
87
  const [savedDialogCursorPos, setSavedDialogCursorPos] = useState<number | undefined>(undefined);
88
+ const [savedDialogRange, setSavedDialogRange] = useState<{ from: number; to: number } | undefined>(
89
+ undefined,
90
+ );
88
91
  const { activeDialog, closeDialogCommand, openDialogCommand } = useDialogCommand();
89
92
  const translations = useTiptapTranslations();
90
93
 
@@ -104,7 +107,7 @@ function TiptapContent({
104
107
  onPaste,
105
108
  onDrop,
106
109
  onMarkupInsertion,
107
- } = useInsertionHandlers();
110
+ } = useInsertionHandlers(imageHandlers);
108
111
 
109
112
  const editor = useEditor(
110
113
  {
@@ -140,7 +143,6 @@ function TiptapContent({
140
143
  event,
141
144
  slice,
142
145
  setIsDisabledInternal,
143
- imageHandlers?.uploadMultipleByURL
144
146
  );
145
147
 
146
148
  if (typeof toReturn === 'boolean') return toReturn;
@@ -153,7 +155,6 @@ function TiptapContent({
153
155
  event,
154
156
  slice,
155
157
  setIsDisabledInternal,
156
- imageHandlers?.uploadMultipleByURL
157
158
  );
158
159
 
159
160
  if (typeof toReturn === 'boolean') return toReturn;
@@ -183,8 +184,9 @@ function TiptapContent({
183
184
 
184
185
  useEffect(() => {
185
186
  if (activeDialog && editor && !editor.isDestroyed) {
186
- const pos = editor.state.selection.from;
187
- setSavedDialogCursorPos(pos);
187
+ const { from, to } = editor.state.selection;
188
+ setSavedDialogCursorPos(from);
189
+ setSavedDialogRange({ from, to });
188
190
  }
189
191
  }, [activeDialog, editor]);
190
192
 
@@ -193,6 +195,7 @@ function TiptapContent({
193
195
  setKatexDialogContext(null);
194
196
  setEditFigureContext(null);
195
197
  setSavedDialogCursorPos(undefined);
198
+ setSavedDialogRange(undefined);
196
199
  closeDialogCommand();
197
200
  };
198
201
 
@@ -208,7 +211,7 @@ function TiptapContent({
208
211
  };
209
212
 
210
213
  const handleLinkAdd = (params: any) => {
211
- createHandleLinkAdd(editor, handleCloseDialog, params, savedDialogCursorPos);
214
+ createHandleLinkAdd(editor, handleCloseDialog, params, savedDialogRange);
212
215
  };
213
216
 
214
217
  const handleKatexAdd = (latex: string) => {
@@ -87,42 +87,28 @@ export const createHandleLinkAdd = (
87
87
  editor: Editor | null,
88
88
  closeDialogCommand: () => void,
89
89
  linkParams: LinkDialogContextProps,
90
- savedPos?: number
90
+ savedRange?: { from: number; to: number }
91
91
  ) => {
92
92
  if (!editor) return;
93
93
 
94
- const hasSelection = !editor.state.selection.empty;
95
- const titleLength = linkParams.title?.length ?? 0;
94
+ const title = linkParams.title ?? "";
95
+ const titleLength = title.length;
96
96
 
97
- if (hasSelection) {
98
- editor
99
- .chain()
100
- .focus()
101
- .command(({ tr, state }) => {
102
- const { from, to } = state.selection;
103
- tr.insertText(linkParams.title ?? "", from, to);
104
- tr.removeMark(from, from + titleLength);
105
- tr.addMark(from, from + titleLength, editor.schema.marks.link.create({ ...linkParams }));
106
- return true;
107
- })
108
- .run();
109
- } else {
110
- const { state } = editor;
111
- const insertPos = savedPos ?? state.selection.from;
112
- const titleLength = linkParams.title?.length ?? 0;
97
+ const from = savedRange?.from ?? editor.state.selection.from;
98
+ const to = savedRange?.to ?? editor.state.selection.to;
99
+
100
+ editor
101
+ .chain()
102
+ .focus()
103
+ .command(({ tr }) => {
104
+ tr.insertText(title, from, to);
105
+ tr.removeMark(from, from + titleLength);
106
+ tr.addMark(from, from + titleLength, editor.schema.marks.link.create({ ...linkParams }));
107
+ tr.setSelection(TextSelection.create(tr.doc, from, from + titleLength));
108
+ return true;
109
+ })
110
+ .run();
113
111
 
114
- editor
115
- .chain()
116
- .focus()
117
- .command(({ tr }) => {
118
- const linkMark = editor.schema.marks.link.create({ ...linkParams });
119
- const textNode = editor.schema.text(linkParams.title ?? "", [linkMark]);
120
- tr.insert(insertPos, textNode);
121
- tr.setSelection(TextSelection.create(tr.doc, insertPos, insertPos + titleLength));
122
- return true;
123
- })
124
- .run();
125
- }
126
112
  closeDialogCommand();
127
113
  };
128
114
 
@@ -1,11 +1,11 @@
1
1
  import { Dispatch, SetStateAction, useEffect, useState } from "react";
2
2
  import { useDialogCommand } from "../context";
3
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";
4
+ import { Slice } from "@tiptap/pm/model";
5
+ import { ImageHandlers } from "@/types";
6
+ import { applyUploadResultsToNodes, collectRehostableImages, removeImagesFromSlice } from "./markup-insertion";
7
7
 
8
- export function useInsertionHandlers() {
8
+ export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
9
9
  const [insertedImage, setInsertedImage] = useState<File | null>(null);
10
10
 
11
11
  const { activeDialog, openDialogCommand } = useDialogCommand();
@@ -22,6 +22,8 @@ export function useInsertionHandlers() {
22
22
  event.preventDefault();
23
23
  event.stopPropagation();
24
24
 
25
+ if (!imageHandlers?.upload) return;
26
+
25
27
  setInsertedImage(file);
26
28
  openDialogCommand("image");
27
29
  }
@@ -41,10 +43,7 @@ export function useInsertionHandlers() {
41
43
  event: ClipboardEvent | DragEvent,
42
44
  slice: Slice,
43
45
  setDisabled: Dispatch<SetStateAction<boolean>>,
44
- uploadMultipleByURL?: ImageUploadMultipleByURLHandler
45
46
  ): boolean | void {
46
- if (!uploadMultipleByURL) return;
47
-
48
47
  let hasMarkupInsertion = false;
49
48
 
50
49
  if (
@@ -66,39 +65,22 @@ export function useInsertionHandlers() {
66
65
  }
67
66
 
68
67
  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;
68
+ if (imageHandlers?.uploadMultipleByURL) {
69
+ setDisabled(true);
70
+
71
+ const imagesToNodes = collectRehostableImages(slice);
72
+
73
+ imageHandlers.uploadMultipleByURL(imagesToNodes.map(([url]) => url))
74
+ .then((results) => {
75
+ applyUploadResultsToNodes(imagesToNodes, results);
76
+ view.dispatch(view.state.tr.replaceSelection(slice));
77
+ })
78
+ .finally(() => {
79
+ setDisabled(false);
95
80
  });
96
-
97
- view.dispatch(view.state.tr.replaceSelection(slice));
98
- })
99
- .finally(() => {
100
- setDisabled(false);
101
- });
81
+ } else {
82
+ view.dispatch(view.state.tr.replaceSelection(removeImagesFromSlice(slice)));
83
+ }
102
84
 
103
85
  return true;
104
86
  }
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { Node, Schema } from "@tiptap/pm/model";
3
+ import { applyUploadResultsToNodes, collectRehostableImages } from "./markup-insertion";
4
+
5
+ const schema = new Schema({
6
+ nodes: {
7
+ doc: { content: "block+" },
8
+ paragraph: { group: "block", content: "inline*" },
9
+ text: { group: "inline" },
10
+ image: {
11
+ group: "block",
12
+ attrs: {
13
+ src: { default: null },
14
+ fileId: { default: null },
15
+ storageKey: { default: null },
16
+ },
17
+ },
18
+ },
19
+ });
20
+
21
+ function image(attrs: Record<string, unknown>): Node {
22
+ return schema.nodes.image.create(attrs);
23
+ }
24
+
25
+ function doc(...children: Node[]): Node {
26
+ return schema.nodes.doc.create(null, children);
27
+ }
28
+
29
+ const PNG_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
30
+
31
+ describe("collectRehostableImages", () => {
32
+ it("collects http(s) image nodes that have no fileId", () => {
33
+ const a = image({ src: "https://a/1.png" });
34
+ const b = image({ src: "http://b/2.png" });
35
+
36
+ const collected = collectRehostableImages(doc(a, b));
37
+
38
+ expect(collected.map(([url]) => url)).toEqual(["https://a/1.png", "http://b/2.png"]);
39
+ expect(collected.map(([, node]) => node)).toEqual([a, b]);
40
+ });
41
+
42
+ it("collects data:-base64 images (Google Docs paste)", () => {
43
+ const collected = collectRehostableImages(doc(image({ src: PNG_DATA_URI })));
44
+
45
+ expect(collected).toHaveLength(1);
46
+ expect(collected[0][0]).toBe(PNG_DATA_URI);
47
+ });
48
+
49
+ it("skips images that already have a fileId", () => {
50
+ const already = image({ src: "https://a/1.png", fileId: "existing" });
51
+ const fresh = image({ src: "https://b/2.png" });
52
+
53
+ const collected = collectRehostableImages(doc(already, fresh));
54
+
55
+ expect(collected.map(([url]) => url)).toEqual(["https://b/2.png"]);
56
+ });
57
+
58
+ it("skips images whose src is not a parseable URL", () => {
59
+ const relative = image({ src: "/local/path.png" });
60
+ const empty = image({ src: null });
61
+ const valid = image({ src: "https://ok/path.png" });
62
+
63
+ const collected = collectRehostableImages(doc(relative, empty, valid));
64
+
65
+ expect(collected.map(([url]) => url)).toEqual(["https://ok/path.png"]);
66
+ });
67
+
68
+ it("passes the URL through new URL() normalization to the upload handler", () => {
69
+ const collected = collectRehostableImages(doc(image({ src: "https://ok.png" })));
70
+
71
+ expect(collected.map(([url]) => url)).toEqual(["https://ok.png/"]);
72
+ });
73
+ });
74
+
75
+ describe("applyUploadResultsToNodes", () => {
76
+ it("on success sets src=fileUrl and keeps fileId/storageKey (dual-model node)", () => {
77
+ const node = image({ src: "https://external/1.png" });
78
+ const imagesToNodes = collectRehostableImages(doc(node));
79
+
80
+ applyUploadResultsToNodes(imagesToNodes, [
81
+ { fileId: "f1", fileUrl: "https://cdn/1.png", storageKey: "key1" },
82
+ ]);
83
+
84
+ expect(node.attrs.src).toBe("https://cdn/1.png");
85
+ expect(node.attrs.fileId).toBe("f1");
86
+ expect(node.attrs.storageKey).toBe("key1");
87
+ });
88
+
89
+ it("leaves the node untouched when the result is null (failed rehost)", () => {
90
+ const node = image({ src: "https://external/1.png" });
91
+ const imagesToNodes = collectRehostableImages(doc(node));
92
+
93
+ applyUploadResultsToNodes(imagesToNodes, [null]);
94
+
95
+ expect(node.attrs.src).toBe("https://external/1.png");
96
+ expect(node.attrs.fileId).toBeNull();
97
+ expect(node.attrs.storageKey).toBeNull();
98
+ });
99
+
100
+ it("keeps index alignment when a result in the middle is null", () => {
101
+ const first = image({ src: "https://a/1.png" });
102
+ const second = image({ src: "https://b/2.png" });
103
+ const third = image({ src: "https://c/3.png" });
104
+ const imagesToNodes = collectRehostableImages(doc(first, second, third));
105
+
106
+ applyUploadResultsToNodes(imagesToNodes, [
107
+ { fileId: "f1", fileUrl: "https://cdn/1.png", storageKey: "k1" },
108
+ null,
109
+ { fileId: "f3", fileUrl: "https://cdn/3.png", storageKey: "k3" },
110
+ ]);
111
+
112
+ expect(first.attrs.fileId).toBe("f1");
113
+ expect(first.attrs.src).toBe("https://cdn/1.png");
114
+
115
+ expect(second.attrs.fileId).toBeNull();
116
+ expect(second.attrs.src).toBe("https://b/2.png");
117
+
118
+ expect(third.attrs.fileId).toBe("f3");
119
+ expect(third.attrs.src).toBe("https://cdn/3.png");
120
+ });
121
+
122
+ it("sets src=null but still applies fileId when result has no fileUrl", () => {
123
+ const node = image({ src: "https://external/1.png" });
124
+ const imagesToNodes = collectRehostableImages(doc(node));
125
+
126
+ applyUploadResultsToNodes(imagesToNodes, [{ fileId: "f1", storageKey: "key1" }]);
127
+
128
+ expect(node.attrs.src).toBeNull();
129
+ expect(node.attrs.fileId).toBe("f1");
130
+ expect(node.attrs.storageKey).toBe("key1");
131
+ });
132
+ });
@@ -0,0 +1,72 @@
1
+ import { Fragment, Node, Slice } from "@tiptap/pm/model";
2
+ import { ImageUploadResult } from "@/types";
3
+ import { prosemirrorIterator } from "@/utils/prosemirror-iterator";
4
+
5
+ export type ImageToNode = [string, Node];
6
+
7
+ export function collectRehostableImages(slice: Slice | Fragment | Node): ImageToNode[] {
8
+ const imagesToNodes: ImageToNode[] = [];
9
+
10
+ prosemirrorIterator(slice, (node) => {
11
+ if (node.type.name === "image" && !node.attrs.fileId) {
12
+ try {
13
+ const url = new URL(node.attrs.src).toString();
14
+ imagesToNodes.push([url, node]);
15
+ } catch {}
16
+ }
17
+ });
18
+
19
+ return imagesToNodes;
20
+ }
21
+
22
+ function removeImagesFromFragment(fragment: Fragment): Fragment {
23
+ const newNodes: Node[] = [];
24
+ fragment.forEach((node) => {
25
+ if (node.type.name === "image" && !node.attrs.fileId) return;
26
+ newNodes.push(node.childCount > 0 ? node.copy(removeImagesFromFragment(node.content)) : node);
27
+ });
28
+ return Fragment.from(newNodes);
29
+ }
30
+
31
+ function validOpenDepth(fragment: Fragment, requestedDepth: number, fromEnd = false): number {
32
+ let depth = 0;
33
+ let current = fragment;
34
+ while (depth < requestedDepth) {
35
+ if (current.childCount === 0) break;
36
+ const node = fromEnd ? current.lastChild! : current.firstChild!;
37
+ if (node.isLeaf) break;
38
+ current = node.content;
39
+ depth++;
40
+ }
41
+ return depth;
42
+ }
43
+
44
+ export function removeImagesFromSlice(slice: Slice): Slice {
45
+ const newContent = removeImagesFromFragment(slice.content);
46
+ return new Slice(
47
+ newContent,
48
+ validOpenDepth(newContent, slice.openStart),
49
+ validOpenDepth(newContent, slice.openEnd, true),
50
+ );
51
+ }
52
+
53
+ export function applyUploadResultsToNodes(
54
+ imagesToNodes: ImageToNode[],
55
+ results: (ImageUploadResult | null)[],
56
+ ): void {
57
+ results.forEach((result, index) => {
58
+ if (!result) return;
59
+
60
+ const node = imagesToNodes[index]?.[1];
61
+ if (!node) return;
62
+
63
+ // Ignoring `readonly` should be safe in this place because we are inserting slice to the Prosemirror tree only after manipilation.
64
+
65
+ // @ts-expect-error ignore `readonly`
66
+ node.attrs.src = result.fileUrl ?? null;
67
+ // @ts-expect-error ignore `readonly`
68
+ node.attrs.fileId = result.fileId;
69
+ // @ts-expect-error ignore `readonly`
70
+ node.attrs.storageKey = result.storageKey;
71
+ });
72
+ }
@@ -1,5 +1,6 @@
1
1
  import Link, { isAllowedUri } from "@tiptap/extension-link";
2
- import { markPasteRule } from "@tiptap/core";
2
+ import { getMarkRange, markPasteRule } from "@tiptap/core";
3
+ import { Plugin, TextSelection } from "@tiptap/pm/state";
3
4
  import { find } from "linkifyjs";
4
5
 
5
6
  const isSameDomain = (url: string): boolean => {
@@ -67,6 +68,26 @@ export const LinkExtension = Link.extend({
67
68
  }),
68
69
  ];
69
70
  },
71
+
72
+ addProseMirrorPlugins() {
73
+ return [
74
+ ...(this.parent?.() ?? []),
75
+ new Plugin({
76
+ props: {
77
+ handleClick: (view, pos) => {
78
+ if (!view.editable) return false;
79
+
80
+ const range = getMarkRange(view.state.doc.resolve(pos), this.type);
81
+ if (!range) return false;
82
+
83
+ const selection = TextSelection.create(view.state.doc, range.from, range.to);
84
+ view.dispatch(view.state.tr.setSelection(selection));
85
+ return true;
86
+ },
87
+ },
88
+ }),
89
+ ];
90
+ },
70
91
  }).configure({
71
92
  openOnClick: false,
72
93
  linkOnPaste: true,
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { resolveImageSrc } from "./image-src-resolver";
3
+
4
+ describe("resolveImageSrc — priority of fileId vs src", () => {
5
+ it("prefers fileId over src when getImageById is provided (Path 1)", () => {
6
+ const getImageById = vi.fn(() => "https://cdn/resolved.png");
7
+
8
+ const result = resolveImageSrc(
9
+ { fileId: "abc", src: "https://other/direct.png" },
10
+ { getImageById },
11
+ );
12
+
13
+ expect(result).toBe("https://cdn/resolved.png");
14
+ expect(getImageById).toHaveBeenCalledWith("abc");
15
+ });
16
+
17
+ it("falls back to src when fileId is present but getImageById is NOT provided (Path 2)", () => {
18
+ const result = resolveImageSrc({ fileId: "abc", src: "https://direct.png" });
19
+
20
+ expect(result).toBe("https://direct.png");
21
+ });
22
+
23
+ it("uses src directly when there is no fileId (Path 2)", () => {
24
+ const result = resolveImageSrc(
25
+ { src: "https://direct.png" },
26
+ { getImageById: vi.fn() },
27
+ );
28
+
29
+ expect(result).toBe("https://direct.png");
30
+ });
31
+
32
+ it("returns empty string when fileId resolution fails (broken image, no silent fallback to src)", () => {
33
+ const getImageById = vi.fn(() => null);
34
+
35
+ const result = resolveImageSrc(
36
+ { fileId: "missing", src: "https://direct.png" },
37
+ { getImageById },
38
+ );
39
+
40
+ expect(result).toBe("");
41
+ });
42
+
43
+ it("returns empty string when fileId is present but no getImageById (Path 3)", () => {
44
+ const result = resolveImageSrc({ fileId: "abc" });
45
+
46
+ expect(result).toBe("");
47
+ });
48
+
49
+ it("returns empty string when neither fileId nor src is present", () => {
50
+ const result = resolveImageSrc({});
51
+
52
+ expect(result).toBe("");
53
+ });
54
+ });