@docs.plus/extension-hyperlink 1.0.4
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/README.md +172 -0
- package/dist/helpers/autoHyperlink.d.ts +8 -0
- package/dist/helpers/clickHandler.d.ts +16 -0
- package/dist/helpers/editHyperlink.d.ts +9 -0
- package/dist/helpers/pasteHandler.d.ts +9 -0
- package/dist/helpers/tippyHelper.d.ts +20 -0
- package/dist/hyperlink.d.ts +75 -0
- package/dist/index.cjs +496 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +490 -0
- package/dist/index.js.map +1 -0
- package/dist/index.umd.js +497 -0
- package/dist/index.umd.js.map +1 -0
- package/package.json +44 -0
- package/src/helpers/autoHyperlink.ts +167 -0
- package/src/helpers/clickHandler.ts +76 -0
- package/src/helpers/editHyperlink.ts +55 -0
- package/src/helpers/pasteHandler.ts +46 -0
- package/src/helpers/tippyHelper.ts +126 -0
- package/src/hyperlink.ts +272 -0
- package/src/index.ts +6 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
combineTransactionSteps,
|
|
3
|
+
findChildrenInRange,
|
|
4
|
+
getChangedRanges,
|
|
5
|
+
getMarksBetween,
|
|
6
|
+
NodeWithPos,
|
|
7
|
+
} from "@tiptap/core";
|
|
8
|
+
import { MarkType } from "@tiptap/pm/model";
|
|
9
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
10
|
+
import { find, test } from "linkifyjs";
|
|
11
|
+
|
|
12
|
+
type AutoHyperlinkOptions = {
|
|
13
|
+
type: MarkType;
|
|
14
|
+
validate?: (url: string) => boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default function autoHyperlink(options: AutoHyperlinkOptions): Plugin {
|
|
18
|
+
return new Plugin({
|
|
19
|
+
key: new PluginKey("autoHyperlink"),
|
|
20
|
+
appendTransaction: (transactions, oldState, newState) => {
|
|
21
|
+
const docChanges =
|
|
22
|
+
transactions.some((transaction) => transaction.docChanged) &&
|
|
23
|
+
!oldState.doc.eq(newState.doc);
|
|
24
|
+
const preventAutoHyperlink = transactions.some((transaction) =>
|
|
25
|
+
transaction.getMeta("preventAutoHyperlink")
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (!docChanges || preventAutoHyperlink) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { tr } = newState;
|
|
33
|
+
const transform = combineTransactionSteps(oldState.doc, [
|
|
34
|
+
...transactions,
|
|
35
|
+
]);
|
|
36
|
+
const { mapping } = transform;
|
|
37
|
+
const changes = getChangedRanges(transform);
|
|
38
|
+
|
|
39
|
+
changes.forEach(({ oldRange, newRange }) => {
|
|
40
|
+
// at first we check if we have to remove links
|
|
41
|
+
getMarksBetween(oldRange.from, oldRange.to, oldState.doc)
|
|
42
|
+
.filter((item) => item.mark.type === options.type)
|
|
43
|
+
.forEach((oldMark) => {
|
|
44
|
+
const newFrom = mapping.map(oldMark.from);
|
|
45
|
+
const newTo = mapping.map(oldMark.to);
|
|
46
|
+
const newMarks = getMarksBetween(
|
|
47
|
+
newFrom,
|
|
48
|
+
newTo,
|
|
49
|
+
newState.doc
|
|
50
|
+
).filter((item) => item.mark.type === options.type);
|
|
51
|
+
|
|
52
|
+
if (!newMarks.length) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const newMark = newMarks[0];
|
|
57
|
+
const oldLinkText = oldState.doc.textBetween(
|
|
58
|
+
oldMark.from,
|
|
59
|
+
oldMark.to,
|
|
60
|
+
undefined,
|
|
61
|
+
" "
|
|
62
|
+
);
|
|
63
|
+
const newLinkText = newState.doc.textBetween(
|
|
64
|
+
newMark.from,
|
|
65
|
+
newMark.to,
|
|
66
|
+
undefined,
|
|
67
|
+
" "
|
|
68
|
+
);
|
|
69
|
+
const wasLink = test(oldLinkText);
|
|
70
|
+
const isLink = test(newLinkText);
|
|
71
|
+
|
|
72
|
+
// remove only the link, if it was a link before too
|
|
73
|
+
// because we don’t want to remove links that were set manually
|
|
74
|
+
if (wasLink && !isLink) {
|
|
75
|
+
tr.removeMark(newMark.from, newMark.to, options.type);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// now let’s see if we can add new links
|
|
80
|
+
const nodesInChangedRanges = findChildrenInRange(
|
|
81
|
+
newState.doc,
|
|
82
|
+
newRange,
|
|
83
|
+
(node) => node.isTextblock
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
let textBlock: NodeWithPos | undefined;
|
|
87
|
+
let textBeforeWhitespace: string | undefined;
|
|
88
|
+
|
|
89
|
+
if (nodesInChangedRanges.length > 1) {
|
|
90
|
+
// Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter)
|
|
91
|
+
textBlock = nodesInChangedRanges[0];
|
|
92
|
+
textBeforeWhitespace = newState.doc.textBetween(
|
|
93
|
+
textBlock.pos,
|
|
94
|
+
textBlock.pos + textBlock.node.nodeSize,
|
|
95
|
+
undefined,
|
|
96
|
+
" "
|
|
97
|
+
);
|
|
98
|
+
} else if (
|
|
99
|
+
nodesInChangedRanges.length &&
|
|
100
|
+
// We want to make sure to include the block seperator argument to treat hard breaks like spaces
|
|
101
|
+
newState.doc
|
|
102
|
+
.textBetween(newRange.from, newRange.to, " ", " ")
|
|
103
|
+
.endsWith(" ")
|
|
104
|
+
) {
|
|
105
|
+
textBlock = nodesInChangedRanges[0];
|
|
106
|
+
textBeforeWhitespace = newState.doc.textBetween(
|
|
107
|
+
textBlock.pos,
|
|
108
|
+
newRange.to,
|
|
109
|
+
undefined,
|
|
110
|
+
" "
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (textBlock && textBeforeWhitespace) {
|
|
115
|
+
const wordsBeforeWhitespace = textBeforeWhitespace
|
|
116
|
+
.split(" ")
|
|
117
|
+
.filter((s) => s !== "");
|
|
118
|
+
|
|
119
|
+
if (wordsBeforeWhitespace.length <= 0) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const lastWordBeforeSpace =
|
|
124
|
+
wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1];
|
|
125
|
+
const lastWordAndBlockOffset =
|
|
126
|
+
textBlock.pos +
|
|
127
|
+
textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace);
|
|
128
|
+
|
|
129
|
+
if (!lastWordBeforeSpace) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
find(lastWordBeforeSpace)
|
|
134
|
+
.filter((link) => link.isLink)
|
|
135
|
+
.filter((link) => {
|
|
136
|
+
if (options.validate) {
|
|
137
|
+
return options.validate(link.value);
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
})
|
|
141
|
+
// calculate link position
|
|
142
|
+
.map((link) => ({
|
|
143
|
+
...link,
|
|
144
|
+
from: lastWordAndBlockOffset + link.start + 1,
|
|
145
|
+
to: lastWordAndBlockOffset + link.end + 1,
|
|
146
|
+
}))
|
|
147
|
+
// add link mark
|
|
148
|
+
.forEach((link) => {
|
|
149
|
+
tr.addMark(
|
|
150
|
+
link.from,
|
|
151
|
+
link.to,
|
|
152
|
+
options.type.create({
|
|
153
|
+
href: link.href,
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (!tr.steps.length) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return tr;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { getAttributes } from "@tiptap/core";
|
|
2
|
+
import { MarkType } from "@tiptap/pm/model";
|
|
3
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
4
|
+
import { Editor } from "@tiptap/core";
|
|
5
|
+
import { EditorView } from "@tiptap/pm/view";
|
|
6
|
+
import * as tippy from "./tippyHelper";
|
|
7
|
+
// import "tippy.js/dist/svg-arrow.css";
|
|
8
|
+
|
|
9
|
+
// Define type for the ClickHandlerOptions
|
|
10
|
+
type ClickHandlerOptions = {
|
|
11
|
+
type: MarkType;
|
|
12
|
+
editor: Editor;
|
|
13
|
+
validate?: (url: string) => boolean;
|
|
14
|
+
view: EditorView;
|
|
15
|
+
modals: {
|
|
16
|
+
previewHyperlink?: ((options: any) => HTMLElement) | null;
|
|
17
|
+
setHyperlink?: ((options: any) => HTMLElement) | null;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default function clickHandler(options: ClickHandlerOptions): Plugin {
|
|
22
|
+
const { tippyModal } = tippy.init(options);
|
|
23
|
+
|
|
24
|
+
return new Plugin({
|
|
25
|
+
key: new PluginKey("handleClickHyperlink"),
|
|
26
|
+
props: {
|
|
27
|
+
handleClick: (view, pos, event) => {
|
|
28
|
+
if (event.button !== 0) return false;
|
|
29
|
+
|
|
30
|
+
// Get the target HTML element and its position
|
|
31
|
+
const nodeTarget: HTMLElement = event.target as HTMLElement;
|
|
32
|
+
const nodePos = view.posAtDOM(nodeTarget, 0);
|
|
33
|
+
|
|
34
|
+
// Find the closest link element to the target element
|
|
35
|
+
const link = nodeTarget?.closest("a");
|
|
36
|
+
|
|
37
|
+
// Extract attributes from the state
|
|
38
|
+
const attrs = getAttributes(view.state, options.type.name);
|
|
39
|
+
|
|
40
|
+
// Extract href and target attributes from the link element or the state
|
|
41
|
+
const href = link?.href ?? attrs.href;
|
|
42
|
+
const target = link?.target ?? attrs.target;
|
|
43
|
+
|
|
44
|
+
// If there is no previewHyperlink modal provided, then open the link in new window
|
|
45
|
+
if (!options.modals.previewHyperlink) {
|
|
46
|
+
if (link && href) {
|
|
47
|
+
window.open(href, target);
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// if the link does not contain href attribute, hide the tooltip
|
|
53
|
+
if (!link?.href) return tippy.hide();
|
|
54
|
+
|
|
55
|
+
// Create a preview of the hyperlink
|
|
56
|
+
const hyperlinkPreview = options.modals.previewHyperlink({
|
|
57
|
+
link,
|
|
58
|
+
nodePos,
|
|
59
|
+
...options,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// If there is no hyperlink preview, hide the modal
|
|
63
|
+
if (!hyperlinkPreview) return tippy.hide();
|
|
64
|
+
|
|
65
|
+
// Empty the modal and append the hyperlink preview box
|
|
66
|
+
tippyModal.innerHTML = "";
|
|
67
|
+
tippyModal.append(hyperlinkPreview);
|
|
68
|
+
|
|
69
|
+
// Update the modal position
|
|
70
|
+
tippy.update(options.view);
|
|
71
|
+
|
|
72
|
+
return false;
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Editor } from "@tiptap/core";
|
|
2
|
+
import { find } from "linkifyjs";
|
|
3
|
+
|
|
4
|
+
type EditHyperlinkOptions = {
|
|
5
|
+
editor: Editor;
|
|
6
|
+
validate?: (url: string) => boolean;
|
|
7
|
+
newURL?: string;
|
|
8
|
+
newText?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function editHyperlink(options: EditHyperlinkOptions) {
|
|
12
|
+
const { state, dispatch } = options.editor.view;
|
|
13
|
+
const { from, to } = state.selection;
|
|
14
|
+
let link: HTMLAnchorElement | null = null;
|
|
15
|
+
|
|
16
|
+
const selectedNode = options.editor.view.domAtPos(from - 1)
|
|
17
|
+
.node as HTMLElement;
|
|
18
|
+
|
|
19
|
+
if (selectedNode?.nodeName === "#text") {
|
|
20
|
+
link = (selectedNode.parentNode as HTMLElement)?.closest("a");
|
|
21
|
+
} else {
|
|
22
|
+
link = selectedNode?.closest("a");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!link) return true;
|
|
26
|
+
|
|
27
|
+
const text = options.newText || link?.innerText;
|
|
28
|
+
// Get the position of the link in the editor view
|
|
29
|
+
const nodePos = options.editor.view.posAtDOM(link, 0);
|
|
30
|
+
|
|
31
|
+
const sanitizeURL = find(options.newURL || link.href)
|
|
32
|
+
.filter((link) => link.isLink)
|
|
33
|
+
.filter((link) => (options.validate ? options.validate(link.value) : true))
|
|
34
|
+
.at(0);
|
|
35
|
+
|
|
36
|
+
return options.editor
|
|
37
|
+
.chain()
|
|
38
|
+
.focus()
|
|
39
|
+
.command(({ tr }) => {
|
|
40
|
+
// The command replaces the range from nodePos to nodePos + length of the text
|
|
41
|
+
// with a new text node with the provided text and a link mark with the sanitized URL
|
|
42
|
+
tr.replaceWith(
|
|
43
|
+
nodePos,
|
|
44
|
+
nodePos + text?.length,
|
|
45
|
+
options.editor.schema.text(text, [
|
|
46
|
+
options.editor.schema.marks.hyperlink.create({
|
|
47
|
+
href: sanitizeURL?.href,
|
|
48
|
+
}),
|
|
49
|
+
])
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return true;
|
|
53
|
+
})
|
|
54
|
+
.run();
|
|
55
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Editor } from "@tiptap/core";
|
|
2
|
+
import { MarkType } from "@tiptap/pm/model";
|
|
3
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
4
|
+
import { find } from "linkifyjs";
|
|
5
|
+
|
|
6
|
+
type PasteHandlerOptions = {
|
|
7
|
+
editor: Editor;
|
|
8
|
+
type: MarkType;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function pasteHandler(options: PasteHandlerOptions): Plugin {
|
|
12
|
+
return new Plugin({
|
|
13
|
+
key: new PluginKey("handlePasteHyperlink"),
|
|
14
|
+
props: {
|
|
15
|
+
handlePaste: (view, event, slice) => {
|
|
16
|
+
const { state } = view;
|
|
17
|
+
const { selection } = state;
|
|
18
|
+
const { empty } = selection;
|
|
19
|
+
|
|
20
|
+
if (empty) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let textContent = "";
|
|
25
|
+
|
|
26
|
+
slice.content.forEach((node) => {
|
|
27
|
+
textContent += node.textContent;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const link = find(textContent).find(
|
|
31
|
+
(item) => item.isLink && item.value === textContent
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (!textContent || !link) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
options.editor.commands.setMark(options.type, {
|
|
39
|
+
href: link.href,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import tippy, { Instance, Props, roundArrow } from "tippy.js";
|
|
2
|
+
import { Editor } from "@tiptap/core";
|
|
3
|
+
import { EditorView } from "@tiptap/pm/view";
|
|
4
|
+
import { getAttributes, posToDOMRect } from "@tiptap/core";
|
|
5
|
+
|
|
6
|
+
let tippyInstance: Instance | undefined;
|
|
7
|
+
let preventHide: boolean = false;
|
|
8
|
+
let tippyWrapper: HTMLDivElement;
|
|
9
|
+
let editor: Editor;
|
|
10
|
+
let view: EditorView;
|
|
11
|
+
|
|
12
|
+
type TippyInitOptions = {
|
|
13
|
+
editor: Editor;
|
|
14
|
+
validate?: (url: string) => boolean;
|
|
15
|
+
view: EditorView;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const init = (options: TippyInitOptions) => {
|
|
19
|
+
editor = options.editor;
|
|
20
|
+
view = options.view;
|
|
21
|
+
if (!tippyInstance) {
|
|
22
|
+
tippyWrapper = document.createElement("div");
|
|
23
|
+
tippyWrapper.addEventListener("mousedown", mousedownHandler, {
|
|
24
|
+
capture: true,
|
|
25
|
+
});
|
|
26
|
+
view.dom.addEventListener("dragstart", dragstartHandler);
|
|
27
|
+
editor.on("focus", focusHandler);
|
|
28
|
+
editor.on("blur", blurHandler);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { tippyModal: tippyWrapper, tippyInstance: tippyInstance };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const show = () => {
|
|
35
|
+
tippyInstance?.show();
|
|
36
|
+
return true;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const hide = () => {
|
|
40
|
+
setTimeout(() => tippyInstance?.hide());
|
|
41
|
+
return false;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const mousedownHandler = () => {
|
|
45
|
+
preventHide = true;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const dragstartHandler = () => {
|
|
49
|
+
hide();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const focusHandler = () => {
|
|
53
|
+
setTimeout(() => update(editor.view));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const blurHandler = ({ event }: { event: FocusEvent }) => {
|
|
57
|
+
if (preventHide) {
|
|
58
|
+
preventHide = false;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (event?.relatedTarget && tippyWrapper.parentNode?.contains(event.relatedTarget as Node)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
hide();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const tippyBlurHandler = (event: FocusEvent) => {
|
|
68
|
+
blurHandler({ event });
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const createTooltip = () => {
|
|
72
|
+
if (!editor || !editor.options) return;
|
|
73
|
+
const { element: editorElement } = editor.options;
|
|
74
|
+
const editorIsAttached = !!editorElement.parentElement;
|
|
75
|
+
|
|
76
|
+
if (tippyInstance || !editorIsAttached) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
tippyInstance = tippy(editorElement, {
|
|
81
|
+
duration: 0,
|
|
82
|
+
getReferenceClientRect: null,
|
|
83
|
+
content: tippyWrapper,
|
|
84
|
+
interactive: true,
|
|
85
|
+
trigger: "manual",
|
|
86
|
+
placement: "bottom",
|
|
87
|
+
hideOnClick: "toggle",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (tippyInstance.popper.firstChild) {
|
|
91
|
+
(tippyInstance.popper.firstChild as HTMLElement).addEventListener("blur", tippyBlurHandler);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const update = (view: EditorView, option: any = {}) => {
|
|
96
|
+
createTooltip();
|
|
97
|
+
|
|
98
|
+
// Ensure 'arrow' is false when not provided
|
|
99
|
+
option.arrow = option?.arrow ?? false;
|
|
100
|
+
|
|
101
|
+
if (tippyInstance) {
|
|
102
|
+
tippyInstance.setProps({
|
|
103
|
+
...option,
|
|
104
|
+
getReferenceClientRect: () => {
|
|
105
|
+
const pos = view.state.selection.from;
|
|
106
|
+
return posToDOMRect(view, pos, pos);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
show();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const destroyTooltip = () => {
|
|
116
|
+
if (tippyInstance) {
|
|
117
|
+
tippyInstance.destroy();
|
|
118
|
+
tippyInstance = undefined;
|
|
119
|
+
tippyWrapper.removeEventListener("mousedown", mousedownHandler, {
|
|
120
|
+
capture: true,
|
|
121
|
+
});
|
|
122
|
+
view.dom.removeEventListener("dragstart", dragstartHandler);
|
|
123
|
+
editor.off("focus", focusHandler);
|
|
124
|
+
editor.off("blur", blurHandler);
|
|
125
|
+
}
|
|
126
|
+
};
|