From 97b83fc26365669fe6b18896767195e45aa4e528 Mon Sep 17 00:00:00 2001 From: alienzhou Date: Sat, 16 May 2020 23:01:33 +0800 Subject: [PATCH 1/9] feat(const): convert some const object to function --- src/index.ts | 35 ++++++++++++++++++++--------------- src/painter/dom.ts | 4 ++-- src/painter/style.ts | 4 ++-- src/types/index.ts | 5 +++++ src/util/const.ts | 13 ++++++------- src/util/interaction.ts | 19 +++++++++++-------- 6 files changed, 46 insertions(+), 34 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8593ecc..5d3a6a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,20 @@ -import '@src/util/dataset.polyfill'; +// import '@src/util/dataset.polyfill'; import EventEmitter from '@src/util/event.emitter'; import HighlightRange from '@src/model/range'; import HighlightSource from '@src/model/source'; import uuid from '@src/util/uuid'; import Hook from '@src/util/hook'; -import event from '@src/util/interaction'; +import getInteraction from '@src/util/interaction'; import Cache from '@src/data/cache'; import Painter from '@src/painter'; -import {DEFAULT_OPTIONS} from '@src/util/const'; +import { getDefaultOptions } from '@src/util/const'; import { ERROR, DomNode, DomMeta, HookMap, EventType, + CreateFrom, HighlighterOptions } from './types'; import { @@ -29,22 +30,26 @@ import { export default class Highlighter extends EventEmitter { static event = EventType; + static isHighlightSource = (d: any) => { + return !!d.__isHighlightSource; + } private _hoverId: string; + private event = getInteraction(); options: HighlighterOptions; hooks: HookMap; painter: Painter; cache: Cache; - constructor(options: HighlighterOptions) { + constructor(options?: HighlighterOptions) { super(); - this.options = DEFAULT_OPTIONS; + this.options = getDefaultOptions(); this.hooks = this._getHooks(); // initialize hooks this.setOption(options); this.cache = new Cache(); // initialize cache const $root = this.options.$root; - addEventListener($root, event.PointerOver, this._handleHighlightHover); // initialize event listener - addEventListener($root, event.PointerTap, this._handleHighlightClick); // initialize event listener + addEventListener($root, this.event.PointerOver, this._handleHighlightHover); // initialize event listener + addEventListener($root, this.event.PointerTap, this._handleHighlightClick); // initialize event listener } private _getHooks = () => ({ @@ -69,13 +74,13 @@ export default class Highlighter extends EventEmitter { return null; } this.cache.save(source); - this.emit(EventType.CREATE, {sources: [source], type: 'from-input'}, this); + this.emit(EventType.CREATE, {sources: [source], type: CreateFrom.INPUT}, this); return source; } private _highlighFromHSource(sources: HighlightSource[] | HighlightSource = []) { const renderedSources: Array = this.painter.highlightSource(sources);; - this.emit(EventType.CREATE, {sources: renderedSources, type: 'from-store'}, this); + this.emit(EventType.CREATE, {sources: renderedSources, type: CreateFrom.STORE}, this); this.cache.save(sources); } @@ -117,8 +122,8 @@ export default class Highlighter extends EventEmitter { } } - run = () => addEventListener(this.options.$root, event.PointerEnd, this._handleSelection); - stop = () => removeEventListener(this.options.$root, event.PointerEnd, this._handleSelection); + run = () => addEventListener(this.options.$root, this.event.PointerEnd, this._handleSelection); + stop = () => removeEventListener(this.options.$root, this.event.PointerEnd, this._handleSelection); addClass = (className: string, id?: string) => this.getDoms(id).forEach($n => addClass($n, className)); removeClass = (className: string, id?: string) => this.getDoms(id).forEach($n => removeClass($n, className)); @@ -130,13 +135,13 @@ export default class Highlighter extends EventEmitter { dispose = () => { const $root = this.options.$root; - removeEventListener($root, event.PointerOver, this._handleHighlightHover); - removeEventListener($root, event.PointerEnd, this._handleSelection); - removeEventListener($root, event.PointerTap, this._handleHighlightClick); + removeEventListener($root, this.event.PointerOver, this._handleHighlightHover); + removeEventListener($root, this.event.PointerEnd, this._handleSelection); + removeEventListener($root, this.event.PointerTap, this._handleHighlightClick); this.removeAll(); } - setOption = (options: HighlighterOptions) => { + setOption = (options?: HighlighterOptions) => { this.options = { ...this.options, ...options diff --git a/src/painter/dom.ts b/src/painter/dom.ts index dc8ca21..b257e72 100644 --- a/src/painter/dom.ts +++ b/src/painter/dom.ts @@ -7,7 +7,7 @@ import { } from '../util/dom'; import { ID_DIVISION, - DEFAULT_OPTIONS, + getDefaultOptions, CAMEL_DATASET_IDENTIFIER, CAMEL_DATASET_IDENTIFIER_EXTRA, DATASET_IDENTIFIER, @@ -150,7 +150,7 @@ export const getSelectedNodes = ( function addClass($el: HTMLElement, className?: string | Array): HTMLElement { let classNames = Array.isArray(className) ? className : [className]; - classNames = classNames.length === 0 ? [DEFAULT_OPTIONS.style.className] : classNames; + classNames = classNames.length === 0 ? [getDefaultOptions().style.className] : classNames; classNames.forEach(c => addElementClass($el, c)); return $el; } diff --git a/src/painter/style.ts b/src/painter/style.ts index 073fa21..b0047dc 100644 --- a/src/painter/style.ts +++ b/src/painter/style.ts @@ -1,14 +1,14 @@ /** * inject styles */ -import {STYLESHEET_ID, STYLESHEET_TEXT} from '@src/util/const'; +import {STYLESHEET_ID, getStylesheet} from '@src/util/const'; export function initDefaultStylesheet () { const styleId = STYLESHEET_ID; let $style: HTMLStyleElement = document.getElementById(styleId) as HTMLStyleElement; if (!$style) { - const $cssNode = document.createTextNode(STYLESHEET_TEXT); + const $cssNode = document.createTextNode(getStylesheet()); $style = document.createElement('style'); $style.id = this.styleId; $style.appendChild($cssNode); diff --git a/src/types/index.ts b/src/types/index.ts index 9a884eb..73d58ab 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -47,6 +47,11 @@ export enum EventType { CLICK = 'selection:click', }; +export enum CreateFrom { + STORE = 'from-store', + INPUT = 'from-input', +}; + export enum SelectedNodeType { text = 'text', span = 'span' diff --git a/src/util/const.ts b/src/util/const.ts index 4349286..a997ca4 100644 --- a/src/util/const.ts +++ b/src/util/const.ts @@ -16,22 +16,21 @@ export const CAMEL_DATASET_IDENTIFIER_EXTRA = camel(DATASET_IDENTIFIER_EXTRA); export const CAMEL_DATASET_SPLIT_TYPE = camel(DATASET_SPLIT_TYPE); const DEFAULT_WRAP_TAG = 'span'; -export const DEFAULT_OPTIONS = { - $root: window.document || window.document.documentElement, +export const getDefaultOptions = () => ({ + $root: document || document.documentElement, exceptSelectors: null, wrapTag: DEFAULT_WRAP_TAG, style: { className: 'highlight-mengshou-wrap' } -}; +}); -const styles = DEFAULT_OPTIONS.style; -export const STYLESHEET_TEXT = ` - .${styles.className} { +export const getStylesheet = () => ` + .${getDefaultOptions().style.className} { background: #ff9; cursor: pointer; } - .${styles.className}.active { + .${getDefaultOptions().style.className}.active { background: #ffb; } `; diff --git a/src/util/interaction.ts b/src/util/interaction.ts index 808a61b..e057c1d 100644 --- a/src/util/interaction.ts +++ b/src/util/interaction.ts @@ -5,12 +5,15 @@ import {IInteraction, UserInputEvent} from '../types'; import detectMobile from './is.mobile'; -const isMobile = detectMobile(window.navigator.userAgent); -const interaction: IInteraction = { - PointerEnd: isMobile ? UserInputEvent.touchend : UserInputEvent.mouseup, - PointerTap: isMobile ? UserInputEvent.touchstart : UserInputEvent.click, - // hover and click will be the same event in mobile - PointerOver: isMobile ? UserInputEvent.touchstart : UserInputEvent.mouseover, -}; +export default (): IInteraction => { + const isMobile = detectMobile(window.navigator.userAgent); -export default interaction; + const interaction: IInteraction = { + PointerEnd: isMobile ? UserInputEvent.touchend : UserInputEvent.mouseup, + PointerTap: isMobile ? UserInputEvent.touchstart : UserInputEvent.click, + // hover and click will be the same event in mobile + PointerOver: isMobile ? UserInputEvent.touchstart : UserInputEvent.mouseover, + }; + + return interaction; +} From 5aed4cfc6716c0233bc7b5bcfbdd5568a1652161 Mon Sep 17 00:00:00 2001 From: alienzhou Date: Sat, 16 May 2020 23:03:20 +0800 Subject: [PATCH 2/9] test: add test framework & some apis tests --- package.json | 15 +++- test/fixtures/index.html | 9 ++ test/highlight.spec.ts | 178 +++++++++++++++++++++++++++++++++++++++ test/index.js | 1 - tsconfig.json | 6 +- 5 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/index.html create mode 100644 test/highlight.spec.ts delete mode 100644 test/index.js diff --git a/package.json b/package.json index 331d415..9c9389a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist/web-highlighter.min.js", "browser": "dist/web-highlighter.min.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "mocha -r ts-node/register -r tsconfig-paths/register test/**.spec.ts", "serve-example": "http-server example/static", "serve": "http-server -p 8081 ./dist", "watch": "webpack --config ./config/webpack.config.prod.js --watch", @@ -34,18 +34,31 @@ "license": "MIT", "dependencies": {}, "devDependencies": { + "@types/chai": "^4.2.11", + "@types/jsdom": "^16.2.3", + "@types/jsdom-global": "^3.0.2", + "@types/mocha": "^7.0.2", + "@types/sinon": "^9.0.1", "better-opn": "^1.0.0", + "chai": "^4.2.0", "chalk": "^2.4.2", "clean-webpack-plugin": "^1.0.0", "css-loader": "^1.0.1", "fs-extra": "^7.0.1", "html-webpack-plugin": "^3.2.0", "http-server": "^0.11.1", + "jsdom": "^16.2.2", + "jsdom-global": "^3.0.2", + "mocha": "^7.1.2", "npm-run-all": "^4.1.5", + "nyc": "^15.0.1", "showdown": "^1.9.0", + "sinon": "^9.0.2", "style-loader": "^0.23.1", "text-replace-html-webpack-plugin": "^1.0.3", "ts-loader": "^5.3.0", + "ts-node": "^8.10.1", + "tsconfig-paths": "^3.9.0", "typescript": "^3.1.6", "webpack": "^4.25.1", "webpack-cli": "^3.1.2", diff --git a/test/fixtures/index.html b/test/fixtures/index.html new file mode 100644 index 0000000..c9705d0 --- /dev/null +++ b/test/fixtures/index.html @@ -0,0 +1,9 @@ +
+

Web Highlighter

+

Background

+

It's from an idea: highlight texts on the website and save the highlighted areas just like what you do in PDF.

+

If you have ever visited medium.com, you must know the feature of highlighting notes: users select a text segment and click the 'highlight' button. Then the text will be highlighted with a shining background color. Besides, the highlighted areas will be saved and recovered when you visit it next time. It's like the simple demo bellow.

+

+

This is a useful feature for readers.If you're a developer, you may want your website support it and attract more visits.If you're a user (like me), you may want a browser-plugin to do this.

+

For this reason, the repo (web-highlighter) aims to help you implement highlighting-note on any website quickly (e.g. blogs, document viewers, online books and so on). It contains the core abilities for note highlighting and persistence. And you can implement your own product by some easy-to-use APIs. It has been used for our sites in production.

