@cyberyozh/text-editor 0.4.0 → 0.5.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.
- package/dist/editor.d.mts +15 -1
- package/dist/editor.d.ts +15 -1
- package/dist/editor.js +56 -17
- package/dist/editor.js.map +1 -1
- package/dist/editor.mjs +61 -18
- package/dist/editor.mjs.map +1 -1
- package/dist/render.d.mts +9 -1
- package/dist/render.d.ts +9 -1
- package/dist/render.js +40 -10
- package/dist/render.js.map +1 -1
- package/dist/render.mjs +45 -11
- package/dist/render.mjs.map +1 -1
- package/package.json +1 -1
- package/src/editor/editor.tsx +8 -0
- package/src/editor/handlers/insertion-handlers.test.ts +37 -0
- package/src/editor/handlers/insertion-handlers.ts +14 -9
- package/src/editor/handlers/markup-insertion.ts +9 -0
- package/src/extensions/base/link.test.ts +60 -0
- package/src/extensions/base/link.ts +61 -9
- package/src/extensions/editor/get-editor-extensions.ts +3 -1
- package/src/utils/get-shared-extensions.ts +20 -4
- package/src/utils/html-to-tiptap-content.ts +13 -4
package/package.json
CHANGED
package/src/editor/editor.tsx
CHANGED
|
@@ -60,6 +60,12 @@ export interface TiptapProps {
|
|
|
60
60
|
*/
|
|
61
61
|
lang?: SupportedLanguage;
|
|
62
62
|
error?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Domains (with all their subdomains) whose links are treated as internal:
|
|
65
|
+
* rel="noopener noreferrer nofollow" is not added on paste / HTML parsing.
|
|
66
|
+
* Read once when the editor instance is created.
|
|
67
|
+
*/
|
|
68
|
+
trustedDomains?: string[];
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
export interface KatexDialogContextProps {
|
|
@@ -75,6 +81,7 @@ function TiptapContent({
|
|
|
75
81
|
isDisabled = false,
|
|
76
82
|
lang,
|
|
77
83
|
error,
|
|
84
|
+
trustedDomains,
|
|
78
85
|
}: TiptapProps) {
|
|
79
86
|
const [linkDialogContext, setLinkDialogContext] = useState<LinkDialogContextProps | null>(null);
|
|
80
87
|
const [katexDialogContext, setKatexDialogContext] = useState<KatexDialogContextProps | null>(
|
|
@@ -117,6 +124,7 @@ function TiptapContent({
|
|
|
117
124
|
suggestion: createSuggestions(translations, !!imageHandlers),
|
|
118
125
|
isEditable: isEditable && !disabled,
|
|
119
126
|
getImageById: imageHandlers?.getImage,
|
|
127
|
+
trustedDomains,
|
|
120
128
|
onKatexClick: (latex, pos) => {
|
|
121
129
|
setKatexDialogContext({ latex, pos });
|
|
122
130
|
openDialogCommand("katex");
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { shouldHandleInsertedFile } from "./markup-insertion";
|
|
3
|
+
|
|
4
|
+
function fakeEvent() {
|
|
5
|
+
return {
|
|
6
|
+
preventDefault: vi.fn(),
|
|
7
|
+
stopPropagation: vi.fn(),
|
|
8
|
+
} as unknown as Event & {
|
|
9
|
+
preventDefault: ReturnType<typeof vi.fn>;
|
|
10
|
+
stopPropagation: ReturnType<typeof vi.fn>;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("shouldHandleInsertedFile", () => {
|
|
15
|
+
it("leaves the event untouched when clipboard has no files (plain text / html paste)", () => {
|
|
16
|
+
const event = fakeEvent();
|
|
17
|
+
expect(shouldHandleInsertedFile(undefined, event)).toBe(false);
|
|
18
|
+
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
19
|
+
expect(event.stopPropagation).not.toHaveBeenCalled();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("blocks the event for a non-image file and does not handle it", () => {
|
|
23
|
+
const event = fakeEvent();
|
|
24
|
+
const file = new File(["data"], "doc.pdf", { type: "application/pdf" });
|
|
25
|
+
expect(shouldHandleInsertedFile(file, event)).toBe(false);
|
|
26
|
+
expect(event.preventDefault).toHaveBeenCalledOnce();
|
|
27
|
+
expect(event.stopPropagation).toHaveBeenCalledOnce();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("blocks the event for an image file and handles it", () => {
|
|
31
|
+
const event = fakeEvent();
|
|
32
|
+
const file = new File(["data"], "pic.png", { type: "image/png" });
|
|
33
|
+
expect(shouldHandleInsertedFile(file, event)).toBe(true);
|
|
34
|
+
expect(event.preventDefault).toHaveBeenCalledOnce();
|
|
35
|
+
expect(event.stopPropagation).toHaveBeenCalledOnce();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -3,7 +3,12 @@ import { useDialogCommand } from "../context";
|
|
|
3
3
|
import { EditorView } from "@tiptap/pm/view";
|
|
4
4
|
import { Slice } from "@tiptap/pm/model";
|
|
5
5
|
import { ImageHandlers } from "@/types";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
applyUploadResultsToNodes,
|
|
8
|
+
collectRehostableImages,
|
|
9
|
+
removeImagesFromSlice,
|
|
10
|
+
shouldHandleInsertedFile,
|
|
11
|
+
} from "./markup-insertion";
|
|
7
12
|
|
|
8
13
|
export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
|
|
9
14
|
const [insertedImage, setInsertedImage] = useState<File | null>(null);
|
|
@@ -17,10 +22,7 @@ export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
|
|
|
17
22
|
}, [insertedImage, activeDialog]);
|
|
18
23
|
|
|
19
24
|
function genericInsertionHandler(file: File | undefined, event: Event) {
|
|
20
|
-
event
|
|
21
|
-
event.stopPropagation();
|
|
22
|
-
|
|
23
|
-
if (!file || !file.type.startsWith("image/")) return;
|
|
25
|
+
if (!file || !shouldHandleInsertedFile(file, event)) return;
|
|
24
26
|
if (!imageHandlers?.upload) return;
|
|
25
27
|
|
|
26
28
|
setInsertedImage(file);
|
|
@@ -48,7 +50,7 @@ export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
|
|
|
48
50
|
if (
|
|
49
51
|
"clipboardData" in event &&
|
|
50
52
|
Array.from(event.clipboardData?.items ?? []).some(
|
|
51
|
-
(item) => item.type === "text/html"
|
|
53
|
+
(item) => item.type === "text/html",
|
|
52
54
|
)
|
|
53
55
|
) {
|
|
54
56
|
hasMarkupInsertion = true;
|
|
@@ -57,7 +59,7 @@ export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
|
|
|
57
59
|
if (
|
|
58
60
|
"dataTransfer" in event &&
|
|
59
61
|
Array.from(event.dataTransfer?.items ?? []).some(
|
|
60
|
-
(item) => item.type === "text/html"
|
|
62
|
+
(item) => item.type === "text/html",
|
|
61
63
|
)
|
|
62
64
|
) {
|
|
63
65
|
hasMarkupInsertion = true;
|
|
@@ -69,7 +71,8 @@ export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
|
|
|
69
71
|
|
|
70
72
|
const imagesToNodes = collectRehostableImages(slice);
|
|
71
73
|
|
|
72
|
-
imageHandlers
|
|
74
|
+
imageHandlers
|
|
75
|
+
.uploadMultipleByURL(imagesToNodes.map(([url]) => url))
|
|
73
76
|
.then((results) => {
|
|
74
77
|
applyUploadResultsToNodes(imagesToNodes, results);
|
|
75
78
|
view.dispatch(view.state.tr.replaceSelection(slice));
|
|
@@ -78,7 +81,9 @@ export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
|
|
|
78
81
|
setDisabled(false);
|
|
79
82
|
});
|
|
80
83
|
} else {
|
|
81
|
-
view.dispatch(
|
|
84
|
+
view.dispatch(
|
|
85
|
+
view.state.tr.replaceSelection(removeImagesFromSlice(slice)),
|
|
86
|
+
);
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
return true;
|
|
@@ -70,3 +70,12 @@ export function applyUploadResultsToNodes(
|
|
|
70
70
|
node.attrs.storageKey = result.storageKey;
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
export function shouldHandleInsertedFile(file: File | undefined, event: Event): boolean {
|
|
75
|
+
if (!file) return false;
|
|
76
|
+
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
event.stopPropagation();
|
|
79
|
+
|
|
80
|
+
return file.type.startsWith("image/");
|
|
81
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { isTrustedDomain } from "./link";
|
|
3
|
+
|
|
4
|
+
describe("isTrustedDomain", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllGlobals();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns false for external urls when no trusted domains configured", () => {
|
|
10
|
+
expect(isTrustedDomain("https://example.com/page", [])).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("matches an exact trusted domain", () => {
|
|
14
|
+
expect(
|
|
15
|
+
isTrustedDomain("https://cyberyozh.com/blog", ["cyberyozh.com"]),
|
|
16
|
+
).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("matches subdomains of a trusted domain", () => {
|
|
20
|
+
expect(
|
|
21
|
+
isTrustedDomain("https://app.cyberyozh.com/account", ["cyberyozh.com"]),
|
|
22
|
+
).toBe(true);
|
|
23
|
+
expect(
|
|
24
|
+
isTrustedDomain("https://www.deep.cyberyozh.pro/", ["cyberyozh.pro"]),
|
|
25
|
+
).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("does not match lookalike domains sharing a suffix without a dot boundary", () => {
|
|
29
|
+
expect(
|
|
30
|
+
isTrustedDomain("https://evilcyberyozh.com/", ["cyberyozh.com"]),
|
|
31
|
+
).toBe(false);
|
|
32
|
+
expect(
|
|
33
|
+
isTrustedDomain("https://cyberyozh.com.attacker.io/", ["cyberyozh.com"]),
|
|
34
|
+
).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("matches any domain from the list", () => {
|
|
38
|
+
const trusted = ["cyberyozh.com", "cyberyozh.pro", "scriptrun.ai"];
|
|
39
|
+
expect(isTrustedDomain("https://scriptrun.ai/docs", trusted)).toBe(true);
|
|
40
|
+
expect(isTrustedDomain("https://cyberyozh.pro/", trusted)).toBe(true);
|
|
41
|
+
expect(isTrustedDomain("https://github.com/", trusted)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("treats the current window hostname as trusted regardless of the list", () => {
|
|
45
|
+
vi.stubGlobal("window", { location: { hostname: "app.detect.expert" } });
|
|
46
|
+
expect(isTrustedDomain("https://app.detect.expert/page", [])).toBe(true);
|
|
47
|
+
expect(isTrustedDomain("https://other.detect.expert/page", [])).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns false for unparseable urls", () => {
|
|
51
|
+
expect(isTrustedDomain("not a url", ["cyberyozh.com"])).toBe(false);
|
|
52
|
+
expect(isTrustedDomain("/relative/path", ["cyberyozh.com"])).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("is case-insensitive for the hostname", () => {
|
|
56
|
+
expect(
|
|
57
|
+
isTrustedDomain("https://App.CyberYozh.com/", ["cyberyozh.com"]),
|
|
58
|
+
).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import Link, { isAllowedUri } from "@tiptap/extension-link";
|
|
1
|
+
import Link, { isAllowedUri, type LinkOptions } from "@tiptap/extension-link";
|
|
2
2
|
import { getMarkRange, markPasteRule } from "@tiptap/core";
|
|
3
3
|
import { Plugin, TextSelection } from "@tiptap/pm/state";
|
|
4
4
|
import { find } from "linkifyjs";
|
|
5
5
|
|
|
6
|
+
export interface ExtendedLinkOptions extends LinkOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Domains (with all their subdomains) whose links are treated as internal:
|
|
9
|
+
* rel="noopener noreferrer nofollow" is not added on paste / HTML parsing.
|
|
10
|
+
* The current window hostname is always trusted.
|
|
11
|
+
*/
|
|
12
|
+
trustedDomains: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
const isSameDomain = (url: string): boolean => {
|
|
7
16
|
if (typeof window === "undefined") return false;
|
|
8
17
|
try {
|
|
@@ -12,8 +21,38 @@ const isSameDomain = (url: string): boolean => {
|
|
|
12
21
|
}
|
|
13
22
|
};
|
|
14
23
|
|
|
15
|
-
export const
|
|
24
|
+
export const isTrustedDomain = (
|
|
25
|
+
url: string,
|
|
26
|
+
trustedDomains: string[],
|
|
27
|
+
): boolean => {
|
|
28
|
+
try {
|
|
29
|
+
const { hostname } = new URL(url);
|
|
30
|
+
if (
|
|
31
|
+
typeof window !== "undefined" &&
|
|
32
|
+
hostname === window.location.hostname
|
|
33
|
+
) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return trustedDomains.some((trusted) => {
|
|
37
|
+
const domain = trusted.toLowerCase();
|
|
38
|
+
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const LinkExtension = Link.extend<ExtendedLinkOptions>({
|
|
46
|
+
addOptions() {
|
|
47
|
+
return {
|
|
48
|
+
...(this.parent?.() ?? ({} as ExtendedLinkOptions)),
|
|
49
|
+
trustedDomains: [] as string[],
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
|
|
16
53
|
addAttributes() {
|
|
54
|
+
const { trustedDomains } = this.options;
|
|
55
|
+
|
|
17
56
|
return {
|
|
18
57
|
...this.parent?.(),
|
|
19
58
|
target: {
|
|
@@ -27,7 +66,9 @@ export const LinkExtension = Link.extend({
|
|
|
27
66
|
default: null,
|
|
28
67
|
parseHTML: (element) => {
|
|
29
68
|
const href = element.getAttribute("href") ?? "";
|
|
30
|
-
return
|
|
69
|
+
return isTrustedDomain(href, trustedDomains)
|
|
70
|
+
? null
|
|
71
|
+
: "noopener noreferrer nofollow";
|
|
31
72
|
},
|
|
32
73
|
},
|
|
33
74
|
};
|
|
@@ -53,16 +94,23 @@ export const LinkExtension = Link.extend({
|
|
|
53
94
|
defaultProtocol,
|
|
54
95
|
}),
|
|
55
96
|
)
|
|
56
|
-
.filter(
|
|
57
|
-
|
|
97
|
+
.filter(
|
|
98
|
+
(link) => this.options.shouldAutoLink?.(link.value) !== false,
|
|
99
|
+
)
|
|
100
|
+
.map((link) => ({
|
|
101
|
+
text: link.value,
|
|
102
|
+
data: { href: link.href },
|
|
103
|
+
index: link.start,
|
|
104
|
+
})),
|
|
58
105
|
type: this.type,
|
|
59
106
|
getAttributes: (match) => {
|
|
60
107
|
const href = match.data?.href ?? "";
|
|
61
|
-
const sameDomain = isSameDomain(href);
|
|
62
108
|
return {
|
|
63
109
|
href,
|
|
64
|
-
target:
|
|
65
|
-
rel:
|
|
110
|
+
target: isSameDomain(href) ? "_self" : "_blank",
|
|
111
|
+
rel: isTrustedDomain(href, this.options.trustedDomains)
|
|
112
|
+
? null
|
|
113
|
+
: "noopener noreferrer nofollow",
|
|
66
114
|
};
|
|
67
115
|
},
|
|
68
116
|
}),
|
|
@@ -80,7 +128,11 @@ export const LinkExtension = Link.extend({
|
|
|
80
128
|
const range = getMarkRange(view.state.doc.resolve(pos), this.type);
|
|
81
129
|
if (!range) return false;
|
|
82
130
|
|
|
83
|
-
const selection = TextSelection.create(
|
|
131
|
+
const selection = TextSelection.create(
|
|
132
|
+
view.state.doc,
|
|
133
|
+
range.from,
|
|
134
|
+
range.to,
|
|
135
|
+
);
|
|
84
136
|
view.dispatch(view.state.tr.setSelection(selection));
|
|
85
137
|
return true;
|
|
86
138
|
},
|
|
@@ -29,6 +29,7 @@ interface GetExtensionsOptions {
|
|
|
29
29
|
getImageById?: (fileId: string) => string | null;
|
|
30
30
|
onKatexClick?: (latex: string, pos?: number) => void;
|
|
31
31
|
onFigureClick?: (attrs: any, pos: number) => void;
|
|
32
|
+
trustedDomains?: string[];
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -44,8 +45,9 @@ export function getEditorExtensions(options: GetExtensionsOptions) {
|
|
|
44
45
|
getImageById,
|
|
45
46
|
onKatexClick,
|
|
46
47
|
onFigureClick,
|
|
48
|
+
trustedDomains,
|
|
47
49
|
} = options;
|
|
48
|
-
const sharedExtensions = getSharedExtensions();
|
|
50
|
+
const sharedExtensions = getSharedExtensions({ trustedDomains });
|
|
49
51
|
|
|
50
52
|
return [
|
|
51
53
|
...sharedExtensions,
|
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
import StarterKit from "@tiptap/starter-kit";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Details,
|
|
4
|
+
DetailsContent,
|
|
5
|
+
DetailsSummary,
|
|
6
|
+
} from "@tiptap/extension-details";
|
|
3
7
|
import Highlight from "@tiptap/extension-highlight";
|
|
4
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
LinkExtension,
|
|
10
|
+
BaseMythReality,
|
|
11
|
+
BaseMythRealityBlock,
|
|
12
|
+
} from "@/extensions/base";
|
|
5
13
|
import Subscript from "@tiptap/extension-subscript";
|
|
6
14
|
import Superscript from "@tiptap/extension-superscript";
|
|
7
15
|
import TextAlign from "@tiptap/extension-text-align";
|
|
8
16
|
import { TextStyleKit } from "@tiptap/extension-text-style";
|
|
9
17
|
import { LegacyInlineLatexSE } from "@/extensions/base/compatibility";
|
|
10
18
|
|
|
19
|
+
export interface SharedExtensionsOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Domains (with all their subdomains) whose links are treated as internal —
|
|
22
|
+
* see the trustedDomains option of LinkExtension.
|
|
23
|
+
*/
|
|
24
|
+
trustedDomains?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
11
27
|
/**
|
|
12
28
|
* Get all Tiptap extensions for editing and rendering without ReactNodeViewRenderer for Editor
|
|
13
29
|
* Can be used for both static render and editor
|
|
14
30
|
*/
|
|
15
|
-
export function getSharedExtensions() {
|
|
31
|
+
export function getSharedExtensions(options: SharedExtensionsOptions = {}) {
|
|
16
32
|
return [
|
|
17
33
|
StarterKit.configure({
|
|
18
34
|
codeBlock: false,
|
|
19
35
|
link: false,
|
|
20
36
|
}),
|
|
21
37
|
Highlight.configure({ multicolor: true }),
|
|
22
|
-
LinkExtension,
|
|
38
|
+
LinkExtension.configure({ trustedDomains: options.trustedDomains ?? [] }),
|
|
23
39
|
Superscript,
|
|
24
40
|
Subscript,
|
|
25
41
|
TextAlign.configure({
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import { generateJSON, type JSONContent } from "@tiptap/core";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getSharedExtensions,
|
|
4
|
+
type SharedExtensionsOptions,
|
|
5
|
+
} from "./get-shared-extensions";
|
|
3
6
|
import { BaseFigure } from "@/extensions/base/figure";
|
|
4
7
|
import { TableKit } from "@tiptap/extension-table";
|
|
5
8
|
import { BaseCodeBlock } from "@/extensions/base";
|
|
6
9
|
|
|
7
10
|
function isJSONContent(value: unknown): value is JSONContent {
|
|
8
11
|
return (
|
|
9
|
-
typeof value === "object" &&
|
|
12
|
+
typeof value === "object" &&
|
|
13
|
+
value !== null &&
|
|
14
|
+
"type" in value &&
|
|
15
|
+
typeof value.type === "string"
|
|
10
16
|
);
|
|
11
17
|
}
|
|
12
18
|
|
|
13
|
-
export function htmlToTiptapContent(
|
|
19
|
+
export function htmlToTiptapContent(
|
|
20
|
+
content: string | JSONContent,
|
|
21
|
+
options: SharedExtensionsOptions = {},
|
|
22
|
+
): JSONContent {
|
|
14
23
|
if (isJSONContent(content)) {
|
|
15
24
|
return content;
|
|
16
25
|
}
|
|
@@ -30,7 +39,7 @@ export function htmlToTiptapContent(content: string | JSONContent): JSONContent
|
|
|
30
39
|
return { type: "doc", content: [{ type: "paragraph" }] };
|
|
31
40
|
}
|
|
32
41
|
|
|
33
|
-
const sharedExtensions = getSharedExtensions();
|
|
42
|
+
const sharedExtensions = getSharedExtensions(options);
|
|
34
43
|
|
|
35
44
|
const extensions = [...sharedExtensions, BaseFigure, TableKit, BaseCodeBlock];
|
|
36
45
|
|