@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.
Files changed (88) hide show
  1. package/.cursor/environment.json +6 -0
  2. package/.eslintrc.js +250 -0
  3. package/.prettierrc +9 -0
  4. package/.travis.yml +17 -0
  5. package/CHANGELOG.md +220 -0
  6. package/LICENSE +21 -0
  7. package/README.md +371 -0
  8. package/README.zh_CN.md +367 -0
  9. package/config/base.config.js +25 -0
  10. package/config/base.example.config.js +38 -0
  11. package/config/paths.js +22 -0
  12. package/config/server.config.js +17 -0
  13. package/config/webpack.config.dev.js +18 -0
  14. package/config/webpack.config.example.js +20 -0
  15. package/config/webpack.config.prod.js +28 -0
  16. package/dist/data/cache.d.ts +13 -0
  17. package/dist/index.d.ts +58 -0
  18. package/dist/model/range/dom.d.ts +6 -0
  19. package/dist/model/range/index.d.ts +20 -0
  20. package/dist/model/range/selection.d.ts +14 -0
  21. package/dist/model/source/dom.d.ts +23 -0
  22. package/dist/model/source/index.d.ts +18 -0
  23. package/dist/painter/dom.d.ts +22 -0
  24. package/dist/painter/index.d.ts +19 -0
  25. package/dist/painter/style.d.ts +1 -0
  26. package/dist/types/index.d.ts +102 -0
  27. package/dist/util/camel.d.ts +5 -0
  28. package/dist/util/const.d.ts +41 -0
  29. package/dist/util/deferred.d.ts +9 -0
  30. package/dist/util/dom.d.ts +32 -0
  31. package/dist/util/event.emitter.d.ts +13 -0
  32. package/dist/util/hook.d.ts +15 -0
  33. package/dist/util/interaction.d.ts +6 -0
  34. package/dist/util/is.mobile.d.ts +5 -0
  35. package/dist/util/tool.d.ts +4 -0
  36. package/dist/util/uuid.d.ts +4 -0
  37. package/dist/web-highlighter.min.js +3 -0
  38. package/dist/web-highlighter.min.js.map +1 -0
  39. package/docs/ADVANCE.md +113 -0
  40. package/docs/ADVANCE.zh_CN.md +111 -0
  41. package/docs/img/create-flow.jpg +0 -0
  42. package/docs/img/create-flow.zh_CN.jpg +0 -0
  43. package/docs/img/logo.png +0 -0
  44. package/docs/img/remove-flow.jpg +0 -0
  45. package/docs/img/remove-flow.zh_CN.jpg +0 -0
  46. package/docs/img/sample.gif +0 -0
  47. package/example/index.css +2 -0
  48. package/example/index.js +214 -0
  49. package/example/local.store.js +72 -0
  50. package/example/my.css +119 -0
  51. package/example/tpl.html +59 -0
  52. package/package.json +103 -0
  53. package/script/build.js +17 -0
  54. package/script/convet-md.js +25 -0
  55. package/script/dev.js +22 -0
  56. package/src/data/cache.ts +57 -0
  57. package/src/index.ts +285 -0
  58. package/src/model/range/dom.ts +94 -0
  59. package/src/model/range/index.ts +88 -0
  60. package/src/model/range/selection.ts +55 -0
  61. package/src/model/source/dom.ts +66 -0
  62. package/src/model/source/index.ts +54 -0
  63. package/src/painter/dom.ts +345 -0
  64. package/src/painter/index.ts +199 -0
  65. package/src/painter/style.ts +21 -0
  66. package/src/types/index.ts +118 -0
  67. package/src/util/camel.ts +6 -0
  68. package/src/util/const.ts +54 -0
  69. package/src/util/deferred.ts +37 -0
  70. package/src/util/dom.ts +155 -0
  71. package/src/util/event.emitter.ts +45 -0
  72. package/src/util/hook.ts +52 -0
  73. package/src/util/interaction.ts +20 -0
  74. package/src/util/is.mobile.ts +7 -0
  75. package/src/util/tool.ts +14 -0
  76. package/src/util/uuid.ts +10 -0
  77. package/test/api.spec.ts +555 -0
  78. package/test/event.spec.ts +284 -0
  79. package/test/fixtures/broken.json +32 -0
  80. package/test/fixtures/index.html +11 -0
  81. package/test/fixtures/source.json +47 -0
  82. package/test/hook.spec.ts +244 -0
  83. package/test/integrate.spec.ts +48 -0
  84. package/test/mobile.spec.ts +87 -0
  85. package/test/option.spec.ts +212 -0
  86. package/test/util.spec.ts +244 -0
  87. package/test-newlines.html +226 -0
  88. 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;