+
diff --git a/test/highlight.spec.ts b/test/highlight.spec.ts new file mode 100644 index 0000000..31d71a5 --- /dev/null +++ b/test/highlight.spec.ts @@ -0,0 +1,178 @@ +import { expect } from 'chai'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import jsdom from 'jsdom-global'; +import Highlighter from '../src/index'; +import { getDefaultOptions, DATASET_SPLIT_TYPE, DATASET_IDENTIFIER } from '../src/util/const'; +import { SplitType, CreateFrom } from '../src/types/index'; +import HighlightSource from '../src/model/source/index'; +import sinon from 'sinon'; + +describe('Highlighter', function () { + this.timeout(50000); + + let highlighter: Highlighter; + let cleanup; + let wrapSelector: string; + + beforeEach(async () => { + const html = readFileSync(resolve(__dirname, 'fixtures', 'index.html'), 'utf-8'); + cleanup = jsdom(); + document.body.innerHTML = html; + highlighter = new Highlighter(); + wrapSelector = getDefaultOptions().wrapTag; + }); + + describe('#fromRange', () => { + it('should wrap correctly in p', () => { + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + const content = range.toString(); + highlighter.fromRange(range); + const wrapper = $p.querySelector(wrapSelector); + + expect(wrapper.textContent).to.be.equal(content, 'wrapped text should be the same as the range') + }); + + it('should wrap nothing when range is empty', () => { + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 0); + highlighter.fromRange(range); + + expect($p.querySelector(wrapSelector).textContent.length).to.be.equal(0); + }); + + it('should wrap correctly when cross multi dom', () => { + const range = document.createRange(); + const $p1 = document.querySelectorAll('p')[0]; + const $p2 = document.querySelectorAll('p')[1]; + range.setStart($p1.childNodes[0], 54); + range.setEnd($p2.childNodes[0], 11); + highlighter.fromRange(range); + + const segContent1 = 'save the highlighted areas just like what you do in PDF.'; + const segContent2 = 'If you have'; + + expect($p1.querySelector(wrapSelector).textContent).to.be.equal(segContent1, 'first segment correct'); + expect($p2.querySelector(wrapSelector).textContent).to.be.equal(segContent2, 'second segment correct'); + }); + + it('should split correctly when the new selection is inside an exist selection', () => { + const range = document.createRange(); + const $p = document.querySelectorAll('p')[3]; + const $highlight = $p.querySelector('span'); + range.setStart($highlight.childNodes[0], 12); + range.setEnd($highlight.childNodes[0], 21); + highlighter.fromRange(range); + + const wraps = $p.querySelectorAll(wrapSelector); + const attr = `data-${DATASET_SPLIT_TYPE}`; + expect(wraps.length).to.be.equal(3, 'split into three pieces'); + expect(wraps[1].textContent).to.be.equal('developer', 'highlighted the correct content'); + expect(wraps[0].getAttribute(attr)).to.be.equal(SplitType.both); + expect(wraps[1].getAttribute(attr)).to.be.equal(SplitType.both); + expect(wraps[2].getAttribute(attr)).to.be.equal(SplitType.both); + }); + + it('should split correctly when the new selection is across an exist selection', () => { + const range = document.createRange(); + const $p = document.querySelectorAll('p')[3]; + range.setStart($p.querySelector('span').childNodes[0], 64); + range.setEnd($p.querySelector('span').nextSibling, 9); + highlighter.fromRange(range); + + const wraps = $p.querySelectorAll(wrapSelector); + const attr = `data-${DATASET_SPLIT_TYPE}`; + expect(wraps.length).to.be.equal(3, 'split into three pieces'); + expect(wraps[1].textContent).to.be.equal('attract more visits.', 'highlighted the correct content'); + expect(wraps[2].textContent).to.be.equal('If you\'re', 'highlighted the correct content'); + expect(wraps[0].getAttribute(attr)).to.be.equal(SplitType.both); + expect(wraps[1].getAttribute(attr)).to.be.equal(SplitType.head); + expect(wraps[2].getAttribute(attr)).to.be.equal(SplitType.tail); + }); + + it('should emit CREATE event when highlighted', callback => { + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + const content = range.toString(); + + (highlighter as any).on(Highlighter.event.CREATE, (data) => { + const sources: HighlightSource[] = data.sources; + expect(data.type).to.be.equal(CreateFrom.INPUT); + expect(sources[0].text).to.be.equal(content); + expect(Highlighter.isHighlightSource(sources[0])).to.be.true; + callback(); + }); + + highlighter.fromRange(range); + }); + }); + + describe('#remove', () => { + let listener: sinon.SinonSpy; + let id: string; + + beforeEach(async () => { + listener = sinon.spy(); + (highlighter as any).on(Highlighter.event.REMOVE, listener); + (highlighter as any).on(Highlighter.event.CREATE, (data) => id = data.sources[0].id); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[3]; + range.setStart($p.querySelector('span').childNodes[0], 64); + range.setEnd($p.querySelector('span').nextSibling, 9); + highlighter.fromRange(range); + + }); + + it('should remove all highlighted areas', () => { + highlighter.remove(id); + const hasItem = [] + .slice + .call(document.querySelectorAll(wrapSelector)) + .some(n => n.getAttribute(`data-${DATASET_IDENTIFIER}`) === id); + expect(hasItem).to.be.false; + }); + + it('should emit REMOVE event', () => { + highlighter.remove(id); + expect(listener.calledOnce).to.be.true; + }); + }); + + describe('#removeAll', () => { + let listener: sinon.SinonSpy; + + beforeEach(async () => { + listener = sinon.spy(); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[3]; + range.setStart($p.querySelector('span').childNodes[0], 64); + range.setEnd($p.querySelector('span').nextSibling, 9); + highlighter.fromRange(range); + + (highlighter as any).on(Highlighter.event.REMOVE, listener); + + highlighter.removeAll(); + }); + + it('should remove all highlighted areas', () => { + expect(document.querySelectorAll(wrapSelector).length).to.be.equal(0); + }); + + it('should emit REMOVE event', () => { + expect(listener.calledOnce).to.be.true; + }); + }); + + afterEach(() => { + cleanup(); + }); +}); diff --git a/test/index.js b/test/index.js deleted file mode 100644 index 0ffdd02..0000000 --- a/test/index.js +++ /dev/null @@ -1 +0,0 @@ -// TODO \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 5fb7c87..07198ee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "esModuleInterop": true, "sourceMap": true, "lib": [ "dom", @@ -11,5 +12,8 @@ "paths": { "@src/*": ["./src/*"] } - } + }, + "include": [ + "src/**/*" + ] } \ No newline at end of file From 9cf50d2c2d67640f7e6789c6acfe5073c797de77 Mon Sep 17 00:00:00 2001 From: alienzhou Date: Sat, 16 May 2020 23:06:16 +0800 Subject: [PATCH 3/9] test: add coverage script --- .gitignore | 4 +++- package.json | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 58334d2..2e76c8b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ example/index.html dist docs/TODO.md package-lock.json -typings \ No newline at end of file +typings +.nyc_output +coverage \ No newline at end of file diff --git a/package.json b/package.json index 9c9389a..dc896db 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "browser": "dist/web-highlighter.min.js", "scripts": { "test": "mocha -r ts-node/register -r tsconfig-paths/register test/**.spec.ts", + "coverage": "nyc -r lcov -e .ts -x \"test/**/*.ts\" npm run test", "serve-example": "http-server example/static", "serve": "http-server -p 8081 ./dist", "watch": "webpack --config ./config/webpack.config.prod.js --watch", From 8c84ecb88cef6670397ed1ebbed76372ed818bd6 Mon Sep 17 00:00:00 2001 From: alienzhou Date: Sun, 17 May 2020 22:37:31 +0800 Subject: [PATCH 4/9] fix(cache): .getAll will not return data --- src/data/cache.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/data/cache.ts b/src/data/cache.ts index 84d953e..e96b68f 100644 --- a/src/data/cache.ts +++ b/src/data/cache.ts @@ -35,7 +35,6 @@ class Cache extends EventEmitter { getAll(): HighlightSource[] { const list: HighlightSource[] = []; - this._data = new Map(); for (let pair of this._data) { list.push(pair[1]); } From 144d982bfc54399fc449b65e8fa1a284a8881fc0 Mon Sep 17 00:00:00 2001 From: alienzhou Date: Sun, 17 May 2020 22:39:12 +0800 Subject: [PATCH 5/9] fix(painter): prevent inject duplicate stylesheets --- src/painter/style.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/painter/style.ts b/src/painter/style.ts index b0047dc..1a48ae4 100644 --- a/src/painter/style.ts +++ b/src/painter/style.ts @@ -10,7 +10,7 @@ export function initDefaultStylesheet () { if (!$style) { const $cssNode = document.createTextNode(getStylesheet()); $style = document.createElement('style'); - $style.id = this.styleId; + $style.id = styleId; $style.appendChild($cssNode); document.head.appendChild($style); } From 8faa37f91e0590861c8704b3fc1181b65d7c8972 Mon Sep 17 00:00:00 2001 From: alienzhou Date: Sun, 17 May 2020 22:41:43 +0800 Subject: [PATCH 6/9] feat: refactor error report; prevent emit REMOVE event when no node affected; support verbose config --- src/index.ts | 46 +++++++++++++++++++++++++++++----------- src/model/range/index.ts | 5 ++++- src/painter/index.ts | 18 ++++++++++++---- src/types/index.ts | 8 ++++--- src/util/const.ts | 6 +++++- 5 files changed, 62 insertions(+), 21 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5d3a6a6..1b44ab2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,11 @@ import Hook from '@src/util/hook'; import getInteraction from '@src/util/interaction'; import Cache from '@src/data/cache'; import Painter from '@src/painter'; -import { getDefaultOptions } from '@src/util/const'; +import { + eventEmitter, + getDefaultOptions, + INTERNAL_ERROR_EVENT +} from '@src/util/const'; import { ERROR, DomNode, @@ -33,6 +37,7 @@ export default class Highlighter extends EventEmitter { static isHighlightSource = (d: any) => { return !!d.__isHighlightSource; } + static isHighlightWrapNode = isHighlightWrapNode; private _hoverId: string; private event = getInteraction(); @@ -50,9 +55,10 @@ export default class Highlighter extends EventEmitter { const $root = this.options.$root; addEventListener($root, this.event.PointerOver, this._handleHighlightHover); // initialize event listener addEventListener($root, this.event.PointerTap, this._handleHighlightClick); // initialize event listener + eventEmitter.on(INTERNAL_ERROR_EVENT, this._handleError); } - private _getHooks = () => ({ + private _getHooks = (): HookMap => ({ Render: { UUID: new Hook('Render.UUID'), SelectedNodes: new Hook('Render.SelectedNodes'), @@ -66,11 +72,13 @@ export default class Highlighter extends EventEmitter { } }); - private _highlighFromHRange = (range: HighlightRange): HighlightSource => { + private _highlightFromHRange = (range: HighlightRange): HighlightSource => { const source: HighlightSource = range.serialize(this.options.$root, this.hooks); const $wraps = this.painter.highlightRange(range); if ($wraps.length === 0) { - console.warn(ERROR.DOM_SELECTION_EMPTY); + eventEmitter.emit(INTERNAL_ERROR_EVENT, { + type: ERROR.DOM_SELECTION_EMPTY + }); return null; } this.cache.save(source); @@ -78,7 +86,7 @@ export default class Highlighter extends EventEmitter { return source; } - private _highlighFromHSource(sources: HighlightSource[] | HighlightSource = []) { + private _highlightFromHSource(sources: HighlightSource[] | HighlightSource = []) { const renderedSources: Array = this.painter.highlightSource(sources);; this.emit(EventType.CREATE, {sources: renderedSources, type: CreateFrom.STORE}, this); this.cache.save(sources); @@ -87,7 +95,7 @@ export default class Highlighter extends EventEmitter { private _handleSelection = (e?: Event) => { const range = HighlightRange.fromSelection(this.hooks.Render.UUID); if (range) { - this._highlighFromHRange(range); + this._highlightFromHRange(range); HighlightRange.removeDomRange(); } } @@ -114,6 +122,12 @@ export default class Highlighter extends EventEmitter { this.emit(EventType.HOVER, {id: this._hoverId}, this, e); } + private _handleError = (type: string, detail?) => { + if(this.options.verbose) { + console.warn(type); + } + } + private _handleHighlightClick = (e): void => { const $target = e.target as HTMLElement; if (isHighlightWrapNode($target)) { @@ -169,20 +183,25 @@ export default class Highlighter extends EventEmitter { id = id !== undefined && id !== null ? id : uuid(); const hRange = new HighlightRange(start, end, text, id); if (!hRange) { - console.warn(ERROR.RANGE_INVALID); + eventEmitter.emit(INTERNAL_ERROR_EVENT, { + type: ERROR.RANGE_INVALID + }); return null; } - return this._highlighFromHRange(hRange); + return this._highlightFromHRange(hRange); } fromStore = (start: DomMeta, end: DomMeta, text, id): HighlightSource => { try { const hs = new HighlightSource(start, end, text, id); - this._highlighFromHSource(hs); + this._highlightFromHSource(hs); return hs; } catch (err) { - console.error(err, id, text, start, end); + eventEmitter.emit(INTERNAL_ERROR_EVENT, { + type: ERROR.HIGHLIGHT_SOURCE_RECREATE, + detail: { err, id, text, start, end } + }); return null; } } @@ -191,9 +210,12 @@ export default class Highlighter extends EventEmitter { if (!id) { return; } - this.painter.removeHighlight(id); + const doseExist = this.painter.removeHighlight(id); this.cache.remove(id); - this.emit(EventType.REMOVE, {ids: [id]}, this); + // only emit REMOVE event when highlight exist + if (doseExist) { + this.emit(EventType.REMOVE, {ids: [id]}, this); + } } removeAll() { diff --git a/src/model/range/index.ts b/src/model/range/index.ts index 6590c52..22cb7f3 100644 --- a/src/model/range/index.ts +++ b/src/model/range/index.ts @@ -10,6 +10,7 @@ import {getDomRange, removeSelection} from './selection'; import Hook from '@src/util/hook'; import uuid from '@src/util/uuid'; import {getDomMeta} from './dom'; +import { eventEmitter, INTERNAL_ERROR_EVENT } from '@src/util/const'; class HighlightRange { start: DomNode; @@ -50,7 +51,9 @@ class HighlightRange { frozen: boolean = false ) { if (start.$node.nodeType !== 3 || end.$node.nodeType !== 3) { - console.warn(ERROR.RANGE_NODE_INVALID); + eventEmitter.emit(INTERNAL_ERROR_EVENT, { + type: ERROR.RANGE_NODE_INVALID + }); } this.start = start; diff --git a/src/painter/index.ts b/src/painter/index.ts index c701c7c..5e18ab9 100644 --- a/src/painter/index.ts +++ b/src/painter/index.ts @@ -12,7 +12,9 @@ import {ERROR, PainterOptions, HookMap} from '@src/types'; import {initDefaultStylesheet} from './style'; import { ID_DIVISION, + eventEmitter, DATASET_IDENTIFIER, + INTERNAL_ERROR_EVENT, CAMEL_DATASET_IDENTIFIER, CAMEL_DATASET_IDENTIFIER_EXTRA } from '../util/const'; @@ -46,7 +48,7 @@ export default class Painter { let $selectedNodes = getSelectedNodes($root, range.start, range.end, exceptSelectors); if (!hooks.Render.SelectedNodes.isEmpty()) { - $selectedNodes = hooks.Render.SelectedNodes.call(range.id, $selectedNodes); + $selectedNodes = hooks.Render.SelectedNodes.call(range.id, $selectedNodes) || []; } return $selectedNodes.map(n => { @@ -66,7 +68,9 @@ export default class Painter { const renderedSources: Array = []; list.forEach(s => { if (!(s instanceof HighlightSource)) { - console.error(ERROR.SOURCE_TYPE_ERROR); + eventEmitter.emit(INTERNAL_ERROR_EVENT, { + type: ERROR.SOURCE_TYPE_ERROR + }); return; } const range = s.deSerialize(this.options.$root); @@ -75,7 +79,10 @@ export default class Painter { renderedSources.push(s); } else { - console.warn(ERROR.HIGHLIGHT_SOURCE_NONE_RENDER, s); + eventEmitter.emit(INTERNAL_ERROR_EVENT, { + type: ERROR.HIGHLIGHT_SOURCE_NONE_RENDER, + detail: s + }); } }); @@ -85,7 +92,8 @@ export default class Painter { /* =========================== clean =========================== */ // id: target id - highlight with this id should be clean - removeHighlight(id: string) { + // if there is no highlight for this id, it will return false, vice versa + removeHighlight(id: string): boolean { // whether extra ids contains the target id const reg = new RegExp(`(${id}\\${ID_DIVISION}|\\${ID_DIVISION}?${id}$)`); @@ -143,6 +151,8 @@ export default class Painter { $s.dataset[CAMEL_DATASET_IDENTIFIER_EXTRA] = extraIds.replace(reg, ''); hooks.Remove.UpdateNodes.call(id, $s, 'extra-update'); }); + + return $toRemove.length + $idToUpdate.length + $extraToUpdate.length !== 0 } removeAllHighlight() { diff --git a/src/types/index.ts b/src/types/index.ts index 73d58ab..a738d8b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,9 +3,10 @@ import Hook from "@src/util/hook"; export type RootElement = Document | HTMLElement; export interface HighlighterOptions { - $root: RootElement; - exceptSelectors: Array; - wrapTag: string; + $root?: RootElement; + exceptSelectors?: Array; + wrapTag?: string; + verbose?: boolean; style?: { className?: string | Array; } @@ -34,6 +35,7 @@ export enum ERROR { CACHE_SET_ERROR = '[CACHE] Cache.data can\'t be set manually, please use .save().', SOURCE_TYPE_ERROR = '[SOURCE] Object isn\'t a highlight source instance.', HIGHLIGHT_RANGE_FROZEN = '[HIGHLIGHT_RANGE] A highlight range must be frozen before render.', + HIGHLIGHT_SOURCE_RECREATE = '[HIGHLIGHT_SOURCE] Recreate highlights from sources error.', HIGHLIGHT_SOURCE_NONE_RENDER = '[HIGHLIGHT_SOURCE] This highlight source isn\'t rendered.' + ' May be the exception skips it or the dom structure has changed.' }; diff --git a/src/util/const.ts b/src/util/const.ts index 067184f..c49f3be 100644 --- a/src/util/const.ts +++ b/src/util/const.ts @@ -4,6 +4,7 @@ */ import camel from './camel'; +import EventEmitter from './event.emitter'; export const ID_DIVISION = ';'; export const LOCAL_STORE_KEY = 'highlight-mengshou'; export const STYLESHEET_ID = 'highlight-mengshou-style'; @@ -20,6 +21,7 @@ export const getDefaultOptions = () => ({ $root: document || document.documentElement, exceptSelectors: null, wrapTag: DEFAULT_WRAP_TAG, + verbose: false, style: { className: 'highlight-mengshou-wrap' } @@ -36,4 +38,6 @@ export const getStylesheet = () => ` `; export const ROOT_IDX = -2; -export const UNKNOWN_IDX = -1; \ No newline at end of file +export const UNKNOWN_IDX = -1; +export const INTERNAL_ERROR_EVENT = 'error'; +export const eventEmitter = new EventEmitter(); \ No newline at end of file From 87834652d16469876ee7c37129404b2b8772fb8e Mon Sep 17 00:00:00 2001 From: alienzhou Date: Sun, 17 May 2020 22:42:25 +0800 Subject: [PATCH 7/9] test: add all basic tests --- test/api.spec.ts | 395 ++++++++++++++++++++++++++++++++++++++ test/event.spec.ts | 284 +++++++++++++++++++++++++++ test/fixtures/broken.json | 32 +++ test/fixtures/index.html | 2 +- test/fixtures/source.json | 47 +++++ test/highlight.spec.ts | 178 ----------------- test/hook.spec.ts | 200 +++++++++++++++++++ test/integrate.spec.ts | 48 +++++ test/mobile.spec.ts | 87 +++++++++ test/option.spec.ts | 191 ++++++++++++++++++ test/util.spec.ts | 65 +++++++ tsconfig.json | 1 + 12 files changed, 1351 insertions(+), 179 deletions(-) create mode 100644 test/api.spec.ts create mode 100644 test/event.spec.ts create mode 100644 test/fixtures/broken.json create mode 100644 test/fixtures/source.json delete mode 100644 test/highlight.spec.ts create mode 100644 test/hook.spec.ts create mode 100644 test/integrate.spec.ts create mode 100644 test/mobile.spec.ts create mode 100644 test/option.spec.ts create mode 100644 test/util.spec.ts diff --git a/test/api.spec.ts b/test/api.spec.ts new file mode 100644 index 0000000..26eff01 --- /dev/null +++ b/test/api.spec.ts @@ -0,0 +1,395 @@ +import { expect } from 'chai'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import jsdomGlobal from 'jsdom-global'; +import Highlighter from '../src/index'; +import { getDefaultOptions, DATASET_SPLIT_TYPE, DATASET_IDENTIFIER } from '../src/util/const'; +import { SplitType } from '../src/types/index'; +import sources from './fixtures/source.json'; +import brokenSources from './fixtures/broken.json'; +import getInteraction from '../src/util/interaction'; + +describe('Highlighter API', function () { + this.timeout(50000); + + let highlighter: Highlighter; + let cleanup; + let wrapSelector: string; + + beforeEach(() => { + const html = readFileSync(resolve(__dirname, 'fixtures', 'index.html'), 'utf-8'); + cleanup = jsdomGlobal(); + document.body.innerHTML = html; + highlighter = new Highlighter(); + wrapSelector = `${getDefaultOptions().wrapTag}[data-${DATASET_IDENTIFIER}]`; + }); + + describe('#fromRange', () => { + it('should wrap correctly in p', () => { + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + const content = range.toString(); + highlighter.fromRange(range); + const wrapper = $p.querySelector(wrapSelector); + + expect(wrapper.textContent).to.be.equal(content, 'wrapped text should be the same as the range') + }); + + it('should wrap nothing when range is empty', () => { + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 0); + highlighter.fromRange(range); + + expect($p.querySelector(wrapSelector).textContent.length).to.be.equal(0); + }); + + it('should wrap correctly when cross multi dom', () => { + const range = document.createRange(); + const $p1 = document.querySelectorAll('p')[0]; + const $p2 = document.querySelectorAll('p')[1]; + range.setStart($p1.childNodes[0], 54); + range.setEnd($p2.childNodes[0], 11); + highlighter.fromRange(range); + + const segContent1 = 'save the highlighted areas just like what you do in PDF.'; + const segContent2 = 'If you have'; + + expect($p1.querySelector(wrapSelector).textContent).to.be.equal(segContent1, 'first segment correct'); + expect($p2.querySelector(wrapSelector).textContent).to.be.equal(segContent2, 'second segment correct'); + }); + + it('should split correctly when the new selection is inside an exist selection', () => { + const range = document.createRange(); + const $p = document.querySelectorAll('p')[3]; + const $highlight = $p.querySelector('span'); + range.setStart($highlight.childNodes[0], 12); + range.setEnd($highlight.childNodes[0], 21); + highlighter.fromRange(range); + + const wraps = $p.querySelectorAll(wrapSelector); + const attr = `data-${DATASET_SPLIT_TYPE}`; + expect(wraps.length).to.be.equal(3, 'split into three pieces'); + expect(wraps[1].textContent).to.be.equal('developer', 'highlighted the correct content'); + expect(wraps[0].getAttribute(attr)).to.be.equal(SplitType.both); + expect(wraps[1].getAttribute(attr)).to.be.equal(SplitType.both); + expect(wraps[2].getAttribute(attr)).to.be.equal(SplitType.both); + }); + + it('should split correctly when the new selection is across an exist selection', () => { + const range = document.createRange(); + const $p = document.querySelectorAll('p')[3]; + range.setStart($p.querySelector('span').childNodes[0], 64); + range.setEnd($p.querySelector('span').nextSibling, 9); + highlighter.fromRange(range); + + const wraps = $p.querySelectorAll(wrapSelector); + const attr = `data-${DATASET_SPLIT_TYPE}`; + expect(wraps.length).to.be.equal(3, 'split into three pieces'); + expect(wraps[1].textContent).to.be.equal('attract more visits.', 'highlighted the correct content'); + expect(wraps[2].textContent).to.be.equal('If you\'re', 'highlighted the correct content'); + expect(wraps[0].getAttribute(attr)).to.be.equal(SplitType.both); + expect(wraps[1].getAttribute(attr)).to.be.equal(SplitType.head); + expect(wraps[2].getAttribute(attr)).to.be.equal(SplitType.tail); + }); + }); + + describe('#fromStore', () => { + it('should re-create(highlighting) correctly', () => { + const s = sources[0]; + const $p = document.querySelectorAll('p')[0]; + let $wrappers = $p.querySelectorAll(wrapSelector); + expect($wrappers.length, 'has no wrapper before highlighting').to.be.equal(0); + + highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id); + $wrappers = $p.querySelectorAll(wrapSelector); + expect($wrappers.length, 'only has one wrapper').to.be.equal(1); + expect($wrappers[0].textContent, 'highlight correct text').to.be.equal(s.text); + }); + + it('should highlight correctly when structure is complex', () => { + sources.forEach(s => highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id)); + const $p = document.querySelectorAll('p')[0]; + const $w = $p.querySelectorAll(wrapSelector); + expect($w.length, 'has three wrapper').to.be.equal(5); + expect($w[0].textContent + $w[1].textContent + $w[2].textContent, 'correct text 1').to.be.equal(sources[0].text); + expect($w[2].textContent + $w[3].textContent + $w[4].textContent, 'correct text 2').to.be.equal(sources[1].text); + expect($w[1].textContent + $w[2].textContent + $w[3].textContent, 'correct text 3').to.be.equal(sources[2].text); + }); + + it('should highlight correctly by different re-creating sequence', () => { + const typeReg = new RegExp(`data-${DATASET_SPLIT_TYPE}=".+"`, 'g'); + sources.forEach(s => highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id)); + const html1 = document.body.innerHTML.replace(typeReg, ''); + + document.body.innerHTML = readFileSync(resolve(__dirname, 'fixtures', 'index.html'), 'utf-8'); + sources.slice(0).reverse().forEach(s => highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id)); + const html2 = document.body.innerHTML.replace(typeReg, ''); + + expect(html1).to.be.equal(html2); + }); + + it('should not crash when highlight source is invalid', () => { + const s = brokenSources[0]; + expect(() => highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id)).not.to.throw(); + }); + }); + + describe('#remove', () => { + beforeEach(() => { + const s = sources[0]; + highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id); + }); + + it('should remove all highlighted areas', () => { + const id = sources[0].id; + highlighter.remove(id); + + const hasItem = [].slice + .call(document.querySelectorAll(wrapSelector)) + .some(n => n.getAttribute(`data-${DATASET_IDENTIFIER}`) === id); + + expect(hasItem).to.be.false; + }); + + it('should not occur errors when the id does not exist', () => { + expect(() => highlighter.remove('fake id')).not.to.throw(); + }); + + it('should not affect document when the id is empty', () => { + const html = document.body.innerHTML; + highlighter.remove(''); + expect(html).to.be.equal(document.body.innerHTML); + }); + }); + + describe('#removeAll', () => { + beforeEach(() => { + const s = sources[0]; + highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id); + highlighter.removeAll(); + }); + + it('should remove all highlighted areas', () => { + expect(document.querySelectorAll(wrapSelector).length).to.be.equal(0); + }); + }); + + describe('#run', () => { + it('should highlight automatically after the user\'s interaction', () => { + expect(getInteraction().PointerEnd).to.be.equal('mouseup'); + + highlighter.run(); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + window.getSelection().addRange(range); + + const content = range.toString(); + const e = new MouseEvent('mouseup', { + view: window, + bubbles: true, + cancelable: true + }); + document.body.dispatchEvent(e); + const $w = document.querySelectorAll('p')[0].querySelector(wrapSelector); + expect($w.textContent).to.be.equal(content); + }); + + it('should not affect the document when selection is collapsed', () => { + highlighter.run(); + + const html = document.body.innerHTML; + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 5); + range.setEnd($p.childNodes[0], 5); + window.getSelection().addRange(range); + + const e = new MouseEvent('mouseup', { + view: window, + bubbles: true, + cancelable: true + }); + document.body.dispatchEvent(e); + + expect(document.body.innerHTML).to.be.equal(html); + }); + }); + + describe('#dispose', () => { + it('should not highlight automatically after calling .dispose', () => { + highlighter.run(); + highlighter.dispose(); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + window.getSelection().addRange(range); + + const e = new MouseEvent('mouseup', { + view: window, + bubbles: true, + cancelable: true + }); + document.body.dispatchEvent(e); + + const $w = document.querySelectorAll('p')[0].querySelectorAll(wrapSelector); + expect($w.length).to.be.equal(0); + }); + + it('should remove all highlights after calling .dispose', () => { + highlighter.dispose(); + + const $w = document.querySelectorAll(wrapSelector); + expect($w.length).to.be.equal(0); + }); + }); + + describe('#stop', () => { + it('should not highlight automatically after calling .stop', () => { + highlighter.run(); + highlighter.stop(); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + window.getSelection().addRange(range); + + const e = new MouseEvent('mouseup', { + view: window, + bubbles: true, + cancelable: true + }); + document.body.dispatchEvent(e); + + const $w = document.querySelectorAll('p')[0].querySelectorAll(wrapSelector); + expect($w.length).to.be.equal(0); + }); + }); + + describe('#getDoms', () => { + beforeEach(() => { + highlighter.removeAll(); + sources.forEach(s => highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id)); + }); + + it('get specific highlight\'s doms by passing the id', () => { + const s = sources[0]; + const doms = highlighter.getDoms(s.id); + expect(doms.length).gt(0); + expect(doms.map(n => n.textContent).join('')).to.be.equal(s.text); + expect(doms.every(Highlighter.isHighlightWrapNode), 'dom is wrapper').to.be.true; + }); + + it('get no doms when id does not exist', () => { + const doms = highlighter.getDoms(sources[0].id + 'fake'); + expect(doms.length).to.be.equal(0); + expect(doms.every(Highlighter.isHighlightWrapNode), 'dom is wrapper').to.be.true; + }); + + it('get all doms without an argument', () => { + const doms = highlighter.getDoms(); + document.querySelectorAll(wrapSelector); + expect(doms.length).to.be.equal(document.querySelectorAll(wrapSelector).length); + expect(doms.every(Highlighter.isHighlightWrapNode), 'dom is wrapper').to.be.true; + }); + }); + + describe('#addClass', () => { + const className = 'test-class'; + + beforeEach(() => { + highlighter.removeAll(); + sources.forEach(s => highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id)); + }); + + it('should add class name to the exact doms by id', () => { + const id = sources[0].id; + highlighter.addClass(className, id); + const containClassName = highlighter + .getDoms(id) + .every(n => n.getAttribute('class').indexOf(className) > -1); + expect(containClassName).to.be.true; + }); + + it('should not add class name to the doms without the id', () => { + const id = sources[0].id; + highlighter.addClass(className, id); + expect(document.querySelectorAll(`.${className}`).length).not.gt(highlighter.getDoms(id).length); + }); + + it('should not affect the document when id dose not exist', () => { + expect(document.querySelectorAll(`.${className}`)).lengthOf(0); + highlighter.addClass(className, sources[0].id + 'fake'); + expect(document.querySelectorAll(`.${className}`)).lengthOf(0); + }); + + it('should affect all wrapper nodes when not passing id', () => { + highlighter.addClass(className); + const $set: HTMLElement[] = [].slice.call(document.querySelectorAll(`.${className}`)); + const $doms = highlighter.getDoms(); + expect($set).lengthOf($doms.length); + expect($set.every(n => $doms.indexOf(n) > -1)).to.be.true; + }); + }); + + describe('#removeClass', () => { + const className = 'test-class'; + + beforeEach(() => { + highlighter.removeAll(); + sources.forEach(s => highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id)); + highlighter.addClass(className); + }); + + it('should remove the class name from all wrappers without the id argument', () => { + highlighter.removeClass(className); + expect(document.querySelectorAll(`.${className}`)).lengthOf(0); + }); + + it('should remove the class name from the specific highlight', () => { + const id = sources[0].id; + highlighter.removeClass(className, id); + const notContain = highlighter + .getDoms(id) + .every(n => n.getAttribute('class').indexOf(className) === -1); + + expect(notContain).to.be.true; + }); + + it('should not affect the document when id dose not exist', () => { + expect(document.querySelectorAll(`.${className}`)).lengthOf(highlighter.getDoms().length); + highlighter.removeClass(className, sources[0].id + 'fake'); + expect(document.querySelectorAll(`.${className}`)).lengthOf(highlighter.getDoms().length); + }); + }); + + describe('#getIdByDom', () => { + beforeEach(() => { + highlighter.removeAll(); + sources.forEach(s => highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id)); + }); + + it('should return the correct id', () => { + const id = sources[0].id; + const dom = highlighter.getDoms(id)[0]; + expect(highlighter.getIdByDom(dom)).to.be.equal(id); + }); + + it('should be empty(\'\') when the dom is not a wrapper', () => { + expect(highlighter.getIdByDom(document.querySelector('img'))).to.be.empty; + }); + }); + + afterEach(() => { + cleanup(); + }); +}); diff --git a/test/event.spec.ts b/test/event.spec.ts new file mode 100644 index 0000000..bf04f5a --- /dev/null +++ b/test/event.spec.ts @@ -0,0 +1,284 @@ +import { expect } from 'chai'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import jsdomGlobal from 'jsdom-global'; +import jsdom from 'jsdom'; +import Highlighter from '../src/index'; +import { CreateFrom } from '../src/types/index'; +import HighlightSource from '../src/model/source/index'; +import sources from './fixtures/source.json'; +import sinon from 'sinon'; + +describe('Event Emit', function () { + this.timeout(50000); + + let highlighter: Highlighter; + let cleanup; + + beforeEach(() => { + const html = readFileSync(resolve(__dirname, 'fixtures', 'index.html'), 'utf-8'); + cleanup = jsdomGlobal('', { + resources: new jsdom.ResourceLoader({ + userAgent: 'Mellblomenator/9000' + }) + }); + document.body.innerHTML = html; + highlighter = new Highlighter(); + }); + + describe('#CREATE event', () => { + let listener: sinon.SinonSpy; + + beforeEach(() => { + listener = sinon.spy(); + (highlighter as any).on(Highlighter.event.CREATE, listener); + }); + + it('should be emitted by calling .fromRange', () => { + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(listener.calledOnce).to.be.true; + }); + + it('should get correct arguments by calling .fromRange', () => { + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + const content = range.toString(); + highlighter.fromRange(range); + const sources: HighlightSource[] = listener.args[0][0].sources; + + expect(listener.args[0][0].type).to.be.equal(CreateFrom.INPUT); + expect(sources[0].text).to.be.equal(content); + expect(Highlighter.isHighlightSource(sources[0])).to.be.true; + }); + + it('should be emitted by calling .fromStore', () => { + const s = sources[0]; + highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id); + + expect(listener.calledOnce).to.be.true; + }); + + it('should get correct arguments by calling .fromStore', () => { + const s = sources[0]; + highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id); + const sourcesRes: HighlightSource[] = listener.args[0][0].sources; + + expect(listener.args[0][0].type).to.be.equal(CreateFrom.STORE); + expect(sourcesRes[0].text).to.be.equal(s.text); + expect(Highlighter.isHighlightSource(sourcesRes[0])).to.be.true; + }); + + it('should be emitted when running automatically', () => { + highlighter.run(); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + window.getSelection().addRange(range); + + const e = new MouseEvent('mouseup', { + view: window, + bubbles: true, + cancelable: true + }); + document.body.dispatchEvent(e); + + expect(listener.calledOnce).to.be.true; + }); + + it('should get correct arguments when running automatically', () => { + highlighter.run(); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + window.getSelection().addRange(range); + + const content = range.toString(); + const e = new MouseEvent('mouseup', { + view: window, + bubbles: true, + cancelable: true + }); + document.body.dispatchEvent(e); + + const sources: HighlightSource[] = listener.args[0][0].sources; + expect(listener.args[0][0].type).to.be.equal(CreateFrom.INPUT); + expect(sources[0].text).to.be.equal(content); + expect(Highlighter.isHighlightSource(sources[0])).to.be.true; + }); + }); + + describe('#REMOVE event', () => { + let listener: sinon.SinonSpy; + + beforeEach(() => { + highlighter.removeAll(); + sources.forEach(s => highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id)); + listener = sinon.spy(); + (highlighter as any).on(Highlighter.event.REMOVE, listener); + }); + + it('should be emitted by calling .removeAll', () => { + highlighter.removeAll(); + expect(listener.calledOnce).to.be.true; + }); + + it('should get correct ids as arguments by calling .removeAll', () => { + highlighter.removeAll(); + const ids: string[] = listener.args[0][0].ids; + expect(ids).lengthOf(sources.length); + expect(ids.every(id => sources.map(o => o.id).indexOf(id) > -1)).to.be.true; + expect(listener.calledOnce).to.be.true; + }); + + it('should be emitted by calling .remove', () => { + highlighter.remove(sources[0].id); + expect(listener.calledOnce).to.be.true; + }); + + it('should get correct ids as arguments by calling .remove', () => { + const id = sources[0].id + highlighter.remove(id); + const ids: string[] = listener.args[0][0].ids; + expect(ids).lengthOf(1); + expect(ids[0]).to.be.equal(id); + }); + + it('should be emitted when the id does not exist', () => { + highlighter.remove('fake id'); + expect(listener.calledOnce).to.be.false; + }); + }); + + describe('#CLICK event', () => { + let listener: sinon.SinonSpy; + let event: MouseEvent; + + beforeEach(() => { + highlighter.removeAll(); + sources.forEach(s => highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id)); + listener = sinon.spy(); + (highlighter as any).on(Highlighter.event.CLICK, listener); + event = new MouseEvent('click', { + view: window, + bubbles: true, + cancelable: true + }); + }); + + it('should be emitted correctly', () => { + highlighter.getDoms(sources[0].id)[0].dispatchEvent(event); + expect(listener.calledOnce).to.be.true; + }); + + it('should get correct id as arguments', () => { + const id = sources[0].id; + highlighter.getDoms(id)[0].dispatchEvent(event); + const args = listener.args[0][0]; + + expect(args.id).to.be.equal(id); + }); + + it('should not be emitted when clicked dom is not a wrapper', () => { + document.querySelector('p').dispatchEvent(event); + expect(listener.calledOnce).to.be.false; + }); + }); + + describe('#HOVER event', () => { + let listener: sinon.SinonSpy; + let event: MouseEvent; + + beforeEach(() => { + highlighter.removeAll(); + sources.forEach(s => highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id)); + listener = sinon.spy(); + (highlighter as any).on(Highlighter.event.HOVER, listener); + event = new MouseEvent('mouseover', { + view: window, + bubbles: true, + cancelable: true + }); + }); + + it('should be emitted correctly', () => { + highlighter.getDoms(sources[0].id)[0].dispatchEvent(event); + expect(listener.calledOnce).to.be.true; + }); + + it('should get correct id as arguments', () => { + const id = sources[0].id; + highlighter.getDoms(id)[0].dispatchEvent(event); + const args = listener.args[0][0]; + + expect(args.id).to.be.equal(id); + }); + + it('should not be emitted when the dom hovered is not a wrapper', () => { + document.querySelector('p').dispatchEvent(event); + expect(listener.calledOnce).to.be.false; + }); + + it('should not be emitted when move to a same highlighted wrapper', () => { + highlighter.getDoms(sources[1].id)[0].dispatchEvent(event); + highlighter.getDoms(sources[1].id)[1].dispatchEvent(event); + expect(listener.calledOnce).to.be.true; + }); + }); + + describe('#HOVER_OUT event', () => { + let listener: sinon.SinonSpy; + let event: MouseEvent; + + beforeEach(() => { + highlighter.removeAll(); + sources.forEach(s => highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id)); + listener = sinon.spy(); + (highlighter as any).on(Highlighter.event.HOVER_OUT, listener); + event = new MouseEvent('mouseover', { + view: window, + bubbles: true, + cancelable: true + }); + }); + + it('should be emitted correctly', () => { + highlighter.getDoms(sources[0].id)[0].dispatchEvent(event); + document.querySelector('p').dispatchEvent(event); + expect(listener.calledOnce).to.be.true; + }); + + it('should get correct id as arguments', () => { + const id = sources[0].id; + highlighter.getDoms(sources[0].id)[0].dispatchEvent(event); + highlighter.getDoms(sources[1].id)[0].dispatchEvent(event); + + expect(listener.args[0][0].id).to.be.equal(id); + }); + + it('should not be emitted when just hover a wrapper', () => { + highlighter.getDoms(sources[0].id)[0].dispatchEvent(event); + expect(listener.calledOnce).to.be.false; + }); + + it('should not be emitted when just move to a wrapper in the same highlight', () => { + highlighter.getDoms(sources[1].id)[0].dispatchEvent(event); + highlighter.getDoms(sources[1].id)[1].dispatchEvent(event); + expect(listener.calledOnce).to.be.false; + }); + }); + + afterEach(() => { + cleanup(); + }); +}); diff --git a/test/fixtures/broken.json b/test/fixtures/broken.json new file mode 100644 index 0000000..f44b4ae --- /dev/null +++ b/test/fixtures/broken.json @@ -0,0 +1,32 @@ +[ + { + "startMeta": { + "parentTagName": "P", + "parentIndex": 0, + "textOffset": 0 + }, + "endMeta": { + "parentTagName": "CODE", + "parentIndex": 0, + "textOffset": 5 + }, + "text": "It's a broken source", + "id": "238bec32-693c-4e59-afe6-670f1a644ca2", + "__isHighlightSource": {} + }, + { + "startMeta": { + "parentTagName": "P", + "parentIndex": 0, + "textOffset": 0 + }, + "endMeta": { + "parentTagName": "CODE", + "parentIndex": 0, + "textOffset": 5 + }, + "text": "It's a broken source", + "id": "238bec32-693c-4e59-afe6-670f1a644ca2", + "__isHighlightSource": {} + } +] \ No newline at end of file diff --git a/test/fixtures/index.html b/test/fixtures/index.html index c9705d0..010f6cb 100644 --- a/test/fixtures/index.html +++ b/test/fixtures/index.html @@ -1,7 +1,7 @@

