-
Notifications
You must be signed in to change notification settings - Fork 146
/
HyperLink.ts
237 lines (211 loc) · 8.26 KB
/
HyperLink.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types';
import { isCharacterValue, isCtrlOrMetaPressed, matchLink } from 'roosterjs-editor-dom';
import type { DOMEventHandler, EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types';
/**
* An editor plugin that show a tooltip for existing link
*/
export default class HyperLink implements EditorPlugin {
private originalHref: string | null = null;
private trackedLink: HTMLAnchorElement | null = null;
private editor: IEditor | null = null;
private disposer: (() => void) | null = null;
/**
* Create a new instance of HyperLink class
* @param getTooltipCallback A callback function to get tooltip text for an existing hyperlink.
* Default value is to return the href itself. If null, there will be no tooltip text.
* @param target (Optional) Target window name for hyperlink. If null, will use "_blank"
* @param onLinkClick (Optional) Open link callback (return false to use default behavior)
*/
constructor(
private getTooltipCallback: (href: string, a: HTMLAnchorElement) => string = href => href,
private target?: string,
private onLinkClick?: (anchor: HTMLAnchorElement, mouseEvent: MouseEvent) => boolean | void
) {}
/**
* Get a friendly name of this plugin
*/
getName() {
return 'Hyperlink';
}
/**
* Initialize this plugin
* @param editor The editor instance
*/
public initialize(editor: IEditor): void {
this.editor = editor;
this.disposer = editor.addDomEventHandler({
mouseover: <DOMEventHandler>this.onMouse,
mouseout: <DOMEventHandler>this.onMouse,
blur: <DOMEventHandler>this.onBlur,
});
}
protected onMouse = (e: MouseEvent) => {
const a = this.editor?.getElementAtCursor(
'a[href]',
<Node>e.target
) as HTMLAnchorElement | null;
const href = a && this.tryGetHref(a);
if (href) {
this.editor?.setEditorDomAttribute(
'title',
e.type == 'mouseover' ? this.getTooltipCallback(href, a) : null
);
}
};
protected onBlur = (e: FocusEvent) => {
if (this.trackedLink) {
this.updateLinkHrefIfShouldUpdate();
}
this.resetLinkTracking();
};
/**
* Dispose this plugin
*/
public dispose(): void {
if (this.disposer) {
this.disposer();
this.disposer = null;
}
this.editor = null;
}
/**
* Handle events triggered from editor
* @param event PluginEvent object
*/
public onPluginEvent(event: PluginEvent): void {
if (
event.eventType == PluginEventType.MouseUp ||
(event.eventType == PluginEventType.KeyUp &&
(!this.isContentEditValue(event.rawEvent) || event.rawEvent.which == Keys.SPACE)) ||
event.eventType == PluginEventType.ContentChanged
) {
const anchor = this.editor?.getElementAtCursor(
'A[href]',
undefined /*startFrom*/,
event
) as HTMLAnchorElement | null;
const shouldCheckUpdateLink =
(anchor && anchor !== this.trackedLink) ||
event.eventType == PluginEventType.KeyUp ||
event.eventType == PluginEventType.ContentChanged;
if (
event.eventType == PluginEventType.ContentChanged &&
event.source == ChangeSource.Keyboard &&
this.trackedLink != anchor &&
anchor
) {
// For Keyboard event that causes content change (mostly come from Content Model), this tracked list may be staled.
// So we need to get an up-to-date link element
// TODO: This is a temporary solution. Later when Content Model can fully take over this behavior, we can remove this code.
this.trackedLink = anchor;
}
if (
this.trackedLink &&
(shouldCheckUpdateLink || this.tryGetHref(this.trackedLink) !== this.originalHref)
) {
// If cursor has moved out of previously tracked link
// update link href if display text doesn't match href anymore.
if (shouldCheckUpdateLink) {
this.updateLinkHrefIfShouldUpdate();
}
// If the link's href value was edited, or the cursor has moved out of the
// previously tracked link, stop tracking the link.
this.resetLinkTracking();
}
// Cache link and href value if its href attribute currently matches its display text
if (!this.trackedLink && anchor && this.doesLinkDisplayMatchHref(anchor)) {
this.trackedLink = anchor;
this.originalHref = this.tryGetHref(anchor);
}
}
if (event.eventType == PluginEventType.MouseUp) {
const anchor = this.editor?.getElementAtCursor(
'A',
<Node>event.rawEvent.srcElement
) as HTMLAnchorElement | null;
if (anchor) {
if (this.onLinkClick && this.onLinkClick(anchor, event.rawEvent) !== false) {
return;
}
let href: string | null;
if (
(href = this.tryGetHref(anchor)) &&
isCtrlOrMetaPressed(event.rawEvent) &&
event.rawEvent.button === 0
) {
event.rawEvent.preventDefault();
try {
const target = this.target || '_blank';
const window = this.editor?.getDocument().defaultView;
window?.open(href, target);
} catch {}
}
}
}
}
/**
* Try get href from an anchor element
* The reason this is put in a try-catch is that
* it has been seen that accessing href may throw an exception, in particular on IE/Edge
*/
private tryGetHref(anchor: HTMLAnchorElement): string | null {
try {
return anchor ? anchor.href : null;
} catch {
return null;
}
}
/**
* Determines if KeyboardEvent is meant to edit content
*/
private isContentEditValue(event: KeyboardEvent): boolean {
return (
isCharacterValue(event) || event.which == Keys.BACKSPACE || event.which == Keys.DELETE
);
}
/**
* Updates the href of the tracked link if the display text doesn't match href anymore
*/
private updateLinkHrefIfShouldUpdate() {
if (this.trackedLink && !this.doesLinkDisplayMatchHref(this.trackedLink)) {
this.updateLinkHref();
}
}
/**
* Clears the tracked link and its original href value so that it's back to default state
*/
private resetLinkTracking() {
this.trackedLink = null;
this.originalHref = '';
}
/**
* Compares the normalized URL of inner text of element to its href to see if they match.
*/
private doesLinkDisplayMatchHref(element: HTMLAnchorElement): boolean {
if (element) {
const display = element.innerText.trim();
// We first escape the display text so that any text passed into the regex is not
// treated as a special character.
const escapedDisplay = display.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const rule = new RegExp(`^(?:https?:\\/\\/)?${escapedDisplay}\\/?`, 'i');
const href = this.tryGetHref(element);
if (href !== null) {
return rule.test(href);
}
}
return false;
}
/**
* Update href of an element in place to new display text if it's a valid URL
*/
private updateLinkHref() {
if (this.trackedLink) {
const linkData = matchLink(this.trackedLink.innerText.trim());
if (linkData !== null) {
this.editor?.addUndoSnapshot(() => {
this.trackedLink!.href = linkData!.normalizedUrl;
});
}
}
}
}