@cyberyozh/text-editor 0.2.1 → 0.2.2

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.2.2",
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
 
@@ -183,8 +186,9 @@ function TiptapContent({
183
186
 
184
187
  useEffect(() => {
185
188
  if (activeDialog && editor && !editor.isDestroyed) {
186
- const pos = editor.state.selection.from;
187
- setSavedDialogCursorPos(pos);
189
+ const { from, to } = editor.state.selection;
190
+ setSavedDialogCursorPos(from);
191
+ setSavedDialogRange({ from, to });
188
192
  }
189
193
  }, [activeDialog, editor]);
190
194
 
@@ -193,6 +197,7 @@ function TiptapContent({
193
197
  setKatexDialogContext(null);
194
198
  setEditFigureContext(null);
195
199
  setSavedDialogCursorPos(undefined);
200
+ setSavedDialogRange(undefined);
196
201
  closeDialogCommand();
197
202
  };
198
203
 
@@ -208,7 +213,7 @@ function TiptapContent({
208
213
  };
209
214
 
210
215
  const handleLinkAdd = (params: any) => {
211
- createHandleLinkAdd(editor, handleCloseDialog, params, savedDialogCursorPos);
216
+ createHandleLinkAdd(editor, handleCloseDialog, params, savedDialogRange);
212
217
  };
213
218
 
214
219
  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,9 +1,9 @@
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";
4
+ import { Slice } from "@tiptap/pm/model";
5
5
  import { ImageUploadMultipleByURLHandler } from "@/types";
6
- import { prosemirrorIterator } from "@/utils/prosemirror-iterator";
6
+ import { applyUploadResultsToNodes, collectRehostableImages } from "./markup-insertion";
7
7
 
8
8
  export function useInsertionHandlers() {
9
9
  const [insertedImage, setInsertedImage] = useState<File | null>(null);
@@ -66,34 +66,13 @@ export function useInsertionHandlers() {
66
66
  }
67
67
 
68
68
  if (hasMarkupInsertion) {
69
- const imagesToNodes: [string, Node][] = [];
69
+ const imagesToNodes = collectRehostableImages(slice);
70
70
 
71
71
  setDisabled(true);
72
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
73
  uploadMultipleByURL(imagesToNodes.map(([url]) => url))
83
74
  .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
-
75
+ applyUploadResultsToNodes(imagesToNodes, results);
97
76
  view.dispatch(view.state.tr.replaceSelection(slice));
98
77
  })
99
78
  .finally(() => {
@@ -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,41 @@
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
+ export function applyUploadResultsToNodes(
23
+ imagesToNodes: ImageToNode[],
24
+ results: (ImageUploadResult | null)[],
25
+ ): void {
26
+ results.forEach((result, index) => {
27
+ if (!result) return;
28
+
29
+ const node = imagesToNodes[index]?.[1];
30
+ if (!node) return;
31
+
32
+ // Ignoring `readonly` should be safe in this place because we are inserting slice to the Prosemirror tree only after manipilation.
33
+
34
+ // @ts-expect-error ignore `readonly`
35
+ node.attrs.src = result.fileUrl ?? null;
36
+ // @ts-expect-error ignore `readonly`
37
+ node.attrs.fileId = result.fileId;
38
+ // @ts-expect-error ignore `readonly`
39
+ node.attrs.storageKey = result.storageKey;
40
+ });
41
+ }
@@ -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
+ });