@plannotator/web-highlighter 0.8.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/.cursor/environment.json +6 -0
- package/.eslintrc.js +250 -0
- package/.prettierrc +9 -0
- package/.travis.yml +17 -0
- package/CHANGELOG.md +220 -0
- package/LICENSE +21 -0
- package/README.md +371 -0
- package/README.zh_CN.md +367 -0
- package/config/base.config.js +25 -0
- package/config/base.example.config.js +38 -0
- package/config/paths.js +22 -0
- package/config/server.config.js +17 -0
- package/config/webpack.config.dev.js +18 -0
- package/config/webpack.config.example.js +20 -0
- package/config/webpack.config.prod.js +28 -0
- package/dist/data/cache.d.ts +13 -0
- package/dist/index.d.ts +58 -0
- package/dist/model/range/dom.d.ts +6 -0
- package/dist/model/range/index.d.ts +20 -0
- package/dist/model/range/selection.d.ts +14 -0
- package/dist/model/source/dom.d.ts +23 -0
- package/dist/model/source/index.d.ts +18 -0
- package/dist/painter/dom.d.ts +22 -0
- package/dist/painter/index.d.ts +19 -0
- package/dist/painter/style.d.ts +1 -0
- package/dist/types/index.d.ts +102 -0
- package/dist/util/camel.d.ts +5 -0
- package/dist/util/const.d.ts +41 -0
- package/dist/util/deferred.d.ts +9 -0
- package/dist/util/dom.d.ts +32 -0
- package/dist/util/event.emitter.d.ts +13 -0
- package/dist/util/hook.d.ts +15 -0
- package/dist/util/interaction.d.ts +6 -0
- package/dist/util/is.mobile.d.ts +5 -0
- package/dist/util/tool.d.ts +4 -0
- package/dist/util/uuid.d.ts +4 -0
- package/dist/web-highlighter.min.js +3 -0
- package/dist/web-highlighter.min.js.map +1 -0
- package/docs/ADVANCE.md +113 -0
- package/docs/ADVANCE.zh_CN.md +111 -0
- package/docs/img/create-flow.jpg +0 -0
- package/docs/img/create-flow.zh_CN.jpg +0 -0
- package/docs/img/logo.png +0 -0
- package/docs/img/remove-flow.jpg +0 -0
- package/docs/img/remove-flow.zh_CN.jpg +0 -0
- package/docs/img/sample.gif +0 -0
- package/example/index.css +2 -0
- package/example/index.js +214 -0
- package/example/local.store.js +72 -0
- package/example/my.css +119 -0
- package/example/tpl.html +59 -0
- package/package.json +103 -0
- package/script/build.js +17 -0
- package/script/convet-md.js +25 -0
- package/script/dev.js +22 -0
- package/src/data/cache.ts +57 -0
- package/src/index.ts +285 -0
- package/src/model/range/dom.ts +94 -0
- package/src/model/range/index.ts +88 -0
- package/src/model/range/selection.ts +55 -0
- package/src/model/source/dom.ts +66 -0
- package/src/model/source/index.ts +54 -0
- package/src/painter/dom.ts +345 -0
- package/src/painter/index.ts +199 -0
- package/src/painter/style.ts +21 -0
- package/src/types/index.ts +118 -0
- package/src/util/camel.ts +6 -0
- package/src/util/const.ts +54 -0
- package/src/util/deferred.ts +37 -0
- package/src/util/dom.ts +155 -0
- package/src/util/event.emitter.ts +45 -0
- package/src/util/hook.ts +52 -0
- package/src/util/interaction.ts +20 -0
- package/src/util/is.mobile.ts +7 -0
- package/src/util/tool.ts +14 -0
- package/src/util/uuid.ts +10 -0
- package/test/api.spec.ts +555 -0
- package/test/event.spec.ts +284 -0
- package/test/fixtures/broken.json +32 -0
- package/test/fixtures/index.html +11 -0
- package/test/fixtures/source.json +47 -0
- package/test/hook.spec.ts +244 -0
- package/test/integrate.spec.ts +48 -0
- package/test/mobile.spec.ts +87 -0
- package/test/option.spec.ts +212 -0
- package/test/util.spec.ts +244 -0
- package/test-newlines.html +226 -0
- package/tsconfig.json +23 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import type { DomNode, DomMeta, HookMap, HighlighterOptions } from '@src/types';
|
|
2
|
+
import EventEmitter from '@src/util/event.emitter';
|
|
3
|
+
import HighlightRange from '@src/model/range';
|
|
4
|
+
import { getTextFromRange } from '@src/model/range/selection';
|
|
5
|
+
import HighlightSource from '@src/model/source';
|
|
6
|
+
import uuid from '@src/util/uuid';
|
|
7
|
+
import Hook from '@src/util/hook';
|
|
8
|
+
import getInteraction from '@src/util/interaction';
|
|
9
|
+
import Cache from '@src/data/cache';
|
|
10
|
+
import Painter from '@src/painter';
|
|
11
|
+
import { eventEmitter, getDefaultOptions, INTERNAL_ERROR_EVENT } from '@src/util/const';
|
|
12
|
+
import { ERROR, EventType, CreateFrom } from '@src/types';
|
|
13
|
+
import {
|
|
14
|
+
addClass,
|
|
15
|
+
removeClass,
|
|
16
|
+
isHighlightWrapNode,
|
|
17
|
+
getHighlightById,
|
|
18
|
+
getExtraHighlightId,
|
|
19
|
+
getHighlightsByRoot,
|
|
20
|
+
getHighlightId,
|
|
21
|
+
addEventListener,
|
|
22
|
+
removeEventListener,
|
|
23
|
+
} from '@src/util/dom';
|
|
24
|
+
|
|
25
|
+
interface EventHandlerMap {
|
|
26
|
+
[key: string]: (...args: any[]) => void;
|
|
27
|
+
[EventType.CLICK]: (data: { id: string }, h: Highlighter, e: MouseEvent | TouchEvent) => void;
|
|
28
|
+
[EventType.HOVER]: (data: { id: string }, h: Highlighter, e: MouseEvent | TouchEvent) => void;
|
|
29
|
+
[EventType.HOVER_OUT]: (data: { id: string }, h: Highlighter, e: MouseEvent | TouchEvent) => void;
|
|
30
|
+
[EventType.CREATE]: (data: { sources: HighlightSource[]; type: CreateFrom }, h: Highlighter) => void;
|
|
31
|
+
[EventType.REMOVE]: (data: { ids: string[] }, h: Highlighter) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default class Highlighter extends EventEmitter<EventHandlerMap> {
|
|
35
|
+
static event = EventType;
|
|
36
|
+
|
|
37
|
+
static isHighlightWrapNode = isHighlightWrapNode;
|
|
38
|
+
|
|
39
|
+
hooks: HookMap;
|
|
40
|
+
|
|
41
|
+
painter: Painter;
|
|
42
|
+
|
|
43
|
+
cache: Cache;
|
|
44
|
+
|
|
45
|
+
private _hoverId: string;
|
|
46
|
+
|
|
47
|
+
private options: HighlighterOptions;
|
|
48
|
+
|
|
49
|
+
private readonly event = getInteraction();
|
|
50
|
+
|
|
51
|
+
constructor(options?: HighlighterOptions) {
|
|
52
|
+
super();
|
|
53
|
+
this.options = getDefaultOptions();
|
|
54
|
+
// initialize hooks
|
|
55
|
+
this.hooks = this._getHooks();
|
|
56
|
+
this.setOption(options);
|
|
57
|
+
// initialize cache
|
|
58
|
+
this.cache = new Cache();
|
|
59
|
+
|
|
60
|
+
const $root = this.options.$root;
|
|
61
|
+
|
|
62
|
+
// initialize event listener
|
|
63
|
+
addEventListener($root, this.event.PointerOver, this._handleHighlightHover);
|
|
64
|
+
// initialize event listener
|
|
65
|
+
addEventListener($root, this.event.PointerTap, this._handleHighlightClick);
|
|
66
|
+
eventEmitter.on(INTERNAL_ERROR_EVENT, this._handleError);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
70
|
+
static isHighlightSource = (d: any) => !!d.__isHighlightSource;
|
|
71
|
+
|
|
72
|
+
run = () => addEventListener(this.options.$root, this.event.PointerEnd, this._handleSelection);
|
|
73
|
+
|
|
74
|
+
stop = () => {
|
|
75
|
+
removeEventListener(this.options.$root, this.event.PointerEnd, this._handleSelection);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
addClass = (className: string, id?: string) => {
|
|
79
|
+
this.getDoms(id).forEach($n => {
|
|
80
|
+
addClass($n, className);
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
removeClass = (className: string, id?: string) => {
|
|
85
|
+
this.getDoms(id).forEach($n => {
|
|
86
|
+
removeClass($n, className);
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
getIdByDom = ($node: HTMLElement): string => getHighlightId($node, this.options.$root);
|
|
91
|
+
|
|
92
|
+
getExtraIdByDom = ($node: HTMLElement): string[] => getExtraHighlightId($node, this.options.$root);
|
|
93
|
+
|
|
94
|
+
getDoms = (id?: string): HTMLElement[] =>
|
|
95
|
+
id
|
|
96
|
+
? getHighlightById(this.options.$root, id, this.options.wrapTag)
|
|
97
|
+
: getHighlightsByRoot(this.options.$root, this.options.wrapTag);
|
|
98
|
+
|
|
99
|
+
dispose = () => {
|
|
100
|
+
const $root = this.options.$root;
|
|
101
|
+
|
|
102
|
+
removeEventListener($root, this.event.PointerOver, this._handleHighlightHover);
|
|
103
|
+
removeEventListener($root, this.event.PointerEnd, this._handleSelection);
|
|
104
|
+
removeEventListener($root, this.event.PointerTap, this._handleHighlightClick);
|
|
105
|
+
this.removeAll();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
setOption = (options?: HighlighterOptions) => {
|
|
109
|
+
this.options = {
|
|
110
|
+
...this.options,
|
|
111
|
+
...options,
|
|
112
|
+
};
|
|
113
|
+
this.painter = new Painter(
|
|
114
|
+
{
|
|
115
|
+
$root: this.options.$root,
|
|
116
|
+
wrapTag: this.options.wrapTag,
|
|
117
|
+
className: this.options.style.className,
|
|
118
|
+
exceptSelectors: this.options.exceptSelectors,
|
|
119
|
+
},
|
|
120
|
+
this.hooks,
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
fromRange = (range: Range): HighlightSource => {
|
|
125
|
+
const start: DomNode = {
|
|
126
|
+
$node: range.startContainer,
|
|
127
|
+
offset: range.startOffset,
|
|
128
|
+
};
|
|
129
|
+
const end: DomNode = {
|
|
130
|
+
$node: range.endContainer,
|
|
131
|
+
offset: range.endOffset,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Use getTextFromRange for proper newline handling between block elements
|
|
135
|
+
const text = getTextFromRange(range);
|
|
136
|
+
let id = this.hooks.Render.UUID.call(start, end, text);
|
|
137
|
+
|
|
138
|
+
id = typeof id !== 'undefined' && id !== null ? id : uuid();
|
|
139
|
+
|
|
140
|
+
const hRange = new HighlightRange(start, end, text, id);
|
|
141
|
+
|
|
142
|
+
if (!hRange) {
|
|
143
|
+
eventEmitter.emit(INTERNAL_ERROR_EVENT, {
|
|
144
|
+
type: ERROR.RANGE_INVALID,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return this._highlightFromHRange(hRange);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
fromStore = (start: DomMeta, end: DomMeta, text: string, id: string, extra?: unknown): HighlightSource => {
|
|
154
|
+
const hs = new HighlightSource(start, end, text, id, extra);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
this._highlightFromHSource(hs);
|
|
158
|
+
|
|
159
|
+
return hs;
|
|
160
|
+
} catch (err: unknown) {
|
|
161
|
+
eventEmitter.emit(INTERNAL_ERROR_EVENT, {
|
|
162
|
+
type: ERROR.HIGHLIGHT_SOURCE_RECREATE,
|
|
163
|
+
error: err,
|
|
164
|
+
detail: hs,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
remove(id: string) {
|
|
172
|
+
if (!id) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const doseExist = this.painter.removeHighlight(id);
|
|
177
|
+
|
|
178
|
+
this.cache.remove(id);
|
|
179
|
+
|
|
180
|
+
// only emit REMOVE event when highlight exist
|
|
181
|
+
if (doseExist) {
|
|
182
|
+
this.emit(EventType.REMOVE, { ids: [id] }, this);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
removeAll() {
|
|
187
|
+
this.painter.removeAllHighlight();
|
|
188
|
+
|
|
189
|
+
const ids = this.cache.removeAll();
|
|
190
|
+
|
|
191
|
+
this.emit(EventType.REMOVE, { ids }, this);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private readonly _getHooks = (): HookMap => ({
|
|
195
|
+
Render: {
|
|
196
|
+
UUID: new Hook('Render.UUID'),
|
|
197
|
+
SelectedNodes: new Hook('Render.SelectedNodes'),
|
|
198
|
+
WrapNode: new Hook('Render.WrapNode'),
|
|
199
|
+
},
|
|
200
|
+
Serialize: {
|
|
201
|
+
Restore: new Hook('Serialize.Restore'),
|
|
202
|
+
RecordInfo: new Hook('Serialize.RecordInfo'),
|
|
203
|
+
},
|
|
204
|
+
Remove: {
|
|
205
|
+
UpdateNodes: new Hook('Remove.UpdateNodes'),
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
private readonly _highlightFromHRange = (range: HighlightRange): HighlightSource => {
|
|
210
|
+
const source: HighlightSource = range.serialize(this.options.$root, this.hooks);
|
|
211
|
+
const $wraps = this.painter.highlightRange(range);
|
|
212
|
+
|
|
213
|
+
if ($wraps.length === 0) {
|
|
214
|
+
eventEmitter.emit(INTERNAL_ERROR_EVENT, {
|
|
215
|
+
type: ERROR.DOM_SELECTION_EMPTY,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.cache.save(source);
|
|
222
|
+
this.emit(EventType.CREATE, { sources: [source], type: CreateFrom.INPUT }, this);
|
|
223
|
+
|
|
224
|
+
return source;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
private _highlightFromHSource(sources: HighlightSource | HighlightSource[] = []) {
|
|
228
|
+
const renderedSources: HighlightSource[] = this.painter.highlightSource(sources);
|
|
229
|
+
|
|
230
|
+
this.emit(EventType.CREATE, { sources: renderedSources, type: CreateFrom.STORE }, this);
|
|
231
|
+
this.cache.save(sources);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private readonly _handleSelection = () => {
|
|
235
|
+
const range = HighlightRange.fromSelection(this.hooks.Render.UUID);
|
|
236
|
+
|
|
237
|
+
if (range) {
|
|
238
|
+
this._highlightFromHRange(range);
|
|
239
|
+
HighlightRange.removeDomRange();
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
private readonly _handleHighlightHover = (e: MouseEvent | TouchEvent) => {
|
|
244
|
+
const $target = e.target as HTMLElement;
|
|
245
|
+
|
|
246
|
+
if (!isHighlightWrapNode($target)) {
|
|
247
|
+
this._hoverId && this.emit(EventType.HOVER_OUT, { id: this._hoverId }, this, e);
|
|
248
|
+
this._hoverId = null;
|
|
249
|
+
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const id = getHighlightId($target, this.options.$root);
|
|
254
|
+
|
|
255
|
+
// prevent trigger in the same highlight range
|
|
256
|
+
if (this._hoverId === id) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// hover another highlight range, need to trigger previous highlight hover out event
|
|
261
|
+
if (this._hoverId) {
|
|
262
|
+
this.emit(EventType.HOVER_OUT, { id: this._hoverId }, this, e);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
this._hoverId = id;
|
|
266
|
+
this.emit(EventType.HOVER, { id: this._hoverId }, this, e);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
private readonly _handleError = (type: { type: ERROR; detail?: HighlightSource; error?: any }) => {
|
|
270
|
+
if (this.options.verbose) {
|
|
271
|
+
// eslint-disable-next-line no-console
|
|
272
|
+
console.warn(type);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
private readonly _handleHighlightClick = (e: MouseEvent | TouchEvent) => {
|
|
277
|
+
const $target = e.target as HTMLElement;
|
|
278
|
+
|
|
279
|
+
if (isHighlightWrapNode($target)) {
|
|
280
|
+
const id = getHighlightId($target, this.options.$root);
|
|
281
|
+
|
|
282
|
+
this.emit(EventType.CLICK, { id }, this, e);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* some dom operations about HighlightRange
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DomMeta, DomNode } from '@src/types';
|
|
6
|
+
import { CAMEL_DATASET_IDENTIFIER, ROOT_IDX, UNKNOWN_IDX } from '@src/util/const';
|
|
7
|
+
|
|
8
|
+
const countGlobalNodeIndex = ($node: Node, $root: Document | HTMLElement): number => {
|
|
9
|
+
const tagName = ($node as HTMLElement).tagName;
|
|
10
|
+
const $list = $root.getElementsByTagName(tagName);
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < $list.length; i++) {
|
|
13
|
+
if ($node === $list[i]) {
|
|
14
|
+
return i;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return UNKNOWN_IDX;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* text total length in all predecessors (text nodes) in the root node
|
|
23
|
+
* (without offset in current node)
|
|
24
|
+
*/
|
|
25
|
+
const getTextPreOffset = ($root: Node, $text: Node): number => {
|
|
26
|
+
const nodeStack: Node[] = [$root];
|
|
27
|
+
|
|
28
|
+
let $curNode: Node = null;
|
|
29
|
+
let offset = 0;
|
|
30
|
+
|
|
31
|
+
while (($curNode = nodeStack.pop())) {
|
|
32
|
+
const children = $curNode.childNodes;
|
|
33
|
+
|
|
34
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
35
|
+
nodeStack.push(children[i]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if ($curNode.nodeType === 3 && $curNode !== $text) {
|
|
39
|
+
offset += $curNode.textContent.length;
|
|
40
|
+
} else if ($curNode.nodeType === 3) {
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return offset;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* find the original dom parent node (none highlight dom)
|
|
50
|
+
*/
|
|
51
|
+
const getOriginParent = ($node: HTMLElement | Text): HTMLElement => {
|
|
52
|
+
if ($node instanceof HTMLElement && (!$node.dataset || !$node.dataset[CAMEL_DATASET_IDENTIFIER])) {
|
|
53
|
+
return $node;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let $originParent = $node.parentNode as HTMLElement;
|
|
57
|
+
|
|
58
|
+
while ($originParent?.dataset[CAMEL_DATASET_IDENTIFIER]) {
|
|
59
|
+
$originParent = $originParent.parentNode as HTMLElement;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return $originParent;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const getDomMeta = ($node: HTMLElement | Text, offset: number, $root: Document | HTMLElement): DomMeta => {
|
|
66
|
+
const $originParent = getOriginParent($node);
|
|
67
|
+
const index = $originParent === $root ? ROOT_IDX : countGlobalNodeIndex($originParent, $root);
|
|
68
|
+
const preNodeOffset = getTextPreOffset($originParent, $node);
|
|
69
|
+
const tagName = $originParent.tagName;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
parentTagName: tagName,
|
|
73
|
+
parentIndex: index,
|
|
74
|
+
textOffset: preNodeOffset + offset,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const formatDomNode = (n: DomNode): DomNode => {
|
|
79
|
+
if (
|
|
80
|
+
// Text
|
|
81
|
+
n.$node.nodeType === 3 ||
|
|
82
|
+
// CDATASection
|
|
83
|
+
n.$node.nodeType === 4 ||
|
|
84
|
+
// Comment
|
|
85
|
+
n.$node.nodeType === 8
|
|
86
|
+
) {
|
|
87
|
+
return n;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
$node: n.$node.childNodes[n.offset],
|
|
92
|
+
offset: 0,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* the HighlightRange Class(HRange)
|
|
3
|
+
* It's a special object called HRange in Highlighter,
|
|
4
|
+
* represents for a piece of chosen dom
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DomNode, HookMap } from '@src/types';
|
|
8
|
+
import type Hook from '@src/util/hook';
|
|
9
|
+
import HighlightSource from '@src/model/source/index';
|
|
10
|
+
import { ERROR } from '@src/types';
|
|
11
|
+
import { getDomRange, removeSelection } from '@src/model/range/selection';
|
|
12
|
+
import uuid from '@src/util/uuid';
|
|
13
|
+
import { getDomMeta, formatDomNode } from '@src/model/range/dom';
|
|
14
|
+
import { eventEmitter, INTERNAL_ERROR_EVENT } from '@src/util/const';
|
|
15
|
+
|
|
16
|
+
class HighlightRange {
|
|
17
|
+
static removeDomRange = removeSelection;
|
|
18
|
+
|
|
19
|
+
start: DomNode;
|
|
20
|
+
|
|
21
|
+
end: DomNode;
|
|
22
|
+
|
|
23
|
+
text: string;
|
|
24
|
+
|
|
25
|
+
id: string;
|
|
26
|
+
|
|
27
|
+
frozen: boolean;
|
|
28
|
+
|
|
29
|
+
constructor(start: DomNode, end: DomNode, text: string, id: string, frozen = false) {
|
|
30
|
+
if (start.$node.nodeType !== 3 || end.$node.nodeType !== 3) {
|
|
31
|
+
eventEmitter.emit(INTERNAL_ERROR_EVENT, {
|
|
32
|
+
type: ERROR.RANGE_NODE_INVALID,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.start = formatDomNode(start);
|
|
37
|
+
this.end = formatDomNode(end);
|
|
38
|
+
this.text = text;
|
|
39
|
+
this.frozen = frozen;
|
|
40
|
+
this.id = id;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static fromSelection(idHook: Hook<string>) {
|
|
44
|
+
const range = getDomRange();
|
|
45
|
+
|
|
46
|
+
if (!range) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const start: DomNode = {
|
|
51
|
+
$node: range.startContainer,
|
|
52
|
+
offset: range.startOffset,
|
|
53
|
+
};
|
|
54
|
+
const end: DomNode = {
|
|
55
|
+
$node: range.endContainer,
|
|
56
|
+
offset: range.endOffset,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Use Selection.toString() for proper newline handling between block elements
|
|
60
|
+
// Range.toString() includes HTML source whitespace, Selection.toString() gives clean output
|
|
61
|
+
const selection = window.getSelection();
|
|
62
|
+
const text = selection?.toString() || range.toString();
|
|
63
|
+
let id = idHook.call(start, end, text);
|
|
64
|
+
|
|
65
|
+
id = typeof id !== 'undefined' && id !== null ? id : uuid();
|
|
66
|
+
|
|
67
|
+
return new HighlightRange(start, end, text, id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// serialize the HRange instance
|
|
71
|
+
// so that you can save the returned object (e.g. use JSON.stringify on it and send to backend)
|
|
72
|
+
serialize($root: Document | HTMLElement, hooks: HookMap): HighlightSource {
|
|
73
|
+
const startMeta = getDomMeta(this.start.$node as Text, this.start.offset, $root);
|
|
74
|
+
const endMeta = getDomMeta(this.end.$node as Text, this.end.offset, $root);
|
|
75
|
+
|
|
76
|
+
let extra;
|
|
77
|
+
|
|
78
|
+
if (!hooks.Serialize.RecordInfo.isEmpty()) {
|
|
79
|
+
extra = hooks.Serialize.RecordInfo.call(this.start, this.end, $root);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.frozen = true;
|
|
83
|
+
|
|
84
|
+
return new HighlightSource(startMeta, endMeta, this.text, this.id, extra);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default HighlightRange;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Something about the Selection/Range API in browsers.
|
|
3
|
+
* If you want to use Highlighter in some old browsers, you may use a polyfill.
|
|
4
|
+
* https://caniuse.com/#search=selection
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const getDomRange = (): Range => {
|
|
8
|
+
const selection = window.getSelection();
|
|
9
|
+
|
|
10
|
+
if (selection.isCollapsed) {
|
|
11
|
+
// eslint-disable-next-line no-console
|
|
12
|
+
console.debug('no text selected');
|
|
13
|
+
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return selection.getRangeAt(0);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const removeSelection = (): void => {
|
|
21
|
+
window.getSelection().removeAllRanges();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get text from a Range with proper newline handling for block elements.
|
|
26
|
+
* Uses innerText on a cloned fragment to get clean output matching Selection.toString().
|
|
27
|
+
* Range.toString() includes HTML source whitespace, this method gives user-expected output.
|
|
28
|
+
* Falls back to range.toString() in environments where innerText isn't available (e.g. jsdom).
|
|
29
|
+
*/
|
|
30
|
+
export const getTextFromRange = (range: Range): string => {
|
|
31
|
+
// Try innerText approach for proper newline handling in real browsers
|
|
32
|
+
try {
|
|
33
|
+
const fragment = range.cloneContents();
|
|
34
|
+
const temp = document.createElement('div');
|
|
35
|
+
|
|
36
|
+
// Position off-screen but keep visible - innerText returns empty for hidden elements!
|
|
37
|
+
temp.style.cssText = 'position:absolute;left:-9999px;top:-9999px;';
|
|
38
|
+
document.body.appendChild(temp);
|
|
39
|
+
temp.appendChild(fragment);
|
|
40
|
+
|
|
41
|
+
const text = temp.innerText;
|
|
42
|
+
|
|
43
|
+
temp.remove();
|
|
44
|
+
|
|
45
|
+
// innerText may be undefined in jsdom, or empty if something went wrong
|
|
46
|
+
if (text) {
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
} catch (e: unknown) {
|
|
50
|
+
// Fall through to fallback
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fallback to range.toString() (loses proper newline handling but works everywhere)
|
|
54
|
+
return range.toString();
|
|
55
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { DomNode } from '@src//types';
|
|
2
|
+
import type HighlightSource from '@src/model/source/index';
|
|
3
|
+
import { ROOT_IDX } from '@src/util/const';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Because of supporting highlighting a same area (range overlapping),
|
|
7
|
+
* Highlighter will calculate which text-node and how much offset it actually be,
|
|
8
|
+
* based on the origin website dom node and the text offset.
|
|
9
|
+
*
|
|
10
|
+
* @param {Node} $parent element node in the origin website dom tree
|
|
11
|
+
* @param {number} offset text offset in the origin website dom tree
|
|
12
|
+
* @return {DomNode} DOM a dom info object
|
|
13
|
+
*/
|
|
14
|
+
export const getTextChildByOffset = ($parent: Node, offset: number): DomNode => {
|
|
15
|
+
const nodeStack: Node[] = [$parent];
|
|
16
|
+
|
|
17
|
+
let $curNode: Node = null;
|
|
18
|
+
let curOffset = 0;
|
|
19
|
+
let startOffset = 0;
|
|
20
|
+
|
|
21
|
+
while (($curNode = nodeStack.pop())) {
|
|
22
|
+
const children = $curNode.childNodes;
|
|
23
|
+
|
|
24
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
25
|
+
nodeStack.push(children[i]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if ($curNode.nodeType === 3) {
|
|
29
|
+
startOffset = offset - curOffset;
|
|
30
|
+
curOffset += $curNode.textContent.length;
|
|
31
|
+
|
|
32
|
+
if (curOffset >= offset) {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!$curNode) {
|
|
39
|
+
$curNode = $parent;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
$node: $curNode,
|
|
44
|
+
offset: startOffset,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* get start and end parent element from meta info
|
|
50
|
+
*
|
|
51
|
+
* @param {HighlightSource} hs
|
|
52
|
+
* @param {HTMLElement | Document} $root root element, default document
|
|
53
|
+
* @return {Object}
|
|
54
|
+
*/
|
|
55
|
+
export const queryElementNode = (hs: HighlightSource, $root: Document | HTMLElement): { start: Node; end: Node } => {
|
|
56
|
+
const start =
|
|
57
|
+
hs.startMeta.parentIndex === ROOT_IDX
|
|
58
|
+
? $root
|
|
59
|
+
: $root.getElementsByTagName(hs.startMeta.parentTagName)[hs.startMeta.parentIndex];
|
|
60
|
+
const end =
|
|
61
|
+
hs.endMeta.parentIndex === ROOT_IDX
|
|
62
|
+
? $root
|
|
63
|
+
: $root.getElementsByTagName(hs.endMeta.parentTagName)[hs.endMeta.parentIndex];
|
|
64
|
+
|
|
65
|
+
return { start, end };
|
|
66
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HighlightSource Class (HSource)
|
|
3
|
+
* This Object can be deSerialized to HRange.
|
|
4
|
+
* Also it has the ability for persistence.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DomMeta, HookMap, DomNode } from '@src/types';
|
|
8
|
+
import HighlightRange from '@src/model/range/index';
|
|
9
|
+
import { queryElementNode, getTextChildByOffset } from '@src/model/source/dom';
|
|
10
|
+
|
|
11
|
+
class HighlightSource {
|
|
12
|
+
startMeta: DomMeta;
|
|
13
|
+
|
|
14
|
+
endMeta: DomMeta;
|
|
15
|
+
|
|
16
|
+
text: string;
|
|
17
|
+
|
|
18
|
+
id: string;
|
|
19
|
+
|
|
20
|
+
extra?: unknown;
|
|
21
|
+
|
|
22
|
+
__isHighlightSource: unknown;
|
|
23
|
+
|
|
24
|
+
constructor(startMeta: DomMeta, endMeta: DomMeta, text: string, id: string, extra?: unknown) {
|
|
25
|
+
this.startMeta = startMeta;
|
|
26
|
+
this.endMeta = endMeta;
|
|
27
|
+
this.text = text;
|
|
28
|
+
this.id = id;
|
|
29
|
+
this.__isHighlightSource = {};
|
|
30
|
+
|
|
31
|
+
if (extra) {
|
|
32
|
+
this.extra = extra;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
deSerialize($root: Document | HTMLElement, hooks: HookMap): HighlightRange {
|
|
37
|
+
const { start, end } = queryElementNode(this, $root);
|
|
38
|
+
let startInfo = getTextChildByOffset(start, this.startMeta.textOffset);
|
|
39
|
+
let endInfo = getTextChildByOffset(end, this.endMeta.textOffset);
|
|
40
|
+
|
|
41
|
+
if (!hooks.Serialize.Restore.isEmpty()) {
|
|
42
|
+
const res: DomNode[] = hooks.Serialize.Restore.call(this, startInfo, endInfo) || [];
|
|
43
|
+
|
|
44
|
+
startInfo = res[0] || startInfo;
|
|
45
|
+
endInfo = res[1] || endInfo;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const range = new HighlightRange(startInfo, endInfo, this.text, this.id, true);
|
|
49
|
+
|
|
50
|
+
return range;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default HighlightSource;
|