Web Highlighter

Background

-

It's from an idea: highlight texts on the website and save the highlighted areas just like what you do in PDF.

+

It's from an idea: highlight texts on the website and save the highlighted areas just like what you do in PDF.

If you have ever visited medium.com, you must know the feature of highlighting notes: users select a text segment and click the 'highlight' button. Then the text will be highlighted with a shining background color. Besides, the highlighted areas will be saved and recovered when you visit it next time. It's like the simple demo bellow.

This is a useful feature for readers.If you're a developer, you may want your website support it and attract more visits.If you're a user (like me), you may want a browser-plugin to do this.

diff --git a/test/fixtures/source.json b/test/fixtures/source.json new file mode 100644 index 0000000..0bc52d0 --- /dev/null +++ b/test/fixtures/source.json @@ -0,0 +1,47 @@ +[ + { + "startMeta": { + "parentTagName": "P", + "parentIndex": 0, + "textOffset": 0 + }, + "endMeta": { + "parentTagName": "P", + "parentIndex": 0, + "textOffset": 17 + }, + "text": "It's from an idea", + "id": "cbfcf177-4a0e-4deb-9f00-66e247ee5363", + "__isHighlightSource": {} + }, + { + "startMeta": { + "parentTagName": "P", + "parentIndex": 0, + "textOffset": 10 + }, + "endMeta": { + "parentTagName": "P", + "parentIndex": 0, + "textOffset": 34 + }, + "text": "an idea: highlight texts", + "id": "69a33f5e-60f5-4b5d-9f9c-18151a24bf95", + "__isHighlightSource": {} + }, + { + "startMeta": { + "parentTagName": "P", + "parentIndex": 0, + "textOffset": 8 + }, + "endMeta": { + "parentTagName": "P", + "parentIndex": 0, + "textOffset": 26 + }, + "text": "m an idea: highlig", + "id": "14206065-205f-4c6d-8f80-1e022a6695a3", + "__isHighlightSource": {} + } +] \ No newline at end of file diff --git a/test/highlight.spec.ts b/test/highlight.spec.ts deleted file mode 100644 index 31d71a5..0000000 --- a/test/highlight.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { expect } from 'chai'; -import { readFileSync } from 'fs'; -import { resolve } from 'path'; -import jsdom from 'jsdom-global'; -import Highlighter from '../src/index'; -import { getDefaultOptions, DATASET_SPLIT_TYPE, DATASET_IDENTIFIER } from '../src/util/const'; -import { SplitType, CreateFrom } from '../src/types/index'; -import HighlightSource from '../src/model/source/index'; -import sinon from 'sinon'; - -describe('Highlighter', function () { - this.timeout(50000); - - let highlighter: Highlighter; - let cleanup; - let wrapSelector: string; - - beforeEach(async () => { - const html = readFileSync(resolve(__dirname, 'fixtures', 'index.html'), 'utf-8'); - cleanup = jsdom(); - document.body.innerHTML = html; - highlighter = new Highlighter(); - wrapSelector = getDefaultOptions().wrapTag; - }); - - describe('#fromRange', () => { - it('should wrap correctly in p', () => { - const range = document.createRange(); - const $p = document.querySelectorAll('p')[0]; - range.setStart($p.childNodes[0], 0); - range.setEnd($p.childNodes[0], 17); - const content = range.toString(); - highlighter.fromRange(range); - const wrapper = $p.querySelector(wrapSelector); - - expect(wrapper.textContent).to.be.equal(content, 'wrapped text should be the same as the range') - }); - - it('should wrap nothing when range is empty', () => { - const range = document.createRange(); - const $p = document.querySelectorAll('p')[0]; - range.setStart($p.childNodes[0], 0); - range.setEnd($p.childNodes[0], 0); - highlighter.fromRange(range); - - expect($p.querySelector(wrapSelector).textContent.length).to.be.equal(0); - }); - - it('should wrap correctly when cross multi dom', () => { - const range = document.createRange(); - const $p1 = document.querySelectorAll('p')[0]; - const $p2 = document.querySelectorAll('p')[1]; - range.setStart($p1.childNodes[0], 54); - range.setEnd($p2.childNodes[0], 11); - highlighter.fromRange(range); - - const segContent1 = 'save the highlighted areas just like what you do in PDF.'; - const segContent2 = 'If you have'; - - expect($p1.querySelector(wrapSelector).textContent).to.be.equal(segContent1, 'first segment correct'); - expect($p2.querySelector(wrapSelector).textContent).to.be.equal(segContent2, 'second segment correct'); - }); - - it('should split correctly when the new selection is inside an exist selection', () => { - const range = document.createRange(); - const $p = document.querySelectorAll('p')[3]; - const $highlight = $p.querySelector('span'); - range.setStart($highlight.childNodes[0], 12); - range.setEnd($highlight.childNodes[0], 21); - highlighter.fromRange(range); - - const wraps = $p.querySelectorAll(wrapSelector); - const attr = `data-${DATASET_SPLIT_TYPE}`; - expect(wraps.length).to.be.equal(3, 'split into three pieces'); - expect(wraps[1].textContent).to.be.equal('developer', 'highlighted the correct content'); - expect(wraps[0].getAttribute(attr)).to.be.equal(SplitType.both); - expect(wraps[1].getAttribute(attr)).to.be.equal(SplitType.both); - expect(wraps[2].getAttribute(attr)).to.be.equal(SplitType.both); - }); - - it('should split correctly when the new selection is across an exist selection', () => { - const range = document.createRange(); - const $p = document.querySelectorAll('p')[3]; - range.setStart($p.querySelector('span').childNodes[0], 64); - range.setEnd($p.querySelector('span').nextSibling, 9); - highlighter.fromRange(range); - - const wraps = $p.querySelectorAll(wrapSelector); - const attr = `data-${DATASET_SPLIT_TYPE}`; - expect(wraps.length).to.be.equal(3, 'split into three pieces'); - expect(wraps[1].textContent).to.be.equal('attract more visits.', 'highlighted the correct content'); - expect(wraps[2].textContent).to.be.equal('If you\'re', 'highlighted the correct content'); - expect(wraps[0].getAttribute(attr)).to.be.equal(SplitType.both); - expect(wraps[1].getAttribute(attr)).to.be.equal(SplitType.head); - expect(wraps[2].getAttribute(attr)).to.be.equal(SplitType.tail); - }); - - it('should emit CREATE event when highlighted', callback => { - const range = document.createRange(); - const $p = document.querySelectorAll('p')[0]; - range.setStart($p.childNodes[0], 0); - range.setEnd($p.childNodes[0], 17); - const content = range.toString(); - - (highlighter as any).on(Highlighter.event.CREATE, (data) => { - const sources: HighlightSource[] = data.sources; - expect(data.type).to.be.equal(CreateFrom.INPUT); - expect(sources[0].text).to.be.equal(content); - expect(Highlighter.isHighlightSource(sources[0])).to.be.true; - callback(); - }); - - highlighter.fromRange(range); - }); - }); - - describe('#remove', () => { - let listener: sinon.SinonSpy; - let id: string; - - beforeEach(async () => { - listener = sinon.spy(); - (highlighter as any).on(Highlighter.event.REMOVE, listener); - (highlighter as any).on(Highlighter.event.CREATE, (data) => id = data.sources[0].id); - - const range = document.createRange(); - const $p = document.querySelectorAll('p')[3]; - range.setStart($p.querySelector('span').childNodes[0], 64); - range.setEnd($p.querySelector('span').nextSibling, 9); - highlighter.fromRange(range); - - }); - - it('should remove all highlighted areas', () => { - highlighter.remove(id); - const hasItem = [] - .slice - .call(document.querySelectorAll(wrapSelector)) - .some(n => n.getAttribute(`data-${DATASET_IDENTIFIER}`) === id); - expect(hasItem).to.be.false; - }); - - it('should emit REMOVE event', () => { - highlighter.remove(id); - expect(listener.calledOnce).to.be.true; - }); - }); - - describe('#removeAll', () => { - let listener: sinon.SinonSpy; - - beforeEach(async () => { - listener = sinon.spy(); - - const range = document.createRange(); - const $p = document.querySelectorAll('p')[3]; - range.setStart($p.querySelector('span').childNodes[0], 64); - range.setEnd($p.querySelector('span').nextSibling, 9); - highlighter.fromRange(range); - - (highlighter as any).on(Highlighter.event.REMOVE, listener); - - highlighter.removeAll(); - }); - - it('should remove all highlighted areas', () => { - expect(document.querySelectorAll(wrapSelector).length).to.be.equal(0); - }); - - it('should emit REMOVE event', () => { - expect(listener.calledOnce).to.be.true; - }); - }); - - afterEach(() => { - cleanup(); - }); -}); diff --git a/test/hook.spec.ts b/test/hook.spec.ts new file mode 100644 index 0000000..5ab1d17 --- /dev/null +++ b/test/hook.spec.ts @@ -0,0 +1,200 @@ +import { expect } from 'chai'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import jsdomGlobal from 'jsdom-global'; +import Highlighter from '../src/index'; +import sinon from 'sinon'; +import sources from './fixtures/source.json'; + +describe('Highlighter Hooks', function () { + this.timeout(50000); + + let highlighter: Highlighter; + let cleanup; + + beforeEach(() => { + const html = readFileSync(resolve(__dirname, 'fixtures', 'index.html'), 'utf-8'); + cleanup = jsdomGlobal(); + document.body.innerHTML = html; + highlighter = new Highlighter(); + }); + + describe('#Render.UUID', () => { + let listener: sinon.SinonSpy; + let id = 'customize-id'; + + beforeEach(() => { + listener = sinon.spy(); + (highlighter as any).on(Highlighter.event.CREATE, listener); + }); + + it('should use the customized id', () => { + highlighter.hooks.Render.UUID.tap(() => id); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(listener.args[0][0].sources[0].id).to.be.equal(id); + }); + + it('should use the internal uuid id when return undefined in the hook', () => { + const spy: sinon.SinonSpy = sinon.spy(); + highlighter.hooks.Render.UUID.tap(spy); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(spy.calledOnce).to.be.true; + expect(listener.args[0][0].sources[0].id).to.not.equal(id); + }); + + it('should get correct arguments in the hook', () => { + const spy: sinon.SinonSpy = sinon.spy(); + highlighter.hooks.Render.UUID.tap(spy); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + const content = range.toString(); + highlighter.fromRange(range); + + expect(spy.args[0][0].$node).to.be.equal($p.childNodes[0]); + expect(spy.args[0][0].offset).to.be.equal(0); + expect(spy.args[0][1].$node).to.be.equal($p.childNodes[0]); + expect(spy.args[0][1].offset).to.be.equal(17); + expect(spy.args[0][2]).to.be.equal(content); + }); + }); + + describe('#Render.SelectedNodes', () => { + + it('should not affect the document when return an empty array in the hook', () => { + highlighter.hooks.Render.SelectedNodes.tap(() => []); + + const html = document.body.innerHTML; + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(document.body.innerHTML).to.be.equal(html); + }); + + it('should not affect the document when return undefined in the hook', () => { + highlighter.hooks.Render.SelectedNodes.tap(() => {}); + + const html = document.body.innerHTML; + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + + expect(() => highlighter.fromRange(range)).not.to.throw(); + expect(document.body.innerHTML).to.be.equal(html); + }); + + it('should get correct arguments in the hook', () => { + const spy: sinon.SinonSpy = sinon.spy(); + highlighter.hooks.Render.SelectedNodes.tap(spy); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(spy.calledOnce).to.be.true; + expect(spy.args[0][0]).not.to.be.empty; + expect(spy.args[0][0]).not.to.be.string; + expect(spy.args[0][1]).lengthOf(1); + }); + }); + + describe('#Render.WrapNode', () => { + it('should get correct arguments in the hook', () => { + const spy: sinon.SinonSpy = sinon.spy(); + highlighter.hooks.Render.WrapNode.tap(spy); + + const range = document.createRange(); + range.setStart(document.querySelectorAll('p')[0].childNodes[0], 0); + range.setEnd(document.querySelectorAll('p')[0].childNodes[0], 17); + highlighter.fromRange(range); + + expect(spy.calledOnce).to.be.true; + expect(spy.args[0][0]).not.to.be.empty; + expect(spy.args[0][0]).not.to.be.string; + expect(Highlighter.isHighlightWrapNode(spy.args[0][1])).to.be.true; + }); + + it('should be called multiple times when creating multiple wrappers', () => { + const spy: sinon.SinonSpy = sinon.spy(); + highlighter.hooks.Render.WrapNode.tap(spy); + + const range = document.createRange(); + range.setStart(document.querySelectorAll('p')[0].childNodes[0], 0); + range.setEnd(document.querySelectorAll('p')[1].childNodes[0], 17); + highlighter.fromRange(range); + + expect(spy.callCount).to.be.equal(3); + }); + }); + + describe('#Serialize.RecordInfo', () => { + it('should get correct arguments in the hook', () => { + const spy: sinon.SinonSpy = sinon.spy(); + highlighter.hooks.Serialize.RecordInfo.tap(spy); + + const range = document.createRange(); + range.setStart(document.querySelectorAll('p')[0].childNodes[0], 0); + range.setEnd(document.querySelectorAll('p')[0].childNodes[0], 17); + highlighter.fromRange(range); + + expect(spy.calledOnce).to.be.true; + expect(spy.args[0][0]).not.to.be.empty; + expect(spy.args[0][1]).not.to.be.empty; + expect(spy.args[0][2]).to.be.equal(document); + }); + + it('should add extra info to sources', () => { + const extra = 'test-extra'; + const spy: sinon.SinonSpy = sinon.spy(); + (highlighter as any).on(Highlighter.event.CREATE, spy); + highlighter.hooks.Serialize.RecordInfo.tap(() => extra); + + const range = document.createRange(); + range.setStart(document.querySelectorAll('p')[0].childNodes[0], 0); + range.setEnd(document.querySelectorAll('p')[0].childNodes[0], 17); + highlighter.fromRange(range); + + expect(spy.args[0][0].sources[0].extra).to.be.equal(extra); + }); + }); + + describe('#Remove.UpdateNodes', () => { + it('should get correct arguments in the hook', () => { + const spy: sinon.SinonSpy = sinon.spy(); + highlighter.hooks.Remove.UpdateNodes.tap(spy); + + const s = sources[0]; + highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id); + highlighter.remove(s.id); + + expect(spy.calledOnce).to.be.true; + expect(spy.args[0][0]).to.be.equal(s.id); + expect(Highlighter.isHighlightWrapNode(spy.args[0][1])).to.be.true; + expect(spy.args[0][2]).to.be.equal('remove'); + }); + }); + + afterEach(() => { + cleanup(); + }); +}); diff --git a/test/integrate.spec.ts b/test/integrate.spec.ts new file mode 100644 index 0000000..425a404 --- /dev/null +++ b/test/integrate.spec.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import jsdomGlobal from 'jsdom-global'; +import Highlighter from '../src/index'; +import HighlightSource from '../src/model/source'; + +describe('Integration Usage', function () { + this.timeout(50000); + + let cleanup; + + beforeEach(() => { + cleanup = jsdomGlobal(); + }); + + it('should work correctly when root contains no dom', () => { + const html = readFileSync(resolve(__dirname, 'fixtures', 'index.html'), 'utf-8'); + document.body.innerHTML = html; + + let highlighter = new Highlighter({ + $root: document.querySelector('p') + }); + + let s: HighlightSource; + (highlighter as any).on(Highlighter.event.CREATE, data => { + s = data.sources[0]; + }); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + const htmlHighlighted = document.body.innerHTML; + + document.body.innerHTML = html; + highlighter = new Highlighter({ + $root: document.querySelector('p') + }); + highlighter.fromStore(s.startMeta, s.endMeta, s.text, s.id); + expect(document.body.innerHTML).to.be.equal(htmlHighlighted); + }); + + afterEach(() => { + cleanup(); + }); +}); diff --git a/test/mobile.spec.ts b/test/mobile.spec.ts new file mode 100644 index 0000000..0e1a75c --- /dev/null +++ b/test/mobile.spec.ts @@ -0,0 +1,87 @@ +import { expect } from 'chai'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import jsdomGlobal from 'jsdom-global'; +import jsdom from 'jsdom'; +import Highlighter from '../src/index'; +import { getDefaultOptions } from '../src/util/const'; +import getInteraction from '../src/util/interaction'; + +describe('Highlighter on mobiles', function () { + this.timeout(50000); + + let highlighter: Highlighter; + let cleanup; + let wrapSelector: string; + + beforeEach(() => { + const html = readFileSync(resolve(__dirname, 'fixtures', 'index.html'), 'utf-8'); + cleanup = jsdomGlobal('', { + resources: new jsdom.ResourceLoader({ + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1' + }) + }); + document.body.innerHTML = html; + highlighter = new Highlighter(); + wrapSelector = getDefaultOptions().wrapTag; + }); + + describe('#run', () => { + it('should highlight automatically after the user\'s interaction', () => { + expect(getInteraction().PointerEnd).to.be.equal('touchend'); + + highlighter.run(); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + window.getSelection().addRange(range); + + const content = range.toString(); + const e = new TouchEvent('touchend', { + view: window, + bubbles: true, + cancelable: true + }); + document.body.dispatchEvent(e); + + const $w = document.querySelectorAll('p')[0].querySelector(wrapSelector); + expect($w.textContent).to.be.equal(content); + }); + }); + + describe('#dispose', () => { + it('should not highlight automatically after calling .dispose', () => { + highlighter.run(); + highlighter.dispose(); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[0]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + window.getSelection().addRange(range); + + const e = new TouchEvent('touchend', { + view: window, + bubbles: true, + cancelable: true + }); + document.body.dispatchEvent(e); + + const $w = document.querySelectorAll('p')[0].querySelectorAll(wrapSelector); + expect($w.length).to.be.equal(0); + }); + + it('should remove all highlights after calling .dispose', () => { + highlighter.dispose(); + + const $w = document.querySelectorAll(wrapSelector); + expect($w.length).to.be.equal(0); + }); + }); + + afterEach(() => { + cleanup(); + }); +}); diff --git a/test/option.spec.ts b/test/option.spec.ts new file mode 100644 index 0000000..68a52ec --- /dev/null +++ b/test/option.spec.ts @@ -0,0 +1,191 @@ +import { expect } from 'chai'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import jsdomGlobal from 'jsdom-global'; +import Highlighter from '../src/index'; +import { getDefaultOptions } from '../src/util/const'; + +describe('Highlighter Options', function () { + this.timeout(50000); + + let cleanup: Function; + + beforeEach(() => { + const html = readFileSync(resolve(__dirname, 'fixtures', 'index.html'), 'utf-8'); + cleanup = jsdomGlobal(); + document.body.innerHTML = html; + }); + + describe('#$root', () => { + it('should highlight text inside $root', () => { + const $root = document.querySelectorAll('p')[0]; + const highlighter = new Highlighter({ $root }); + highlighter.removeAll(); + + expect(highlighter.getDoms()).lengthOf(0); + + const range = document.createRange(); + range.setStart($root.childNodes[0], 0); + range.setEnd($root.childNodes[0], 17); + highlighter.fromRange(range); + + expect(highlighter.getDoms()).lengthOf.gt(0); + }); + + it('should not highlight text outside $root', () => { + const $root = document.querySelectorAll('p')[0]; + const highlighter = new Highlighter({ $root }); + highlighter.removeAll(); + expect(highlighter.getDoms()).lengthOf(0); + + const range = document.createRange(); + const $p = document.querySelectorAll('p')[1]; + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(highlighter.getDoms()).lengthOf(0); + }); + }); + + describe('#exceptSelectors', () => { + + it('should skip nodes because of the tag selector filters', () => { + const highlighter = new Highlighter({ + exceptSelectors: ['p'] + }); + highlighter.removeAll(); + + const $p = document.querySelectorAll('p')[0]; + const range = document.createRange(); + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(highlighter.getDoms()).lengthOf(0); + }); + + it('should skip nodes because of the className selector filters', () => { + const highlighter = new Highlighter({ + exceptSelectors: ['.first-p'] + }); + highlighter.removeAll(); + + const $p = document.querySelectorAll('p')[0]; + const range = document.createRange(); + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(highlighter.getDoms()).lengthOf(0); + }); + + it('should skip nodes because of the id selector filters', () => { + const highlighter = new Highlighter({ + exceptSelectors: ['#js-first-p'] + }); + highlighter.removeAll(); + + const $p = document.querySelectorAll('p')[0]; + const range = document.createRange(); + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(highlighter.getDoms()).lengthOf(0); + }); + + it('should not skip when no filter matches', () => { + const highlighter = new Highlighter({ + exceptSelectors: ['.no-match'] + }); + highlighter.removeAll(); + + const $p = document.querySelectorAll('p')[0]; + const range = document.createRange(); + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(highlighter.getDoms()).lengthOf.gt(0); + }); + }); + + describe('#wrapTag', () => { + it('should use default tag for wrapping node when there\'s no config', () => { + const wrapTag = getDefaultOptions().wrapTag; + const highlighter = new Highlighter(); + highlighter.removeAll(); + + const $p = document.querySelectorAll('p')[0]; + const range = document.createRange(); + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(highlighter.getDoms()).lengthOf.gt(0); + const useCorrectTag = highlighter + .getDoms() + .every(n => n.tagName.toUpperCase() === wrapTag.toUpperCase()); + expect(useCorrectTag).to.be.true; + }); + + it('should use customized tag for wrapping nodes', () => { + const wrapTag = getDefaultOptions().wrapTag === 'b' ? 'i' : 'b'; + const highlighter = new Highlighter({ wrapTag }); + highlighter.removeAll(); + + const $p = document.querySelectorAll('p')[0]; + const range = document.createRange(); + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(highlighter.getDoms()).lengthOf.gt(0); + const useCorrectTag = highlighter + .getDoms() + .every(n => n.tagName.toUpperCase() === wrapTag.toUpperCase()); + expect(useCorrectTag).to.be.true; + }); + }); + + describe('#style.className', () => { + it('should use default className for wrapping nodes when there\'s no config', () => { + const defaultClassName = getDefaultOptions().style.className; + const highlighter = new Highlighter(); + highlighter.removeAll(); + + const $p = document.querySelectorAll('p')[0]; + const range = document.createRange(); + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(highlighter.getDoms()).lengthOf.gt(0); + expect(highlighter.getDoms().every(n => n.classList.contains(defaultClassName))).to.be.true; + }); + + it('should use customized className for wrapping nodes', () => { + const className = 'test-class-config'; + const defaultClassName = getDefaultOptions().style.className; + const highlighter = new Highlighter({ + style: { className } + }); + highlighter.removeAll(); + + const $p = document.querySelectorAll('p')[0]; + const range = document.createRange(); + range.setStart($p.childNodes[0], 0); + range.setEnd($p.childNodes[0], 17); + highlighter.fromRange(range); + + expect(highlighter.getDoms()).lengthOf.gt(0); + expect(highlighter.getDoms().every(n => n.classList.contains(className))).to.be.true; + expect(highlighter.getDoms().some(n => n.classList.contains(defaultClassName))).to.be.false; + }); + }); + + afterEach(() => { + cleanup(); + }); +}); diff --git a/test/util.spec.ts b/test/util.spec.ts new file mode 100644 index 0000000..9cf1060 --- /dev/null +++ b/test/util.spec.ts @@ -0,0 +1,65 @@ +import { expect } from 'chai'; +import jsdomGlobal from 'jsdom-global'; +import HighlightSource from '../src/model/source/index'; +import sources from './fixtures/source.json'; +import Cache from '../src/data/cache'; +import { initDefaultStylesheet } from '../src/painter/style'; + +describe('Else Utils', function () { + this.timeout(50000); + + describe('Cache', () => { + let cache: Cache; + + beforeEach(() => { + cache = new Cache(); + }); + + it('should work correctly when saving source', () => { + const s = sources[0]; + const highlightSource = new HighlightSource(s.startMeta, s.endMeta, s.text, s.id); + cache.save(highlightSource); + expect(cache.getAll()).lengthOf(1); + expect(cache.getAll()[0]).to.be.equal(highlightSource); + }); + + it('should throw error when set data', () => { + const s = sources[0]; + const highlightSource = new HighlightSource(s.startMeta, s.endMeta, s.text, s.id); + cache.save(highlightSource); + expect(() => cache.data = [highlightSource]).to.throw(); + }); + + it('should be the same when using .data and .getAll', () => { + const s = sources[0]; + const highlightSource = new HighlightSource(s.startMeta, s.endMeta, s.text, s.id); + cache.save(highlightSource); + expect(cache.getAll()).lengthOf(cache.data.length); + expect(cache.getAll().every(c => cache.data.indexOf(c) > -1)).to.be.true; + }); + + it('should support an array as the argument', () => { + const highlightSources = sources.map(s => new HighlightSource(s.startMeta, s.endMeta, s.text, s.id)); + cache.save(highlightSources); + expect(cache.getAll()).lengthOf(highlightSources.length); + }); + + it('should return the correct data by .get', () => { + const s = sources[0]; + const highlightSource = new HighlightSource(s.startMeta, s.endMeta, s.text, s.id); + cache.save(highlightSource); + expect(cache.get(s.id)).to.be.equal(highlightSource); + }); + }); + + describe('Style', () => { + it('should not generate duplicate stylesheet', () => { + const cleanup = jsdomGlobal(); + expect(document.querySelectorAll('style')).lengthOf(0); + initDefaultStylesheet(); + expect(document.querySelectorAll('style')).lengthOf(1); + initDefaultStylesheet(); + expect(document.querySelectorAll('style')).lengthOf(1); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 07198ee..8083471 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ ], "target": "es5", "downlevelIteration": true, + "resolveJsonModule": true, "baseUrl": ".", "paths": { "@src/*": ["./src/*"] From 35d8c423146f185d5aecb70cbaeacc81f577529e Mon Sep 17 00:00:00 2001 From: alienzhou Date: Sun, 17 May 2020 23:20:15 +0800 Subject: [PATCH 8/9] ci(test): add coverage --- .travis.yml | 5 ++++- package.json | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3b9b0a6..8b1e7a3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,4 +11,7 @@ install: - npm install script: - - npm run build \ No newline at end of file + - npm run build + - npm run coverage + +after_script: "cat coverage/lcov.info | node_modules/coveralls/bin/coveralls.js" \ No newline at end of file diff --git a/package.json b/package.json index 9f1afca..2e0cc4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web-highlighter", - "version": "0.5.1", + "version": "0.5.2", "description": "✨A no-runtime dependency lib for text highlighting & persistence on any website ✨🖍️", "main": "dist/web-highlighter.min.js", "browser": "dist/web-highlighter.min.js", @@ -44,6 +44,7 @@ "chai": "^4.2.0", "chalk": "^2.4.2", "clean-webpack-plugin": "^1.0.0", + "coveralls": "^3.1.0", "css-loader": "^1.0.1", "fs-extra": "^7.0.1", "html-webpack-plugin": "^3.2.0", From 261d81f7dfb8f75bd75082fcde61016bcd060f95 Mon Sep 17 00:00:00 2001 From: alienzhou Date: Sun, 17 May 2020 23:40:49 +0800 Subject: [PATCH 9/9] docs(readme): update --- README.md | 79 +++++++++++++++++++++++++++-------- README.zh_CN.md | 107 ++++++++++++++++++++++++++++++++++-------------- 2 files changed, 138 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index d91dea9..13ab20b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,32 @@ -![Web Highlighter](https://raw.githubusercontent.com/alienzhou/web-highlighter/master/docs/img/logo.png) +
+

Web Highlighter  🖍️

+

+ ✨A no-runtime dependency lib for highlighting-note & persistence on any website ✨🖍️ +

+ +

+ + Build status + + + NPM version + + + Coverage Status + + + Gzip size + + + Codebeat + + + MIT Licence + +

+
-✨A no-runtime dependency lib for highlighting-note & persistence on any website ✨🖍️ - -[![NPM version](https://img.shields.io/npm/v/web-highlighter.svg)](https://www.npmjs.com/package/web-highlighter) [![](https://api.travis-ci.org/alienzhou/web-highlighter.svg?branch=master)](https://travis-ci.org/alienzhou/web-highlighter) [![gzip size](https://img.badgesize.io/https://unpkg.com/web-highlighter/dist/web-highlighter.min.js?compression=gzip)](https://unpkg.com/web-highlighter) [![codebeat badge](https://codebeat.co/badges/f5a18a9b-9765-420e-a17f-fa0b54b3a125)](https://codebeat.co/projects/github-com-alienzhou-web-highlighter-master) [![install size](https://packagephobia.now.sh/badge?p=web-highlighter)](https://packagephobia.now.sh/result?p=web-highlighter) [![MIT Licence](https://badges.frapsoft.com/os/mit/mit.svg?v=103)](https://opensource.org/licenses/mit-license.php) +--- English | [简体中文](https://github.com/alienzhou/web-highlighter/blob/master/README.zh_CN.md) @@ -117,9 +141,13 @@ It will read the selected range by [`Selection API`](https://caniuse.com/#search For more details, please read [this article (in Chinese)](https://www.alienzhou.com/2019/04/21/web-note-highlight-in-js/). -## API +## APIs -### `highlighter = new Highlighter([opts])` +### Options + +```JavaScript +const highlighter = new Highlighter([opts]) +``` Create a new `highlighter` instance. @@ -143,6 +171,7 @@ All options: | $root | `Document | HTMLElement` | the container to enable highlighting | No | `document` | | exceptSelectors | `Array` | if an element matches the selector, it won't be highlighted | No | `null` | | wrapTag | `string` | the html tag used to wrap highlighted texts | No | `span` | +| verbose | `boolean` | dose it need to output (print) some warning and error message | No | `false` | | style | `Object` | control highlighted areas style | No | details below | `style` field options: @@ -159,19 +188,31 @@ var highlighter = new Highlighter({ }); ``` -### `highlighter.run()` +### Static Methods + +#### `Highlighter.isHighlightSource(source)` + +If the `source` is a highlight source object, it will return `true`, vice verse. + +#### `Highlighter.isHighlightWrapNode($node)` + +If the `$node` is a highlight wrapper dom node, it will return `true`, vice verse. + +### Instance Methods + +#### `highlighter.run()` Start auto-highlighting. When the user select a text segment, a highlighting will be added to the text automatically. -### `highlighter.stop()` +#### `highlighter.stop()` It will stop the auto-highlighting. -### `highlighter.dispose()` +#### `highlighter.dispose()` When you don't want the highlighter anymore, remember to call it first. It will remove some listeners and do some cleanup. -### `highlighter.fromRange(range)` +#### `highlighter.fromRange(range)` You can pass a [`Range`](https://developer.mozilla.org/en-US/docs/Web/API/Range) object to it and then it will be highlighted. You can use `window.getSelection().getRangeAt(0)` to get a range object or use `document.createRange()` to create a new range. @@ -184,7 +225,7 @@ if (selection.isCollapsed) { } ``` -### `highlighter.fromStore(start, end, text, id)` +#### `highlighter.fromStore(start, end, text, id)` Mostly, you use this api to highlight text by the persisted information stored from backend. @@ -197,31 +238,31 @@ Four attributes' meanings: - text `string`: text content - id `string`: unique id -### `highlighter.remove(id)` +#### `highlighter.remove(id)` Remove (clean) a highlighted area by it's unique id. The id will be generated by web-highlighter by default. You can also add a hook for your own rule. [Hooks doc here](https://github.com/alienzhou/web-highlighter/blob/master/docs/ADVANCE.md). -### `highlighter.removeAll()` +#### `highlighter.removeAll()` Remove all highlighted areas belonging to the root. -### `highlighter.addClass(className, id)` +#### `highlighter.addClass(className, id)` Add a className for highlighted areas (wrap elements) by unique id. You can change a highlighted area's style by using this api. -### `highlighter.removeClass(className, id)` +#### `highlighter.removeClass(className, id)` Remove the className by unique id. It's `highlighter.addClass`'s inverse operation. -### `highlighter.getDoms([id])` +#### `highlighter.getDoms([id])` Get all the wrap nodes in a highlighted area. A highlighted area may contain many segments. It will return all the dom nodes wrapping these segments. If the `id` is not passed, it will return all the areas' wrap nodes. -### `highlighter.getIdByDom(node)` +#### `highlighter.getIdByDom(node)` If you have a wrap node, it can return the unique highlight id for you. @@ -294,6 +335,10 @@ Different event has different `data`. Attributes below: |---|---|---| |`ids`|a list of the highlight id|Array| +### Hooks + +Hooks let you control the highlighting flow powerfully. You can almost customize any logic by hooks. See more in ['Advance' part](#Advance). + ## Compatibility > It depends on [Selection API](https://caniuse.com/#search=selection%20api). diff --git a/README.zh_CN.md b/README.zh_CN.md index 72c369f..a5eb5e8 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -1,12 +1,36 @@ -![Web Highlighter](https://raw.githubusercontent.com/alienzhou/web-highlighter/master/docs/img/logo.png) +
+

Web Highlighter  🖍️

+

+ ✨ 一个可以在任何网页上做高亮笔记前端库,支持高亮文本的持久化存储与还原 ✨🖍️ +

+ +

+ + Build status + + + NPM version + + + Coverage Status + + + Gzip size + + + Codebeat + + + MIT Licence + +

+
-✨A no-runtime dependency lib for highlighting-note & persistence on any website ✨🖍️ - -[![NPM version](https://img.shields.io/npm/v/web-highlighter.svg)](https://www.npmjs.com/package/web-highlighter) [![](https://api.travis-ci.org/alienzhou/web-highlighter.svg?branch=master)](https://travis-ci.org/alienzhou/web-highlighter) [![gzip size](https://img.badgesize.io/https://unpkg.com/web-highlighter/dist/web-highlighter.min.js?compression=gzip)](https://unpkg.com/web-highlighter) [![codebeat badge](https://codebeat.co/badges/f5a18a9b-9765-420e-a17f-fa0b54b3a125)](https://codebeat.co/projects/github-com-alienzhou-web-highlighter-master) [![install size](https://packagephobia.now.sh/badge?p=web-highlighter)](https://packagephobia.now.sh/result?p=web-highlighter) [![MIT Licence](https://badges.frapsoft.com/os/mit/mit.svg?v=103)](https://opensource.org/licenses/mit-license.php) +--- [English](https://github.com/alienzhou/web-highlighter/blob/master/README.md) | 简体中文 -## 背景 +## 1. 背景 灵感来源:当有天我访问某个网页时,突然希望能够像在PDF上一样,对网页文本添加高亮笔记,并支持永久保存这些高亮笔记区域。 @@ -19,13 +43,13 @@ 因此,「web-highlighter」仓库的目标就是帮助你在任意的网页上快速地实现高亮笔记功能(例如博客网页、文档阅读器、在线图书等)。它包含了文本高亮笔记与高亮持久化下场景的核心能力,并且支持通过它简单易用的 API 来实现你自己的产品需求。「web-highlighter」已经被用在了我们网站的生产环境中。 -## 安装 +## 2. 安装 ```bash npm i web-highlighter ``` -## 使用方式 +## 3. 使用方式 两行代码,即可开启文本选中时的自动高亮功能。 @@ -52,7 +76,7 @@ highlighter.on(Highlighter.event.CREATE, ({sources}) => save(sources)); highlighter.run(); ``` -## 示例 +## 4. 示例 一个更复杂的使用示例。 @@ -110,15 +134,19 @@ npm start ![product sample](https://user-images.githubusercontent.com/9822789/64678049-632e8500-d4ab-11e9-99d6-f960bc90d17b.gif) -## 工作原理 +## 5. 工作原理 web-highlighter 会通过 [`Selection API`](https://caniuse.com/#search=selection%20api) 来读取被选择的文本范围。然后选区的信息会被转换为一个可序列化的数据结构,以便于能够发送并存储在后端。当用户再次访问你的页面时,这些存储的数据被返回然后在你的页面上进行反序列化。数据结构本身是技术栈无关的。所以你可以用在任意技术栈构建的页面上(例如 React、Vue、Angular 或者 jQuery 等等)。 想要了解更多实现细节,可以阅读[这篇文章](https://www.alienzhou.com/2019/04/21/web-note-highlight-in-js/)。 -## API +## 6. 详细使用文档 -### `highlighter = new Highlighter([opts])` +### 6.1. 配置项 + +```JavaScript +const highlighter = new Highlighter([opts]) +``` 创建一个新的 `highlighter` 实例. @@ -142,6 +170,7 @@ web-highlighter 会通过 [`Selection API`](https://caniuse.com/#search=selectio | $root | `Document | HTMLElement` | 高亮区域的根容器元素 | 否 | `document` | | exceptSelectors | `Array` | 过滤器,符合的元素将不会被高亮 | 否 | `null` | | wrapTag | `string` | 用于包裹高亮文本的 HTML 标签名 | 否 | `span` | +| verbose | `boolean` | 是否需要输出警告和错误信息 | 否 | `false` | | style | `Object` | 用于控制高亮区域的样式 | 否 | 详见下方 | `style` 属性配置: @@ -158,19 +187,31 @@ var highlighter = new Highlighter({ }); ``` -### `highlighter.run()` +### 6.2. 静态方法 + +#### 6.2.1. `Highlighter.isHighlightSource(source)` + +用于判断 `source` 参数是否为一个 highlight source 对象。如果是则返回 `true`, 反之亦然. + +#### 6.2.2. `Highlighter.isHighlightWrapNode($node)` + +用于判断 `$node` 参数是否为一个高亮包裹元素。如果是则返回 `true`, 反之亦然. + +### 6.3. 实例方法 + +#### 6.3.1. `highlighter.run()` 开启自动划词高亮。当用户选择了一段文本时,「web-highlighter」会自动为其添加高亮效果。 -### `highlighter.stop()` +#### 6.3.2. `highlighter.stop()` 关闭自动划词高亮。 -### `highlighter.dispose()` +#### 6.3.3. `highlighter.dispose()` 当你不再需要使用高亮功能时,需要先使用该方法来移除一些事件监听,回收一些资源。 -### `highlighter.fromRange(range)` +#### 6.3.4. `highlighter.fromRange(range)` 该方法支持你传一个 [`Range`](https://developer.mozilla.org/en-US/docs/Web/API/Range),并基于该对象进行高亮笔记操作。你可以通过 `window.getSelection().getRangeAt(0)` 方法来获取一个 range 对象,或者使用 `document.createRange()` 方法来创建一个新的 range 对象。 @@ -183,7 +224,7 @@ if (selection.isCollapsed) { } ``` -### `highlighter.fromStore(start, end, text, id)` +#### 6.3.5. `highlighter.fromStore(start, end, text, id)` 大多数情况下,这个 API 用于通过后端的持久化信息还原出文本高亮效果。 @@ -196,33 +237,33 @@ if (selection.isCollapsed) { - text `string`: 文本内容 - id `string`: 高亮的唯一 ID -### `highlighter.remove(id)` +#### 6.3.6. `highlighter.remove(id)` 清除指定 id 的高亮区域。该 id 默认会由 web-highlighter 在创建高亮区域使生成。你也可以通过添加钩子来应用你自己的 id 生成规则。钩子相关文档可以[看这里](https://github.com/alienzhou/web-highlighter/blob/master/docs/ADVANCE.zh_CN.md)。 -### `highlighter.removeAll()` +#### 6.3.7. `highlighter.removeAll()` 清除根节点下的所有高亮区域。 -### `highlighter.addClass(classname, id)` +#### 6.3.8. `highlighter.addClass(classname, id)` 为某个 id 的高亮区域添加 CSS 类名。你可以通过这个 API 来改变某个高亮区域的样式。 -### `highlighter.removeClass(classname, id)` +#### 6.3.9. `highlighter.removeClass(classname, id)` 移除某个 id 的高亮区域的指定 CSS 类名。类似于 `highlighter.addClass` 的逆操作。 -### `highlighter.getDoms([id])` +#### 6.3.10. `highlighter.getDoms([id])` 获取高亮区域内的所有包裹节点。一个高亮区域可能会包含多个片段。它会返回所有这些片段的包裹节点(DOM 节点)。 如果 `id` 参数留空,它会返回根节点下的所有高亮区域中的包裹节点。 -### `highlighter.getIdByDom(node)` +#### 6.3.11. `highlighter.getIdByDom(node)` 传入一个包裹节点,返回该节点对应的高亮区域去的唯一 ID。 -### `Event Listener` +### 6.4. `Event Listener` web-highlighter 使用监听器方式来处理异步事件。 @@ -251,25 +292,25 @@ highlighter.on(Highlighter.event.CREATE, function (data, inst, e) { 对于不同的事件类型,其 `data` 所包含的具体属性如下: -#### `EventType.CLICK` +#### 6.4.1. `EventType.CLICK` |name|description|type| |---|---|---| |`id`| 高亮区域唯一 ID |string| -#### `EventType.HOVER` +#### 6.4.2. `EventType.HOVER` |name|description|type| |---|---|---| |`id`| 高亮区域唯一 ID |string| -#### `EventType.HOVER_OUT` +#### 6.4.3. `EventType.HOVER_OUT` |name|description|type| |---|---|---| |`id`| 高亮区域唯一 ID |string| -#### `EventType.CREATE` +#### 6.4.4. `EventType.CREATE` > 不包含参数 `e` @@ -282,7 +323,7 @@ highlighter.on(Highlighter.event.CREATE, function (data, inst, e) { `type` 用来告知开发者高亮区域被创建的原因。目前 `type` 包含两种可能的值:`from-input` 和 `from-store`。`from-input` 表明该高亮区域是通过用户操作(用户划词的选区)创建的;`from-store` 则表示该高亮区域是通过持久化的 `HighlightSource` 中的数据还原出来的。 -#### `EventType.REMOVE` +#### 6.4.5. `EventType.REMOVE` > 不包含参数 `e` @@ -290,7 +331,11 @@ highlighter.on(Highlighter.event.CREATE, function (data, inst, e) { |---|---|---| |`ids`|一组高亮区域唯一 ID|Array| -## 兼容性 +### 6.5. Hooks(钩子) + +钩子可以用来更好地控制整个高亮流程。通过它你几乎可以实现任何自定义的逻辑。详细内容请参考[下面部分](#更多使用方式)。 + +## 7. 兼容性 > 依赖 [Selection API](https://caniuse.com/#search=selection%20api)。 @@ -303,12 +348,12 @@ highlighter.on(Highlighter.event.CREATE, function (data, inst, e) { _**移动端支持:**_ 如果检测为移动端,则会自动使用相应的事件监听来替代 PC 端事件。 -## 更多使用方式 +## 8. 更多使用方式 为了便于开发者更好地控制相关的高亮行为,web-highlighter 提供一些内部的钩子。 想了解内部钩子及其使用方式,可以阅读[这篇文档](https://github.com/alienzhou/web-highlighter/blob/master/docs/ADVANCE.zh_CN.md)。 -## 许可证 +## 9. 许可证 [MIT](./LICENCE)