@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/dist/editor.js +78 -56
- package/dist/editor.js.map +1 -1
- package/dist/editor.mjs +77 -55
- 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 +8 -3
- package/src/editor/handlers/dialog-handlers.ts +17 -31
- package/src/editor/handlers/insertion-handlers.ts +4 -25
- package/src/editor/handlers/markup-insertion.test.ts +132 -0
- package/src/editor/handlers/markup-insertion.ts +41 -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.2.
|
|
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"
|
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
|
|
|
@@ -183,8 +186,9 @@ function TiptapContent({
|
|
|
183
186
|
|
|
184
187
|
useEffect(() => {
|
|
185
188
|
if (activeDialog && editor && !editor.isDestroyed) {
|
|
186
|
-
const
|
|
187
|
-
setSavedDialogCursorPos(
|
|
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,
|
|
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
|
-
|
|
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,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 {
|
|
4
|
+
import { Slice } from "@tiptap/pm/model";
|
|
5
5
|
import { ImageUploadMultipleByURLHandler } from "@/types";
|
|
6
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|