@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
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import type HighlightRange from '@src/model/range';
|
|
2
|
+
import type { SelectedNode, DomNode } from '@src/types';
|
|
3
|
+
import { SplitType, SelectedNodeType } from '@src/types';
|
|
4
|
+
import { hasClass, addClass as addElementClass, isHighlightWrapNode, removeAllClass } from '@src/util/dom';
|
|
5
|
+
import {
|
|
6
|
+
ID_DIVISION,
|
|
7
|
+
getDefaultOptions,
|
|
8
|
+
CAMEL_DATASET_IDENTIFIER,
|
|
9
|
+
CAMEL_DATASET_IDENTIFIER_EXTRA,
|
|
10
|
+
DATASET_IDENTIFIER,
|
|
11
|
+
DATASET_SPLIT_TYPE,
|
|
12
|
+
DATASET_IDENTIFIER_EXTRA,
|
|
13
|
+
} from '../util/const';
|
|
14
|
+
import { unique } from '../util/tool';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 支持的选择器类型
|
|
18
|
+
* - class: .title, .main-nav
|
|
19
|
+
* - id: #nav, #js-toggle-btn
|
|
20
|
+
* - tag: div, p, span
|
|
21
|
+
*/
|
|
22
|
+
const isMatchSelector = ($node: HTMLElement, selector: string): boolean => {
|
|
23
|
+
if (!$node) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (/^\./.test(selector)) {
|
|
28
|
+
const className = selector.replace(/^\./, '');
|
|
29
|
+
|
|
30
|
+
return $node && hasClass($node, className);
|
|
31
|
+
} else if (/^#/.test(selector)) {
|
|
32
|
+
const id = selector.replace(/^#/, '');
|
|
33
|
+
|
|
34
|
+
return $node && $node.id === id;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tagName = selector.toUpperCase();
|
|
38
|
+
|
|
39
|
+
return $node && $node.tagName === tagName;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* If start node and end node is the same, don't need to tranvers the dom tree.
|
|
44
|
+
*/
|
|
45
|
+
const getNodesIfSameStartEnd = (
|
|
46
|
+
$startNode: Text,
|
|
47
|
+
startOffset: number,
|
|
48
|
+
endOffset: number,
|
|
49
|
+
exceptSelectors?: string[],
|
|
50
|
+
) => {
|
|
51
|
+
let $element = $startNode as Node;
|
|
52
|
+
|
|
53
|
+
const isExcepted = ($e: HTMLElement) => exceptSelectors?.some(s => isMatchSelector($e, s));
|
|
54
|
+
|
|
55
|
+
while ($element) {
|
|
56
|
+
if ($element.nodeType === 1 && isExcepted($element as HTMLElement)) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
$element = $element.parentNode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
$startNode.splitText(startOffset);
|
|
64
|
+
|
|
65
|
+
const passedNode = $startNode.nextSibling as Text;
|
|
66
|
+
|
|
67
|
+
passedNode.splitText(endOffset - startOffset);
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
$node: passedNode,
|
|
72
|
+
type: SelectedNodeType.text,
|
|
73
|
+
splitType: SplitType.both,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* get all the dom nodes between the start and end node
|
|
80
|
+
*/
|
|
81
|
+
export const getSelectedNodes = (
|
|
82
|
+
$root: Document | HTMLElement,
|
|
83
|
+
start: DomNode,
|
|
84
|
+
end: DomNode,
|
|
85
|
+
exceptSelectors: string[],
|
|
86
|
+
): SelectedNode[] => {
|
|
87
|
+
const $startNode = start.$node;
|
|
88
|
+
const $endNode = end.$node;
|
|
89
|
+
const startOffset = start.offset;
|
|
90
|
+
const endOffset = end.offset;
|
|
91
|
+
|
|
92
|
+
// split current node when the start-node and end-node is the same
|
|
93
|
+
if ($startNode === $endNode && $startNode instanceof Text) {
|
|
94
|
+
return getNodesIfSameStartEnd($startNode, startOffset, endOffset, exceptSelectors);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const nodeStack: (ChildNode | Document | HTMLElement | Text)[] = [$root];
|
|
98
|
+
const selectedNodes: SelectedNode[] = [];
|
|
99
|
+
|
|
100
|
+
const isExcepted = ($e: HTMLElement) => exceptSelectors?.some(s => isMatchSelector($e, s));
|
|
101
|
+
|
|
102
|
+
let withinSelectedRange = false;
|
|
103
|
+
let curNode: Node = null;
|
|
104
|
+
|
|
105
|
+
while ((curNode = nodeStack.pop())) {
|
|
106
|
+
// do not traverse the excepted node
|
|
107
|
+
if (curNode.nodeType === 1 && isExcepted(curNode as HTMLElement)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const children = curNode.childNodes;
|
|
112
|
+
|
|
113
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
114
|
+
nodeStack.push(children[i]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// only collect text nodes
|
|
118
|
+
if (curNode === $startNode) {
|
|
119
|
+
if (curNode.nodeType === 3) {
|
|
120
|
+
(curNode as Text).splitText(startOffset);
|
|
121
|
+
|
|
122
|
+
const node = curNode.nextSibling as Text;
|
|
123
|
+
|
|
124
|
+
selectedNodes.push({
|
|
125
|
+
$node: node,
|
|
126
|
+
type: SelectedNodeType.text,
|
|
127
|
+
splitType: SplitType.head,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// meet the start-node (begin to traverse)
|
|
132
|
+
withinSelectedRange = true;
|
|
133
|
+
} else if (curNode === $endNode) {
|
|
134
|
+
if (curNode.nodeType === 3) {
|
|
135
|
+
const node = curNode as Text;
|
|
136
|
+
|
|
137
|
+
node.splitText(endOffset);
|
|
138
|
+
selectedNodes.push({
|
|
139
|
+
$node: node,
|
|
140
|
+
type: SelectedNodeType.text,
|
|
141
|
+
splitType: SplitType.tail,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// meet the end-node
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
// handle text nodes between the range
|
|
149
|
+
else if (withinSelectedRange && curNode.nodeType === 3) {
|
|
150
|
+
selectedNodes.push({
|
|
151
|
+
$node: curNode as Text,
|
|
152
|
+
type: SelectedNodeType.text,
|
|
153
|
+
splitType: SplitType.none,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return selectedNodes;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const addClass = ($el: HTMLElement, className?: string[] | string): HTMLElement => {
|
|
162
|
+
let classNames = Array.isArray(className) ? className : [className];
|
|
163
|
+
|
|
164
|
+
classNames = classNames.length === 0 ? [getDefaultOptions().style.className] : classNames;
|
|
165
|
+
classNames.forEach(c => {
|
|
166
|
+
addElementClass($el, c);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return $el;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const isNodeEmpty = ($n: Node): boolean => !$n || !$n.textContent;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Wrap a common wrapper.
|
|
176
|
+
*/
|
|
177
|
+
const wrapNewNode = (
|
|
178
|
+
selected: SelectedNode,
|
|
179
|
+
range: HighlightRange,
|
|
180
|
+
className: string[] | string,
|
|
181
|
+
wrapTag: string,
|
|
182
|
+
): HTMLElement => {
|
|
183
|
+
const $wrap = document.createElement(wrapTag);
|
|
184
|
+
|
|
185
|
+
addClass($wrap, className);
|
|
186
|
+
|
|
187
|
+
$wrap.appendChild(selected.$node.cloneNode(false));
|
|
188
|
+
selected.$node.parentNode.replaceChild($wrap, selected.$node);
|
|
189
|
+
|
|
190
|
+
$wrap.setAttribute(`data-${DATASET_IDENTIFIER}`, range.id);
|
|
191
|
+
$wrap.setAttribute(`data-${DATASET_SPLIT_TYPE}`, selected.splitType);
|
|
192
|
+
$wrap.setAttribute(`data-${DATASET_IDENTIFIER_EXTRA}`, '');
|
|
193
|
+
|
|
194
|
+
return $wrap;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Split and wrapper each one.
|
|
199
|
+
*/
|
|
200
|
+
const wrapPartialNode = (
|
|
201
|
+
selected: SelectedNode,
|
|
202
|
+
range: HighlightRange,
|
|
203
|
+
className: string[] | string,
|
|
204
|
+
wrapTag: string,
|
|
205
|
+
): HTMLElement => {
|
|
206
|
+
const $wrap: HTMLElement = document.createElement(wrapTag);
|
|
207
|
+
|
|
208
|
+
const $parent = selected.$node.parentNode as HTMLElement;
|
|
209
|
+
const $prev = selected.$node.previousSibling;
|
|
210
|
+
const $next = selected.$node.nextSibling;
|
|
211
|
+
const $fr = document.createDocumentFragment();
|
|
212
|
+
const parentId = $parent.dataset[CAMEL_DATASET_IDENTIFIER];
|
|
213
|
+
const parentExtraId = $parent.dataset[CAMEL_DATASET_IDENTIFIER_EXTRA];
|
|
214
|
+
const extraInfo = parentExtraId ? parentId + ID_DIVISION + parentExtraId : parentId;
|
|
215
|
+
|
|
216
|
+
$wrap.setAttribute(`data-${DATASET_IDENTIFIER}`, range.id);
|
|
217
|
+
$wrap.setAttribute(`data-${DATASET_IDENTIFIER_EXTRA}`, extraInfo);
|
|
218
|
+
$wrap.appendChild(selected.$node.cloneNode(false));
|
|
219
|
+
|
|
220
|
+
let headSplit = false;
|
|
221
|
+
let tailSplit = false;
|
|
222
|
+
let splitType: SplitType;
|
|
223
|
+
|
|
224
|
+
if ($prev) {
|
|
225
|
+
const $span = $parent.cloneNode(false);
|
|
226
|
+
|
|
227
|
+
$span.textContent = $prev.textContent;
|
|
228
|
+
$fr.appendChild($span);
|
|
229
|
+
headSplit = true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const classNameList: string[] = [];
|
|
233
|
+
|
|
234
|
+
if (Array.isArray(className)) {
|
|
235
|
+
classNameList.push(...className);
|
|
236
|
+
} else {
|
|
237
|
+
classNameList.push(className);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
addClass($wrap, unique(classNameList));
|
|
241
|
+
$fr.appendChild($wrap);
|
|
242
|
+
|
|
243
|
+
if ($next) {
|
|
244
|
+
const $span = $parent.cloneNode(false);
|
|
245
|
+
|
|
246
|
+
$span.textContent = $next.textContent;
|
|
247
|
+
$fr.appendChild($span);
|
|
248
|
+
tailSplit = true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (headSplit && tailSplit) {
|
|
252
|
+
splitType = SplitType.both;
|
|
253
|
+
} else if (headSplit) {
|
|
254
|
+
splitType = SplitType.head;
|
|
255
|
+
} else if (tailSplit) {
|
|
256
|
+
splitType = SplitType.tail;
|
|
257
|
+
} else {
|
|
258
|
+
splitType = SplitType.none;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
$wrap.setAttribute(`data-${DATASET_SPLIT_TYPE}`, splitType);
|
|
262
|
+
$parent.parentNode.replaceChild($fr, $parent);
|
|
263
|
+
|
|
264
|
+
return $wrap;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Just update id info (no wrapper updated).
|
|
269
|
+
*/
|
|
270
|
+
const wrapOverlapNode = (selected: SelectedNode, range: HighlightRange, className: string[] | string): HTMLElement => {
|
|
271
|
+
const $parent = selected.$node.parentNode as HTMLElement;
|
|
272
|
+
const $wrap: HTMLElement = $parent;
|
|
273
|
+
|
|
274
|
+
removeAllClass($wrap);
|
|
275
|
+
addClass($wrap, className);
|
|
276
|
+
|
|
277
|
+
const dataset = $parent.dataset;
|
|
278
|
+
const formerId = dataset[CAMEL_DATASET_IDENTIFIER];
|
|
279
|
+
|
|
280
|
+
dataset[CAMEL_DATASET_IDENTIFIER] = range.id;
|
|
281
|
+
dataset[CAMEL_DATASET_IDENTIFIER_EXTRA] = dataset[CAMEL_DATASET_IDENTIFIER_EXTRA]
|
|
282
|
+
? formerId + ID_DIVISION + dataset[CAMEL_DATASET_IDENTIFIER_EXTRA]
|
|
283
|
+
: formerId;
|
|
284
|
+
|
|
285
|
+
return $wrap;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* wrap a dom node with highlight wrapper
|
|
290
|
+
*
|
|
291
|
+
* Because of supporting the highlight-overlapping,
|
|
292
|
+
* Highlighter can't just wrap all nodes in a simple way.
|
|
293
|
+
* There are three types:
|
|
294
|
+
* - wrapping a whole new node (without any wrapper)
|
|
295
|
+
* - wrapping part of the node
|
|
296
|
+
* - wrapping the whole wrapped node
|
|
297
|
+
*/
|
|
298
|
+
export const wrapHighlight = (
|
|
299
|
+
selected: SelectedNode,
|
|
300
|
+
range: HighlightRange,
|
|
301
|
+
className: string[] | string,
|
|
302
|
+
wrapTag: string,
|
|
303
|
+
): HTMLElement => {
|
|
304
|
+
const $parent = selected.$node.parentNode as HTMLElement;
|
|
305
|
+
const $prev = selected.$node.previousSibling;
|
|
306
|
+
const $next = selected.$node.nextSibling;
|
|
307
|
+
|
|
308
|
+
let $wrap: HTMLElement;
|
|
309
|
+
|
|
310
|
+
// text node, not in a highlight wrapper -> should be wrapped in a highlight wrapper
|
|
311
|
+
if (!isHighlightWrapNode($parent)) {
|
|
312
|
+
$wrap = wrapNewNode(selected, range, className, wrapTag);
|
|
313
|
+
}
|
|
314
|
+
// text node, in a highlight wrap -> should split the existing highlight wrapper
|
|
315
|
+
else if (isHighlightWrapNode($parent) && (!isNodeEmpty($prev) || !isNodeEmpty($next))) {
|
|
316
|
+
$wrap = wrapPartialNode(selected, range, className, wrapTag);
|
|
317
|
+
}
|
|
318
|
+
// completely overlap (with a highlight wrap) -> only add extra id info
|
|
319
|
+
else {
|
|
320
|
+
$wrap = wrapOverlapNode(selected, range, className);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return $wrap;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* merge the adjacent text nodes
|
|
328
|
+
* .normalize() API has some bugs in IE11
|
|
329
|
+
*/
|
|
330
|
+
export const normalizeSiblingText = ($s: Node, isNext = true) => {
|
|
331
|
+
if (!$s || $s.nodeType !== 3) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const $sibling = isNext ? $s.nextSibling : $s.previousSibling;
|
|
336
|
+
|
|
337
|
+
if ($sibling.nodeType !== 3) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const text = $sibling.nodeValue;
|
|
342
|
+
|
|
343
|
+
$s.nodeValue = isNext ? $s.nodeValue + text : text + $s.nodeValue;
|
|
344
|
+
$sibling.parentNode.removeChild($sibling);
|
|
345
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Painter object is designed for some painting work about higlighting,
|
|
3
|
+
* including rendering, cleaning...
|
|
4
|
+
* No need to instantiate repeatly. A Highlighter instance will bind a Painter instance.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type HighlightRange from '@src/model/range';
|
|
8
|
+
import type { PainterOptions, HookMap } from '@src/types';
|
|
9
|
+
import HighlightSource from '@src/model/source';
|
|
10
|
+
import { wrapHighlight, getSelectedNodes, normalizeSiblingText } from '@src/painter/dom';
|
|
11
|
+
import { getHighlightsByRoot, forEach, addClass, removeAllClass } from '@src/util/dom';
|
|
12
|
+
import { ERROR } from '@src/types';
|
|
13
|
+
import { initDefaultStylesheet } from '@src/painter/style';
|
|
14
|
+
import {
|
|
15
|
+
ID_DIVISION,
|
|
16
|
+
eventEmitter,
|
|
17
|
+
DATASET_IDENTIFIER,
|
|
18
|
+
INTERNAL_ERROR_EVENT,
|
|
19
|
+
CAMEL_DATASET_IDENTIFIER,
|
|
20
|
+
CAMEL_DATASET_IDENTIFIER_EXTRA,
|
|
21
|
+
} from '@src/util/const';
|
|
22
|
+
|
|
23
|
+
export default class Painter {
|
|
24
|
+
options: PainterOptions;
|
|
25
|
+
|
|
26
|
+
$style: HTMLStyleElement;
|
|
27
|
+
|
|
28
|
+
styleId: string;
|
|
29
|
+
|
|
30
|
+
hooks: HookMap;
|
|
31
|
+
|
|
32
|
+
constructor(options: PainterOptions, hooks: HookMap) {
|
|
33
|
+
this.options = {
|
|
34
|
+
$root: options.$root,
|
|
35
|
+
wrapTag: options.wrapTag,
|
|
36
|
+
exceptSelectors: options.exceptSelectors,
|
|
37
|
+
className: options.className,
|
|
38
|
+
};
|
|
39
|
+
this.hooks = hooks;
|
|
40
|
+
|
|
41
|
+
initDefaultStylesheet();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* =========================== render =========================== */
|
|
45
|
+
highlightRange(range: HighlightRange): HTMLElement[] {
|
|
46
|
+
if (!range.frozen) {
|
|
47
|
+
throw ERROR.HIGHLIGHT_RANGE_FROZEN;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { $root, className, exceptSelectors } = this.options;
|
|
51
|
+
const hooks = this.hooks;
|
|
52
|
+
|
|
53
|
+
let $selectedNodes = getSelectedNodes($root, range.start, range.end, exceptSelectors);
|
|
54
|
+
|
|
55
|
+
if (!hooks.Render.SelectedNodes.isEmpty()) {
|
|
56
|
+
$selectedNodes = hooks.Render.SelectedNodes.call(range.id, $selectedNodes) || [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return $selectedNodes.map(n => {
|
|
60
|
+
let $node = wrapHighlight(n, range, className, this.options.wrapTag);
|
|
61
|
+
|
|
62
|
+
if (!hooks.Render.WrapNode.isEmpty()) {
|
|
63
|
+
$node = hooks.Render.WrapNode.call(range.id, $node);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return $node;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
highlightSource(sources: HighlightSource | HighlightSource[]): HighlightSource[] {
|
|
71
|
+
const list = Array.isArray(sources) ? sources : [sources];
|
|
72
|
+
|
|
73
|
+
const renderedSources: HighlightSource[] = [];
|
|
74
|
+
|
|
75
|
+
list.forEach(s => {
|
|
76
|
+
if (!(s instanceof HighlightSource)) {
|
|
77
|
+
eventEmitter.emit(INTERNAL_ERROR_EVENT, {
|
|
78
|
+
type: ERROR.SOURCE_TYPE_ERROR,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const range = s.deSerialize(this.options.$root, this.hooks);
|
|
85
|
+
const $nodes = this.highlightRange(range);
|
|
86
|
+
|
|
87
|
+
if ($nodes.length > 0) {
|
|
88
|
+
renderedSources.push(s);
|
|
89
|
+
} else {
|
|
90
|
+
eventEmitter.emit(INTERNAL_ERROR_EVENT, {
|
|
91
|
+
type: ERROR.HIGHLIGHT_SOURCE_NONE_RENDER,
|
|
92
|
+
detail: s,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return renderedSources;
|
|
98
|
+
}
|
|
99
|
+
/* ============================================================== */
|
|
100
|
+
|
|
101
|
+
/* =========================== clean =========================== */
|
|
102
|
+
// id: target id - highlight with this id should be clean
|
|
103
|
+
// if there is no highlight for this id, it will return false, vice versa
|
|
104
|
+
removeHighlight(id: string): boolean {
|
|
105
|
+
// whether extra ids contains the target id
|
|
106
|
+
const reg = new RegExp(`(${id}\\${ID_DIVISION}|\\${ID_DIVISION}?${id}$)`);
|
|
107
|
+
|
|
108
|
+
const hooks = this.hooks;
|
|
109
|
+
const wrapTag = this.options.wrapTag;
|
|
110
|
+
const $spans = document.querySelectorAll<HTMLElement>(`${wrapTag}[data-${DATASET_IDENTIFIER}]`);
|
|
111
|
+
|
|
112
|
+
// nodes to remove
|
|
113
|
+
const $toRemove: HTMLElement[] = [];
|
|
114
|
+
// nodes to update main id
|
|
115
|
+
const $idToUpdate: HTMLElement[] = [];
|
|
116
|
+
// nodes to update extra id
|
|
117
|
+
const $extraToUpdate: HTMLElement[] = [];
|
|
118
|
+
|
|
119
|
+
for (const $s of $spans) {
|
|
120
|
+
const spanId = $s.dataset[CAMEL_DATASET_IDENTIFIER];
|
|
121
|
+
const spanExtraIds = $s.dataset[CAMEL_DATASET_IDENTIFIER_EXTRA];
|
|
122
|
+
|
|
123
|
+
// main id is the target id and no extra ids --> to remove
|
|
124
|
+
if (spanId === id && !spanExtraIds) {
|
|
125
|
+
$toRemove.push($s);
|
|
126
|
+
}
|
|
127
|
+
// main id is the target id but there is some extra ids -> update main id & extra id
|
|
128
|
+
else if (spanId === id) {
|
|
129
|
+
$idToUpdate.push($s);
|
|
130
|
+
}
|
|
131
|
+
// main id isn't the target id but extra ids contains it -> just remove it from extra id
|
|
132
|
+
else if (spanId !== id && reg.test(spanExtraIds)) {
|
|
133
|
+
$extraToUpdate.push($s);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
$toRemove.forEach($s => {
|
|
138
|
+
const $parent = $s.parentNode;
|
|
139
|
+
const $fr = document.createDocumentFragment();
|
|
140
|
+
|
|
141
|
+
forEach($s.childNodes, ($c: Node) => $fr.appendChild($c.cloneNode(false)));
|
|
142
|
+
|
|
143
|
+
const $prev = $s.previousSibling;
|
|
144
|
+
const $next = $s.nextSibling;
|
|
145
|
+
|
|
146
|
+
$parent.replaceChild($fr, $s);
|
|
147
|
+
// there are bugs in IE11, so use a more reliable function
|
|
148
|
+
normalizeSiblingText($prev, true);
|
|
149
|
+
normalizeSiblingText($next, false);
|
|
150
|
+
hooks.Remove.UpdateNodes.call(id, $s, 'remove');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
$idToUpdate.forEach($s => {
|
|
154
|
+
const dataset = $s.dataset;
|
|
155
|
+
const ids = dataset[CAMEL_DATASET_IDENTIFIER_EXTRA].split(ID_DIVISION);
|
|
156
|
+
const newId = ids.shift();
|
|
157
|
+
|
|
158
|
+
// find overlapped wrapper associated with "extra id"
|
|
159
|
+
const $overlapSpan = document.querySelector<HTMLElement>(
|
|
160
|
+
`${wrapTag}[data-${DATASET_IDENTIFIER}="${newId}"]`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
if ($overlapSpan) {
|
|
164
|
+
// empty the current class list
|
|
165
|
+
removeAllClass($s);
|
|
166
|
+
// retain the class list of the overlapped wrapper which associated with "extra id"
|
|
167
|
+
addClass($s, [...$overlapSpan.classList]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
dataset[CAMEL_DATASET_IDENTIFIER] = newId;
|
|
171
|
+
dataset[CAMEL_DATASET_IDENTIFIER_EXTRA] = ids.join(ID_DIVISION);
|
|
172
|
+
|
|
173
|
+
hooks.Remove.UpdateNodes.call(id, $s, 'id-update');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
$extraToUpdate.forEach($s => {
|
|
177
|
+
const extraIds = $s.dataset[CAMEL_DATASET_IDENTIFIER_EXTRA];
|
|
178
|
+
|
|
179
|
+
$s.dataset[CAMEL_DATASET_IDENTIFIER_EXTRA] = extraIds.replace(reg, '');
|
|
180
|
+
hooks.Remove.UpdateNodes.call(id, $s, 'extra-update');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return $toRemove.length + $idToUpdate.length + $extraToUpdate.length !== 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
removeAllHighlight() {
|
|
187
|
+
const { wrapTag, $root } = this.options;
|
|
188
|
+
const $spans = getHighlightsByRoot($root, wrapTag);
|
|
189
|
+
|
|
190
|
+
$spans.forEach($s => {
|
|
191
|
+
const $parent = $s.parentNode;
|
|
192
|
+
const $fr = document.createDocumentFragment();
|
|
193
|
+
|
|
194
|
+
forEach($s.childNodes, ($c: Node) => $fr.appendChild($c.cloneNode(false)));
|
|
195
|
+
$parent.replaceChild($fr, $s);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/* ============================================================== */
|
|
199
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inject styles
|
|
3
|
+
*/
|
|
4
|
+
import { STYLESHEET_ID, getStylesheet } from '@src/util/const';
|
|
5
|
+
|
|
6
|
+
export const initDefaultStylesheet = () => {
|
|
7
|
+
const styleId = STYLESHEET_ID;
|
|
8
|
+
|
|
9
|
+
let $style: HTMLStyleElement = document.getElementById(styleId) as HTMLStyleElement;
|
|
10
|
+
|
|
11
|
+
if (!$style) {
|
|
12
|
+
const $cssNode = document.createTextNode(getStylesheet());
|
|
13
|
+
|
|
14
|
+
$style = document.createElement('style');
|
|
15
|
+
$style.id = styleId;
|
|
16
|
+
$style.appendChild($cssNode);
|
|
17
|
+
document.head.appendChild($style);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return $style;
|
|
21
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type Hook from '@src/util/hook';
|
|
2
|
+
|
|
3
|
+
export type RootElement = Document | HTMLElement;
|
|
4
|
+
|
|
5
|
+
export interface HighlighterOptions {
|
|
6
|
+
$root?: RootElement;
|
|
7
|
+
exceptSelectors?: string[];
|
|
8
|
+
wrapTag?: string;
|
|
9
|
+
verbose?: boolean;
|
|
10
|
+
style?: {
|
|
11
|
+
className?: string[] | string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PainterOptions {
|
|
16
|
+
$root: RootElement;
|
|
17
|
+
wrapTag: string;
|
|
18
|
+
className: string[] | string;
|
|
19
|
+
exceptSelectors: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export enum SplitType {
|
|
23
|
+
none = 'none',
|
|
24
|
+
head = 'head',
|
|
25
|
+
tail = 'tail',
|
|
26
|
+
both = 'both',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export enum ERROR {
|
|
30
|
+
DOM_TYPE_ERROR = '[DOM] Receive wrong node type.',
|
|
31
|
+
DOM_SELECTION_EMPTY = '[DOM] The selection contains no dom node, may be you except them.',
|
|
32
|
+
RANGE_INVALID = "[RANGE] Got invalid dom range, can't convert to a valid highlight range.",
|
|
33
|
+
RANGE_NODE_INVALID = "[RANGE] Start or end node isn't a text node, it may occur an error.",
|
|
34
|
+
DB_ID_DUPLICATE_ERROR = '[STORE] Unique id conflict.',
|
|
35
|
+
CACHE_SET_ERROR = "[CACHE] Cache.data can't be set manually, please use .save().",
|
|
36
|
+
SOURCE_TYPE_ERROR = "[SOURCE] Object isn't a highlight source instance.",
|
|
37
|
+
HIGHLIGHT_RANGE_FROZEN = '[HIGHLIGHT_RANGE] A highlight range must be frozen before render.',
|
|
38
|
+
HIGHLIGHT_SOURCE_RECREATE = '[HIGHLIGHT_SOURCE] Recreate highlights from sources error.',
|
|
39
|
+
// eslint-disable-next-line max-len
|
|
40
|
+
HIGHLIGHT_SOURCE_NONE_RENDER = "[HIGHLIGHT_SOURCE] This highlight source isn't rendered. May be the exception skips it or the dom structure has changed.",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export enum EventType {
|
|
44
|
+
CREATE = 'selection:create',
|
|
45
|
+
REMOVE = 'selection:remove',
|
|
46
|
+
MODIFY = 'selection:modify',
|
|
47
|
+
HOVER = 'selection:hover',
|
|
48
|
+
HOVER_OUT = 'selection:hover-out',
|
|
49
|
+
CLICK = 'selection:click',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export enum CreateFrom {
|
|
53
|
+
STORE = 'from-store',
|
|
54
|
+
INPUT = 'from-input',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export enum SelectedNodeType {
|
|
58
|
+
text = 'text',
|
|
59
|
+
span = 'span',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SelectedNode {
|
|
63
|
+
$node: Node | Text;
|
|
64
|
+
type: SelectedNodeType;
|
|
65
|
+
splitType: SplitType;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DomMeta {
|
|
69
|
+
parentTagName: string;
|
|
70
|
+
parentIndex: number;
|
|
71
|
+
textOffset: number;
|
|
72
|
+
extra?: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface DomNode {
|
|
76
|
+
$node: Node;
|
|
77
|
+
offset: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface HighlightPosition {
|
|
81
|
+
start: {
|
|
82
|
+
top: number;
|
|
83
|
+
left: number;
|
|
84
|
+
};
|
|
85
|
+
end: {
|
|
86
|
+
top: number;
|
|
87
|
+
left: number;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface HookMap {
|
|
92
|
+
Render: {
|
|
93
|
+
UUID: Hook<string>;
|
|
94
|
+
SelectedNodes: Hook<SelectedNode[]>;
|
|
95
|
+
WrapNode: Hook<HTMLElement>;
|
|
96
|
+
};
|
|
97
|
+
Serialize: {
|
|
98
|
+
Restore: Hook<DomNode[]>;
|
|
99
|
+
RecordInfo: Hook<string>;
|
|
100
|
+
};
|
|
101
|
+
Remove: {
|
|
102
|
+
UpdateNodes: Hook;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export enum UserInputEvent {
|
|
107
|
+
touchend = 'touchend',
|
|
108
|
+
mouseup = 'mouseup',
|
|
109
|
+
touchstart = 'touchstart',
|
|
110
|
+
click = 'click',
|
|
111
|
+
mouseover = 'mouseover',
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface IInteraction {
|
|
115
|
+
PointerEnd: UserInputEvent;
|
|
116
|
+
PointerTap: UserInputEvent;
|
|
117
|
+
PointerOver: UserInputEvent;
|
|
118
|
+
}
|