@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/dist/editor.js +126 -71
- package/dist/editor.js.map +1 -1
- package/dist/editor.mjs +128 -73
- package/dist/editor.mjs.map +1 -1
- package/dist/render.js +23 -5
- package/dist/render.js.map +1 -1
- package/dist/render.mjs +24 -6
- package/dist/render.mjs.map +1 -1
- package/package.json +5 -2
- package/src/editor/dialogs/image-dialog/image-dialog.tsx +0 -1
- package/src/editor/editor.tsx +9 -6
- package/src/editor/handlers/dialog-handlers.ts +17 -31
- package/src/editor/handlers/insertion-handlers.ts +21 -39
- package/src/editor/handlers/markup-insertion.test.ts +132 -0
- package/src/editor/handlers/markup-insertion.ts +72 -0
- package/src/extensions/base/link.ts +22 -1
- package/src/utils/image-src-resolver.test.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyberyozh/text-editor",
|
|
3
|
-
"version": "0.
|
|
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"
|
package/src/editor/editor.tsx
CHANGED
|
@@ -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
|
|
187
|
-
setSavedDialogCursorPos(
|
|
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,
|
|
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
|
-
|
|
90
|
+
savedRange?: { from: number; to: number }
|
|
91
91
|
) => {
|
|
92
92
|
if (!editor) return;
|
|
93
93
|
|
|
94
|
-
const
|
|
95
|
-
const titleLength =
|
|
94
|
+
const title = linkParams.title ?? "";
|
|
95
|
+
const titleLength = title.length;
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
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
|
-
|
|
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
|
+
});
|