@cyberyozh/text-editor 0.3.2 → 0.5.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.css +35 -43
- package/dist/editor.css.map +1 -1
- package/dist/editor.d.mts +20 -2
- package/dist/editor.d.ts +20 -2
- package/dist/editor.js +397 -327
- package/dist/editor.js.map +1 -1
- package/dist/editor.mjs +320 -246
- 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/dist/styles/index.css +35 -43
- package/package.json +1 -1
- package/src/editor/components/dialog-error/dialog-error.css +35 -0
- package/src/editor/components/dialog-error/dialog-error.tsx +65 -0
- package/src/editor/components/dialog-error/index.ts +1 -0
- package/src/editor/components/index.ts +1 -0
- package/src/editor/dialogs/add-link-dialog/add-link-dialog.css +0 -9
- package/src/editor/dialogs/add-link-dialog/add-link-dialog.tsx +2 -2
- package/src/editor/dialogs/add-video-dialog/add-video-dialog.css +0 -9
- package/src/editor/dialogs/add-video-dialog/add-video-dialog.tsx +2 -2
- package/src/editor/dialogs/image-dialog/image-dialog.css +1 -29
- package/src/editor/dialogs/image-dialog/image-dialog.tsx +7 -13
- package/src/editor/editor.tsx +8 -0
- package/src/editor/index.ts +1 -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/styles/components.css +1 -0
- package/src/translations/en.json +4 -4
- package/src/translations/ru.json +4 -4
- package/src/types/common.ts +3 -0
- package/src/types/index.ts +1 -0
- package/src/utils/get-shared-extensions.ts +20 -4
- package/src/utils/html-to-tiptap-content.ts +13 -4
- package/src/utils/index.ts +1 -0
- package/src/utils/is-combined-error.ts +9 -0
package/dist/styles/index.css
CHANGED
|
@@ -158,6 +158,40 @@
|
|
|
158
158
|
padding: 24px;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/* src/editor/components/dialog-error/dialog-error.css */
|
|
162
|
+
.dialog-error {
|
|
163
|
+
position: relative;
|
|
164
|
+
padding: 8px 12px;
|
|
165
|
+
border: 1px solid var(--ui-error-border);
|
|
166
|
+
border-radius: 4px;
|
|
167
|
+
background-color: var(--ui-error-bg);
|
|
168
|
+
color: var(--ui-error-text);
|
|
169
|
+
font-size: 13px;
|
|
170
|
+
}
|
|
171
|
+
.dialog-error-list {
|
|
172
|
+
margin: 0;
|
|
173
|
+
padding-left: 16px;
|
|
174
|
+
}
|
|
175
|
+
.dialog-error-clear-button {
|
|
176
|
+
display: flex;
|
|
177
|
+
position: absolute;
|
|
178
|
+
top: 8px;
|
|
179
|
+
right: 8px;
|
|
180
|
+
align-items: center;
|
|
181
|
+
justify-content: center;
|
|
182
|
+
width: 20px;
|
|
183
|
+
height: 20px;
|
|
184
|
+
padding: 0;
|
|
185
|
+
border: none;
|
|
186
|
+
background-color: transparent;
|
|
187
|
+
cursor: pointer;
|
|
188
|
+
}
|
|
189
|
+
.dialog-error-clear-button svg {
|
|
190
|
+
width: 14px;
|
|
191
|
+
height: 14px;
|
|
192
|
+
color: var(--ui-error-text);
|
|
193
|
+
}
|
|
194
|
+
|
|
161
195
|
/* src/editor/dialogs/add-katex-dialog/add-katex-dialog.css */
|
|
162
196
|
.katex-dialog-title {
|
|
163
197
|
margin: 0 0 16px 0;
|
|
@@ -298,14 +332,6 @@
|
|
|
298
332
|
.link-dialog-input::placeholder {
|
|
299
333
|
color: var(--tiptap-color-text-placeholder);
|
|
300
334
|
}
|
|
301
|
-
.link-dialog-error {
|
|
302
|
-
padding: 8px 12px;
|
|
303
|
-
border: 1px solid var(--ui-error-border);
|
|
304
|
-
border-radius: 4px;
|
|
305
|
-
background-color: var(--ui-error-bg);
|
|
306
|
-
color: var(--ui-error-text);
|
|
307
|
-
font-size: 13px;
|
|
308
|
-
}
|
|
309
335
|
.link-dialog-actions {
|
|
310
336
|
display: flex;
|
|
311
337
|
justify-content: flex-end;
|
|
@@ -423,14 +449,6 @@
|
|
|
423
449
|
.video-dialog-platform-list li {
|
|
424
450
|
margin: 4px 0;
|
|
425
451
|
}
|
|
426
|
-
.video-dialog-error {
|
|
427
|
-
padding: 8px 12px;
|
|
428
|
-
border: 1px solid var(--ui-error-border);
|
|
429
|
-
border-radius: 4px;
|
|
430
|
-
background-color: var(--ui-error-bg);
|
|
431
|
-
color: var(--ui-error-text);
|
|
432
|
-
font-size: 13px;
|
|
433
|
-
}
|
|
434
452
|
.video-dialog-actions {
|
|
435
453
|
display: flex;
|
|
436
454
|
justify-content: flex-end;
|
|
@@ -563,34 +581,8 @@
|
|
|
563
581
|
background: var(--ui-secondary-bg);
|
|
564
582
|
color: var(--ui-text-primary);
|
|
565
583
|
}
|
|
566
|
-
.image-dialog-error {
|
|
567
|
-
position: relative;
|
|
584
|
+
.image-dialog-error-spacing {
|
|
568
585
|
margin-bottom: 16px;
|
|
569
|
-
padding: 8px 12px;
|
|
570
|
-
border: 1px solid var(--ui-error-border);
|
|
571
|
-
border-radius: 4px;
|
|
572
|
-
background-color: var(--ui-error-bg);
|
|
573
|
-
color: var(--ui-error-text);
|
|
574
|
-
font-size: 13px;
|
|
575
|
-
}
|
|
576
|
-
.image-dialog-error-clear-button {
|
|
577
|
-
display: flex;
|
|
578
|
-
position: absolute;
|
|
579
|
-
top: 8px;
|
|
580
|
-
right: 8px;
|
|
581
|
-
align-items: center;
|
|
582
|
-
justify-content: center;
|
|
583
|
-
width: 20px;
|
|
584
|
-
height: 20px;
|
|
585
|
-
padding: 0;
|
|
586
|
-
border: none;
|
|
587
|
-
background-color: transparent;
|
|
588
|
-
cursor: pointer;
|
|
589
|
-
}
|
|
590
|
-
.image-dialog-error-clear-button svg {
|
|
591
|
-
width: 14px;
|
|
592
|
-
height: 14px;
|
|
593
|
-
color: var(--ui-error-text);
|
|
594
586
|
}
|
|
595
587
|
|
|
596
588
|
/* src/styles/components.css */
|
package/package.json
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.dialog-error {
|
|
2
|
+
position: relative;
|
|
3
|
+
padding: 8px 12px;
|
|
4
|
+
border: 1px solid var(--ui-error-border);
|
|
5
|
+
border-radius: 4px;
|
|
6
|
+
background-color: var(--ui-error-bg);
|
|
7
|
+
color: var(--ui-error-text);
|
|
8
|
+
font-size: 13px;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.dialog-error-list {
|
|
12
|
+
margin: 0;
|
|
13
|
+
padding-left: 16px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.dialog-error-clear-button {
|
|
17
|
+
display: flex;
|
|
18
|
+
position: absolute;
|
|
19
|
+
top: 8px;
|
|
20
|
+
right: 8px;
|
|
21
|
+
align-items: center;
|
|
22
|
+
justify-content: center;
|
|
23
|
+
width: 20px;
|
|
24
|
+
height: 20px;
|
|
25
|
+
padding: 0;
|
|
26
|
+
border: none;
|
|
27
|
+
background-color: transparent;
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.dialog-error-clear-button svg {
|
|
32
|
+
width: 14px;
|
|
33
|
+
height: 14px;
|
|
34
|
+
color: var(--ui-error-text);
|
|
35
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { isCombinedError } from "@/utils";
|
|
4
|
+
|
|
5
|
+
import { TiptapX } from "../../../icons";
|
|
6
|
+
import { useTiptapTranslations } from "../../context";
|
|
7
|
+
import { useMemo } from "react";
|
|
8
|
+
|
|
9
|
+
interface DialogErrorProps {
|
|
10
|
+
error: unknown;
|
|
11
|
+
onClose?: () => void;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DialogError = ({ error, onClose, className }: DialogErrorProps) => {
|
|
16
|
+
const translations = useTiptapTranslations();
|
|
17
|
+
|
|
18
|
+
const renderedMessage = useMemo(() => {
|
|
19
|
+
if (typeof error === "string") {
|
|
20
|
+
return error || translations.common.unknownError;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (isCombinedError(error)) {
|
|
24
|
+
const safeErrors = error.cause.filter((e) => Boolean(e.message));
|
|
25
|
+
const errorsToDisplay =
|
|
26
|
+
safeErrors.length === error.cause.length
|
|
27
|
+
? safeErrors
|
|
28
|
+
: [new Error(translations.common.unknownError), ...safeErrors];
|
|
29
|
+
|
|
30
|
+
if (errorsToDisplay.length === 1) {
|
|
31
|
+
return errorsToDisplay[0].message;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<ul className="dialog-error-list">
|
|
36
|
+
{errorsToDisplay.map((cause, index) => (
|
|
37
|
+
<li key={`${index}_${cause.message}`}>{cause.message}</li>
|
|
38
|
+
))}
|
|
39
|
+
</ul>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (error instanceof Error) {
|
|
44
|
+
return error.message || translations.common.unknownError;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return translations.common.unknownError;
|
|
48
|
+
}, [error, translations]);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className={`dialog-error ${className || ""}`}>
|
|
52
|
+
{renderedMessage}
|
|
53
|
+
{onClose && (
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
className="dialog-error-clear-button"
|
|
57
|
+
onClick={onClose}
|
|
58
|
+
aria-label={translations.common.clearError}
|
|
59
|
+
>
|
|
60
|
+
<TiptapX />
|
|
61
|
+
</button>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DialogError } from './dialog-error';
|
|
@@ -48,15 +48,6 @@
|
|
|
48
48
|
color: var(--tiptap-color-text-placeholder);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
.link-dialog-error {
|
|
52
|
-
padding: 8px 12px;
|
|
53
|
-
border: 1px solid var(--ui-error-border);
|
|
54
|
-
border-radius: 4px;
|
|
55
|
-
background-color: var(--ui-error-bg);
|
|
56
|
-
color: var(--ui-error-text);
|
|
57
|
-
font-size: 13px;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
51
|
.link-dialog-actions {
|
|
61
52
|
display: flex;
|
|
62
53
|
justify-content: flex-end;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
|
4
4
|
|
|
5
|
-
import { CommonModal } from "../../components";
|
|
5
|
+
import { CommonModal, DialogError } from "../../components";
|
|
6
6
|
import { useTiptapTranslations } from "../../context";
|
|
7
7
|
import { LinkDialogContextProps } from "@/types/link";
|
|
8
8
|
|
|
@@ -186,7 +186,7 @@ export const AddLinkDialog = ({
|
|
|
186
186
|
<p className="link-dialog-help-text">{translations.link.addNofollowHelp}</p>
|
|
187
187
|
</div>
|
|
188
188
|
|
|
189
|
-
{error && <
|
|
189
|
+
{error && <DialogError error={error} />}
|
|
190
190
|
</div>
|
|
191
191
|
|
|
192
192
|
<div className="link-dialog-actions">
|
|
@@ -83,15 +83,6 @@
|
|
|
83
83
|
margin: 4px 0;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
.video-dialog-error {
|
|
87
|
-
padding: 8px 12px;
|
|
88
|
-
border: 1px solid var(--ui-error-border);
|
|
89
|
-
border-radius: 4px;
|
|
90
|
-
background-color: var(--ui-error-bg);
|
|
91
|
-
color: var(--ui-error-text);
|
|
92
|
-
font-size: 13px;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
86
|
.video-dialog-actions {
|
|
96
87
|
display: flex;
|
|
97
88
|
justify-content: flex-end;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
|
4
4
|
|
|
5
|
-
import { CommonModal } from "../../components";
|
|
5
|
+
import { CommonModal, DialogError } from "../../components";
|
|
6
6
|
import { detectVideoHosting, VideoHosting } from "../../../utils/detect-video-hosting";
|
|
7
7
|
import { useTiptapTranslations } from "../../context";
|
|
8
8
|
|
|
@@ -128,7 +128,7 @@ export const AddVideoDialog = ({ open, onClose, onVideoAdd }: AddVideoDialogProp
|
|
|
128
128
|
</ul>
|
|
129
129
|
</div>
|
|
130
130
|
|
|
131
|
-
{error && <
|
|
131
|
+
{error && <DialogError error={error} />}
|
|
132
132
|
</div>
|
|
133
133
|
|
|
134
134
|
<div className="video-dialog-actions">
|
|
@@ -101,34 +101,6 @@
|
|
|
101
101
|
color: var(--ui-text-primary);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
.image-dialog-error {
|
|
105
|
-
position: relative;
|
|
104
|
+
.image-dialog-error-spacing {
|
|
106
105
|
margin-bottom: 16px;
|
|
107
|
-
padding: 8px 12px;
|
|
108
|
-
border: 1px solid var(--ui-error-border);
|
|
109
|
-
border-radius: 4px;
|
|
110
|
-
background-color: var(--ui-error-bg);
|
|
111
|
-
color: var(--ui-error-text);
|
|
112
|
-
font-size: 13px;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
.image-dialog-error-clear-button {
|
|
116
|
-
display: flex;
|
|
117
|
-
position: absolute;
|
|
118
|
-
top: 8px;
|
|
119
|
-
right: 8px;
|
|
120
|
-
align-items: center;
|
|
121
|
-
justify-content: center;
|
|
122
|
-
width: 20px;
|
|
123
|
-
height: 20px;
|
|
124
|
-
padding: 0;
|
|
125
|
-
border: none;
|
|
126
|
-
background-color: transparent;
|
|
127
|
-
cursor: pointer;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
.image-dialog-error-clear-button svg {
|
|
131
|
-
width: 14px;
|
|
132
|
-
height: 14px;
|
|
133
|
-
color: var(--ui-error-text);
|
|
134
106
|
}
|
|
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
|
|
|
4
4
|
|
|
5
5
|
import { TiptapX } from "../../../icons";
|
|
6
6
|
|
|
7
|
-
import { Button, CommonModal, Input } from "../../components";
|
|
7
|
+
import { Button, CommonModal, DialogError, Input } from "../../components";
|
|
8
8
|
import { ImageUploadHandler, TiptapImageAttrs, ImageGetHandler, FigureAttrs } from "../../../types";
|
|
9
9
|
import { resolveImageSrc } from "../../../utils/image-src-resolver";
|
|
10
10
|
import { useTiptapTranslations } from "../../context";
|
|
@@ -219,7 +219,7 @@ export const ImageDialog = ({
|
|
|
219
219
|
|
|
220
220
|
handleClose();
|
|
221
221
|
} catch (error) {
|
|
222
|
-
setFileUploadError(error instanceof Error ? error : new Error(translations.
|
|
222
|
+
setFileUploadError(error instanceof Error ? error : new Error(translations.common.unknownError));
|
|
223
223
|
} finally {
|
|
224
224
|
setIsUploading(false);
|
|
225
225
|
}
|
|
@@ -334,17 +334,11 @@ export const ImageDialog = ({
|
|
|
334
334
|
</div>
|
|
335
335
|
</div>
|
|
336
336
|
{fileUploadError && (
|
|
337
|
-
<
|
|
338
|
-
{fileUploadError
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
onClick={() => setFileUploadError(null)}
|
|
343
|
-
aria-label={translations.imageDialog.clearError}
|
|
344
|
-
>
|
|
345
|
-
<TiptapX />
|
|
346
|
-
</button>
|
|
347
|
-
</div>
|
|
337
|
+
<DialogError
|
|
338
|
+
error={fileUploadError}
|
|
339
|
+
onClose={() => setFileUploadError(null)}
|
|
340
|
+
className="image-dialog-error-spacing"
|
|
341
|
+
/>
|
|
348
342
|
)}
|
|
349
343
|
<div className="image-dialog-actions">
|
|
350
344
|
<Button
|
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");
|
package/src/editor/index.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { Tiptap, type TiptapProps, type KatexDialogContextProps } from "./editor
|
|
|
2
2
|
export type { SupportedLanguage as TiptapSupportedLanguages } from "../utils/get-translations";
|
|
3
3
|
export { type JSONContent } from "@tiptap/react";
|
|
4
4
|
export { htmlToTiptapContent } from "../utils/html-to-tiptap-content";
|
|
5
|
+
export { type CombinedErrors as TipTapCombinedErrors } from '@/types/common'
|
|
@@ -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,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
@import "../editor/components/button/button.css";
|
|
3
3
|
@import "../editor/components/input/input.css";
|
|
4
4
|
@import "../editor/components/common-modal/common-modal.css";
|
|
5
|
+
@import "../editor/components/dialog-error/dialog-error.css";
|
|
5
6
|
|
|
6
7
|
/* Dialog Styles */
|
|
7
8
|
@import "../editor/dialogs/add-katex-dialog/add-katex-dialog.css";
|
package/src/translations/en.json
CHANGED
|
@@ -113,9 +113,7 @@
|
|
|
113
113
|
"captionPlaceholder": "Description under image...",
|
|
114
114
|
"save": "Save",
|
|
115
115
|
"add": "Add",
|
|
116
|
-
"invalidFileType": "
|
|
117
|
-
"unknownError": "Unknown error",
|
|
118
|
-
"clearError": "Clear error"
|
|
116
|
+
"invalidFileType": "Invalid file format."
|
|
119
117
|
},
|
|
120
118
|
"videoUpload": {
|
|
121
119
|
"title": "Add video",
|
|
@@ -160,6 +158,8 @@
|
|
|
160
158
|
"words": ["word", "words", "words"]
|
|
161
159
|
},
|
|
162
160
|
"common": {
|
|
163
|
-
"cancel": "Cancel"
|
|
161
|
+
"cancel": "Cancel",
|
|
162
|
+
"unknownError": "Unknown error",
|
|
163
|
+
"clearError": "Clear error"
|
|
164
164
|
}
|
|
165
165
|
}
|
package/src/translations/ru.json
CHANGED
|
@@ -113,9 +113,7 @@
|
|
|
113
113
|
"captionPlaceholder": "Описание под изображением...",
|
|
114
114
|
"save": "Сохранить",
|
|
115
115
|
"add": "Добавить",
|
|
116
|
-
"invalidFileType": "
|
|
117
|
-
"unknownError": "Неизвестная ошибка",
|
|
118
|
-
"clearError": "Сбросить ошибку"
|
|
116
|
+
"invalidFileType": "Неверный формат файла."
|
|
119
117
|
},
|
|
120
118
|
"videoUpload": {
|
|
121
119
|
"title": "Добавить видео",
|
|
@@ -160,6 +158,8 @@
|
|
|
160
158
|
"words": ["слово", "слова", "слов"]
|
|
161
159
|
},
|
|
162
160
|
"common": {
|
|
163
|
-
"cancel": "Отменить"
|
|
161
|
+
"cancel": "Отменить",
|
|
162
|
+
"unknownError": "Неизвестная ошибка",
|
|
163
|
+
"clearError": "Сбросить ошибку"
|
|
164
164
|
}
|
|
165
165
|
}
|
package/src/types/index.ts
CHANGED