├── .eslintignore ├── demo.gif ├── .gitignore ├── .prettierrc ├── src ├── index.js ├── lib │ ├── types.js │ ├── position.js │ ├── touch_event.js │ ├── text_node.js │ ├── markdown.js │ ├── region.js │ └── helpers.js ├── element │ ├── base.js │ ├── cursor.js │ ├── highlight.js │ ├── mask.js │ └── menu.js ├── region_easy_marker.js ├── node_easy_marker.js └── base_easy_marker.js ├── .eslintrc.json ├── LICENSE.md ├── package.json ├── index.d.ts ├── api.md └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /test/ -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luojilab/easy-marker/HEAD/demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | dist 3 | customizeCursor.js 4 | .DS_Store 5 | node_modules 6 | test -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 120, 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import NodeEasyMarker from './node_easy_marker' 2 | import RegionEasyMarker from './region_easy_marker' 3 | 4 | export { NodeEasyMarker, RegionEasyMarker } 5 | export default NodeEasyMarker 6 | -------------------------------------------------------------------------------- /src/lib/types.js: -------------------------------------------------------------------------------- 1 | 2 | const SelectStatus = { 3 | NONE: 'none', 4 | SELECTING: 'selecting', 5 | FINISH: 'finish', 6 | } 7 | 8 | const EasyMarkerMode = { 9 | NODE: 'node', 10 | REGION: 'region', 11 | } 12 | 13 | const NoteType = { 14 | UNDERLINE: 'underline', 15 | HIGHLIGHT: 'highlight', 16 | } 17 | 18 | const DeviceType = { 19 | PC: 'pc', 20 | MOBILE: 'MOBILE', 21 | } 22 | 23 | const MenuType = { 24 | SELECT: 'select', 25 | HIGHLIGHT: 'highlight', 26 | } 27 | 28 | export { SelectStatus, EasyMarkerMode, NoteType, DeviceType, MenuType } 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "parser": "babel-eslint", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "env": { 9 | "browser": true 10 | }, 11 | "rules": { 12 | "semi": ["error", "never"], 13 | "comma-dangle": ["error", "always-multiline", { "functions": "never" }], 14 | "no-underscore-dangle": ["error", { "allowAfterThis": true }], 15 | "max-len": ["warn", 120], 16 | "no-mixed-operators": [ 17 | "error", 18 | { 19 | "groups": [ 20 | ["&", "|", "^", "~", "<<", ">>", ">>>"], 21 | ["==", "!=", "===", "!==", ">", ">=", "<", "<="], 22 | ["&&", "||"], 23 | ["in", "instanceof"] 24 | ], 25 | "allowSamePrecedence": true 26 | } 27 | ], 28 | "no-plusplus": "off", 29 | "no-param-reassign": "off", 30 | "no-use-before-define": ["error", { "functions": false, "classes": false }] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Beijing logicreation Information & Technology Co., Ltd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/lib/position.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Position class 3 | * 4 | * @export 5 | * @class Position 6 | */ 7 | export default class Position { 8 | constructor() { 9 | this.y = 0 10 | this.x = 0 11 | this.width = 0 12 | this.height = 0 13 | } 14 | 15 | /** 16 | * Return position set whether or not 17 | * 18 | * @readonly 19 | * @memberof Position 20 | */ 21 | get isSet() { 22 | return this.y !== 0 || this.x !== 0 || this.width !== 0 || this.height !== 0 23 | } 24 | 25 | 26 | /** 27 | * Set the position 28 | * 29 | * @param {any} position 30 | * @memberof Position 31 | */ 32 | setAll(position) { 33 | this.x = position.x 34 | this.y = position.y 35 | this.width = position.width 36 | this.height = position.height 37 | } 38 | 39 | /** 40 | * Check if the current position is equal to the specified position 41 | * 42 | * @param {any} position 43 | * @returns {bool} 44 | * @memberof Position 45 | */ 46 | equal(position) { 47 | return this.x === position.x 48 | && this.y === position.y 49 | && this.width === position.width 50 | && this.height === position.height 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/element/base.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Base element class 3 | * 4 | * @export 5 | * @class BaseElement 6 | */ 7 | export default class BaseElement { 8 | constructor() { 9 | this.element = null 10 | this.container = document.body 11 | } 12 | 13 | /** 14 | * Create element interface 15 | * 16 | * @memberof BaseElement 17 | */ 18 | createElement() { // eslint-disable-line class-methods-use-this 19 | } 20 | 21 | /** 22 | * Mount the element under the container 23 | * 24 | * @memberof BaseElement 25 | */ 26 | mount() { 27 | this.container.appendChild(this.element) 28 | } 29 | 30 | /** 31 | * get Element Style 32 | * 33 | * @memberof BaseElement 34 | */ 35 | get style() { 36 | return this.element.style 37 | } 38 | 39 | 40 | show() { 41 | this.style.display = 'block' 42 | } 43 | 44 | hide() { 45 | this.style.display = 'none' 46 | } 47 | 48 | get isShow() { 49 | return this.style.display !== 'none' && this.style.display !== '' 50 | } 51 | 52 | /** 53 | * Remove Element 54 | * 55 | * @memberof BaseElement 56 | */ 57 | destroy() { 58 | if (this.element) { 59 | this.element.parentElement.removeChild(this.element) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "easy-marker", 3 | "version": "1.1.23", 4 | "description": "Easy marker", 5 | "main": "dist/easy-marker.esm.js", 6 | "scripts": { 7 | "build": "rollup -c build/esm.rollup.config.js && rollup -c build/rollup.config.js", 8 | "testRegion": "rollup -f iife -o test/regionDist.js -i test/testRegion.js -n test -w", 9 | "testNode": "rollup -f iife -o test/nodeDist.js -i test/testNode.js -n test -w", 10 | "doc": "jsdoc2md src/base_easy_marker.js > api.md", 11 | "preversion": "npm run build", 12 | "postversion": "git push && git push --tags" 13 | }, 14 | "files": [ 15 | "dist", 16 | "src", 17 | "api.md", 18 | "build", 19 | "index.d.ts" 20 | ], 21 | "author": "jiangq.leaves@gmail.com", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/luojilab/easy-marker.git" 25 | }, 26 | "license": "ISC", 27 | "devDependencies": { 28 | "babel-core": "^6.26.3", 29 | "babel-eslint": "^8.2.3", 30 | "babel-plugin-transform-runtime": "^6.23.0", 31 | "babel-preset-env": "^1.6.1", 32 | "babel-runtime": "^6.26.0", 33 | "eslint": "^4.19.1", 34 | "eslint-config-airbnb-base": "^12.1.0", 35 | "eslint-plugin-import": "^2.11.0", 36 | "jsdoc": "^3.6.3", 37 | "jsdoc-to-markdown": "^5.0.3", 38 | "rollup": "^0.58.2", 39 | "rollup-plugin-babel": "^3.0.4", 40 | "rollup-plugin-commonjs": "^9.1.3", 41 | "rollup-plugin-node-resolve": "^3.3.0", 42 | "rollup-plugin-uglify": "^3.0.0" 43 | }, 44 | "dependencies": {} 45 | } 46 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare class EasyMarker { 2 | constructor(options: EasyMarkerOptions); 3 | public create( 4 | containerElement: HTMLElement, 5 | scrollContainerElement?: HTMLElement, 6 | options?: HTMLElement[] | InitOptions, 7 | ): void; 8 | public getSelectText(): string; 9 | public highlightLine( 10 | selection: SelectionIdentifier, 11 | id?: string | number, 12 | meta?: unknown, 13 | ): void; 14 | public highlightLines(HighlightLines: HighlightLine[]): void; 15 | public cancelHighlightLine(id: string | number): boolean; 16 | public onHighlightLineClick( 17 | cb: ( 18 | id: string | number, 19 | meta: unknown, 20 | selection: SelectionContent, 21 | ) => void, 22 | ): void; 23 | public onSelectStatusChange(cb: (status: SelectStatus) => void): void; 24 | public onMenuClick( 25 | cb: (id: string | number, selection: SelectionContent) => void, 26 | ): void; 27 | public registerEventHook(cb: () => void): void; 28 | public destroy(): void; 29 | public disable(): void; 30 | public enable(): void; 31 | public static create( 32 | containerElement: HTMLElement, 33 | scrollContainerElement?: HTMLElement, 34 | options?: HTMLElement[] | InitOptions, 35 | ): EasyMarker; 36 | } 37 | 38 | export enum SelectStatus { 39 | NONE = 'none', 40 | SELECTING = 'selecting', 41 | FINISH = 'finish', 42 | } 43 | 44 | export interface InitOptions { 45 | excludeElements?: HTMLElement[]; 46 | includeElements?: HTMLElement[]; 47 | } 48 | 49 | export interface EasyMarkerOptions { 50 | menuItems?: MenuItem[]; 51 | menuTopOffset?: number | string; 52 | menuStyle?: MenuStyle; 53 | disableTapHighlight?: boolean; 54 | cursor?: CursorOptions; 55 | mask?: MaskOptions; 56 | highlight?: HighlightOptions; 57 | scrollSpeedLevel?: number; 58 | scrollOffsetBottom?: number | string; 59 | markdownOptions?: MarkdownOptions; 60 | disableSelect?: boolean; 61 | regions?: Region; 62 | } 63 | 64 | export interface Region { 65 | text: string; 66 | width: number; 67 | height: number; 68 | left: number; 69 | top: number; 70 | meta?: unknown; 71 | } 72 | 73 | export interface MenuItem { 74 | text: string; 75 | type?: MenuType; 76 | iconName?: string; 77 | style?: Record; 78 | handler?: (selection: SelectionContent) => void; 79 | } 80 | 81 | export enum MenuType { 82 | SELECT = 'select', 83 | HIGHLIGHT = 'highlight', 84 | } 85 | 86 | export interface SelectionContent extends SelectionIdentifier { 87 | toString: () => string; 88 | toMarkdown: () => string; 89 | } 90 | 91 | export interface SelectionIdentifier { 92 | anchorNode: Text; 93 | anchorOffset: number; 94 | focusNode: Text; 95 | focusOffset: number; 96 | } 97 | 98 | export interface MenuStyle { 99 | menu?: Record; 100 | triangle?: Record; 101 | item?: Record; 102 | } 103 | 104 | export interface CursorOptions { 105 | color?: string; 106 | same?: boolean; 107 | customClass?: unknown; 108 | } 109 | 110 | export interface MaskOptions { 111 | color?: string; 112 | } 113 | 114 | export interface HighlightOptions { 115 | color?: string; 116 | } 117 | 118 | export interface MarkdownOptions { 119 | [k: string]: (key: string) => string; 120 | } 121 | 122 | export interface HighlightLine { 123 | selection: SelectionIdentifier; 124 | id?: string | number; 125 | meta?: unknown; 126 | } 127 | 128 | export default EasyMarker; 129 | -------------------------------------------------------------------------------- /src/element/cursor.js: -------------------------------------------------------------------------------- 1 | import BaseElement from './base' 2 | import { getDistance, getDeviceType } from '../lib/helpers' 3 | import { DeviceType } from '../lib/types' 4 | 5 | export const CursorType = { 6 | START: 'start', 7 | END: 'end', 8 | } 9 | 10 | const defaultOptions = { 11 | color: '#ff6b00', 12 | } 13 | 14 | /** 15 | * Cursor class 16 | * 17 | * @export 18 | * @class Cursor 19 | * @extends {BaseElement} 20 | */ 21 | export default class Cursor extends BaseElement { 22 | /** 23 | * Creates an instance of Cursor. 24 | * @param {any} container 25 | * @param {any} type 26 | * @param {any} options 27 | * @memberof Cursor 28 | */ 29 | constructor(container, type, options) { 30 | super() 31 | this.container = container 32 | this.type = type 33 | this.options = Object.assign({}, defaultOptions, options) 34 | this.$position = { x: 0, y: 0 } 35 | this.$height = 0 36 | this.touchCallbackStack = [] 37 | this.topPoint = null 38 | this.lineElement = null 39 | this.bottomPoint = null 40 | this.deviceType = getDeviceType() 41 | this.createElement() 42 | this.mount() 43 | } 44 | 45 | set position(val) { 46 | const { x, y } = val 47 | this.$position = { x, y } 48 | 49 | this.moveTo(x, y) 50 | } 51 | 52 | get position() { 53 | return this.$position 54 | } 55 | 56 | get height() { 57 | return this.$height 58 | } 59 | 60 | set height(val) { 61 | if (val !== this.$height) { 62 | this.$height = val 63 | this.setCursorSize(val) 64 | } 65 | } 66 | 67 | get width() { 68 | return this.height / 4 69 | } 70 | 71 | show() { 72 | if (this.deviceType === DeviceType.PC) { 73 | return 74 | } 75 | 76 | this.style.display = 'block' 77 | } 78 | 79 | /** 80 | * Move to the specified location 81 | * 82 | * @param {number} x px 83 | * @param {number} y px 84 | * @memberof Cursor 85 | */ 86 | moveTo(x, y) { 87 | this.style.top = `${y - this.width}px` 88 | this.style.left = `${x - (this.width / 2)}px` 89 | } 90 | 91 | /** 92 | * Create the element 93 | * 94 | * @override 95 | * @memberof Cursor 96 | */ 97 | createElement() { 98 | this.element = document.createElement('div') 99 | this.style.userSelect = 'none' 100 | this.style.webkitUserSelect = 'none' 101 | this.style.zIndex = '30' 102 | this.style.transition = 'top 0.1s, left 0.1s' 103 | this.style.display = 'none' 104 | this.style.position = 'absolute' 105 | 106 | this.topPoint = document.createElement('div') 107 | this.topPoint.style.borderRadius = '50%' 108 | this.topPoint.style.margin = 'auto' 109 | 110 | this.lineElement = document.createElement('div') 111 | this.lineElement.style.margin = 'auto' 112 | this.lineElement.style.backgroundColor = this.options.color 113 | 114 | this.bottomPoint = document.createElement('div') 115 | this.bottomPoint.style.borderRadius = '50%' 116 | this.bottomPoint.style.margin = 'auto' 117 | 118 | if (this.type === CursorType.START) { 119 | this.topPoint.style.backgroundColor = this.options.color 120 | } else { 121 | this.bottomPoint.style.backgroundColor = this.options.color 122 | } 123 | 124 | this.element.appendChild(this.topPoint) 125 | this.element.appendChild(this.lineElement) 126 | this.element.appendChild(this.bottomPoint) 127 | } 128 | 129 | /** 130 | * Set the size of the cursor 131 | * 132 | * @param {number} size 133 | * @memberof Cursor 134 | */ 135 | setCursorSize(size) { 136 | const pointDiameter = `${this.width}px` 137 | 138 | this.style.width = pointDiameter 139 | 140 | this.topPoint.style.height = pointDiameter 141 | this.topPoint.style.width = pointDiameter 142 | this.bottomPoint.style.height = pointDiameter 143 | this.bottomPoint.style.width = pointDiameter 144 | 145 | this.lineElement.style.height = `${size}px` 146 | this.lineElement.style.width = `${size / 15}px` 147 | } 148 | 149 | /** 150 | * Check if it is in the region of the cursor 151 | * 152 | * @param {Object} position 153 | * @param {number} position.x 154 | * @param {number} position.y 155 | * @memberof Cursor 156 | */ 157 | inRegion(position = {}) { 158 | if (this.deviceType === DeviceType.PC) { 159 | return { inRegion: false } 160 | } 161 | const maxDistance = this.height 162 | let distance = Number.MAX_SAFE_INTEGER 163 | if (position.y > this.position.y && position.y < this.position.y + this.height) { 164 | distance = Math.abs(position.x - this.position.x) 165 | } 166 | if (position.y >= this.position.y + this.with) { 167 | distance = getDistance(position, { x: this.position.x, y: this.position.y + this.height }) 168 | } 169 | if (position.y <= this.position.y) { 170 | distance = getDistance(position, this.position) 171 | } 172 | return { inRegion: distance <= maxDistance, distance } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/lib/touch_event.js: -------------------------------------------------------------------------------- 1 | import { getDistance, getTouchPosition, getDeviceType, getTouch } from './helpers' 2 | import { DeviceType } from './types' 3 | 4 | export const EventType = { 5 | TOUCH_START: 'touchstart', 6 | TOUCH_MOVE: 'touchmove', 7 | TOUCH_MOVE_THROTTLE: 'touchmovethrottle', 8 | TOUCH_END: 'touchend', 9 | TAP: 'tap', 10 | LONG_TAP: 'longtap', 11 | } 12 | 13 | /** 14 | * Touch Event class 15 | * 16 | * @export 17 | * @class TouchEvent 18 | */ 19 | export default class TouchEvent { 20 | constructor(element, options) { 21 | this.disabled = false 22 | this.options = { 23 | longTapTime: 600, 24 | tapTime: 500, 25 | slideDistance: 20, 26 | throttleTime: 50, 27 | } 28 | // this.element = getDeviceType() === DeviceType.MOBILE ? element : window 29 | this.element = element || window 30 | this.options = Object.assign(this.options, options) 31 | this.touchStartCallbacks = [] 32 | this.touchMoveCallbacks = [] 33 | this.touchMoveThrottleCallbacks = [] 34 | this.touchEndCallbacks = [] 35 | this.tapCallbacks = [] 36 | this.longTapCallbacks = [] 37 | this.hook = () => true 38 | 39 | this.touchStartPosition = { x: 0, y: 0 } 40 | this.longTapTimerHandler = null 41 | this.touchMoveTimerHandler = null 42 | this.touchStartTime = Date.now() 43 | this.lastMoveTime = Date.now() 44 | this.onTouchStart = this.onTouchStart.bind(this) 45 | this.onTouchMove = this.onTouchMove.bind(this) 46 | this.onTouchEnd = this.onTouchEnd.bind(this) 47 | this.startEventName = getDeviceType() === DeviceType.MOBILE ? 'touchstart' : 'mousedown' 48 | this.moveEventName = getDeviceType() === DeviceType.MOBILE ? 'touchmove' : 'mousemove' 49 | this.endEventName = getDeviceType() === DeviceType.MOBILE ? 'touchend' : 'mouseup' 50 | this.cancelEventName = getDeviceType() === DeviceType.MOBILE ? 'touchcancel' : 'mouseleave' 51 | this.element.addEventListener(this.startEventName, this.onTouchStart) 52 | this.element.addEventListener(this.moveEventName, this.onTouchMove, { 53 | passive: false, 54 | }) 55 | this.element.addEventListener(this.endEventName, this.onTouchEnd) 56 | this.element.addEventListener(this.cancelEventName, this.onTouchEnd) 57 | } 58 | 59 | disable() { 60 | this.disabled = true 61 | } 62 | enable() { 63 | this.disabled = false 64 | } 65 | /** 66 | * Register event 67 | * 68 | * @param {string} eventType 69 | * @param {Function} callback 70 | * @memberof TouchEvent 71 | */ 72 | registerEvent(eventType, callback) { 73 | if (typeof callback !== 'function') return 74 | 75 | switch (eventType) { 76 | case EventType.TOUCH_START: 77 | this.touchStartCallbacks.push(callback) 78 | break 79 | case EventType.TOUCH_MOVE: 80 | this.touchMoveCallbacks.push(callback) 81 | break 82 | case EventType.TOUCH_MOVE_THROTTLE: 83 | this.touchMoveThrottleCallbacks.push(callback) 84 | break 85 | case EventType.TOUCH_END: 86 | this.touchEndCallbacks.push(callback) 87 | break 88 | case EventType.TAP: 89 | this.tapCallbacks.push(callback) 90 | break 91 | case EventType.LONG_TAP: 92 | this.longTapCallbacks.push(callback) 93 | break 94 | default: 95 | break 96 | } 97 | } 98 | 99 | registerHook(callback) { 100 | this.hook = callback 101 | } 102 | 103 | onTouchStart(e) { 104 | if (this.disabled) return 105 | if (e.touches && e.touches.length > 1) return 106 | if (!this.hook('touchstart', e)) return 107 | this.touchStartCallbacks.forEach(callback => callback(e)) 108 | 109 | this.longTapTimerHandler = setTimeout(() => { 110 | this.onLongTap(e) 111 | }, this.options.longTapTime) 112 | 113 | this.touchStartPosition = getTouchPosition(e) 114 | this.touchStartTime = Date.now() 115 | } 116 | 117 | onTouchMove(e) { 118 | if (this.disabled) return 119 | if (e.touches && e.touches.length > 1) return 120 | if (!this.hook('touchmove', e)) return 121 | 122 | this.touchMoveCallbacks.forEach(callback => callback(e)) 123 | 124 | clearTimeout(this.touchMoveTimerHandler) 125 | this.touchMoveTimerHandler = setTimeout(() => { 126 | this.onTouchMoveThrottle(e) 127 | }, this.options.throttleTime) 128 | if (Date.now() - this.lastMoveTime > this.options.throttleTime) { 129 | this.lastMoveTime = Date.now() 130 | this.onTouchMoveThrottle(e) 131 | } 132 | 133 | const currentPosition = getTouchPosition(e) 134 | const moveDistance = getDistance(currentPosition, this.touchStartPosition) 135 | if (moveDistance > this.options.slideDistance) { 136 | clearTimeout(this.longTapTimerHandler) 137 | this.longTapTimerHandler = null 138 | } 139 | } 140 | 141 | onTouchEnd(e) { 142 | if (this.disabled) return 143 | if (e.touches && e.touches.length > 1) return 144 | if (!this.hook('touchmove', e)) return 145 | 146 | this.touchEndCallbacks.forEach(callback => callback(e)) 147 | 148 | clearTimeout(this.longTapTimerHandler) 149 | this.longTapTimerHandler = null 150 | if (Date.now() - this.touchStartTime < this.options.tapTime) { 151 | const currentPosition = getTouchPosition(e) 152 | const moveDistance = getDistance(currentPosition, this.touchStartPosition) 153 | if (moveDistance < this.options.slideDistance) { 154 | e.preventDefault() 155 | const clickEvent = this.constructor.createMouseEvent('click', e) 156 | this.onTap(e) 157 | e.target.dispatchEvent(clickEvent) 158 | } 159 | } 160 | } 161 | 162 | onTouchMoveThrottle(e) { 163 | this.touchMoveThrottleCallbacks.forEach(callback => callback(e)) 164 | } 165 | 166 | onTap(e) { 167 | if (this.disabled) return 168 | if (!this.hook('tap', e)) return 169 | 170 | this.tapCallbacks.forEach(callback => callback(e)) 171 | } 172 | 173 | onLongTap(e) { 174 | if (this.disabled) return 175 | this.longTapCallbacks.forEach(callback => callback(e)) 176 | } 177 | 178 | destroy() { 179 | this.element.removeEventListener(this.startEventName, this.onTouchStart) 180 | this.element.removeEventListener(this.moveEventName, this.onTouchMove) 181 | this.element.removeEventListener(this.endEventName, this.onTouchEnd) 182 | this.element.removeEventListener(this.cancelEventName, this.onTouchEnd) 183 | } 184 | 185 | static createMouseEvent(type, e) { 186 | const touch = getTouch(e) 187 | const event = new MouseEvent(type) 188 | event.initMouseEvent( 189 | type, 190 | true, 191 | true, 192 | window, 193 | 1, 194 | touch.screenX, 195 | touch.screenY, 196 | touch.clientX, 197 | touch.clientY, 198 | false, 199 | false, 200 | false, 201 | false, 202 | 0, 203 | null 204 | ) 205 | event.forwardedTouchEvent = true 206 | return event 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/lib/text_node.js: -------------------------------------------------------------------------------- 1 | import { getNodeRects, copyRect } from './helpers' 2 | import Position from './position' 3 | 4 | /** 5 | * Text node 6 | * 7 | * @export 8 | * @class TextNode 9 | */ 10 | export default class TextNode { 11 | constructor(node, offset) { 12 | this.node = node 13 | this.offset = offset 14 | } 15 | 16 | /** 17 | * Get the selected text 18 | * 19 | * @static 20 | * @param {any} startTextNode 21 | * @param {any} endTextNode 22 | * @memberof TextNode 23 | */ 24 | static getSelectText(startTextNode, endTextNode) { 25 | try { 26 | const { text } = this.getSelectNodeRectAndText( 27 | startTextNode.node, 28 | endTextNode.node, 29 | startTextNode.offset, 30 | endTextNode.offset 31 | ) 32 | return text 33 | } catch (error) { 34 | console.error('EasyMarkerError:', error) // eslint-disable-line no-console 35 | return '' 36 | } 37 | } 38 | 39 | /** 40 | * Get the selected area 41 | * 42 | * @static 43 | * @param {any} startTextNode 44 | * @param {any} endTextNode 45 | * @returns 46 | * @memberof TextNode 47 | */ 48 | static getSelectRects(startTextNode, endTextNode) { 49 | const headerLine = new Position() 50 | const bodyLine = new Position() 51 | const footerLine = new Position() 52 | 53 | if (!startTextNode || !endTextNode) { 54 | return { 55 | header: headerLine, 56 | body: bodyLine, 57 | footer: footerLine, 58 | } 59 | } 60 | 61 | if (startTextNode.node.nodeName !== '#text' || endTextNode.node.nodeName !== '#text') { 62 | // eslint-disable-next-line no-console 63 | console.error('The parameters for getting the selection rect should be a TextNode', { 64 | startTextNode, 65 | endTextNode, 66 | }) 67 | return { 68 | header: headerLine, 69 | body: bodyLine, 70 | footer: footerLine, 71 | } 72 | } 73 | let rects 74 | try { 75 | ({ rects } = this.getSelectNodeRectAndText( 76 | startTextNode.node, 77 | endTextNode.node, 78 | startTextNode.offset, 79 | endTextNode.offset 80 | )) 81 | } catch (error) { 82 | console.error('EasyMarkerError:', error) // eslint-disable-line no-console 83 | rects = [] 84 | } 85 | 86 | const lineMergedRects = [] 87 | rects.forEach((rect) => { 88 | if (lineMergedRects.length > 0) { 89 | const lastLineMergedRect = lineMergedRects[lineMergedRects.length - 1] 90 | const safetyBoundary = lastLineMergedRect.height / 2 91 | if (Math.abs(lastLineMergedRect.top - rect.top) < safetyBoundary 92 | && Math.abs(lastLineMergedRect.bottom - rect.bottom) < safetyBoundary) { 93 | lastLineMergedRect.width += rect.width 94 | lastLineMergedRect.height = lastLineMergedRect.height - rect.height > 0 95 | ? lastLineMergedRect.height : rect.height 96 | lastLineMergedRect.top = lastLineMergedRect.top - rect.top < 0 97 | ? lastLineMergedRect.top : rect.top 98 | lastLineMergedRect.bottom = lastLineMergedRect.bottom - rect.bottom > 0 99 | ? lastLineMergedRect.bottom : rect.bottom 100 | } else { 101 | lineMergedRects.push(copyRect(rect)) 102 | } 103 | } else { 104 | lineMergedRects.push(copyRect(rect)) 105 | } 106 | }) 107 | 108 | let startLineHeight = 0 109 | const leftArr = [] 110 | const rightArr = [] 111 | if (lineMergedRects.length > 0) { 112 | const headRect = lineMergedRects.shift() 113 | startLineHeight = 114 | Number(window.getComputedStyle(startTextNode.node.parentElement).lineHeight.replace('px', '')) || 115 | headRect.height 116 | headerLine.x = headRect.left 117 | headerLine.width = headRect.width 118 | headerLine.y = headRect.top - (startLineHeight - headRect.height) / 2 119 | headerLine.height = startLineHeight 120 | leftArr.push(headerLine.x) 121 | rightArr.push(headRect.right) 122 | } 123 | 124 | let endLineHight = 0 125 | if (lineMergedRects.length > 0) { 126 | const footRect = lineMergedRects.pop() 127 | endLineHight = 128 | Number(window.getComputedStyle(endTextNode.node.parentElement).lineHeight.replace('px', '')) || footRect.height 129 | footerLine.x = footRect.left 130 | footerLine.width = footRect.width 131 | footerLine.y = footRect.top - (endLineHight - footRect.height) / 2 132 | footerLine.height = endLineHight 133 | 134 | leftArr.push(footerLine.x) 135 | rightArr.push(footRect.right) 136 | } 137 | 138 | if (lineMergedRects.length > 0) { 139 | let maxRight = 0 140 | lineMergedRects.forEach((lineRect) => { 141 | if (bodyLine.x && bodyLine.width) { 142 | if (lineRect.left < bodyLine.x) { 143 | bodyLine.x = lineRect.left 144 | } 145 | 146 | if (lineRect.width > bodyLine.width) { 147 | bodyLine.width = lineRect.width 148 | } 149 | if (maxRight < lineRect.right) { 150 | maxRight = lineRect.right 151 | } 152 | } else { 153 | bodyLine.x = lineRect.left 154 | bodyLine.width = lineRect.width 155 | maxRight = lineRect.right 156 | } 157 | }) 158 | leftArr.push(bodyLine.x) 159 | rightArr.push(maxRight) 160 | } 161 | const minLeft = Math.min(...leftArr) 162 | if (minLeft !== Infinity) { 163 | bodyLine.x = minLeft 164 | } 165 | const maxRight = Math.max(...rightArr) 166 | if (maxRight !== -Infinity) { 167 | bodyLine.width = maxRight - bodyLine.x 168 | } 169 | 170 | bodyLine.y = headerLine.y + startLineHeight 171 | if (footerLine.isSet) { 172 | bodyLine.height = footerLine.y - bodyLine.y 173 | } else { 174 | footerLine.x = headerLine.x 175 | footerLine.y = headerLine.y + startLineHeight 176 | } 177 | 178 | return { 179 | header: headerLine, 180 | body: bodyLine, 181 | footer: footerLine, 182 | } 183 | } 184 | 185 | static getSelectNodeRectAndText(startNode, endNode, startIndex, endIndex) { 186 | const result = { 187 | rects: [], 188 | text: '', 189 | } 190 | if (startNode.childNodes.length > 0 && startNode.nodeName !== 'SCRIPT' && startNode.nodeName !== 'STYLE') { 191 | const childNode = startNode.childNodes[0] 192 | const { text, rects } = this.getSelectNodeRectAndText(childNode, endNode, 0, endIndex) 193 | result.rects.push(...rects) 194 | result.text += text 195 | return result 196 | } 197 | 198 | if (startNode.nodeName === '#text') { 199 | const textEndIndex = startNode === endNode ? endIndex : startNode.textContent.length 200 | result.rects.push(...getNodeRects(startNode, startIndex, textEndIndex)) 201 | result.text += startNode.textContent.substring(startIndex, textEndIndex) 202 | } 203 | 204 | if (startNode.nodeName === 'IMG') { 205 | result.rects.push(startNode.getBoundingClientRect()) 206 | } 207 | 208 | if (startNode === endNode) { 209 | return result 210 | } 211 | 212 | const nextNode = startNode.nextSibling 213 | if (nextNode) { 214 | const { text, rects } = this.getSelectNodeRectAndText(nextNode, endNode, 0, endIndex) 215 | result.rects.push(...rects) 216 | result.text += text 217 | } else { 218 | let currentNode = startNode.parentNode 219 | while (currentNode && currentNode.nextSibling === null) { 220 | currentNode = currentNode.parentNode 221 | } 222 | if (currentNode) { 223 | const { text, rects } = this.getSelectNodeRectAndText(currentNode.nextSibling, endNode, 0, endIndex) 224 | result.rects.push(...rects) 225 | result.text += text 226 | } else { 227 | throw new Error('Invalid end node') 228 | } 229 | } 230 | return result 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/lib/markdown.js: -------------------------------------------------------------------------------- 1 | import { domCollectionToArray } from './helpers' 2 | 3 | const defaultOptions = Object.freeze({ 4 | H1: text => `\n# ${text}\n\n`, 5 | H2: text => `\n## ${text}\n\n`, 6 | H3: text => `\n### ${text}\n\n`, 7 | H4: text => `\n#### ${text}\n\n`, 8 | H5: text => `\n##### ${text}\n\n`, 9 | H6: text => `\n###### ${text}\n\n`, 10 | P: text => `${text}\n\n`, 11 | FIGCAPTION: text => `${text}\n\n`, 12 | STRONG: text => `**${text}**`, 13 | B: text => `**${text}**`, 14 | EM: text => `*${text}*`, 15 | I: text => `*${text}*`, 16 | S: text => `~~${text}~~`, 17 | INS: text => `++${text}++`, 18 | IMG: option => `![${option.alt}](${option.src}?size=${option.width}x${option.height})\n`, 19 | UL: (text, option) => { 20 | if (option.listLevel > 1) { 21 | return `\n${text}` 22 | } 23 | return `\n${text}\n\n` 24 | }, 25 | OL: (text, option) => { 26 | if (option.listLevel > 1) { 27 | return `\n${text}` 28 | } 29 | return `\n${text}\n\n` 30 | }, 31 | LI: (text, option) => { 32 | let spaceString = '' 33 | for (let i = 1; i < option.itemLevel; i++) { spaceString += ' ' } 34 | let endString = '\n' 35 | if (option.hasChild || option.isLastOne) { 36 | endString = '' 37 | } 38 | if (option.type === 'UL') { return `${spaceString}- ${text}${endString}` } 39 | return `${spaceString}${option.index}. ${text}${endString}` 40 | }, 41 | }) 42 | 43 | /** 44 | * Markdown 45 | * 46 | * @export 47 | * @class Markdown 48 | */ 49 | export default class Markdown { 50 | constructor(container, options = {}) { 51 | this.container = container 52 | this.wrapMarkdown = Markdown.wrapMarkdown 53 | this.options = Object.assign({}, defaultOptions, options) 54 | } 55 | 56 | /** 57 | * Get the selected markdown 58 | * 59 | * @param {Node} startNode 60 | * @param {Node} endNode 61 | * @param {Number} startIndex 62 | * @param {number} endIndex 63 | * @param {Stack} markdownStack 64 | */ 65 | getSelectMarkdown(startNode, endNode, startIndex, endIndex, markdownStack) { 66 | const result = { 67 | markdown: '', 68 | } 69 | let popText = '' 70 | if (markdownStack === undefined) markdownStack = [] 71 | if (startNode.childNodes.length > 0 && startNode.nodeName !== 'SCRIPT' && startNode.nodeName !== 'STYLE') { 72 | const childNode = startNode.childNodes[0] 73 | const { markdown } = this.getSelectMarkdown(childNode, endNode, 0, endIndex, markdownStack) 74 | result.markdown = markdown 75 | return result 76 | } 77 | 78 | if (startNode.nodeName === '#text') { 79 | let node = startNode 80 | const tempMarkdownStack = [] 81 | const textEndIndex = startNode === endNode ? endIndex : startNode.textContent.length 82 | const currentText = startNode.textContent.substring(startIndex, textEndIndex).replace(/(^\s*)|(\s*$)/g, '') 83 | if (markdownStack.length !== 0 && node.parentNode === markdownStack[markdownStack.length - 1].node) { 84 | popText = currentText 85 | } 86 | let isContainer = false 87 | while (!isContainer 88 | && (markdownStack.length === 0 || node.parentNode !== markdownStack[markdownStack.length - 1].node)) { 89 | if (node === this.container) isContainer = true 90 | let text = '' 91 | if (node.nodeName === '#text') { 92 | text = currentText 93 | } 94 | node = node.parentNode 95 | tempMarkdownStack.push({ 96 | node, 97 | text, 98 | }) 99 | } 100 | while (tempMarkdownStack.length !== 0) { 101 | markdownStack.push(tempMarkdownStack.pop()) 102 | } 103 | } 104 | 105 | if (startNode.nodeName === 'IMG') { 106 | if (markdownStack.length > 0) { 107 | markdownStack[markdownStack.length - 1].text += this.wrapMarkdown(startNode, this.options) 108 | } else { 109 | result.markdown += this.wrapMarkdown(startNode, this.options) 110 | } 111 | } 112 | 113 | if (startNode === endNode) { 114 | if (markdownStack.length !== 0) { 115 | const popMarkdown = markdownStack.pop() 116 | popMarkdown.text += popText 117 | result.markdown = this.wrapMarkdown(popMarkdown.node, this.options, popMarkdown.text) 118 | if (markdownStack.length !== 0) { markdownStack[markdownStack.length - 1].text += result.markdown } 119 | } 120 | while (markdownStack.length !== 0) { 121 | const popMarkdown = markdownStack.pop() 122 | result.markdown = this.wrapMarkdown(popMarkdown.node, this.options, popMarkdown.text) 123 | if (markdownStack.length !== 0) { markdownStack[markdownStack.length - 1].text += result.markdown } 124 | } 125 | return result 126 | } 127 | const nextNode = startNode.nextSibling 128 | if (nextNode) { 129 | const { markdown } = this.getSelectMarkdown(nextNode, endNode, 0, endIndex, markdownStack) 130 | if (markdownStack.length > 0) { 131 | markdownStack[markdownStack.length - 1].text += markdown 132 | } else { 133 | result.markdown += markdown 134 | } 135 | } else { 136 | let currentNode = startNode.parentNode 137 | let popMarkdown = markdownStack.pop() 138 | popMarkdown.text += popText 139 | result.markdown += this.wrapMarkdown(popMarkdown.node, this.options, popMarkdown.text) 140 | if (markdownStack.length !== 0) { markdownStack[markdownStack.length - 1].text += result.markdown } 141 | while (currentNode && currentNode.nextSibling === null) { 142 | currentNode = currentNode.parentNode 143 | popMarkdown = markdownStack.pop() 144 | popMarkdown.text += popText 145 | result.markdown = this.wrapMarkdown(popMarkdown.node, this.options, popMarkdown.text) 146 | if (markdownStack.length !== 0) { markdownStack[markdownStack.length - 1].text += result.markdown } 147 | } 148 | if (currentNode) { 149 | const { markdown } = this.getSelectMarkdown(currentNode.nextSibling, endNode, 0, endIndex, markdownStack) 150 | if (markdownStack.length !== 0) { 151 | markdownStack[markdownStack.length - 1].text += markdown 152 | } else { 153 | result.markdown = markdown 154 | } 155 | } else { 156 | throw new Error('Invalid end node') 157 | } 158 | } 159 | return result 160 | } 161 | 162 | static wrapMarkdown(node, options, text) { 163 | if (node.nodeName === 'IMG') { 164 | const imgOption = { 165 | alt: node.alt, 166 | src: node.src, 167 | width: node.width, 168 | height: node.height, 169 | } 170 | return options[node.nodeName] ? options[node.nodeName](imgOption) : '' 171 | } else if (node.nodeName === 'LI') { 172 | let itemLevel = 1 173 | let tempNode = node.parentNode 174 | while (tempNode.parentNode) { 175 | tempNode = tempNode.parentNode 176 | if (tempNode.nodeName === node.parentNode.nodeName) itemLevel++ 177 | } 178 | let hasChild = false 179 | if (domCollectionToArray(node.childNodes) 180 | .some(childNode => childNode.nodeName === 'UL' || childNode.nodeName === 'OL')) { 181 | hasChild = true 182 | } 183 | let isLastOne = false 184 | if (!node.nextElementSibling) { 185 | isLastOne = true 186 | } 187 | const option = { 188 | type: node.parentNode.nodeName, 189 | isLastOne, 190 | itemLevel, 191 | hasChild, 192 | index: [].indexOf.call(node.parentNode.children, node) + 1, 193 | } 194 | return options[node.nodeName] ? options[node.nodeName](text, option) : text 195 | } else if (node.nodeName === 'UL' || node.nodeName === 'OL') { 196 | let listLevel = 1 197 | let tempNode = node.parentNode 198 | while (tempNode.parentNode) { 199 | tempNode = tempNode.parentNode 200 | if (tempNode.nodeName === node.nodeName) listLevel++ 201 | } 202 | return options[node.nodeName] ? options[node.nodeName](text, { listLevel }) : text 203 | } 204 | return options[node.nodeName] ? options[node.nodeName](text) : text 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/region_easy_marker.js: -------------------------------------------------------------------------------- 1 | import BaseEasyMarker from './base_easy_marker' 2 | import { SelectStatus, EasyMarkerMode, DeviceType } from './lib/types' 3 | import Region from './lib/region' 4 | 5 | class RegionEasyMarker extends BaseEasyMarker { 6 | constructor(options) { 7 | super(options) 8 | this.selectRegion = { 9 | start: null, 10 | end: null, 11 | } 12 | this.region = new Region(options.regions || []) 13 | this.mode = EasyMarkerMode.REGION 14 | this.touchStartTime = 0 15 | } 16 | get start() { 17 | return this.selectRegion.start 18 | } 19 | 20 | get end() { 21 | return this.selectRegion.end 22 | } 23 | /** 24 | * Update Regions 25 | * 26 | * @memberof EasyMarker 27 | * @returns {string} 28 | */ 29 | setRegions(regions) { 30 | this.region.setRegions(regions) 31 | } 32 | 33 | /** 34 | * Get the selected text 35 | * 36 | * @memberof EasyMarker 37 | * @returns {string} 38 | */ 39 | getSelectText() { 40 | const text = this.region.getText(this.selectRegion.start, this.selectRegion.end) || '' 41 | return text 42 | } 43 | 44 | /** 45 | * touchstart event handler 46 | * 47 | * @private 48 | * @param {TouchEvent} e 49 | * @memberof EasyMarker 50 | */ 51 | handleTouchStart(e) { 52 | super.handleTouchStart(e) 53 | if (this.deviceType === DeviceType.PC) { 54 | if (this.selectStatus === SelectStatus.FINISH) { 55 | const isMenuClick = this.menu.inRegion(e) 56 | const position = this.getTouchRelativePosition(e) 57 | const startCursorRegion = this.cursor.start.inRegion(position) 58 | const endCursorRegion = this.cursor.end.inRegion(position) 59 | if (!isMenuClick && !startCursorRegion.inRegion && !endCursorRegion.inRegion) { 60 | this.reset() 61 | } 62 | } 63 | 64 | if (this.selectStatus === SelectStatus.NONE && this.isContains(e.target)) { 65 | this.touchStartTime = Date.now() 66 | const position = this.getTouchRelativePosition(e) 67 | this.selectRegion.start = this.region.getRegionByPoint(position, true) 68 | if (this.selectRegion.start) { 69 | const { height: lineHeight } = this.region.getLineInfoByRegion(this.selectRegion.start) 70 | this.cursor.start.height = lineHeight 71 | this.cursor.start.position = { x: this.selectRegion.start.left, y: this.selectRegion.start.top } 72 | } 73 | } 74 | } 75 | } 76 | handleTouchMoveThrottle(e) { 77 | if (this.deviceType === DeviceType.PC) { 78 | if (this.selectStatus === SelectStatus.NONE && this.selectRegion.start && !this.selectRegion.end) { 79 | if (Date.now() - this.touchStartTime < 100) return 80 | const position = this.getTouchRelativePosition(e) 81 | this.selectRegion.end = this.region.getRegionByPoint(position) 82 | if (this.selectRegion.end) { 83 | const { height: lineHeight } = this.region.getLineInfoByRegion(this.selectRegion.end) 84 | this.cursor.end.height = lineHeight 85 | this.cursor.end.position = { 86 | x: this.selectRegion.end.left + this.selectRegion.end.width, 87 | y: this.selectRegion.end.top, 88 | } 89 | this.selectStatus = SelectStatus.SELECTING 90 | } 91 | } 92 | } 93 | super.handleTouchMoveThrottle(e) 94 | } 95 | 96 | handleTouchEnd(e) { 97 | super.handleTouchEnd(e) 98 | if (this.selectStatus === SelectStatus.SELECTING) { 99 | this.selectStatus = SelectStatus.FINISH 100 | } 101 | if (this.deviceType === DeviceType.PC) { 102 | if (this.selectStatus === SelectStatus.NONE) { 103 | this.reset() 104 | } 105 | } 106 | } 107 | 108 | /** 109 | * copy listener 110 | * 111 | * @private 112 | * @memberof EasyMarker 113 | */ 114 | copyListener(e) { 115 | if (this.selectStatus === SelectStatus.FINISH) { 116 | this.menu.copyListener( 117 | { 118 | start: this.selectRegion.start, 119 | end: this.selectRegion.end, 120 | content: this.getSelectText(), 121 | markdown: RegionEasyMarker.getSelectMarkdown(), 122 | }, 123 | e 124 | ) 125 | this.reset() 126 | } 127 | } 128 | 129 | /** 130 | * Tap event 131 | * 132 | * @private 133 | * @param {TouchEvent} e 134 | * @memberof EasyMarker 135 | */ 136 | handleTap(e) { 137 | if (this.selectStatus === SelectStatus.FINISH) { 138 | this.menu.handleTap(e, { 139 | start: this.selectRegion.start, 140 | end: this.selectRegion.end, 141 | content: this.getSelectText(), 142 | markdown: RegionEasyMarker.getSelectMarkdown(), 143 | }) 144 | const position = this.getTouchRelativePosition(e) 145 | const startCursorRegion = this.cursor.start.inRegion(position) 146 | const endCursorRegion = this.cursor.end.inRegion(position) 147 | if (startCursorRegion.inRegion || endCursorRegion.inRegion) return 148 | this.reset() 149 | } else if (this.selectStatus === SelectStatus.NONE) { 150 | const inHighlightLine = this.highlight.handleTap(e) 151 | if ( 152 | !inHighlightLine && 153 | !this.options.disableTapHighlight && 154 | !this.options.disableSelect && 155 | this.isContains(e.target) && 156 | this.deviceType === DeviceType.MOBILE 157 | ) { 158 | const position = this.getTouchRelativePosition(e) 159 | this.selectThisSentence(position) 160 | } 161 | } 162 | } 163 | 164 | /** 165 | * Long press event 166 | * 167 | * @private 168 | * @param {TouchEvent} e 169 | * @memberof EasyMarker 170 | */ 171 | handleLongTap(e) { 172 | if (this.deviceType === DeviceType.MOBILE) { 173 | if (this.isContains(e.target)) { 174 | const position = this.getTouchRelativePosition(e) 175 | this.selectThisSentence(position) 176 | } 177 | } 178 | } 179 | selectThisSentence(position) { 180 | const { start, end } = this.region.getSentenceByPosition(position) 181 | this.selectRegion = { 182 | start, 183 | end, 184 | } 185 | this.cursor.start.height = this.region.getLineInfoByRegion(this.selectRegion.start).height 186 | this.cursor.start.position = { x: this.selectRegion.start.left, y: this.selectRegion.start.top } 187 | // { height: lineHeight } = this.region.getLineInfoByRegion(this.selectRegion.end) 188 | this.cursor.end.height = this.region.getLineInfoByRegion(this.selectRegion.end).height 189 | this.cursor.end.position = { 190 | x: this.selectRegion.end.left + this.selectRegion.end.width, 191 | y: this.selectRegion.end.top, 192 | } 193 | this.cursor.start.show() 194 | this.cursor.end.show() 195 | 196 | this.renderMask() 197 | this.selectStatus = SelectStatus.FINISH 198 | } 199 | /** 200 | * Move the cursor to the specified location 201 | * 202 | * @private 203 | * @param {HTMLElement} element 204 | * @param {number} x Relative to the screen positioning x 205 | * @param {number} y Relative to the screen positioning Y 206 | * @memberof EasyMarker 207 | */ 208 | moveCursor(element, x, y) { 209 | const relativeX = x - this.screenRelativeOffset.x 210 | const relativeY = y - this.screenRelativeOffset.y 211 | // const relativePosition = this.getTouchRelativePosition({ x, y }) 212 | const clickRegion = this.region.getRegionByPoint({ x: relativeX, y: relativeY }) 213 | if (!clickRegion) return 214 | const unmovingCursor = this.movingCursor === this.cursor.start ? this.cursor.end : this.cursor.start 215 | if (unmovingCursor.position.x === relativeX && unmovingCursor.position.y === relativeY) { 216 | return 217 | } 218 | 219 | this.swapCursor(clickRegion, { x: relativeX, y: relativeY }) 220 | const { height: lineHeight } = this.region.getLineInfoByRegion(clickRegion) 221 | if (this.movingCursor === this.cursor.start) { 222 | this.movingCursor.height = lineHeight 223 | this.movingCursor.position = { 224 | x: clickRegion.left, 225 | y: clickRegion.top, 226 | } 227 | } else { 228 | this.movingCursor.height = lineHeight 229 | this.movingCursor.position = { 230 | x: clickRegion.left + clickRegion.width, 231 | y: clickRegion.top, 232 | } 233 | } 234 | 235 | this.cursor.start.show() 236 | this.cursor.end.show() 237 | this.renderMask() 238 | } 239 | 240 | /** 241 | * Swap the start and end cursors 242 | * 243 | * @private 244 | * @param {any} clickRegion 245 | * @param {any} currentPosition 246 | * @memberof EasyMarker 247 | */ 248 | swapCursor(clickRegion) { 249 | if (this.movingCursor === this.cursor.start) { 250 | if (clickRegion.originalIndex > this.selectRegion.end.originalIndex) { 251 | this.cursor.start.position = this.cursor.end.position 252 | this.movingCursor = this.cursor.end 253 | this.selectRegion.start = this.region.getNextRegion(this.selectRegion.end) 254 | this.selectRegion.end = clickRegion 255 | } else { 256 | this.selectRegion.start = clickRegion 257 | } 258 | } else { 259 | // eslint-disable-next-line no-lonely-if 260 | if (clickRegion.originalIndex < this.selectRegion.start.originalIndex) { 261 | this.cursor.end.position = this.cursor.start.position 262 | this.movingCursor = this.cursor.start 263 | this.selectRegion.end = this.region.getPreviousRegion(this.selectRegion.start) 264 | this.selectRegion.start = clickRegion 265 | } else { 266 | this.selectRegion.end = clickRegion 267 | } 268 | } 269 | } 270 | 271 | renderMask() { 272 | this.mask.render(this.selectRegion.start, this.selectRegion.end) 273 | } 274 | 275 | setSelection(selection) { 276 | this.selectRegion.start = selection.start 277 | this.selectRegion.end = selection.end 278 | } 279 | 280 | reset() { 281 | super.reset() 282 | this.selectRegion = { 283 | start: null, 284 | end: null, 285 | } 286 | } 287 | 288 | destroy() { 289 | super.destroy() 290 | this.selectRegion = { 291 | start: null, 292 | end: null, 293 | } 294 | this.region.destroy() 295 | this.region = null 296 | this.mode = EasyMarkerMode.REGION 297 | this.touchStartTime = 0 298 | } 299 | 300 | static getSelectMarkdown() { 301 | return 'Markdown is not supported in current mode.' 302 | } 303 | } 304 | export default RegionEasyMarker 305 | -------------------------------------------------------------------------------- /src/element/highlight.js: -------------------------------------------------------------------------------- 1 | import BaseElement from './base' 2 | import TextNode from '../lib/text_node' 3 | import { getTouchPosition, inRectangle, anyToPx, rectToPointArray } from '../lib/helpers' 4 | import { EasyMarkerMode, NoteType } from '../lib/types' 5 | 6 | /** 7 | * Highlight 8 | * 9 | * @export 10 | * @class Highlight 11 | * @extends {BaseElement} 12 | */ 13 | export default class Highlight extends BaseElement { 14 | constructor(container, option) { 15 | super() 16 | const defaultOptions = { 17 | highlightColor: '#FEFFCA', 18 | underlineColor: '#af8978', 19 | underlineWidth: 1, 20 | tagBackground: '#af8978', 21 | tagColor: '#fff', 22 | opacity: 1, 23 | type: 'highlight', 24 | // margin: '0.1rem', 25 | } 26 | this.container = container 27 | this.mode = option.mode || EasyMarkerMode.NODE 28 | this.option = Object.assign(defaultOptions, option) 29 | if (option.color) { 30 | this.option.highlightColor = option.color 31 | } 32 | this.type = this.option.type || NoteType.Highlight 33 | this.option.margin = anyToPx(this.option.margin) 34 | this.lineMap = new Map() 35 | // this.onClick = () => { } 36 | this.createElement() 37 | this.mount() 38 | this.id = 0 39 | this.easyMarker = null 40 | } 41 | 42 | get screenRelativeOffset() { 43 | if (!this.easyMarker) { 44 | return { 45 | x: 0, 46 | y: 0, 47 | } 48 | } 49 | return this.easyMarker.screenRelativeOffset 50 | } 51 | getID() { 52 | return this.id++ 53 | } 54 | 55 | /** 56 | * 57 | * 58 | * @param {Selection} selection 59 | * @param {any} id 60 | * @param {any} meta 61 | * @memberof Highlight 62 | */ 63 | highlight(selection, id, meta = {}) { 64 | const lineID = id === undefined || id === null ? this.getID() : id 65 | let points 66 | let selectionContent 67 | let relativeRects = [] 68 | let lineHeight 69 | if (this.mode === EasyMarkerMode.NODE) { 70 | const startTextNode = new TextNode(selection.anchorNode, selection.anchorOffset) 71 | const endTextNode = new TextNode(selection.focusNode, selection.focusOffset) 72 | lineHeight = Number(window.getComputedStyle(selection.anchorNode.parentElement).lineHeight.replace('px', '')) 73 | let rects 74 | let text 75 | try { 76 | ({ rects, text } = TextNode.getSelectNodeRectAndText( 77 | startTextNode.node, 78 | endTextNode.node, 79 | startTextNode.offset, 80 | endTextNode.offset 81 | )) 82 | } catch (error) { 83 | console.error('EasyMarkerError:', error) // eslint-disable-line no-console 84 | rects = [] 85 | text = '' 86 | } 87 | 88 | const offset = this.screenRelativeOffset 89 | 90 | points = rects.map((rect) => { 91 | const relativeRect = { 92 | top: rect.top - offset.y, 93 | bottom: rect.bottom - offset.y, 94 | height: rect.height, 95 | width: rect.width, 96 | left: rect.left - offset.x, 97 | right: rect.right - offset.x, 98 | } 99 | relativeRects.push(relativeRect) 100 | lineHeight = lineHeight || rect.height 101 | const margin = this.option.margin || (lineHeight - rect.height) / 4 102 | return rectToPointArray(rect, offset, margin) 103 | }) 104 | let markdown 105 | if (this.easyMarker && this.easyMarker.markdown) { 106 | ({ markdown } = this.easyMarker.markdown.getSelectMarkdown( 107 | startTextNode.node, 108 | endTextNode.node, 109 | startTextNode.offset, 110 | endTextNode.offset 111 | )) 112 | } else { 113 | markdown = '' 114 | } 115 | 116 | selectionContent = Object.assign({ 117 | toString() { 118 | return text 119 | }, 120 | toMarkdown() { 121 | return markdown 122 | }, 123 | }, selection) 124 | } else { 125 | const { start, end } = selection 126 | relativeRects = this.easyMarker && this.easyMarker.region.getRects(start, end) 127 | const text = this.easyMarker && this.easyMarker.region.getText(start, end) 128 | const markdown = this.easyMarker && this.easyMarker.constructor.getSelectMarkdown() 129 | points = relativeRects.map((rect) => { 130 | const margin = 0 131 | return rectToPointArray(rect, { x: 0, y: 0 }, margin) 132 | }) 133 | selectionContent = Object.assign({ 134 | toString() { 135 | return text 136 | }, 137 | toMarkdown() { 138 | return markdown 139 | }, 140 | }, selection) 141 | } 142 | 143 | this.lineMap.set(lineID, { 144 | selection: selectionContent, points, relativeRects, meta, lineHeight, 145 | }) 146 | return lineID 147 | } 148 | 149 | render() { 150 | this.removeAllRectangle() 151 | this.lineMap.forEach((line) => { 152 | const type = line.meta.type || this.type 153 | line.points.forEach((points, index) => { 154 | if (type === NoteType.UNDERLINE) { 155 | this.element.appendChild(this.createLine(points)) 156 | } else { 157 | this.element.appendChild(this.createRectangle(points)) 158 | } 159 | if (line.points.length - 1 === index && line.meta && line.meta.tag) { 160 | const text = document.createElementNS('http://www.w3.org/2000/svg', 'text') 161 | text.setAttribute('x', points[2][0] - 5) 162 | text.setAttribute('y', points[2][1] + 4) 163 | text.setAttribute('dominant-baseline', 'hanging') 164 | text.setAttribute('text-anchor', 'end') 165 | text.setAttribute('font-size', '10') 166 | text.setAttribute('fill', this.option.tagColor) 167 | text.textContent = line.meta.tag 168 | text.classList.add('em-highlight-tag-text') 169 | this.element.appendChild(text) 170 | // setTimeout(() => { 171 | // 异步获取位置在某些情况无法正常渲染 172 | // 同步执行在某些时候无法取到getBox 173 | // const textRect = text.getBBox() 174 | const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect') 175 | // rect.setAttribute('x', textRect.x - 5) 176 | // rect.setAttribute('y', textRect.y - 1) 177 | rect.setAttribute('x', points[2][0] - 25 - 5) 178 | rect.setAttribute('y', points[2][1] - 0) 179 | rect.setAttribute('rx', 2) 180 | rect.setAttribute('ry', 2) 181 | rect.setAttribute('width', 20 + 10) 182 | rect.setAttribute('height', 14 + 2) 183 | rect.setAttribute('fill', this.option.tagBackground) 184 | this.element.insertBefore(rect, text) 185 | // }, 10) 186 | } 187 | }) 188 | }) 189 | } 190 | 191 | /** 192 | * 193 | * 194 | * @param {Object[]} lines 195 | * @param {Selection} lines[].selection 196 | * @param {any} [lines[].id] 197 | * @param {any} [lines[].meta] 198 | * @memberof Highlight 199 | */ 200 | highlightLines(lines) { 201 | this.lineMap.clear() 202 | const ids = lines.map(({ selection, id, meta }) => this.highlight(selection, id, meta)) 203 | this.render() 204 | return ids 205 | } 206 | 207 | /** 208 | * 209 | * 210 | * @param {Selection} selection 211 | * @param {*} id 212 | * @param {*} meta 213 | * @memberof Highlight 214 | */ 215 | highlightLine(selection, id, meta) { 216 | const lineID = this.highlight(selection, id, meta) 217 | this.render() 218 | return lineID 219 | } 220 | 221 | /** 222 | * 223 | * 224 | * @param {any} id 225 | * @returns {boolean} 226 | * @memberof Highlight 227 | */ 228 | cancelHighlightLine(id) { 229 | this.lineMap.delete(id) 230 | this.render() 231 | } 232 | 233 | createElement() { 234 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') 235 | svg.style.zIndex = '10' 236 | svg.style.pointerEvents = 'none' 237 | svg.style.width = '100%' 238 | svg.style.height = '100%' 239 | svg.style.position = 'absolute' 240 | svg.style.top = '0' 241 | svg.style.left = '0' 242 | svg.style.overflow = 'visible' 243 | this.element = svg 244 | } 245 | 246 | createLine(pointList) { 247 | const x1 = pointList[2][0] 248 | const y1 = pointList[2][1] + 1 249 | const x2 = pointList[3][0] 250 | const y2 = pointList[3][1] + 1 251 | const line = document.createElementNS('http://www.w3.org/2000/svg', 'line') 252 | line.style.stroke = this.option.underlineColor 253 | line.style.strokeWidth = this.option.underlineWidth 254 | line.setAttribute('x1', x1) 255 | line.setAttribute('y1', y1) 256 | line.setAttribute('x2', x2) 257 | line.setAttribute('y2', y2) 258 | return line 259 | } 260 | 261 | createRectangle(pointList) { 262 | const points = pointList.reduce((acc, [x, y]) => (acc === '' ? `${x},${y}` : `${acc} ${x},${y}`), '') 263 | const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon') 264 | polygon.style.fill = this.option.highlightColor 265 | polygon.style.strokeWidth = 0 266 | polygon.style.strokeOpacity = this.option.opacity 267 | polygon.style.opacity = this.option.opacity 268 | polygon.setAttribute('points', points) 269 | return polygon 270 | } 271 | 272 | handleTap(e) { 273 | const { x, y } = getTouchPosition(e) 274 | const { top, left } = this.container.getBoundingClientRect() 275 | let clickLine 276 | this.lineMap.forEach((line, id) => { 277 | for (let i = 0; i < line.relativeRects.length; i++) { 278 | const rect = line.relativeRects[i] 279 | const margin = line.lineHeight ? (line.lineHeight - rect.height) / 2 : 0 280 | if (inRectangle(x - left, y - top, rect, margin)) { 281 | clickLine = { id, line } 282 | break 283 | } 284 | } 285 | }) 286 | if (clickLine && this.easyMarker) { 287 | if (this.easyMarker.highlightLineClick) { 288 | this.easyMarker.highlightLineClick(clickLine.id, clickLine.line.meta, clickLine.line.selection, e) 289 | } else { 290 | this.easyMarker.showHighlightMenu(clickLine.line.selection, { id: clickLine.id, meta: clickLine.line.meta }) 291 | } 292 | return true 293 | } 294 | return false 295 | } 296 | 297 | inRegion(e) { 298 | const { x, y } = getTouchPosition(e) 299 | const { top, left } = this.container.getBoundingClientRect() 300 | let clickLine 301 | this.lineMap.forEach((line, id) => { 302 | for (let i = 0; i < line.relativeRects.length; i++) { 303 | const rect = line.relativeRects[i] 304 | const margin = line.lineHeight ? (line.lineHeight - rect.height) / 2 : 0 305 | if (inRectangle(x - left, y - top, rect, margin)) { 306 | clickLine = { id, line } 307 | break 308 | } 309 | } 310 | }) 311 | if (clickLine && this.easyMarker) { 312 | return true 313 | } 314 | return false 315 | } 316 | 317 | removeAllRectangle() { 318 | while (this.element.firstChild) { 319 | this.element.removeChild(this.element.firstChild) 320 | } 321 | } 322 | } 323 | 324 | -------------------------------------------------------------------------------- /src/element/mask.js: -------------------------------------------------------------------------------- 1 | import BaseElement from './base' 2 | import Position from '../lib/position' 3 | import { rectToPointArray, screenRelativeToContainerRelative } from '../lib/helpers' 4 | import { EasyMarkerMode } from '../lib/types' 5 | import TextNode from '../lib/text_node' 6 | 7 | export const MaskType = { 8 | BLOCK: 'block', 9 | LINE: 'line', 10 | } 11 | 12 | export default class Mask extends BaseElement { 13 | constructor(container, option) { 14 | super() 15 | const defaultOptions = { 16 | color: '#FEFFCA', 17 | opacity: 0.5, 18 | animateDuration: 100, 19 | } 20 | this.mode = option.mode || EasyMarkerMode.NODE 21 | this.maskType = option.maskType || (this.mode === EasyMarkerMode.NODE ? MaskType.BLOCK : MaskType.LINE) 22 | this.container = container 23 | this.option = Object.assign(defaultOptions, option) 24 | 25 | if (this.maskType === MaskType.BLOCK) { 26 | this.paths = [] 27 | this.position = { 28 | header: new Position(), 29 | body: new Position(), 30 | footer: new Position(), 31 | } 32 | this.animateStartTime = 0 33 | this.animateEndTime = 0 34 | this.animatePercent = 0 35 | this.polygonElement = null 36 | } else { 37 | this.rects = [] 38 | } 39 | this.animating = false 40 | this.easyMarker = null 41 | this.createElement() 42 | this.mount() 43 | } 44 | 45 | // get screenRelativeOffset() { 46 | // const { top, left } = this.container.getBoundingClientRect() 47 | // return { 48 | // x: left, 49 | // y: top, 50 | // } 51 | // } 52 | 53 | get screenRelativeOffset() { 54 | if (!this.easyMarker) { 55 | return { 56 | x: 0, 57 | y: 0, 58 | } 59 | } 60 | return this.easyMarker.screenRelativeOffset 61 | } 62 | 63 | get top() { 64 | if (this.maskType === MaskType.BLOCK) { 65 | return this.position.header.y 66 | } 67 | if (this.rects[0]) { 68 | return this.mode === EasyMarkerMode.NODE ? this.rects[0].top - this.screenRelativeOffset.y : this.rects[0].top 69 | } 70 | return 0 71 | } 72 | 73 | get left() { 74 | if (this.maskType === MaskType.BLOCK) { 75 | return this.position.header.x 76 | } 77 | if (this.rects[0]) { 78 | return this.mode === EasyMarkerMode.NODE ? this.rects[0].left - this.screenRelativeOffset.x : this.rects[0].left 79 | } 80 | return 0 81 | } 82 | 83 | get height() { 84 | if (this.maskType === MaskType.BLOCK) { 85 | return this.position.header.height + this.position.body.height + this.position.footer.height 86 | } 87 | const lastRect = this.rects[this.rects.length - 1] 88 | if (lastRect) { 89 | return lastRect.top + lastRect.height 90 | } 91 | return 0 92 | } 93 | 94 | createElement() { 95 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') 96 | svg.style.zIndex = '20' 97 | svg.style.pointerEvents = 'none' 98 | svg.style.width = '100%' 99 | svg.style.height = '100%' 100 | svg.style.position = 'absolute' 101 | svg.style.top = '0' 102 | svg.style.left = '0' 103 | svg.style.overflow = 'visible' 104 | 105 | if (this.maskType === MaskType.BLOCK) { 106 | const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon') 107 | polygon.style.fill = this.option.color 108 | polygon.style.strokeWidth = 0 109 | polygon.style.strokeOpacity = this.option.opacity 110 | polygon.style.opacity = this.option.opacity 111 | polygon.style.transition = 'opacity 0.2s ease' 112 | svg.appendChild(polygon) 113 | this.polygonElement = polygon 114 | } 115 | this.element = svg 116 | } 117 | 118 | render(start, end) { 119 | if (this.mode === EasyMarkerMode.NODE) { 120 | if (this.maskType === 'line') { 121 | let rects 122 | try { 123 | ({ rects } = TextNode.getSelectNodeRectAndText(start.node, end.node, start.offset, end.offset)) 124 | } catch (error) { 125 | console.error('EasyMarkerError:', error) // eslint-disable-line no-console 126 | rects = [] 127 | } 128 | const lineHeight = Number(window.getComputedStyle(start.node.parentElement).lineHeight.replace('px', '')) 129 | this.renderRectsLine(rects, lineHeight) 130 | } else { 131 | const { header, body, footer } = TextNode.getSelectRects(start, end) 132 | const relativeHeader = screenRelativeToContainerRelative(header, this.screenRelativeOffset) 133 | const relativeBody = screenRelativeToContainerRelative(body, this.screenRelativeOffset) 134 | const relativeFooter = screenRelativeToContainerRelative(footer, this.screenRelativeOffset) 135 | this.renderBlock(relativeHeader, relativeBody, relativeFooter) 136 | } 137 | } 138 | 139 | if (this.mode === EasyMarkerMode.REGION) { 140 | const rects = this.easyMarker.region.getRects(start, end) 141 | if (this.maskType === 'line') { 142 | this.renderRectsLine(rects) 143 | } else { 144 | let header 145 | let footer 146 | let body 147 | rects.forEach((rect, index) => { 148 | if (index === 0) { 149 | header = new Position() 150 | body = new Position() 151 | footer = new Position() 152 | header.setAll(rect) 153 | body.setAll(rect) 154 | footer.setAll(rect) 155 | body.y = rect.y + rect.height 156 | body.height = 0 157 | footer.height = 0 158 | footer.y = rect.y + rect.height 159 | } else if (index === rects.length - 1) { 160 | footer.setAll(rect) 161 | body.height = rect.y - (header.y + header.height) 162 | } else { 163 | const right = body.x + body.width 164 | const left = body.x 165 | if (rect.x < left) { 166 | body.x = rect.x 167 | } 168 | if (rect.x + rect.width >= right) { 169 | body.width = rect.x + rect.width - body.x 170 | } 171 | } 172 | }) 173 | this.renderBlock(header, body, footer) 174 | } 175 | } 176 | } 177 | 178 | renderBlock(headerPosition, bodyPosition, footerPosition) { 179 | const { header, body, footer } = this.position 180 | if ( 181 | this.paths.length !== 0 && 182 | header.equal(headerPosition) && 183 | body.equal(bodyPosition) && 184 | footer.equal(footerPosition) 185 | ) { 186 | return 187 | } 188 | this.polygonElement.style.opacity = this.option.opacity 189 | const fromPosition = this.position 190 | this.position.header.setAll(headerPosition) 191 | this.position.body.setAll(bodyPosition) 192 | this.position.footer.setAll(footerPosition) 193 | 194 | this.animateStartTime = Date.now() 195 | this.animateEndTime = this.animateStartTime + this.option.animateDuration 196 | this.animatePercent = 0 197 | if (!this.animating) { 198 | this.animating = true 199 | this.animated(fromPosition) 200 | } 201 | } 202 | 203 | animated(from) { 204 | const realPercent = (Date.now() - this.animateStartTime) / (this.animateEndTime - this.animateStartTime) 205 | let nextPercent = 0 206 | 207 | if (realPercent >= 1) { 208 | nextPercent = 1 209 | this.animatePercent = 1 210 | } else { 211 | const nextAnimationPercent = 1000 / 60 / this.option.animateDuration + (realPercent - this.animatePercent) * 1.3 212 | this.animatePercent += nextAnimationPercent 213 | nextPercent = nextAnimationPercent > 1 ? 1 : nextAnimationPercent / (1 - realPercent) 214 | } 215 | 216 | const nextHeaderPosition = this.constructor.getAnimateFrame(from.header, this.position.header, nextPercent) 217 | const nextBodyPosition = this.constructor.getAnimateFrame(from.body, this.position.body, nextPercent) 218 | const nextFooterPosition = this.constructor.getAnimateFrame(from.footer, this.position.footer, nextPercent) 219 | const nextPosition = { 220 | header: nextHeaderPosition, 221 | body: nextBodyPosition, 222 | footer: nextFooterPosition, 223 | } 224 | this.paths = this.constructor.getPaths(nextPosition) 225 | const points = this.paths.map(([x, y]) => `${x},${y}`).join(' ') 226 | this.polygonElement.setAttribute('points', points) 227 | if (realPercent >= 1) { 228 | this.animating = false 229 | return 230 | } 231 | window.requestAnimationFrame(() => this.animated(nextPosition)) 232 | } 233 | 234 | reset() { 235 | if (this.maskType === MaskType.BLOCK) { 236 | this.paths = [] 237 | this.polygonElement.style.opacity = '0' 238 | this.polygonElement.setAttribute('points', '') 239 | } 240 | if (this.maskType === MaskType.LINE) { 241 | this.removeAllRectangle() 242 | } 243 | } 244 | 245 | static getAnimateFrame(from, to, percent) { 246 | const framePosition = new Position() 247 | framePosition.x = from.x + (to.x - from.x) * percent 248 | framePosition.y = from.y + (to.y - from.y) * percent 249 | framePosition.height = from.height + (to.height - from.height) * percent 250 | framePosition.width = from.width + (to.width - from.width) * percent 251 | return framePosition 252 | } 253 | 254 | static getPaths(position) { 255 | const { header, body, footer } = position 256 | const paths = [] 257 | if (header.isSet) { 258 | paths.push([header.x, header.y]) 259 | paths.push([header.x + header.width, header.y]) 260 | paths.push([header.x + header.width, header.y + header.height]) 261 | } 262 | if (body.isSet) { 263 | paths.push([body.x + body.width, body.y]) 264 | paths.push([body.x + body.width, body.y + body.height]) 265 | } 266 | if (footer.isSet) { 267 | paths.push([footer.x + footer.width, footer.y]) 268 | paths.push([footer.x + footer.width, footer.y + footer.height]) 269 | paths.push([footer.x, footer.y + footer.height]) 270 | paths.push([footer.x, footer.y]) 271 | } 272 | if (body.isSet) { 273 | paths.push([body.x, body.y + body.height]) 274 | paths.push([body.x, body.y]) 275 | } 276 | if (header.isSet) { 277 | paths.push([header.x, header.y + header.height]) 278 | } 279 | 280 | return paths 281 | } 282 | 283 | renderRectsLine(rects, lineHeight) { 284 | this.rects = rects 285 | const points = rects.map((rect) => { 286 | let margin = 0 287 | let offset = { x: 0, y: 0 } 288 | if (this.mode === EasyMarkerMode.NODE) { 289 | lineHeight = lineHeight || rect.height 290 | margin = this.option.margin || (lineHeight - rect.height) / 4 291 | offset = this.screenRelativeOffset 292 | } 293 | return rectToPointArray(rect, offset, margin) 294 | }) 295 | if (!this.animating) { 296 | this.animating = true 297 | window.requestAnimationFrame(() => this.renderRectsLineAnimated(points)) 298 | } 299 | } 300 | renderRectsLineAnimated(points) { 301 | this.removeAllRectangle() 302 | points.forEach((linePoints) => { 303 | this.element.appendChild(this.createRectangle(linePoints)) 304 | }) 305 | this.animating = false 306 | } 307 | createRectangle(pointList) { 308 | const points = pointList.reduce((acc, [x, y]) => (acc === '' ? `${x},${y}` : `${acc} ${x},${y}`), '') 309 | const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon') 310 | polygon.style.fill = this.option.color 311 | polygon.style.strokeWidth = 0 312 | polygon.style.strokeOpacity = this.option.opacity 313 | polygon.style.opacity = this.option.opacity 314 | polygon.setAttribute('points', points) 315 | return polygon 316 | } 317 | removeAllRectangle() { 318 | while (this.element.firstChild) { 319 | this.element.removeChild(this.element.firstChild) 320 | } 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /src/node_easy_marker.js: -------------------------------------------------------------------------------- 1 | import BaseEasyMarker from './base_easy_marker' 2 | import TextNode from './lib/text_node' 3 | import { 4 | getClickWordsPosition, 5 | getTouchPosition, 6 | matchSubString, 7 | getClickPosition, 8 | } from './lib/helpers' 9 | import { SelectStatus, EasyMarkerMode, DeviceType } from './lib/types' 10 | 11 | class NodeEasyMarker extends BaseEasyMarker { 12 | constructor(options) { 13 | super(options) 14 | this.textNode = { 15 | start: null, 16 | end: null, 17 | } 18 | this.markdown = null 19 | this.mode = EasyMarkerMode.NODE 20 | this.touchStartTime = 0 21 | } 22 | get start() { 23 | return this.textNode.start 24 | } 25 | 26 | get end() { 27 | return this.textNode.end 28 | } 29 | 30 | /** 31 | * Get the selected text 32 | * 33 | * @memberof EasyMarker 34 | * @returns {string} 35 | */ 36 | getSelectText() { 37 | const text = 38 | TextNode.getSelectText(this.textNode.start, this.textNode.end) || '' 39 | return matchSubString(this.container.innerText, text) || text 40 | } 41 | 42 | getSelectMarkdown() { 43 | return ( 44 | this.markdown.getSelectMarkdown( 45 | this.textNode.start.node, 46 | this.textNode.end.node, 47 | this.textNode.start.offset, 48 | this.textNode.end.offset, 49 | ).markdown || '' 50 | ) 51 | } 52 | 53 | /** 54 | * Swap the start and end cursors 55 | * 56 | * @private 57 | * @param {any} clickPosition 58 | * @param {any} currentPosition 59 | * @memberof EasyMarker 60 | */ 61 | swapCursor(clickPosition, currentPosition) { 62 | const { x, y } = currentPosition 63 | if (this.movingCursor === this.cursor.start) { 64 | const endPosition = this.cursor.end.position 65 | if (y > endPosition.y || (y === endPosition.y && x >= endPosition.x)) { 66 | this.cursor.start.position = this.cursor.end.position 67 | this.movingCursor = this.cursor.end 68 | this.textNode.start = new TextNode( 69 | this.textNode.end.node, 70 | this.textNode.end.offset, 71 | ) 72 | this.textNode.end = new TextNode( 73 | clickPosition.node, 74 | clickPosition.index, 75 | ) 76 | } else { 77 | this.textNode.start = new TextNode( 78 | clickPosition.node, 79 | clickPosition.index, 80 | ) 81 | } 82 | } else { 83 | const startPosition = this.cursor.start.position 84 | if ( 85 | y < startPosition.y || 86 | (y === startPosition.y && x <= startPosition.x) 87 | ) { 88 | this.cursor.end.position = this.cursor.start.position 89 | this.movingCursor = this.cursor.start 90 | this.textNode.end = new TextNode( 91 | this.textNode.start.node, 92 | this.textNode.start.offset, 93 | ) 94 | this.textNode.start = new TextNode( 95 | clickPosition.node, 96 | clickPosition.index, 97 | ) 98 | } else { 99 | this.textNode.end = new TextNode( 100 | clickPosition.node, 101 | clickPosition.index, 102 | ) 103 | } 104 | } 105 | } 106 | 107 | /** 108 | * Start text selection 109 | * 110 | * @private 111 | * @param {any} element 112 | * @param {any} x 113 | * @param {any} y 114 | * @memberof EasyMarker 115 | */ 116 | selectWords(element, x, y) { 117 | const separators = [ 118 | '\u3002\u201D', 119 | '\uFF1F\u201D', 120 | '\uFF01\u201D', 121 | '\u3002', 122 | '\uFF1F', 123 | '\uFF01', 124 | ] 125 | const { 126 | rects, node, index, wordsLength, 127 | } = 128 | getClickWordsPosition(element, x, y, separators) || {} 129 | if (!rects || (rects && rects.length === 0)) return 130 | const startRect = rects[0] 131 | const endRect = rects[rects.length - 1] 132 | // start 133 | const startLeft = startRect.left - this.screenRelativeOffset.x 134 | const startTop = startRect.top - this.screenRelativeOffset.y 135 | this.textNode.start = new TextNode(node, index) 136 | this.cursor.start.height = startRect.height 137 | this.cursor.start.position = { x: startLeft, y: startTop } 138 | 139 | // end 140 | const endLeft = endRect.left - this.screenRelativeOffset.x 141 | const endTop = endRect.top - this.screenRelativeOffset.y 142 | this.textNode.end = new TextNode(node, index + wordsLength) 143 | this.cursor.end.height = endRect.height 144 | this.cursor.end.position = { x: endLeft + endRect.width, y: endTop } 145 | 146 | this.cursor.start.show() 147 | this.cursor.end.show() 148 | 149 | this.renderMask() 150 | this.selectStatus = SelectStatus.FINISH 151 | } 152 | 153 | /** 154 | * Renders the selected mask layer 155 | * @private 156 | * @memberof EasyMarker 157 | */ 158 | renderMask() { 159 | this.mask.render(this.textNode.start, this.textNode.end) 160 | } 161 | 162 | /** 163 | * Move the cursor to the specified location 164 | * 165 | * @private 166 | * @param {HTMLElement} element 167 | * @param {number} x Relative to the screen positioning x 168 | * @param {number} y Relative to the screen positioning Y 169 | * @memberof EasyMarker 170 | */ 171 | moveCursor(element, x, y) { 172 | const clickPosition = getClickPosition( 173 | element, 174 | x, 175 | y, 176 | this.movingCursor === this.cursor.start, 177 | ) 178 | if (clickPosition === null) return 179 | const relativeX = clickPosition.x - this.screenRelativeOffset.x 180 | const relativeY = clickPosition.y - this.screenRelativeOffset.y 181 | const unmovingCursor = 182 | this.movingCursor === this.cursor.start 183 | ? this.cursor.end 184 | : this.cursor.start 185 | if ( 186 | unmovingCursor.position.x === relativeX && 187 | unmovingCursor.position.y === relativeY 188 | ) { return } 189 | this.swapCursor(clickPosition, { x: relativeX, y: relativeY }) 190 | 191 | this.movingCursor.height = clickPosition.height 192 | this.movingCursor.position = { x: relativeX, y: relativeY } 193 | 194 | this.cursor.start.show() 195 | this.cursor.end.show() 196 | this.renderMask() 197 | } 198 | 199 | /** 200 | * touchstart event handler 201 | * 202 | * @private 203 | * @param {TouchEvent} e 204 | * @memberof EasyMarker 205 | */ 206 | handleTouchStart(e) { 207 | super.handleTouchStart(e) 208 | if (this.deviceType === DeviceType.PC) { 209 | if (this.selectStatus === SelectStatus.FINISH) { 210 | const isMenuClick = this.menu.inRegion(e) 211 | const position = this.getTouchRelativePosition(e) 212 | const startCursorRegion = this.cursor.start.inRegion(position) 213 | const endCursorRegion = this.cursor.end.inRegion(position) 214 | if (!isMenuClick && !startCursorRegion.inRegion && !endCursorRegion.inRegion) { 215 | this.reset() 216 | } 217 | } 218 | if (this.selectStatus === SelectStatus.NONE && this.isContains(e.target)) { 219 | this.touchStartTime = Date.now() 220 | const { x, y } = getTouchPosition(e) 221 | const element = document.elementFromPoint(x, y) 222 | const clickPosition = getClickPosition( 223 | element, 224 | x, 225 | y, 226 | this.movingCursor !== this.cursor.start, 227 | ) 228 | if (clickPosition) { 229 | this.textNode.start = new TextNode( 230 | clickPosition.node, 231 | clickPosition.index, 232 | ) 233 | if (this.textNode.start) { 234 | const startLeft = clickPosition.x - this.screenRelativeOffset.x 235 | const startTop = clickPosition.y - this.screenRelativeOffset.y 236 | 237 | this.cursor.start.height = clickPosition.height 238 | this.cursor.start.position = { x: startLeft, y: startTop } 239 | } 240 | } 241 | } 242 | } 243 | } 244 | 245 | handleTouchMoveThrottle(e) { 246 | if (this.deviceType === DeviceType.PC) { 247 | if (this.selectStatus === SelectStatus.NONE && this.textNode.start && !this.textNode.end) { 248 | if (Date.now() - this.touchStartTime < 100) return 249 | const { x, y } = getTouchPosition(e) 250 | const element = document.elementFromPoint(x, y) 251 | const clickPosition = getClickPosition( 252 | element, 253 | x, 254 | y, 255 | this.movingCursor === this.cursor.start, 256 | ) 257 | if (clickPosition) { 258 | this.textNode.end = new TextNode( 259 | clickPosition.node, 260 | clickPosition.index, 261 | ) 262 | 263 | if (this.textNode.end) { 264 | const endLeft = clickPosition.x - this.screenRelativeOffset.x 265 | const endTop = clickPosition.y - this.screenRelativeOffset.y 266 | 267 | this.cursor.end.height = clickPosition.height 268 | this.cursor.end.position = { x: endLeft, y: endTop } 269 | this.selectStatus = SelectStatus.SELECTING 270 | } 271 | } 272 | } 273 | } 274 | super.handleTouchMoveThrottle(e) 275 | } 276 | 277 | /** 278 | * copy listener 279 | * 280 | * @private 281 | * @memberof EasyMarker 282 | */ 283 | copyListener(e) { 284 | if (this.selectStatus === SelectStatus.FINISH) { 285 | this.menu.copyListener({ 286 | start: this.textNode.start, 287 | end: this.textNode.end, 288 | content: this.getSelectText(), 289 | markdown: this.getSelectMarkdown(), 290 | }, e) 291 | this.reset() 292 | } 293 | } 294 | 295 | /** 296 | * Tap event 297 | * 298 | * @private 299 | * @param {TouchEvent} e 300 | * @memberof EasyMarker 301 | */ 302 | handleTap(e) { 303 | if (this.selectStatus === SelectStatus.FINISH) { 304 | this.menu.handleTap(e, { 305 | start: this.textNode.start, 306 | end: this.textNode.end, 307 | content: this.getSelectText(), 308 | markdown: this.getSelectMarkdown(), 309 | }) 310 | const position = this.getTouchRelativePosition(e) 311 | const startCursorRegion = this.cursor.start.inRegion(position) 312 | const endCursorRegion = this.cursor.end.inRegion(position) 313 | if (startCursorRegion.inRegion || endCursorRegion.inRegion) return 314 | this.reset() 315 | } else if (this.selectStatus === SelectStatus.NONE) { 316 | const inHighlightLine = this.highlight.handleTap(e) 317 | if ( 318 | !inHighlightLine && 319 | !this.options.disableTapHighlight && 320 | !this.options.disableSelect && 321 | this.isContains(e.target) && 322 | this.deviceType === DeviceType.MOBILE 323 | ) { 324 | const { x, y } = getTouchPosition(e) 325 | this.selectWords(e.target, x, y) 326 | } 327 | } 328 | } 329 | 330 | /** 331 | * Long press event 332 | * 333 | * @private 334 | * @param {TouchEvent} e 335 | * @memberof EasyMarker 336 | */ 337 | handleLongTap(e) { 338 | if (this.deviceType === DeviceType.MOBILE) { 339 | if (this.isContains(e.target)) { 340 | const { x, y } = getTouchPosition(e) 341 | this.selectWords(e.target, x, y) 342 | } 343 | } 344 | } 345 | 346 | /** 347 | * touchmove event handler 348 | * 349 | * @private 350 | * @param {TouchEvent} e 351 | * @memberof EasyMarker 352 | */ 353 | handleTouchEnd(e) { 354 | super.handleTouchEnd(e) 355 | if (this.selectStatus === SelectStatus.SELECTING) { 356 | this.selectStatus = SelectStatus.FINISH 357 | } 358 | if (this.deviceType === DeviceType.PC) { 359 | if (this.selectStatus === SelectStatus.NONE) { 360 | this.reset() 361 | } 362 | } 363 | } 364 | 365 | setSelection(selection) { 366 | this.textNode.start = new TextNode( 367 | selection.anchorNode, 368 | selection.anchorOffset, 369 | ) 370 | this.textNode.end = new TextNode( 371 | selection.focusNode, 372 | selection.focusOffset, 373 | ) 374 | } 375 | 376 | destroy() { 377 | super.destroy() 378 | this.textNode = { 379 | start: null, 380 | end: null, 381 | } 382 | this.markdown = null 383 | this.mode = EasyMarkerMode.NODE 384 | } 385 | 386 | reset() { 387 | super.reset() 388 | this.textNode = { 389 | start: null, 390 | end: null, 391 | } 392 | } 393 | } 394 | export default NodeEasyMarker 395 | -------------------------------------------------------------------------------- /src/element/menu.js: -------------------------------------------------------------------------------- 1 | import BaseElement from './base' 2 | import { anyToPx } from '../lib/helpers' 3 | import TextNode from '../lib/text_node' 4 | import { EasyMarkerMode, MenuType } from '../lib/types' 5 | 6 | /** 7 | * 8 | * 9 | * @export 10 | * @extends {BaseElement} 11 | */ 12 | export default class Menu extends BaseElement { 13 | constructor(container, options = {}) { 14 | super() 15 | this.container = container 16 | this.handler = null 17 | this.mode = options.mode 18 | this.option = { 19 | items: options.menuItems, 20 | isMultiColumnLayout: options.isMultiColumnLayout, 21 | topOffset: anyToPx(options.topOffset), 22 | style: { 23 | menu: { 24 | fontSize: '0.4rem', 25 | backgroundColor: '#262626', 26 | fontColor: '#fff', 27 | borderRadius: '0.1rem', 28 | color: '#fff', 29 | border: '0px', 30 | display: 'inline-block', 31 | padding: '0 0.2rem', 32 | margin: 'auto', 33 | }, 34 | triangle: { 35 | marginLeft: 'auto', 36 | marginRight: 'auto', 37 | borderTop: '0.2rem solid #262626', 38 | borderRight: '0.2rem solid transparent', 39 | borderLeft: '0.2rem solid transparent', 40 | width: '0', 41 | height: '0', 42 | marginTop: '-1px', 43 | }, 44 | item: { 45 | lineHeight: '1.24rem', 46 | padding: '0 0.3rem', 47 | color: '#fff', 48 | display: 'inline-block', 49 | }, 50 | icon: { 51 | display: 'block', 52 | }, 53 | }, 54 | } 55 | 56 | if (options.style) { 57 | Object.assign(this.option.style.menu, options.style.menu) 58 | Object.assign(this.option.style.triangle, options.style.triangle) 59 | Object.assign(this.option.style.item, options.style.item) 60 | Object.assign(this.option.style.icon, options.style.icon) 61 | } 62 | 63 | this.easyMarker = null 64 | this.menuElement = null 65 | this.itemMap = new Map() 66 | this.positionRange = { 67 | top: 0, 68 | bottom: 0, 69 | left: 0, 70 | } 71 | this.windowWidth = document.documentElement.clientWidth 72 | this.ticking = false 73 | this.height = 0 74 | this.width = 0 75 | this.type = MenuType.SELECT 76 | this.options = {} 77 | this.createElement() 78 | this.mount() 79 | this.hide() 80 | } 81 | 82 | get screenRelativeOffset() { 83 | // const { top, left } = this.container.getBoundingClientRect() 84 | // return { 85 | // x: left, 86 | // y: top, 87 | // } 88 | if (!this.easyMarker) { 89 | return { 90 | x: 0, 91 | y: 0, 92 | } 93 | } 94 | return this.easyMarker.screenRelativeOffset 95 | } 96 | 97 | createElement() { 98 | const wrapper = document.createElement('div') 99 | wrapper.style.position = 'absolute' 100 | wrapper.style.width = 'max-content' 101 | wrapper.style.textAlign = 'center' 102 | wrapper.style.lineHeight = '0' 103 | wrapper.style.zIndex = '50' 104 | wrapper.style.transform = 'translate3d(-50%, -100%, 0)' 105 | wrapper.style.webkitTransform = 'translate3d(-50%, -100%, 0)' 106 | wrapper.style.transition = 'transform 0.2s ease, opacity 0.2s ease' 107 | 108 | const menu = document.createElement('div') 109 | menu.classList.add('em-menu') 110 | Object.assign(menu.style, this.option.style.menu) 111 | 112 | const bottomTriangle = document.createElement('div') 113 | bottomTriangle.classList.add('em-menu-triangle') 114 | Object.assign(bottomTriangle.style, this.option.style.triangle) 115 | 116 | wrapper.appendChild(menu) 117 | wrapper.appendChild(bottomTriangle) 118 | this.option.items.forEach((item) => { 119 | const menuItem = this.createMenuItemElement(item) 120 | this.itemMap.set(menuItem, item) 121 | menu.appendChild(menuItem) 122 | }) 123 | this.menuElement = menu 124 | this.element = wrapper 125 | const style = document.createElement('style') 126 | style.type = 'text/css' 127 | style.rel = 'stylesheet' 128 | // eslint-disable-next-line max-len 129 | const styleText = 130 | '.em-menu-wrapper-select .em-menu-item-highlight{display: none !important} .em-menu-wrapper-highlight .em-menu-item-select{display: none !important}' 131 | style.appendChild(document.createTextNode(styleText)) 132 | const head = document.getElementsByTagName('head')[0] 133 | head.appendChild(style) 134 | } 135 | 136 | createMenuItemElement({ 137 | text, iconName, style: itemStyle, iconStyle, type, 138 | }) { 139 | // eslint-disable-line class-methods-use-this 140 | const menuItem = document.createElement('span') 141 | menuItem.classList.add('em-menu-item') 142 | if (type !== undefined) { 143 | menuItem.classList.add(`em-menu-item-${type}`) 144 | } 145 | Object.assign(menuItem.style, this.option.style.item, itemStyle) 146 | if (iconName) { 147 | const iconItem = document.createElement('span') 148 | Object.assign(iconItem.style, this.option.style.icon, iconStyle) 149 | iconItem.className = 'em-menu-item-icon '.concat(iconName) 150 | const textNode = document.createTextNode(text) 151 | menuItem.appendChild(iconItem) 152 | menuItem.appendChild(textNode) 153 | } else { 154 | const textNode = document.createTextNode(text) 155 | menuItem.appendChild(textNode) 156 | } 157 | 158 | return menuItem 159 | } 160 | 161 | setPosition(start, end) { 162 | const mergeRects = {} 163 | if (this.mode === EasyMarkerMode.REGION) { 164 | let rects 165 | if (start.pageIndex !== end.pageIndex) { 166 | // menu 跟随最后一页走 167 | const startRegion = this.easyMarker.region.regions[end.pageIndex].regions[0].regions[0] 168 | rects = this.easyMarker.region.getRects(startRegion, end) 169 | } else { 170 | rects = this.easyMarker.region.getRects(start, end) 171 | } 172 | // const rects = this.easyMarker.region.getRects(start, end) 173 | rects.forEach((rect, index) => { 174 | if (index === 0) { 175 | mergeRects.left = rect.left 176 | mergeRects.top = rect.top 177 | mergeRects.right = rect.right 178 | mergeRects.bottom = rect.bottom 179 | } else { 180 | mergeRects.left = Math.min(rect.left, mergeRects.left) 181 | mergeRects.top = Math.min(rect.top, mergeRects.top) 182 | mergeRects.right = Math.max(rect.right, mergeRects.right) 183 | mergeRects.bottom = Math.max(rect.bottom, mergeRects.bottom) 184 | } 185 | }) 186 | } else { 187 | const { rects } = TextNode.getSelectNodeRectAndText(start.node, end.node, start.offset, end.offset) 188 | rects 189 | .filter(item => item.left <= this.windowWidth && item.left >= 0) 190 | .forEach((rect, index) => { 191 | if (index === 0) { 192 | mergeRects.left = rect.left - this.screenRelativeOffset.x 193 | mergeRects.top = rect.top - this.screenRelativeOffset.y 194 | mergeRects.right = rect.right - this.screenRelativeOffset.x 195 | mergeRects.bottom = rect.bottom - this.screenRelativeOffset.y 196 | } else if (index === rects.length - 1) { 197 | mergeRects.bottom = rect.bottom - this.screenRelativeOffset.y 198 | } else { 199 | mergeRects.left = Math.min(rect.left - this.screenRelativeOffset.x, mergeRects.left) 200 | // mergeRects.top = Math.min(rect.top - this.screenRelativeOffset.y, mergeRects.top) 201 | mergeRects.right = Math.max(rect.right - this.screenRelativeOffset.x, mergeRects.right) 202 | // mergeRects.bottom = Math.max(rect.bottom - this.screenRelativeOffset.y, mergeRects.bottom) 203 | } 204 | }) 205 | } 206 | 207 | this.positionRange.top = mergeRects.top 208 | this.positionRange.bottom = mergeRects.bottom 209 | this.positionRange.left = (mergeRects.left + mergeRects.right) / 2 210 | } 211 | 212 | hide() { 213 | this.style.visibility = 'hidden' 214 | this.style.opacity = '0' 215 | this.reset() 216 | } 217 | 218 | reset() { 219 | this.options = {} 220 | } 221 | 222 | get isShow() { 223 | return this.style.visibility === 'visible' 224 | } 225 | 226 | show() { 227 | if (this.type === MenuType.HIGHLIGHT) { 228 | this.element.classList.remove('em-menu-wrapper-select') 229 | this.element.classList.add('em-menu-wrapper-highlight') 230 | } else if (this.type === MenuType.SELECT) { 231 | this.element.classList.remove('em-menu-wrapper-highlight') 232 | this.element.classList.add('em-menu-wrapper-select') 233 | } 234 | let relativeTop = 0 235 | if (!this.height || !this.width) { 236 | this.height = Number((window.getComputedStyle(this.menuElement).height || '').replace('px', '')) 237 | this.width = Number((window.getComputedStyle(this.menuElement).width || '').replace('px', '')) 238 | } 239 | const { top: containerTop, right: containerRight, left: containerLeft } = this.container.getBoundingClientRect() 240 | if (containerTop < 0 && this.positionRange.bottom < -containerTop) { 241 | relativeTop = this.positionRange.bottom 242 | this.style.position = 'absolute' 243 | } else if (this.positionRange.top - this.height - this.option.topOffset > -containerTop) { 244 | relativeTop = this.positionRange.top 245 | this.style.position = 'absolute' 246 | } else { 247 | // relativeTop = this.option.topOffset + menuHeight - containerTop 248 | this.style.position = 'fixed' 249 | relativeTop = this.option.topOffset + this.height 250 | } 251 | 252 | // this.style.display = 'block' 253 | this.style.visibility = 'visible' 254 | this.style.top = `${relativeTop}px` 255 | if (this.positionRange.left + containerLeft + this.width / 2 > this.windowWidth) { 256 | let right 257 | if (this.style.position === 'fixed' && !this.option.isMultiColumnLayout) { 258 | right = containerRight - this.positionRange.left - this.width / 2 259 | right = containerLeft < 0 ? -this.width / 2 : right 260 | } else { 261 | right = containerRight - this.positionRange.left - containerLeft - this.width / 2 262 | } 263 | this.style.right = `${right}px` 264 | this.style.left = '' 265 | } else if (this.positionRange.left + containerLeft - this.width / 2 < 0) { 266 | let left 267 | if (this.style.position === 'fixed' && !this.option.isMultiColumnLayout) { 268 | left = this.width / 2 + this.positionRange.left + containerLeft 269 | left = left < 0 ? this.width / 2 : left 270 | } else { 271 | left = this.width / 2 + this.positionRange.left 272 | } 273 | this.style.left = `${left}px` 274 | this.style.right = '' 275 | } else { 276 | let left 277 | if (this.style.position === 'fixed' && !this.option.isMultiColumnLayout) { 278 | left = this.positionRange.left + containerLeft 279 | left = left < 0 ? this.width / 2 : left 280 | } else { 281 | // eslint-disable-next-line prefer-destructuring 282 | left = this.positionRange.left 283 | } 284 | this.style.left = `${left}px` 285 | this.style.right = '' 286 | } 287 | this.style.opacity = '1' 288 | } 289 | copyListener(options, e) { 290 | let copyItem 291 | this.itemMap.forEach((item) => { 292 | if (item.id === 'copy' || item.text === '复制') { 293 | copyItem = item 294 | } 295 | }) 296 | if (!copyItem) return 297 | const selection = this.getSelection(options) 298 | if (copyItem.id && this.easyMarker.menuOnClick) { 299 | this.easyMarker.menuOnClick(copyItem.id, selection, Object.assign({}, this.options, { e })) 300 | } else { 301 | copyItem.handler.call(this.easyMarker, selection, Object.assign({}, this.options, { e })) 302 | } 303 | } 304 | 305 | inRegion(e) { 306 | const tapTarget = this.getTapTarget(e.target) 307 | if (!this.itemMap.has(tapTarget)) return false 308 | return true 309 | } 310 | 311 | handleTap(e, options) { 312 | const tapTarget = this.getTapTarget(e.target) 313 | if (!this.itemMap.has(tapTarget)) return false 314 | 315 | const selection = this.getSelection(options) 316 | 317 | const item = this.itemMap.get(tapTarget) 318 | if (item.id && this.easyMarker.menuOnClick) { 319 | this.easyMarker.menuOnClick(item.id, selection, this.options) 320 | } else { 321 | item.handler.call(this.easyMarker, selection, this.options) 322 | } 323 | return true 324 | } 325 | 326 | getSelection(options) { 327 | let selection 328 | if (this.mode === EasyMarkerMode.NODE) { 329 | const { 330 | start, end, content, markdown, 331 | } = options 332 | selection = { 333 | anchorNode: start.node, 334 | anchorOffset: start.offset, 335 | focusNode: end.node, 336 | focusOffset: end.offset, 337 | toString() { 338 | return content 339 | }, 340 | toMarkdown() { 341 | return markdown 342 | }, 343 | } 344 | } else { 345 | const { 346 | start, end, content, markdown, 347 | } = options 348 | selection = { 349 | start, 350 | end, 351 | toString() { 352 | return content 353 | }, 354 | toMarkdown() { 355 | return markdown 356 | }, 357 | } 358 | } 359 | return selection 360 | } 361 | 362 | getTapTarget(target) { 363 | if ( 364 | this.itemMap.has(target) 365 | // || (target.classList && target.classList.contains('em-menu')) 366 | ) { 367 | return target 368 | } 369 | if (target.parentNode) { 370 | return this.getTapTarget(target.parentNode) 371 | } 372 | return null 373 | } 374 | 375 | handleScroll() { 376 | if (!this.ticking) { 377 | window.requestAnimationFrame(() => { 378 | this.show() 379 | this.ticking = false 380 | }) 381 | this.ticking = true 382 | } 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /src/lib/region.js: -------------------------------------------------------------------------------- 1 | import { BSearchUpperBound } from './helpers' 2 | 3 | export default class Region { 4 | constructor(list) { 5 | this.originalRegionList = list 6 | this.regions = [] 7 | // { 8 | // text: '', required 9 | // width: '', required 10 | // height: '', required 11 | // left: '', required 12 | // top: '', required 13 | // page: '', required 14 | // } 15 | 16 | this.initRectRegion() 17 | } 18 | initRectRegion() { 19 | this.regions = Region.getLineRectRegionList(this.originalRegionList) 20 | } 21 | 22 | static getLineRectRegionList(originalRegionList) { 23 | const pageRegionList = [] 24 | const pageRegion = { 25 | page: 0, 26 | top: 0, 27 | right: 0, 28 | left: 0, 29 | width: 0, 30 | height: 0, 31 | regions: [], 32 | } 33 | const lineRectRegion = { 34 | top: 0, 35 | bottom: 0, 36 | left: 0, 37 | width: 0, 38 | height: 0, 39 | regions: [], 40 | } 41 | let lineIndex = 0 42 | let columnIndex = 0 43 | let pageIndex = 0 44 | originalRegionList.forEach((region, index) => { 45 | const { 46 | top, height, left, page, width, 47 | } = region 48 | 49 | if (lineRectRegion.regions.length === 0) { 50 | // 第一个字 51 | pageRegion.page = region.page 52 | pageRegion.top = top 53 | pageRegion.right = left + width 54 | pageRegion.left = left 55 | pageRegion.width = width 56 | 57 | lineRectRegion.top = top 58 | lineRectRegion.bottom = top + height 59 | lineRectRegion.left = left 60 | lineRectRegion.height = height 61 | lineRectRegion.page = page 62 | 63 | lineRectRegion.regions.push(Object.assign({ 64 | originalIndex: index, lineIndex, columnIndex, pageIndex, 65 | }, region)) 66 | } else if ( 67 | pageRegion.page === region.page && 68 | Region.isSameLine({ top: lineRectRegion.top, height: lineRectRegion.bottom - lineRectRegion.top }, region) 69 | ) { 70 | // 同页 且 同行 71 | columnIndex++ 72 | lineRectRegion.regions.push(Object.assign({ 73 | originalIndex: index, lineIndex, columnIndex, pageIndex, 74 | }, region)) 75 | lineRectRegion.top = Math.min(lineRectRegion.top, top) 76 | lineRectRegion.bottom = Math.max(lineRectRegion.bottom, top + height) 77 | lineRectRegion.height = Math.max(lineRectRegion.height, lineRectRegion.bottom - lineRectRegion.top) 78 | } else if (pageRegion.page === region.page) { 79 | // 同页不同行 80 | lineIndex++ 81 | columnIndex = 0 82 | const lastItem = lineRectRegion.regions[lineRectRegion.regions.length - 1] 83 | const lastWidth = lastItem.width 84 | const lastLeft = lastItem.left 85 | lineRectRegion.width = (lastLeft + lastWidth) - lineRectRegion.left 86 | 87 | pageRegion.regions.push(Object.assign({}, lineRectRegion)) 88 | pageRegion.top = Math.min(pageRegion.top, lineRectRegion.top) 89 | pageRegion.left = Math.min(pageRegion.left, lineRectRegion.left) 90 | pageRegion.right = Math.max(pageRegion.right, lineRectRegion.left + lineRectRegion.width) 91 | pageRegion.width = Math.max(pageRegion.width, pageRegion.right - lineRectRegion.left) 92 | 93 | lineRectRegion.top = top 94 | lineRectRegion.bottom = top + height 95 | lineRectRegion.left = left 96 | lineRectRegion.height = height 97 | lineRectRegion.page = page 98 | lineRectRegion.regions = [] 99 | lineRectRegion.regions.push(Object.assign({ 100 | originalIndex: index, lineIndex, columnIndex, pageIndex, 101 | }, region)) 102 | } else { 103 | // 不同页 104 | const lastItem = lineRectRegion.regions[lineRectRegion.regions.length - 1] 105 | const lastWidth = lastItem.width 106 | const lastLeft = lastItem.left 107 | lineRectRegion.width = (lastLeft + lastWidth) - lineRectRegion.left 108 | pageRegion.regions.push(Object.assign({}, lineRectRegion)) 109 | 110 | const lastLineItem = pageRegion.regions[pageRegion.regions.length - 1] 111 | const lastLineTop = lastLineItem.top 112 | const lastLineHeight = lastLineItem.height 113 | pageRegion.height = (lastLineTop + lastLineHeight) - pageRegion.top 114 | pageRegionList.push(Object.assign({}, pageRegion)) 115 | 116 | pageIndex++ 117 | lineIndex = 0 118 | columnIndex = 0 119 | 120 | pageRegion.page = region.page 121 | pageRegion.top = top 122 | pageRegion.right = left + width 123 | pageRegion.left = left 124 | pageRegion.width = width 125 | pageRegion.regions = [] 126 | 127 | lineRectRegion.top = top 128 | lineRectRegion.bottom = top + height 129 | lineRectRegion.left = left 130 | lineRectRegion.height = height 131 | lineRectRegion.page = page 132 | lineRectRegion.regions = [] 133 | 134 | lineRectRegion.regions.push(Object.assign({ 135 | originalIndex: index, lineIndex, columnIndex, pageIndex, 136 | }, region)) 137 | } 138 | if (index === originalRegionList.length - 1) { 139 | const lastItem = lineRectRegion.regions[lineRectRegion.regions.length - 1] 140 | const lastWidth = lastItem.width 141 | const lastLeft = lastItem.left 142 | lineRectRegion.width = (lastLeft + lastWidth) - lineRectRegion.left 143 | pageRegion.regions.push(Object.assign({}, lineRectRegion)) 144 | 145 | const lastLineItem = pageRegion.regions[pageRegion.regions.length - 1] 146 | const lastLineTop = lastLineItem.top 147 | const lastLineHeight = lastLineItem.height 148 | pageRegion.height = (lastLineTop + lastLineHeight) - pageRegion.top 149 | pageRegionList.push(Object.assign({}, pageRegion)) 150 | } 151 | }) 152 | return pageRegionList 153 | } 154 | 155 | setRegions(list) { 156 | const regions = Region.getLineRectRegionList(list) 157 | this.originalRegionList = list 158 | this.regions = regions 159 | } 160 | 161 | getSentenceByPosition(point) { 162 | let startRegion 163 | let endRegion 164 | const currentRegion = this.getRegionByPoint(point) 165 | const separators = [ 166 | '\u3002\u201D', 167 | '\uFF1F\u201D', 168 | '\uFF01\u201D', 169 | '\u3002', 170 | '\uFF1F', 171 | '\uFF01', 172 | ] 173 | const separatorRegStr = separators.reduce((acc, separator) => { 174 | if (separator === '') return acc 175 | if (acc === '') return `\\${separator}` 176 | return `${acc}|\\${separator}` 177 | }, '') 178 | const separator = new RegExp(`(${separatorRegStr})`) 179 | let tempEndRegion = currentRegion 180 | while (!endRegion) { 181 | const nextRegion = this.getNextRegion(tempEndRegion) 182 | if (nextRegion === null) { 183 | endRegion = tempEndRegion 184 | } else if (separator.test(nextRegion.text)) { 185 | endRegion = nextRegion 186 | } else { 187 | tempEndRegion = nextRegion 188 | } 189 | } 190 | let tempStartRegion = currentRegion 191 | while (!startRegion) { 192 | const nextRegion = this.getPreviousRegion(tempStartRegion) 193 | if (nextRegion === null) { 194 | startRegion = tempStartRegion 195 | } else if (separator.test(nextRegion.text)) { 196 | startRegion = tempStartRegion 197 | } else { 198 | tempStartRegion = nextRegion 199 | } 200 | } 201 | return { start: startRegion, end: endRegion } 202 | } 203 | 204 | getText(startRegion, endRegion) { 205 | const startIndex = startRegion.originalIndex 206 | const endIndex = endRegion.originalIndex 207 | const resultRegions = this.originalRegionList.slice(startIndex, endIndex + 1) 208 | let text = '' 209 | resultRegions.forEach((item) => { 210 | text += item.text 211 | }) 212 | return text 213 | } 214 | 215 | getRects(startRegion, endRegion) { 216 | const startPageIndex = startRegion.pageIndex 217 | const endPageIndex = endRegion.pageIndex 218 | const startLineIndex = startRegion.lineIndex 219 | const endLineIndex = endRegion.lineIndex 220 | const startColumnIndex = startRegion.columnIndex 221 | const endColumnIndex = endRegion.columnIndex 222 | const rects = [] 223 | if (startLineIndex === endLineIndex && startPageIndex === endPageIndex) { 224 | const lineRectRegion = this.regions[startPageIndex].regions[startLineIndex] 225 | rects.push(new DOMRect( 226 | startRegion.left, lineRectRegion.top, 227 | (endRegion.left + endRegion.width) - startRegion.left, lineRectRegion.height 228 | )) 229 | return rects 230 | } 231 | 232 | if (startPageIndex === endPageIndex) { 233 | const regions = this.regions[startPageIndex].regions.slice(startLineIndex, endLineIndex + 1) 234 | rects.push(...Region.getRectsByRegions(regions, startColumnIndex, endColumnIndex)) 235 | } else { 236 | for (let i = startPageIndex; i <= endPageIndex; i++) { 237 | if (i === startPageIndex) { 238 | const regions = this.regions[i].regions.slice(startLineIndex) 239 | rects.push(...Region.getRectsByRegions(regions, startColumnIndex, null)) 240 | } else if (i === endPageIndex) { 241 | const regions = this.regions[i].regions.slice(0, endLineIndex + 1) 242 | rects.push(...Region.getRectsByRegions(regions, null, endColumnIndex)) 243 | } else { 244 | const { regions } = this.regions[i] 245 | rects.push(...Region.getRectsByRegions(regions, null, null)) 246 | } 247 | } 248 | } 249 | return rects 250 | } 251 | static getRectsByRegions(LineRegions, startColumnIndex, endColumnIndex) { 252 | const rects = [] 253 | LineRegions.forEach((lineRectRegion, index) => { 254 | if (index === 0 && startColumnIndex !== null) { 255 | const region = lineRectRegion.regions[startColumnIndex] 256 | rects.push(new DOMRect( 257 | region.left, lineRectRegion.top, 258 | lineRectRegion.width - region.left + lineRectRegion.left, lineRectRegion.height 259 | )) 260 | } else if (index === LineRegions.length - 1 && endColumnIndex !== null) { 261 | const region = lineRectRegion.regions[endColumnIndex] 262 | rects.push(new DOMRect( 263 | lineRectRegion.left, lineRectRegion.top, 264 | region.left + region.width - lineRectRegion.left, lineRectRegion.height 265 | )) 266 | } else { 267 | rects.push(new DOMRect(lineRectRegion.left, lineRectRegion.top, lineRectRegion.width, lineRectRegion.height)) 268 | } 269 | }) 270 | return rects 271 | } 272 | /** 273 | * get Region By Point 274 | * 275 | * @public 276 | * @param {object} point 277 | * @param {number} point.x 278 | * @param {number} point.y 279 | * @param {boolean} is the boundary strict 280 | * @returns {(Region | null)} 281 | */ 282 | getRegionByPoint(point, isLoose = false) { 283 | const pointPosition = { 284 | top: point.y, 285 | left: point.x, 286 | } 287 | if (this.regions.length <= 0) return null 288 | const pageRegions = BSearchUpperBound(this.regions, pointPosition, 'left', isLoose) 289 | if (pageRegions === -1) return null 290 | const lineRectRegionList = this.regions[pageRegions].regions 291 | const lineRegions = BSearchUpperBound(lineRectRegionList, pointPosition, 'top', isLoose) 292 | if (lineRegions === -1) return null 293 | 294 | const touchLine = lineRectRegionList[lineRegions] 295 | const regionIndex = BSearchUpperBound(touchLine.regions, pointPosition, 'left', isLoose) 296 | if (regionIndex === -1) return null 297 | return touchLine.regions[regionIndex] 298 | } 299 | 300 | getLineInfoByRegion(region) { 301 | const { pageIndex, lineIndex } = region 302 | const { 303 | top, 304 | left, 305 | height, 306 | width, 307 | } = this.regions[pageIndex].regions[lineIndex] 308 | 309 | return { 310 | top, 311 | left, 312 | height, 313 | width, 314 | } 315 | } 316 | 317 | getPreviousRegion(region) { 318 | const { lineIndex, columnIndex, pageIndex } = region 319 | let previousRegion 320 | if (columnIndex === 0) { 321 | if (lineIndex !== 0) { 322 | const lineRectRegion = this.regions[pageIndex].regions[lineIndex - 1] 323 | if (lineRectRegion) { 324 | previousRegion = lineRectRegion.regions[lineRectRegion.regions.length - 1] 325 | } 326 | } else if (pageIndex !== 0) { 327 | const lineRectRegion = this.regions[pageIndex - 1].regions[this.regions[pageIndex - 1].region.length - 1] 328 | if (lineRectRegion) { 329 | previousRegion = lineRectRegion.regions[lineRectRegion.regions.length - 1] 330 | } 331 | } 332 | } else { 333 | previousRegion = this.regions[pageIndex].regions[lineIndex].regions[columnIndex - 1] 334 | } 335 | return previousRegion || null 336 | } 337 | 338 | getNextRegion(region) { 339 | const { lineIndex, columnIndex, pageIndex } = region 340 | let nextRegion 341 | const lineRectRegion = this.regions[pageIndex].regions[lineIndex] 342 | if (lineRectRegion) { 343 | if (columnIndex === lineRectRegion.regions.length - 1) { 344 | const nextLineRectRegion = this.regions[pageIndex].regions[lineIndex + 1] 345 | if (nextLineRectRegion) { 346 | [nextRegion] = nextLineRectRegion.regions 347 | } else if (this.regions[pageIndex + 1]) { 348 | [nextRegion] = this.regions[pageIndex + 1].regions[0].regions 349 | } 350 | } else { 351 | nextRegion = lineRectRegion.regions[columnIndex + 1] 352 | } 353 | } 354 | return nextRegion || null 355 | } 356 | 357 | static pointInRect(targetPoint, leftTopPoint, rightBottomPoint) { 358 | if (targetPoint.x > leftTopPoint.x 359 | && targetPoint.x <= rightBottomPoint.x 360 | && targetPoint.y > leftTopPoint.y 361 | && targetPoint.y <= rightBottomPoint.y) { 362 | return true 363 | } 364 | return false 365 | } 366 | 367 | static isSameLine(region1, region2) { 368 | return (region1.top - (region2.top + region2.height)) * (region2.top - (region1.top + region1.height)) > 0 369 | } 370 | 371 | destroy() { 372 | this.originalRegionList = [] 373 | this.regions = [] 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /src/lib/helpers.js: -------------------------------------------------------------------------------- 1 | import { DeviceType } from './types' 2 | 3 | /** 4 | * Get the location of the clicked word 5 | * 6 | * @export 7 | * @param {HTMLElement} pElement 8 | * @param {number} x 9 | * @param {number} y 10 | * @param {Array} separators 11 | * @returns 12 | */ 13 | export function getClickWordsPosition(pElement, x, y, separators = ['']) { 14 | if (!pElement || (pElement && !pElement.childNodes)) return null 15 | let lineHeight = Number(window.getComputedStyle(pElement).lineHeight.replace('px', '')) 16 | for (let i = 0; i < pElement.childNodes.length; i++) { 17 | const node = pElement.childNodes[i] 18 | 19 | if (node.nodeName === '#text') { 20 | const words = split(node.textContent, separators) 21 | let currentTextIndex = 0 22 | const range = document.createRange() 23 | 24 | for (let index = 0; index < words.length; index++) { 25 | const wordsLength = words[index].length 26 | range.setStart(node, currentTextIndex) 27 | range.setEnd(node, currentTextIndex + wordsLength) 28 | const textRects = range.getClientRects() 29 | 30 | for (let j = 0; j < textRects.length; j++) { 31 | const rect = textRects[j] 32 | lineHeight = lineHeight || rect.height 33 | const margin = (lineHeight - rect.height) / 2 34 | if (rect.left < x && rect.right > x && rect.top - margin < y && rect.bottom + margin > y) { 35 | const rects = [] 36 | 37 | for (let k = 0; k < textRects.length; k++) { 38 | const textRect = textRects[k] 39 | rects.push(Object.assign({}, textRect, { 40 | top: textRect.top - margin, 41 | height: lineHeight, 42 | bottom: textRect.bottom + margin, 43 | left: textRect.left, 44 | right: textRect.right, 45 | width: textRect.width, 46 | })) 47 | } 48 | return { 49 | node, 50 | rects, 51 | textRects, 52 | index: currentTextIndex, 53 | wordsLength, 54 | } 55 | } 56 | } 57 | currentTextIndex += wordsLength 58 | } 59 | } else if (node.nodeName === '#comment') { 60 | continue // eslint-disable-line no-continue 61 | } else { 62 | const result = getClickWordsPosition(node, x, y, separators) 63 | if (result) return result 64 | } 65 | } 66 | return null 67 | } 68 | 69 | /** 70 | * Get the location of the click 71 | * 72 | * @export 73 | * @param {HTMLElement| Node} pElement 74 | * @param {number} x 75 | * @param {number} y 76 | * @param {boolean} isStart 77 | */ 78 | export function getClickPosition(pElement, x, y, isStart) { 79 | if (!pElement || (!pElement && pElement.childNodes)) return null 80 | let lineHeight = Number(window.getComputedStyle(pElement).lineHeight.replace('px', '')) 81 | let tempResult 82 | for (let i = 0; i < pElement.childNodes.length; i++) { 83 | const node = pElement.childNodes[i] 84 | let position = null 85 | 86 | if (node.nodeName === '#text') { 87 | const words = [...node.textContent] 88 | const range = document.createRange() 89 | let preRect 90 | for (let index = 0; index < words.length; index++) { 91 | range.setStart(node, index) 92 | range.setEnd(node, index + 1) 93 | let nextRect 94 | const rects = range.getClientRects() 95 | let rect 96 | if (rects.length > 1) { 97 | rect = rects[1] // eslint-disable-line prefer-destructuring 98 | } else if (rects.length === 1) { 99 | rect = rects[0] // eslint-disable-line prefer-destructuring 100 | } else { 101 | continue // eslint-disable-line no-continue 102 | } 103 | lineHeight = lineHeight || rect.height 104 | const margin = (lineHeight - rect.height) / 2 105 | if (rect.top - margin <= y && rect.bottom + margin >= y) { 106 | position = { 107 | x: rect.left, 108 | y: rect.top - margin, 109 | height: lineHeight, 110 | index, 111 | node, 112 | } 113 | 114 | try { 115 | range.setStart(node, index + 1) 116 | range.setEnd(node, index + 2) 117 | const nextRects = range.getClientRects() 118 | 119 | if (nextRects.length > 1) { 120 | nextRect = nextRects[1] // eslint-disable-line prefer-destructuring 121 | } else if (nextRects.length === 1) { 122 | nextRect = nextRects[0] // eslint-disable-line prefer-destructuring 123 | } else { 124 | nextRect = null 125 | } 126 | } catch (error) { 127 | nextRect = null 128 | } 129 | 130 | const isLineStart = preRect === undefined || (preRect && preRect.bottom <= rect.top) 131 | const isLineEnd = nextRect === null || (nextRect && nextRect.top >= rect.bottom) 132 | 133 | if (x < rect.right) { 134 | const isLeft = x < (rect.left + rect.right) / 2 135 | if ((isLineStart && !isStart) || (!isLeft && !(isLineEnd && isStart))) { 136 | position.x += rect.width 137 | position.index += 1 138 | } 139 | return position 140 | } 141 | 142 | if (isLineEnd) { 143 | if (!isStart) { 144 | position.x += rect.width 145 | position.index += 1 146 | } 147 | tempResult = position 148 | } 149 | } 150 | preRect = rect 151 | } 152 | } else if (node.nodeName === '#comment') { 153 | continue // eslint-disable-line no-continue 154 | } else { 155 | const result = getClickPosition(node, x, y, isStart) 156 | if (result) { 157 | tempResult = result 158 | } 159 | } 160 | if (i === pElement.childNodes.length - 1 && tempResult) { 161 | return tempResult 162 | } 163 | } 164 | return null 165 | } 166 | 167 | /** 168 | * Get the relative position of the touch 169 | * 170 | * @export 171 | * @param {TouchEvent} e 172 | * @param {Object} offset Offset of the clicked location 173 | * @returns 174 | */ 175 | export function getTouchPosition(e, offset = { x: 0, y: 0 }) { 176 | const touch = getTouch(e) 177 | return { 178 | x: touch.clientX + offset.x, 179 | y: touch.clientY + offset.y, 180 | } 181 | } 182 | 183 | /** 184 | * Returns the distance between two points 185 | * 186 | * @export 187 | * @param {any} start 188 | * @param {any} end 189 | * @returns 190 | */ 191 | export function getDistance(start, end) { 192 | return Math.sqrt((start.x - end.x) ** 2 + (start.y - end.y) ** 2) 193 | } 194 | 195 | /** 196 | * Convert px to rem 197 | * 198 | * @export 199 | * @param {any} px 200 | * @returns 201 | */ 202 | export function pxToRem(px) { 203 | const baseFontSize = Number((document.documentElement.style.fontSize || '24px').replace('px', '')) 204 | return px / baseFontSize 205 | } 206 | 207 | /** 208 | * 209 | * 210 | * @export 211 | * @param {any} pixelUnit 212 | * @returns 213 | */ 214 | export function anyToPx(pixelUnit) { 215 | if (typeof pixelUnit === 'number') return pixelUnit 216 | if (typeof pixelUnit === 'string') { 217 | if (pixelUnit.indexOf('px') > -1) return Number(pixelUnit.replace('px', '')) 218 | if (pixelUnit.indexOf('rem') > -1) { 219 | const baseFontSize = Number((document.documentElement.style.fontSize || '24px').replace('px', '')) 220 | return Number(pixelUnit.replace('rem', '')) * baseFontSize 221 | } 222 | return Number(pixelUnit) 223 | } 224 | return 0 225 | } 226 | 227 | /** 228 | * Get the text node areas 229 | * 230 | * @export 231 | * @param {any} node 232 | * @param {any} start 233 | * @param {any} end 234 | * @returns 235 | */ 236 | export function getNodeRects(node, start, end) { 237 | const range = document.createRange() 238 | const startIndex = start === undefined ? 0 : start 239 | const endIndex = end === undefined ? node.textContent.length : end 240 | try { 241 | range.setStart(node, startIndex) 242 | range.setEnd(node, endIndex) 243 | return domCollectionToArray(range.getClientRects()) 244 | } catch (error) { 245 | console.error('EasyMarkerError:', error) // eslint-disable-line no-console 246 | return [] 247 | } 248 | } 249 | 250 | /** 251 | * Get the absolute positioning of the element 252 | * todo: use getBoundingClientRect() 253 | * @export 254 | * @param {HTMLElement} element 255 | */ 256 | export function getElementAbsolutePosition(element) { 257 | let x = element.offsetLeft 258 | let y = element.offsetTop 259 | const width = element.clientWidth 260 | const height = element.clientHeight 261 | let current = element.offsetParent 262 | 263 | while (current !== null) { 264 | x += current.offsetLeft 265 | y += current.offsetTop 266 | current = current.offsetParent 267 | } 268 | 269 | return { 270 | x, 271 | y, 272 | width, 273 | height, 274 | } 275 | } 276 | 277 | /** 278 | * Converts the location relative to the screen to the location relative to the parent container 279 | * 280 | * @export 281 | * @param {Object} position 282 | * @param {any} containerPosition 283 | * @param {any} scrollTop 284 | */ 285 | export function screenRelativeToContainerRelative(position, offset) { 286 | if (!position.isSet) return position 287 | 288 | position.y -= offset.y 289 | position.x -= offset.x 290 | 291 | return position 292 | } 293 | 294 | /** 295 | * Split the string, the result contains the separator 296 | * E.g: 297 | * separators:[',','!'] 298 | * 'hello, world! => ['hello,', ' world!'] 299 | * @export 300 | * @param {string} string 301 | * @param {Array} [separators=['']] 302 | */ 303 | export function split(string, separators = ['']) { 304 | const separatorRegStr = separators.reduce((acc, separator) => { 305 | if (separator === '') return acc 306 | if (acc === '') return `\\${separator}` 307 | return `${acc}|\\${separator}` 308 | }, '') 309 | const separator = new RegExp(`(${separatorRegStr})`) 310 | const splitStrings = string.split(separator) 311 | const resultStrings = [] 312 | for (let i = 0; i < splitStrings.length; i += 2) { 313 | const mergedStr = splitStrings[i] + (splitStrings[i + 1] || '') 314 | if (mergedStr.length > 0) { 315 | resultStrings.push(mergedStr) 316 | } 317 | } 318 | return resultStrings 319 | } 320 | 321 | /** 322 | * Check whether in the rectangle 323 | * 324 | * @export 325 | * @param {number} x 326 | * @param {number} y 327 | * @param {ClientRect} rect 328 | * @returns {boolean} 329 | */ 330 | export function inRectangle(x, y, rect, margin) { 331 | return rect.top - margin <= y && rect.bottom + margin >= y && rect.left <= x && rect.right >= x 332 | } 333 | 334 | export function copyRect(rect) { 335 | return { 336 | bottom: rect.bottom, 337 | height: rect.height, 338 | left: rect.left, 339 | right: rect.right, 340 | top: rect.top, 341 | width: rect.width, 342 | } 343 | } 344 | export function domCollectionToArray(collection) { 345 | const array = [] 346 | for (let i = 0; i < collection.length; i++) { 347 | array.push(collection[i]) 348 | } 349 | return array 350 | } 351 | 352 | export function matchSubString(originStr = '', subStr = '') { 353 | let matchSubstr = '' 354 | const formatSubStr = subStr.replace(/\s+/g, '') 355 | for (let i = 0, j = 0; i < originStr.length; i++) { 356 | if (j >= formatSubStr.length) { 357 | return matchSubstr 358 | } 359 | if (originStr[i] === formatSubStr[j]) { 360 | matchSubstr += originStr[i] 361 | j++ 362 | } else if (originStr[i].match(/\n|\r|\s/)) { 363 | if (matchSubstr !== '') { 364 | matchSubstr += originStr[i] 365 | } 366 | } else { 367 | i -= matchSubstr.length 368 | j = 0 369 | matchSubstr = '' 370 | } 371 | } 372 | return matchSubstr 373 | } 374 | 375 | /** 376 | * get Device Type (mobile || PC) 377 | * 378 | * @param {Event} Event 379 | * @returns {object} { x, y } 380 | */ 381 | export function getDeviceType() { 382 | if (typeof navigator !== 'undefined' && navigator.userAgent) { 383 | const ua = navigator.userAgent 384 | if (ua.indexOf('Tablet') > -1 || ua.indexOf('Pad') > -1 || ua.indexOf('Nexus 7') > -1) return DeviceType.MOBILE 385 | if (ua.indexOf('Mobi') > -1 || ua.indexOf('Android') > -1 || ua.indexOf('iPh') > -1 || ua.indexOf('FLOW') > -1) { return DeviceType.MOBILE } 386 | return DeviceType.PC 387 | } 388 | return DeviceType.MOBILE 389 | } 390 | 391 | /** 392 | * get eventTouch Support mobile and PC 393 | * 394 | * @param {Event} Event 395 | * @returns {object} { x, y } 396 | */ 397 | export function getTouch(e) { 398 | if (getDeviceType() === DeviceType.MOBILE) { 399 | return e.changedTouches[0] 400 | } 401 | return { 402 | clientX: e.clientX, 403 | clientY: e.clientY, 404 | } 405 | } 406 | 407 | /** 408 | * Binary search 409 | * 410 | * @param {array} array 411 | * @param {object | string} target 412 | * @param {string} target key 413 | * @param {boolean} is the boundary strict 414 | * @returns {number} index 415 | */ 416 | export function BSearchUpperBound(arr, target, key, isLoose = false) { 417 | let start = 0 418 | let end = arr.length - 1 419 | let mid = Math.floor((start + end) / 2) 420 | const targetValue = key ? target[key] : target 421 | if (targetValue >= (key ? arr[end][key] : arr[end])) { 422 | return end 423 | } else if (targetValue < (key ? arr[start][key] : arr[start])) { 424 | return isLoose ? start : -1 425 | } 426 | while (start <= end) { 427 | if (start === mid || end === mid) { 428 | return mid 429 | } 430 | if ((key ? arr[mid][key] : arr[mid]) > targetValue) { 431 | end = mid 432 | } else { 433 | start = mid 434 | } 435 | mid = Math.floor((start + end) / 2) 436 | } 437 | return mid 438 | } 439 | 440 | /** 441 | * rect => Point[] 442 | * 443 | * @static 444 | * @param {ClientRect} rect 445 | * @param {Object} offset 446 | * @param {number} offset.x 447 | * @param {number} offset.y 448 | * @memberof Highlight 449 | */ 450 | export function rectToPointArray(rect, offset, margin) { 451 | const points = [] 452 | if (rect.width === 0) return points 453 | 454 | points.push([rect.left - margin, rect.top - margin]) 455 | points.push([rect.right + margin, rect.top - margin]) 456 | points.push([rect.right + margin, rect.bottom + margin]) 457 | points.push([rect.left - margin, rect.bottom + margin]) 458 | 459 | points.forEach((point) => { 460 | point[0] -= offset.x 461 | point[1] -= offset.y 462 | }) 463 | return points 464 | } 465 | -------------------------------------------------------------------------------- /api.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## EasyMarker 4 | A simple article marker library 5 | 6 | **Kind**: global class 7 | 8 | * [EasyMarker](#EasyMarker) 9 | * [new EasyMarker(options)](#new_EasyMarker_new) 10 | * _instance_ 11 | * [.create(containerElement, [scrollContainerElement], options)](#EasyMarker+create) 12 | * [.highlightLine(selection, [id], [meta])](#EasyMarker+highlightLine) 13 | * [.highlightLines(lines)](#EasyMarker+highlightLines) 14 | * [.cancelHighlightLine(id)](#EasyMarker+cancelHighlightLine) ⇒ boolean 15 | * [.onHighlightLineClick(cb)](#EasyMarker+onHighlightLineClick) 16 | * [.onSelectStatusChange(cb)](#EasyMarker+onSelectStatusChange) 17 | * [.onMenuClick(cb)](#EasyMarker+onMenuClick) 18 | * [.registerEventHook(cb)](#EasyMarker+registerEventHook) 19 | * [.destroy()](#EasyMarker+destroy) 20 | * _static_ 21 | * [.create(containerElement, [scrollContainerElement], options)](#EasyMarker.create) ⇒ [EasyMarker](#EasyMarker) 22 | * _inner_ 23 | * [~menuClickHandler](#EasyMarker..menuClickHandler) : function 24 | * [~highlightLineClickHandler](#EasyMarker..highlightLineClickHandler) : function 25 | 26 | 27 | 28 | ### new EasyMarker(options) 29 | Creates an instance of EasyMarker. 30 | 31 | 32 | | Param | Type | Description | 33 | | --- | --- | --- | 34 | | options | Object | options | 35 | | options.menuItems | Array.<Object> | menu item option | 36 | | options.menuItems[].text | string | menu text | 37 | | options.menuItems[].type | string | menu type 'select'(Show menu only when selected) 'highlight' (Show menu only when click highlight) | 38 | | options.menuItems[].iconName | Array.<string> | menu icon class | 39 | | options.menuItems[].style | Object | menu item style | 40 | | options.menuItems[].iconStyle | Object | menu item icon style | 41 | | options.menuTopOffset | number \| string | the offset from the top of the menu relative screen, default 0. | 42 | | options.menuStyle | Object | the menu style | 43 | | options.menuStyle.menu | Object | the menu style | 44 | | options.menuStyle.triangle | Object | the triangle style | 45 | | options.menuStyle.item | Object | the sub menu style | 46 | | options.menuStyle.icon | Object | the sub menu icon style | 47 | | options.disableTapHighlight | boolean | disable highlight when tap | 48 | | options.cursor | Object | cursor config | 49 | | options.cursor.color | string | cursor color | 50 | | options.cursor.same | boolean | whether the cursor is in the same direction | 51 | | options.mask | Object | mask config | 52 | | options.mask.color | string | mask color | 53 | | options.highlight | Object | highlight config | 54 | | options.highlight.color | string | highlight color | 55 | | options.scrollSpeedLevel | number | The speed of scrolling when touching bottom, default 4 | 56 | | options.scrollOffsetBottom | number \| string | triggering scrolling, distance from the bottom, default 100 | 57 | | options.markdownOptions | Object | Customize options about the mapping relations between HTML and Markdown | 58 | | options.regions | Array.<Object> | In region mode, all region info | 59 | | options.regions[].text | string | region text | 60 | | options.regions[].top | number | region top | 61 | | options.regions[].left | number | region left | 62 | | options.regions[].width | number | region width | 63 | | options.regions[].height | number | region height | 64 | | options.disableSelect | boolean | disabled select | 65 | 66 | **Example** 67 | ```js 68 | // A simple example 69 | const em = new EasyMarker({ 70 | menuTopOffset: '2rem', 71 | menuItems: [ 72 | { 73 | text: '划线笔记', 74 | id: 1 75 | }, 76 | { 77 | text: '分享', 78 | style: { 79 | backgroundColor: '#407ff2', 80 | paddingLeft: '0.5rem' 81 | }, 82 | id: 2 83 | }, 84 | { 85 | text: '复制', 86 | id: 3 87 | } 88 | ], 89 | ) 90 | 91 | em.create(document.querySelector('.article-body'), 92 | document.body, 93 | document.querySelectorAll('.article-body>:not(.text)') 94 | 95 | // a markdown example 96 | const em = new EasyMarker({ 97 | menuTopOffset:'2rem', 98 | scrollSpeedLevel: 6, 99 | scrollOffsetBottom: '1.5rem', 100 | menuItems: [ 101 | { 102 | text: '划线笔记', 103 | id: 1, 104 | iconName:'iconfont icon-mark' 105 | }, 106 | { 107 | text: '分享', 108 | style: { 109 | backgroundColor: '#407ff2', 110 | paddingLeft: '0.5rem' 111 | }, 112 | id: 2, 113 | iconName:'iconfont icon-share' 114 | }, 115 | { 116 | text: '复制', 117 | id: 3, 118 | iconName:'iconfont icon-copy' 119 | } 120 | ], 121 | // Not required 122 | markdownOptions: { 123 | H1: text => `\n# ${text}\n\n`, 124 | H2: text => `\n## ${text}\n\n`, 125 | H3: text => `\n### ${text}\n\n`, 126 | H4: text => `\n#### ${text}\n\n`, 127 | H5: text => `\n##### ${text}\n\n`, 128 | H6: text => `\n###### ${text}\n\n`, 129 | P: text => `${text}\n\n`, 130 | FIGCAPTION: text => `${text}\n\n`, 131 | STRONG: text => `**${text}**`, 132 | B: text => `**${text}**`, 133 | EM: text => `*${text}*`, 134 | I: text => `*${text}*`, 135 | S: text => `~~${text}~~`, 136 | INS: text => `++${text}++`, 137 | // IMG 138 | // option.alt: IMG alt 139 | // option.src: IMG src 140 | // option.width: IMG width 141 | // option.height: IMG height 142 | IMG: option => `![${option.alt}](${option.src}?size=${option.width}x${option.height})\n`, 143 | // UL 144 | // option.listLevel: List nesting level 145 | UL: (text, option) => { 146 | if (option.listLevel > 1) { 147 | return `\n${text}` 148 | } 149 | return `\n${text}\n` 150 | }, 151 | // OL 152 | // option.listLevel: List nesting level 153 | OL: (text, option) => { 154 | if (option.listLevel > 1) { 155 | return `\n${text}` 156 | } 157 | return `\n${text}\n` 158 | }, 159 | // LI 160 | // option.type: parentNode nodeName, 161 | // option.isLastOne: Whether the last item in the list 162 | // option.itemLevel: List nesting level 163 | // option.hasChild: Is the node has child node 164 | // option.index: The index in the list 165 | LI: (text, option) => { 166 | let spaceString = '' 167 | for (let i = 1; i < option.itemLevel; i++) { spaceString += ' ' } 168 | let endString = '\n' 169 | if (option.hasChild || option.isLastOne) { 170 | endString = '' 171 | } 172 | if (option.type === 'UL') { return `${spaceString}- ${text}${endString}` } 173 | return `${spaceString}${option.index}. ${text}${endString}` 174 | }, 175 | } 176 | }) 177 | 178 | em.create(document.querySelector('.article-body'), document.body) 179 | em.onMenuClick((id, data) => { 180 | console.log('You click the menu!'); 181 | console.log(id, data); 182 | }); 183 | 184 | // A Region example 185 | 186 | const em = new EasyMarker({ 187 | regions: texts, 188 | menuTopOffset: '120px', 189 | scrollSpeedLevel: 6, 190 | scrollOffsetBottom: '1.5rem', 191 | mask: { 192 | color: '#407ff2', 193 | }, 194 | menuStyle: { 195 | menu: {}, 196 | item: { 197 | fontSize: '15px', 198 | padding: '0px 10px', 199 | lineHeight: '30px', 200 | }, 201 | triangle: {}, 202 | }, 203 | menuItems: [ 204 | { 205 | text: '划线', 206 | type: 'select', 207 | iconName: 'iconfont mark', 208 | id: '302', 209 | style: { 210 | backgroundColor: 'yellow', 211 | paddingLeft: '1rem', 212 | }, 213 | iconStyle: { 214 | background: 'green', 215 | }, 216 | }, 217 | { 218 | text: '删除划线', 219 | type: 'highlight', 220 | iconName: 'iconfont icon-delete', 221 | id: '302', 222 | }, 223 | { 224 | id: 222, 225 | text: '复制', 226 | iconName: 'iconfont icon-copy', 227 | }, 228 | ], 229 | }); 230 | 231 | em.onMenuClick(function (id, data) { 232 | console.log('You click the menu!', id, data); 233 | }); 234 | 235 | em.onSelectStatusChange((val) => { 236 | console.log('onSelectStatusChange', val); 237 | }); 238 | 239 | em.create(document.body); 240 | ``` 241 | 242 | 243 | ### easyMarker.create(containerElement, [scrollContainerElement], options) 244 | Initialization 245 | 246 | **Kind**: instance method of [EasyMarker](#EasyMarker) 247 | 248 | | Param | Type | Description | 249 | | --- | --- | --- | 250 | | containerElement | HTMLElement | container element | 251 | | [scrollContainerElement] | HTMLElement | scroll container element | 252 | | options | Object | options | 253 | | options.includeElements | Object | included elements | 254 | | options.excludeElements | Object | not included elements, Higher priority | 255 | 256 | 257 | 258 | ### easyMarker.highlightLine(selection, [id], [meta]) 259 | Highlight the lines between the specified nodes 260 | 261 | **Kind**: instance method of [EasyMarker](#EasyMarker) 262 | 263 | | Param | Type | Description | 264 | | --- | --- | --- | 265 | | selection | Object | selection | 266 | | selection.anchorNode | Node | start node | 267 | | selection.anchorOffset | number | start node's text offset | 268 | | selection.focusNode | Node | end node | 269 | | selection.focusOffset | number | start node's text offset | 270 | | [id] | \* | line id | 271 | | [meta] | \* | meta information | 272 | 273 | **Example** 274 | ```js 275 | const id = 2; 276 | const selection = { 277 | anchorNode: textNodeA, 278 | anchorOffset: 1, 279 | focusNode: textNodeB, 280 | focusOffset: 2 281 | }; 282 | const meta = { someKey: 'someValue' }; 283 | em.highlightLine(selection, id, meta); 284 | ``` 285 | 286 | 287 | ### easyMarker.highlightLines(lines) 288 | Highlight multiple lines 289 | 290 | **Kind**: instance method of [EasyMarker](#EasyMarker) 291 | 292 | | Param | Type | 293 | | --- | --- | 294 | | lines | Array.<Object> | 295 | | [lines[].id] | \* | 296 | | [lines[].meta] | \* | 297 | | lines[].selection | Object | 298 | | lines[].selection.anchorNode | Node | 299 | | lines[].selection.anchorOffset | number | 300 | | lines[].selection.focusNode | Node | 301 | | lines[].selection.focusOffset | number | 302 | 303 | **Example** 304 | ```js 305 | const id = 2; 306 | const selection = { 307 | anchorNode: textNodeA, 308 | anchorOffset: 1, 309 | focusNode: textNodeB, 310 | focusOffset: 2 311 | }; 312 | const meta = { someKey: 'someValue' }; 313 | em.highlightLines([{selection, id, meta}]); 314 | ``` 315 | 316 | 317 | ### easyMarker.cancelHighlightLine(id) ⇒ boolean 318 | Cancel highlight 319 | 320 | **Kind**: instance method of [EasyMarker](#EasyMarker) 321 | 322 | | Param | Type | Description | 323 | | --- | --- | --- | 324 | | id | \* | line ID | 325 | 326 | 327 | 328 | ### easyMarker.onHighlightLineClick(cb) 329 | Highlight line click handler 330 | 331 | **Kind**: instance method of [EasyMarker](#EasyMarker) 332 | 333 | | Param | Type | 334 | | --- | --- | 335 | | cb | [highlightLineClickHandler](#EasyMarker..highlightLineClickHandler) | 336 | 337 | 338 | 339 | ### easyMarker.onSelectStatusChange(cb) 340 | Select status changing callback 341 | 342 | **Kind**: instance method of [EasyMarker](#EasyMarker) 343 | 344 | | Param | Type | 345 | | --- | --- | 346 | | cb | function | 347 | 348 | 349 | 350 | ### easyMarker.onMenuClick(cb) 351 | menu item click handler 352 | 353 | **Kind**: instance method of [EasyMarker](#EasyMarker) 354 | 355 | | Param | Type | 356 | | --- | --- | 357 | | cb | [menuClickHandler](#EasyMarker..menuClickHandler) | 358 | 359 | 360 | 361 | ### easyMarker.registerEventHook(cb) 362 | Register event hook 363 | 364 | **Kind**: instance method of [EasyMarker](#EasyMarker) 365 | 366 | | Param | Type | 367 | | --- | --- | 368 | | cb | \* | 369 | 370 | 371 | 372 | ### easyMarker.destroy() 373 | Destroy instance 374 | 375 | **Kind**: instance method of [EasyMarker](#EasyMarker) 376 | 377 | 378 | ### EasyMarker.create(containerElement, [scrollContainerElement], options) ⇒ [EasyMarker](#EasyMarker) 379 | Initialization factory 380 | 381 | **Kind**: static method of [EasyMarker](#EasyMarker) 382 | 383 | | Param | Type | Description | 384 | | --- | --- | --- | 385 | | containerElement | HTMLElement | container element | 386 | | [scrollContainerElement] | HTMLElement | scroll container element | 387 | | options | Object | options | 388 | | options.includeElements | Object | included elements | 389 | | options.excludeElements | Object | not included elements, Higher priority | 390 | 391 | 392 | 393 | ### EasyMarker~menuClickHandler : function 394 | Menu item click handler 395 | 396 | **Kind**: inner typedef of [EasyMarker](#EasyMarker) 397 | 398 | | Param | Type | Description | 399 | | --- | --- | --- | 400 | | id | \* | menu ID | 401 | | selection | Object | selection | 402 | | selection.anchorNode | Node | start node | 403 | | selection.anchorOffset | number | start node's text offset | 404 | | selection.focusNode | Node | end node | 405 | | selection.focusOffset | number | start node's text offset | 406 | 407 | 408 | 409 | ### EasyMarker~highlightLineClickHandler : function 410 | Menu item click handler 411 | 412 | **Kind**: inner typedef of [EasyMarker](#EasyMarker) 413 | 414 | | Param | Type | Description | 415 | | --- | --- | --- | 416 | | id | \* | line ID | 417 | | meta | \* | meta information | 418 | | selection | Object | selection | 419 | | selection.anchorNode | Node | start node | 420 | | selection.anchorOffset | number | start node's text offset | 421 | | selection.focusNode | Node | end node | 422 | | selection.focusOffset | number | start node's text offset | 423 | 424 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## easy-marker 2 | 3 | `easy-marker` is a library for marking text in html. An example is as follows: 4 | 5 | ![demo](./demo.gif) 6 | 7 | ## Install 8 | 9 | ```bash 10 | npm i easy-marker 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```js 16 | import EasyMarker from 'easy-marker'; 17 | 18 | const easyMarker = new EasyMarker(); 19 | const container = document.querySelector('xxxx'); 20 | easyMarker.create(container); 21 | ``` 22 | 23 | ## API 24 | 25 | - [EasyMarker](#EasyMarker) 26 | - [new EasyMarker(options)](#new_EasyMarker_new) 27 | - _instance_ 28 | - [.create(containerElement, [scrollContainerElement], options)](#EasyMarker+create) 29 | - [.highlightLine(selection, [id], [meta])](#EasyMarker+highlightLine) 30 | - [.highlightLines(lines)](#EasyMarker+highlightLines) 31 | - [.cancelHighlightLine(id)](#EasyMarker+cancelHighlightLine) ⇒ boolean 32 | - [.onHighlightLineClick(cb)](#EasyMarker+onHighlightLineClick) 33 | - [.onSelectStatusChange(cb)](#EasyMarker+onSelectStatusChange) 34 | - [.onMenuClick(cb)](#EasyMarker+onMenuClick) 35 | - [.registerEventHook(cb)](#EasyMarker+registerEventHook) 36 | - [.destroy()](#EasyMarker+destroy) 37 | - _static_ 38 | - [.create(containerElement, [scrollContainerElement], options)](#EasyMarker.create) ⇒ [EasyMarker](#EasyMarker) 39 | - _inner_ 40 | - [~menuClickHandler](#EasyMarker..menuClickHandler) : function 41 | - [~highlightLineClickHandler](#EasyMarker..highlightLineClickHandler) : function 42 | 43 | 44 | 45 | ### new EasyMarker(options) 46 | 47 | Creates an instance of EasyMarker. 48 | 49 | | Param | Type | Description | 50 | | ----------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------- | 51 | | options | Object | options | 52 | | options.menuItems | Array.<Object> | menu item option | 53 | | options.menuItems[].text | string | menu text | 54 | | options.menuItems[].type | string | menu type 'select'(Show menu only when selected) 'highlight' (Show menu only when click highlight) | 55 | | options.menuItems[].iconName | Array.<string> | menu icon class | 56 | | options.menuItems[].style | Object | menu item style | 57 | | options.menuItems[].iconStyle | Object | menu item icon style | 58 | | options.menuTopOffset | number \| string | the offset from the top of the menu relative screen, default 0. | 59 | | options.menuStyle | Object | the menu style | 60 | | options.menuStyle.menu | Object | the menu style | 61 | | options.menuStyle.triangle | Object | the triangle style | 62 | | options.menuStyle.item | Object | the sub menu style | 63 | | options.menuStyle.icon | Object | the sub menu icon style | 64 | | options.disableTapHighlight | boolean | disable highlight when tap | 65 | | options.cursor | Object | cursor config | 66 | | options.cursor.color | string | cursor color | 67 | | options.cursor.same | boolean | whether the cursor is in the same direction | 68 | | options.mask | Object | mask config | 69 | | options.mask.color | string | mask color | 70 | | options.highlight | Object | highlight config | 71 | | options.highlight.color | string | highlight color | 72 | | options.scrollSpeedLevel | number | The speed of scrolling when touching bottom, default 4 | 73 | | options.scrollOffsetBottom | number \| string | triggering scrolling, distance from the bottom, default 100 | 74 | | options.markdownOptions | Object | Customize options about the mapping relations between HTML and Markdown | 75 | | options.regions | Array.<Object> | In region mode, all region info | 76 | | options.regions[].text | string | region text | 77 | | options.regions[].top | number | region top | 78 | | options.regions[].left | number | region left | 79 | | options.regions[].width | number | region width | 80 | | options.regions[].height | number | region height | 81 | | options.disableSelect | boolean | disabled select | 82 | 83 | **Example** 84 | 85 | ```js 86 | // A simple example 87 | const em = new EasyMarker({ 88 | menuTopOffset: '2rem', 89 | menuItems: [ 90 | { 91 | text: '划线笔记', 92 | id: 1 93 | }, 94 | { 95 | text: '分享', 96 | style: { 97 | backgroundColor: '#407ff2', 98 | paddingLeft: '0.5rem' 99 | }, 100 | id: 2 101 | }, 102 | { 103 | text: '复制', 104 | id: 3 105 | } 106 | ], 107 | ) 108 | 109 | em.create(document.querySelector('.article-body'), 110 | document.body, 111 | document.querySelectorAll('.article-body>:not(.text)') 112 | 113 | // a markdown example 114 | const em = new EasyMarker({ 115 | menuTopOffset:'2rem', 116 | scrollSpeedLevel: 6, 117 | scrollOffsetBottom: '1.5rem', 118 | menuItems: [ 119 | { 120 | text: '划线笔记', 121 | id: 1, 122 | iconName:'iconfont icon-mark' 123 | }, 124 | { 125 | text: '分享', 126 | style: { 127 | backgroundColor: '#407ff2', 128 | paddingLeft: '0.5rem' 129 | }, 130 | id: 2, 131 | iconName:'iconfont icon-share' 132 | }, 133 | { 134 | text: '复制', 135 | id: 3, 136 | iconName:'iconfont icon-copy' 137 | } 138 | ], 139 | // Not required 140 | markdownOptions: { 141 | H1: text => `\n# ${text}\n\n`, 142 | H2: text => `\n## ${text}\n\n`, 143 | H3: text => `\n### ${text}\n\n`, 144 | H4: text => `\n#### ${text}\n\n`, 145 | H5: text => `\n##### ${text}\n\n`, 146 | H6: text => `\n###### ${text}\n\n`, 147 | P: text => `${text}\n\n`, 148 | FIGCAPTION: text => `${text}\n\n`, 149 | STRONG: text => `**${text}**`, 150 | B: text => `**${text}**`, 151 | EM: text => `*${text}*`, 152 | I: text => `*${text}*`, 153 | S: text => `~~${text}~~`, 154 | INS: text => `++${text}++`, 155 | // IMG 156 | // option.alt: IMG alt 157 | // option.src: IMG src 158 | // option.width: IMG width 159 | // option.height: IMG height 160 | IMG: option => `![${option.alt}](${option.src}?size=${option.width}x${option.height})\n`, 161 | // UL 162 | // option.listLevel: List nesting level 163 | UL: (text, option) => { 164 | if (option.listLevel > 1) { 165 | return `\n${text}` 166 | } 167 | return `\n${text}\n` 168 | }, 169 | // OL 170 | // option.listLevel: List nesting level 171 | OL: (text, option) => { 172 | if (option.listLevel > 1) { 173 | return `\n${text}` 174 | } 175 | return `\n${text}\n` 176 | }, 177 | // LI 178 | // option.type: parentNode nodeName, 179 | // option.isLastOne: Whether the last item in the list 180 | // option.itemLevel: List nesting level 181 | // option.hasChild: Is the node has child node 182 | // option.index: The index in the list 183 | LI: (text, option) => { 184 | let spaceString = '' 185 | for (let i = 1; i < option.itemLevel; i++) { spaceString += ' ' } 186 | let endString = '\n' 187 | if (option.hasChild || option.isLastOne) { 188 | endString = '' 189 | } 190 | if (option.type === 'UL') { return `${spaceString}- ${text}${endString}` } 191 | return `${spaceString}${option.index}. ${text}${endString}` 192 | }, 193 | } 194 | }) 195 | 196 | em.create(document.querySelector('.article-body'), document.body) 197 | em.onMenuClick((id, data) => { 198 | console.log('You click the menu!'); 199 | console.log(id, data); 200 | }); 201 | 202 | // A Region example 203 | 204 | const em = new EasyMarker({ 205 | regions: texts, 206 | menuTopOffset: '120px', 207 | scrollSpeedLevel: 6, 208 | scrollOffsetBottom: '1.5rem', 209 | mask: { 210 | color: '#407ff2', 211 | }, 212 | menuStyle: { 213 | menu: {}, 214 | item: { 215 | fontSize: '15px', 216 | padding: '0px 10px', 217 | lineHeight: '30px', 218 | }, 219 | triangle: {}, 220 | }, 221 | menuItems: [ 222 | { 223 | text: '划线', 224 | type: 'select', 225 | iconName: 'iconfont mark', 226 | id: '302', 227 | style: { 228 | backgroundColor: 'yellow', 229 | paddingLeft: '1rem', 230 | }, 231 | iconStyle: { 232 | background: 'green', 233 | }, 234 | }, 235 | { 236 | text: '删除划线', 237 | type: 'highlight', 238 | iconName: 'iconfont icon-delete', 239 | id: '302', 240 | }, 241 | { 242 | id: 222, 243 | text: '复制', 244 | iconName: 'iconfont icon-copy', 245 | }, 246 | ], 247 | }); 248 | 249 | em.onMenuClick(function (id, data) { 250 | console.log('You click the menu!', id, data); 251 | }); 252 | 253 | em.onSelectStatusChange((val) => { 254 | console.log('onSelectStatusChange', val); 255 | }); 256 | 257 | em.create(document.body); 258 | ``` 259 | 260 | 261 | 262 | ### easyMarker.create(containerElement, [scrollContainerElement], options) 263 | 264 | Initialization 265 | 266 | **Kind**: instance method of [EasyMarker](#EasyMarker) 267 | 268 | | Param | Type | Description | 269 | | ------------------------ | ------------------------ | -------------------------------------- | 270 | | containerElement | HTMLElement | container element | 271 | | [scrollContainerElement] | HTMLElement | scroll container element | 272 | | options | Object | options | 273 | | options.includeElements | Object | included elements | 274 | | options.excludeElements | Object | not included elements, Higher priority | 275 | 276 | 277 | 278 | ### easyMarker.highlightLine(selection, [id], [meta]) 279 | 280 | Highlight the lines between the specified nodes 281 | 282 | **Kind**: instance method of [EasyMarker](#EasyMarker) 283 | 284 | | Param | Type | Description | 285 | | ---------------------- | ------------------- | ------------------------ | 286 | | selection | Object | selection | 287 | | selection.anchorNode | Node | start node | 288 | | selection.anchorOffset | number | start node's text offset | 289 | | selection.focusNode | Node | end node | 290 | | selection.focusOffset | number | start node's text offset | 291 | | [id] | \* | line id | 292 | | [meta] | \* | meta information | 293 | 294 | **Example** 295 | 296 | ```js 297 | const id = 2; 298 | const selection = { 299 | anchorNode: textNodeA, 300 | anchorOffset: 1, 301 | focusNode: textNodeB, 302 | focusOffset: 2, 303 | }; 304 | const meta = { someKey: 'someValue' }; 305 | em.highlightLine(selection, id, meta); 306 | ``` 307 | 308 | 309 | 310 | ### easyMarker.highlightLines(lines) 311 | 312 | Highlight multiple lines 313 | 314 | **Kind**: instance method of [EasyMarker](#EasyMarker) 315 | 316 | | Param | Type | 317 | | ------------------------------ | --------------------------------- | 318 | | lines | Array.<Object> | 319 | | [lines[].id] | \* | 320 | | [lines[].meta] | \* | 321 | | lines[].selection | Object | 322 | | lines[].selection.anchorNode | Node | 323 | | lines[].selection.anchorOffset | number | 324 | | lines[].selection.focusNode | Node | 325 | | lines[].selection.focusOffset | number | 326 | 327 | **Example** 328 | 329 | ```js 330 | const id = 2; 331 | const selection = { 332 | anchorNode: textNodeA, 333 | anchorOffset: 1, 334 | focusNode: textNodeB, 335 | focusOffset: 2, 336 | }; 337 | const meta = { someKey: 'someValue' }; 338 | em.highlightLines([{ selection, id, meta }]); 339 | ``` 340 | 341 | 342 | 343 | ### easyMarker.cancelHighlightLine(id) ⇒ boolean 344 | 345 | Cancel highlight 346 | 347 | **Kind**: instance method of [EasyMarker](#EasyMarker) 348 | 349 | | Param | Type | Description | 350 | | ----- | --------------- | ----------- | 351 | | id | \* | line ID | 352 | 353 | 354 | 355 | ### easyMarker.onHighlightLineClick(cb) 356 | 357 | Highlight line click handler 358 | 359 | **Kind**: instance method of [EasyMarker](#EasyMarker) 360 | 361 | | Param | Type | 362 | | ----- | -------------------------------------------------------------------------------- | 363 | | cb | [highlightLineClickHandler](#EasyMarker..highlightLineClickHandler) | 364 | 365 | 366 | 367 | ### easyMarker.onSelectStatusChange(cb) 368 | 369 | Select status changing callback 370 | 371 | **Kind**: instance method of [EasyMarker](#EasyMarker) 372 | 373 | | Param | Type | 374 | | ----- | --------------------- | 375 | | cb | function | 376 | 377 | 378 | 379 | ### easyMarker.onMenuClick(cb) 380 | 381 | menu item click handler 382 | 383 | **Kind**: instance method of [EasyMarker](#EasyMarker) 384 | 385 | | Param | Type | 386 | | ----- | -------------------------------------------------------------- | 387 | | cb | [menuClickHandler](#EasyMarker..menuClickHandler) | 388 | 389 | 390 | 391 | ### easyMarker.registerEventHook(cb) 392 | 393 | Register event hook 394 | 395 | **Kind**: instance method of [EasyMarker](#EasyMarker) 396 | 397 | | Param | Type | 398 | | ----- | --------------- | 399 | | cb | \* | 400 | 401 | 402 | 403 | ### easyMarker.destroy() 404 | 405 | Destroy instance 406 | 407 | **Kind**: instance method of [EasyMarker](#EasyMarker) 408 | 409 | 410 | ### EasyMarker.create(containerElement, [scrollContainerElement], options) ⇒ [EasyMarker](#EasyMarker) 411 | 412 | Initialization factory 413 | 414 | **Kind**: static method of [EasyMarker](#EasyMarker) 415 | 416 | | Param | Type | Description | 417 | | ------------------------ | ------------------------ | -------------------------------------- | 418 | | containerElement | HTMLElement | container element | 419 | | [scrollContainerElement] | HTMLElement | scroll container element | 420 | | options | Object | options | 421 | | options.includeElements | Object | included elements | 422 | | options.excludeElements | Object | not included elements, Higher priority | 423 | 424 | 425 | 426 | ### EasyMarker~menuClickHandler : function 427 | 428 | Menu item click handler 429 | 430 | **Kind**: inner typedef of [EasyMarker](#EasyMarker) 431 | 432 | | Param | Type | Description | 433 | | ---------------------- | ------------------- | ------------------------ | 434 | | id | \* | menu ID | 435 | | selection | Object | selection | 436 | | selection.anchorNode | Node | start node | 437 | | selection.anchorOffset | number | start node's text offset | 438 | | selection.focusNode | Node | end node | 439 | | selection.focusOffset | number | start node's text offset | 440 | 441 | 442 | 443 | ### EasyMarker~highlightLineClickHandler : function 444 | 445 | Menu item click handler 446 | 447 | **Kind**: inner typedef of [EasyMarker](#EasyMarker) 448 | 449 | | Param | Type | Description | 450 | | ---------------------- | ------------------- | ------------------------ | 451 | | id | \* | line ID | 452 | | meta | \* | meta information | 453 | | selection | Object | selection | 454 | | selection.anchorNode | Node | start node | 455 | | selection.anchorOffset | number | start node's text offset | 456 | | selection.focusNode | Node | end node | 457 | | selection.focusOffset | number | start node's text offset | 458 | -------------------------------------------------------------------------------- /src/base_easy_marker.js: -------------------------------------------------------------------------------- 1 | import Cursor, { CursorType } from './element/cursor' 2 | import Menu from './element/menu' 3 | import Mask from './element/mask' 4 | import Highlight from './element/highlight' 5 | 6 | import Markdown from './lib/markdown' 7 | import TouchEvent, { EventType } from './lib/touch_event' 8 | 9 | import { getTouchPosition, anyToPx, getTouch, getDeviceType } from './lib/helpers' 10 | import { SelectStatus, DeviceType, MenuType } from './lib/types' 11 | 12 | const defaultOptions = { 13 | disableSelect: false, 14 | menuItems: [], 15 | menuTopOffset: 0, 16 | isMultiColumnLayout: false, 17 | cursor: { 18 | same: false, 19 | }, 20 | scrollOffsetBottom: 100, 21 | scrollSpeedLevel: 4, 22 | adjustTextStyleDisabled: false, 23 | } 24 | 25 | const preventDefaultCb = e => e.preventDefault() 26 | let copyListener = () => {} 27 | /** 28 | * A simple article marker library 29 | * @example 30 | * // A simple example 31 | * const em = new EasyMarker({ 32 | * menuTopOffset: '2rem', 33 | * menuItems: [ 34 | * { 35 | * text: '划线笔记', 36 | * id: 1 37 | * }, 38 | * { 39 | * text: '分享', 40 | * style: { 41 | * backgroundColor: '#407ff2', 42 | * paddingLeft: '0.5rem' 43 | * }, 44 | * id: 2 45 | * }, 46 | * { 47 | * text: '复制', 48 | * id: 3 49 | * } 50 | * ], 51 | * ) 52 | * 53 | * em.create(document.querySelector('.article-body'), 54 | * document.body, 55 | * document.querySelectorAll('.article-body>:not(.text)') 56 | * 57 | * // a markdown example 58 | * const em = new EasyMarker({ 59 | * menuTopOffset:'2rem', 60 | * scrollSpeedLevel: 6, 61 | * scrollOffsetBottom: '1.5rem', 62 | * menuItems: [ 63 | * { 64 | * text: '划线笔记', 65 | * id: 1, 66 | * iconName:'iconfont icon-mark' 67 | * }, 68 | * { 69 | * text: '分享', 70 | * style: { 71 | * backgroundColor: '#407ff2', 72 | * paddingLeft: '0.5rem' 73 | * }, 74 | * id: 2, 75 | * iconName:'iconfont icon-share' 76 | * }, 77 | * { 78 | * text: '复制', 79 | * id: 3, 80 | * iconName:'iconfont icon-copy' 81 | * } 82 | * ], 83 | * // Not required 84 | * markdownOptions: { 85 | * H1: text => `\n# ${text}\n\n`, 86 | * H2: text => `\n## ${text}\n\n`, 87 | * H3: text => `\n### ${text}\n\n`, 88 | * H4: text => `\n#### ${text}\n\n`, 89 | * H5: text => `\n##### ${text}\n\n`, 90 | * H6: text => `\n###### ${text}\n\n`, 91 | * P: text => `${text}\n\n`, 92 | * FIGCAPTION: text => `${text}\n\n`, 93 | * STRONG: text => `**${text}**`, 94 | * B: text => `**${text}**`, 95 | * EM: text => `*${text}*`, 96 | * I: text => `*${text}*`, 97 | * S: text => `~~${text}~~`, 98 | * INS: text => `++${text}++`, 99 | * // IMG 100 | * // option.alt: IMG alt 101 | * // option.src: IMG src 102 | * // option.width: IMG width 103 | * // option.height: IMG height 104 | * IMG: option => `![${option.alt}](${option.src}?size=${option.width}x${option.height})\n`, 105 | * // UL 106 | * // option.listLevel: List nesting level 107 | * UL: (text, option) => { 108 | * if (option.listLevel > 1) { 109 | * return `\n${text}` 110 | * } 111 | * return `\n${text}\n` 112 | * }, 113 | * // OL 114 | * // option.listLevel: List nesting level 115 | * OL: (text, option) => { 116 | * if (option.listLevel > 1) { 117 | * return `\n${text}` 118 | * } 119 | * return `\n${text}\n` 120 | * }, 121 | * // LI 122 | * // option.type: parentNode nodeName, 123 | * // option.isLastOne: Whether the last item in the list 124 | * // option.itemLevel: List nesting level 125 | * // option.hasChild: Is the node has child node 126 | * // option.index: The index in the list 127 | * LI: (text, option) => { 128 | * let spaceString = '' 129 | * for (let i = 1; i < option.itemLevel; i++) { spaceString += ' ' } 130 | * let endString = '\n' 131 | * if (option.hasChild || option.isLastOne) { 132 | * endString = '' 133 | * } 134 | * if (option.type === 'UL') { return `${spaceString}- ${text}${endString}` } 135 | * return `${spaceString}${option.index}. ${text}${endString}` 136 | * }, 137 | * } 138 | * }) 139 | * 140 | * em.create(document.querySelector('.article-body'), document.body) 141 | * em.onMenuClick((id, data) => { 142 | * console.log('You click the menu!'); 143 | * console.log(id, data); 144 | * }); 145 | * 146 | * // A Region example 147 | * 148 | * const em = new EasyMarker({ 149 | * regions: texts, 150 | * menuTopOffset: '120px', 151 | * scrollSpeedLevel: 6, 152 | * scrollOffsetBottom: '1.5rem', 153 | * mask: { 154 | * color: '#407ff2', 155 | * }, 156 | * menuStyle: { 157 | * menu: {}, 158 | * item: { 159 | * fontSize: '15px', 160 | * padding: '0px 10px', 161 | * lineHeight: '30px', 162 | * }, 163 | * triangle: {}, 164 | * }, 165 | * menuItems: [ 166 | * { 167 | * text: '划线', 168 | * type: 'select', 169 | * iconName: 'iconfont mark', 170 | * id: '302', 171 | * style: { 172 | * backgroundColor: 'yellow', 173 | * paddingLeft: '1rem', 174 | * }, 175 | * iconStyle: { 176 | * background: 'green', 177 | * }, 178 | * }, 179 | * { 180 | * text: '删除划线', 181 | * type: 'highlight', 182 | * iconName: 'iconfont icon-delete', 183 | * id: '302', 184 | * }, 185 | * { 186 | * id: 222, 187 | * text: '复制', 188 | * iconName: 'iconfont icon-copy', 189 | * }, 190 | * ], 191 | * }); 192 | * 193 | * em.onMenuClick(function (id, data) { 194 | * console.log('You click the menu!', id, data); 195 | * }); 196 | * 197 | * em.onSelectStatusChange((val) => { 198 | * console.log('onSelectStatusChange', val); 199 | * }); 200 | * 201 | * em.create(document.body); 202 | * 203 | * @export 204 | */ 205 | class EasyMarker { 206 | /** 207 | * Creates an instance of EasyMarker. 208 | * @param {Object} options options 209 | * @param {Object[]} options.menuItems menu item option 210 | * @param {string} options.menuItems[].text menu text 211 | * @param {string} options.menuItems[].type menu type 'select'(Show menu only when selected) 'highlight' (Show menu only when click highlight) 212 | * @param {string[]} options.menuItems[].iconName menu icon class 213 | * @param {Object} options.menuItems[].style menu item style 214 | * @param {Object} options.menuItems[].iconStyle menu item icon style 215 | * @param {number|string} options.menuTopOffset the offset from the top of the menu relative screen, default 0. 216 | * @param {Object} options.menuStyle the menu style 217 | * @param {Object} options.menuStyle.menu the menu style 218 | * @param {Object} options.menuStyle.triangle the triangle style 219 | * @param {Object} options.menuStyle.item the sub menu style 220 | * @param {Object} options.menuStyle.icon the sub menu icon style 221 | * @param {boolean} options.disableTapHighlight disable highlight when tap 222 | * @param {Object} options.cursor cursor config 223 | * @param {string} options.cursor.color cursor color 224 | * @param {boolean} options.cursor.same whether the cursor is in the same direction 225 | * @param {Object} options.mask mask config 226 | * @param {string} options.mask.color mask color 227 | * @param {Object} options.highlight highlight config 228 | * @param {string} options.highlight.color highlight color 229 | * @param {number} options.scrollSpeedLevel The speed of scrolling when touching bottom, default 4 230 | * @param {number|string} options.scrollOffsetBottom triggering scrolling, distance from the bottom, default 100 231 | * @param {Object} options.markdownOptions Customize options about the mapping relations between HTML and Markdown 232 | * @param {Object[]} options.regions In region mode, all region info 233 | * @param {string} options.regions[].text region text 234 | * @param {number} options.regions[].top region top 235 | * @param {number} options.regions[].left region left 236 | * @param {number} options.regions[].width region width 237 | * @param {number} options.regions[].height region height 238 | * @param {boolean} options.disableSelect disabled select 239 | */ 240 | constructor(options) { 241 | this.options = Object.assign({}, defaultOptions, options) 242 | this.$selectStatus = SelectStatus.NONE 243 | this.windowHeight = null 244 | this.container = null 245 | this.scrollContainer = null 246 | this.excludeElements = [] 247 | this.includeElements = [] 248 | this.highlight = null 249 | this.movingCursor = null 250 | this.touchEvent = null 251 | this.scrollInterval = null 252 | this.cursor = { 253 | start: null, 254 | end: null, 255 | } 256 | 257 | this.mask = null 258 | this.menu = null 259 | this.scrollOffsetBottom = null 260 | this.scrollSpeedLevel = null 261 | this.containerScroll = null 262 | this.deviceType = getDeviceType() 263 | this.selectStatusChangeHandler = () => {} 264 | this.menuOnClick = () => {} 265 | this.highlightLineClick = null 266 | } 267 | 268 | get selectStatus() { 269 | return this.$selectStatus 270 | } 271 | 272 | set selectStatus(val) { 273 | if (val !== this.$selectStatus) { 274 | this.selectStatusChangeHandler(val) 275 | } 276 | this.$selectStatus = val 277 | if (val === SelectStatus.FINISH) { 278 | this.menu.setPosition(this.start, this.end) 279 | this.menu.type = MenuType.SELECT 280 | this.menu.show() 281 | } else { 282 | this.menu.hide() 283 | } 284 | } 285 | 286 | /** 287 | * Initialization factory 288 | * 289 | * @static 290 | * @param {HTMLElement} containerElement container element 291 | * @param {HTMLElement} [scrollContainerElement] scroll container element 292 | * @param {Object} options options 293 | * @param {Object} options.includeElements included elements 294 | * @param {Object} options.excludeElements not included elements, Higher priority 295 | * @returns {EasyMarker} 296 | * @memberof EasyMarker 297 | */ 298 | static create(containerElement, scrollContainerElement, options = []) { 299 | const easyMarker = new this() 300 | easyMarker.create(containerElement, scrollContainerElement, options) 301 | return easyMarker 302 | } 303 | 304 | /** 305 | * Initialization 306 | * 307 | * @param {HTMLElement} containerElement container element 308 | * @param {HTMLElement} [scrollContainerElement] scroll container element 309 | * @param {Object} options options 310 | * @param {Object} options.includeElements included elements 311 | * @param {Object} options.excludeElements not included elements, Higher priority 312 | * @memberof EasyMarker 313 | */ 314 | create(containerElement, scrollContainerElement, options = []) { 315 | this.container = containerElement 316 | this.adjustTextStyle() 317 | // eslint-disable-next-line arrow-parens 318 | this.container.oncontextmenu = event => { 319 | event.returnValue = false 320 | } 321 | 322 | this.windowHeight = document.documentElement.clientHeight 323 | if (options.constructor === Object) { 324 | this.excludeElements = options.excludeElements ? [...options.excludeElements] : [] 325 | this.includeElements = options.includeElements ? [...options.includeElements] : [containerElement] 326 | } else { 327 | // deprecated 328 | // Compatible with older versions,options equivalent to excludeElements 329 | this.excludeElements = [...options] 330 | this.includeElements = [containerElement] 331 | } 332 | this.scrollContainer = scrollContainerElement || document.body 333 | this.container.addEventListener('contextmenu', preventDefaultCb) 334 | copyListener = e => this.copyListener(e) 335 | document.addEventListener('copy', copyListener) 336 | if (this.scrollContainer === document.body) { 337 | this.scrollContainer.onscroll = this.handleScroll.bind(this) 338 | } else { 339 | this.containerScroll = () => { 340 | this.handleScroll() 341 | } 342 | this.scrollContainer.addEventListener('scroll', this.containerScroll) 343 | } 344 | // this.position.setAll(getElementAbsolutePosition(this.container)) 345 | 346 | this.container.style.userSelect = 'none' 347 | this.container.style.webkitUserSelect = 'none' 348 | this.container.style.position = 'relative' 349 | 350 | this.touchEvent = new TouchEvent(this.container) 351 | if (!this.options.disableSelect) { 352 | this.touchEvent.registerEvent(EventType.TOUCH_START, this.handleTouchStart.bind(this)) 353 | this.touchEvent.registerEvent(EventType.TOUCH_MOVE, this.handleTouchMove.bind(this)) 354 | this.touchEvent.registerEvent(EventType.TOUCH_MOVE_THROTTLE, this.handleTouchMoveThrottle.bind(this)) 355 | this.touchEvent.registerEvent(EventType.TOUCH_END, this.handleTouchEnd.bind(this)) 356 | this.touchEvent.registerEvent(EventType.LONG_TAP, this.handleLongTap.bind(this)) 357 | } 358 | 359 | this.touchEvent.registerEvent(EventType.TAP, this.handleTap.bind(this)) 360 | 361 | const CursorElement = this.options.cursor && this.options.cursor.Cursor ? this.options.cursor.Cursor : Cursor 362 | 363 | if (this.options.cursor.same) { 364 | this.cursor.start = new CursorElement( 365 | this.container, 366 | CursorType.END, 367 | Object.assign({ mode: this.mode }, this.options.cursor || {}) 368 | ) 369 | } else { 370 | this.cursor.start = new CursorElement( 371 | this.container, 372 | CursorType.START, 373 | Object.assign({ mode: this.mode }, this.options.cursor || {}) 374 | ) 375 | } 376 | this.cursor.end = new CursorElement(this.container, CursorType.END, this.options.cursor || {}) 377 | this.movingCursor = this.cursor.end 378 | 379 | this.mask = new Mask(this.container, Object.assign({ mode: this.mode }, this.options.mask || {})) 380 | this.highlight = new Highlight(this.container, Object.assign({ mode: this.mode }, this.options.highlight || {})) 381 | this.menu = new Menu(this.container, { 382 | menuItems: this.options.menuItems, 383 | topOffset: this.options.menuTopOffset, 384 | style: this.options.menuStyle, 385 | isMultiColumnLayout: this.options.isMultiColumnLayout, 386 | mode: this.mode, 387 | }) 388 | this.menu.easyMarker = this 389 | this.highlight.easyMarker = this 390 | this.mask.easyMarker = this 391 | this.markdown = new Markdown(this.container, this.options.markdownOptions) 392 | this.scrollOffsetBottom = anyToPx(this.options.scrollOffsetBottom) 393 | this.scrollSpeedLevel = this.options.scrollSpeedLevel 394 | } 395 | 396 | /** 397 | * Disable touch event 398 | */ 399 | disable() { 400 | this.touchEvent.disable() 401 | } 402 | 403 | /** 404 | * Enable touch event 405 | */ 406 | enable() { 407 | this.touchEvent.enable() 408 | } 409 | /** 410 | * Highlight the lines between the specified nodes 411 | * @example 412 | * const id = 2; 413 | * const selection = { 414 | * anchorNode: textNodeA, 415 | * anchorOffset: 1, 416 | * focusNode: textNodeB, 417 | * focusOffset: 2 418 | * }; 419 | * const meta = { someKey: 'someValue' }; 420 | * em.highlightLine(selection, id, meta); 421 | * @param {Object} selection selection 422 | * @param {Node} selection.anchorNode start node 423 | * @param {number} selection.anchorOffset start node's text offset 424 | * @param {Node} selection.focusNode end node 425 | * @param {number} selection.focusOffset start node's text offset 426 | * @param {*} [id] line id 427 | * @param {*} [meta] meta information 428 | * @memberof EasyMarker 429 | */ 430 | highlightLine(selection, id, meta) { 431 | this.highlight.highlightLine(selection, id, meta) 432 | } 433 | 434 | /** 435 | * Highlight multiple lines 436 | * @example 437 | * const id = 2; 438 | * const selection = { 439 | * anchorNode: textNodeA, 440 | * anchorOffset: 1, 441 | * focusNode: textNodeB, 442 | * focusOffset: 2 443 | * }; 444 | * const meta = { someKey: 'someValue' }; 445 | * em.highlightLines([{selection, id, meta}]); 446 | * @param {Object[]} lines 447 | * @param {*} [lines[].id] 448 | * @param {*} [lines[].meta] 449 | * @param {Object} lines[].selection 450 | * @param {Node} lines[].selection.anchorNode 451 | * @param {number} lines[].selection.anchorOffset 452 | * @param {Node} lines[].selection.focusNode 453 | * @param {number} lines[].selection.focusOffset 454 | * @memberof EasyMarker 455 | */ 456 | highlightLines(lines) { 457 | this.highlight.highlightLines(lines) 458 | } 459 | 460 | /** 461 | * Cancel highlight 462 | * 463 | * @param {*} id line ID 464 | * @returns {boolean} 465 | * @memberof EasyMarker 466 | */ 467 | cancelHighlightLine(id) { 468 | this.highlight.cancelHighlightLine(id) 469 | } 470 | 471 | /** 472 | * Highlight line click handler 473 | * 474 | * @param {EasyMarker~highlightLineClickHandler} cb 475 | * @memberof EasyMarker 476 | */ 477 | onHighlightLineClick(cb) { 478 | this.highlightLineClick = cb 479 | } 480 | 481 | /** 482 | * Select status changing callback 483 | * 484 | * @param {Function} cb 485 | * @memberof EasyMarker 486 | */ 487 | onSelectStatusChange(cb) { 488 | this.selectStatusChangeHandler = cb 489 | } 490 | 491 | /** 492 | * menu item click handler 493 | * 494 | * @param {EasyMarker~menuClickHandler} cb 495 | * @memberof EasyMarker 496 | */ 497 | onMenuClick(cb) { 498 | // this.menu.handler = cb 499 | this.menuOnClick = cb 500 | } 501 | 502 | /** 503 | * Register event hook 504 | * 505 | * @param {*} cb 506 | * @memberof EasyMarker 507 | */ 508 | registerEventHook(cb) { 509 | this.touchEvent.registerHook(cb) 510 | } 511 | /** 512 | * Destroy instance 513 | * 514 | * @memberof EasyMarker 515 | */ 516 | destroy() { 517 | this.container.oncontextmenu = null 518 | this.container.removeEventListener('contextmenu', preventDefaultCb) 519 | document.removeEventListener('copy', copyListener) 520 | if (this.containerScroll !== null) { 521 | this.scrollContainer.removeEventListener('scroll', this.containerScroll) 522 | this.containerScroll = null 523 | } 524 | this.scrollContainer.onscroll = null 525 | 526 | this.touchEvent.destroy() 527 | this.cursor.start.destroy() 528 | this.cursor.end.destroy() 529 | this.mask.destroy() 530 | this.highlight.destroy() 531 | this.menu.destroy() 532 | 533 | this.$selectStatus = SelectStatus.NONE 534 | this.container = null 535 | this.scrollContainer = null 536 | this.excludeElements = [] 537 | this.highlight = null 538 | this.movingCursor = null 539 | this.touchEvent = null 540 | this.cursor = { 541 | start: null, 542 | end: null, 543 | } 544 | this.mask = null 545 | this.menu = null 546 | 547 | this.windowHeight = null 548 | this.includeElements = [] 549 | this.scrollInterval = null 550 | this.scrollOffsetBottom = null 551 | this.scrollSpeedLevel = null 552 | this.selectStatusChangeHandler = () => {} 553 | this.menuOnClick = () => {} 554 | this.highlightLineClick = null 555 | } 556 | 557 | reset() { 558 | this.selectStatus = SelectStatus.NONE 559 | this.cursor.start.hide() 560 | this.cursor.end.hide() 561 | this.mask.reset() 562 | this.menu.hide() 563 | } 564 | 565 | // endregion 566 | 567 | // region private fields 568 | 569 | /** 570 | * Screen relative offset 571 | * 572 | * @readonly 573 | * @private 574 | * @memberof EasyMarker 575 | */ 576 | get screenRelativeOffset() { 577 | const { top, left } = this.container.getBoundingClientRect() 578 | return { 579 | x: left, 580 | y: top, 581 | } 582 | } 583 | /** 584 | * 585 | * @private 586 | * @memberof EasyMarker 587 | */ 588 | adjustTextStyle() { 589 | if (this.options.adjustTextStyleDisabled) return 590 | const { children } = this.container 591 | for (let i = 0; i < children.length; i++) { 592 | children[i].style.zIndex = '40' 593 | children[i].style.position = 'relative' 594 | } 595 | } 596 | 597 | /** 598 | * 599 | * @private 600 | * @param {HTMLElement} element 601 | * @memberof EasyMarker 602 | */ 603 | isContains(element) { 604 | // exclude > include 605 | return ( 606 | this.includeElements.findIndex(el => el.contains(element)) !== -1 && 607 | this.excludeElements.findIndex(el => el.contains(element)) === -1 608 | ) 609 | } 610 | 611 | /** 612 | * Long press event 613 | * 614 | * @private 615 | * @param {TouchEvent} e 616 | * @memberof EasyMarker 617 | */ 618 | // eslint-disable-next-line class-methods-use-this 619 | handleLongTap() {} 620 | 621 | /** 622 | * Tap event 623 | * 624 | * @private 625 | * @param {TouchEvent} e 626 | * @memberof EasyMarker 627 | */ 628 | // eslint-disable-next-line class-methods-use-this 629 | handleTap() {} 630 | 631 | /** 632 | * copy listener 633 | * 634 | * @private 635 | * @memberof EasyMarker 636 | */ 637 | // eslint-disable-next-line class-methods-use-this 638 | copyListener() {} 639 | 640 | /** 641 | * touchstart event handler 642 | * 643 | * @private 644 | * @param {TouchEvent} e 645 | * @memberof EasyMarker 646 | */ 647 | handleTouchStart(e) { 648 | if (this.selectStatus === SelectStatus.FINISH && this.menu.isShow && this.menu.type !== MenuType.HIGHLIGHT) { 649 | const position = this.getTouchRelativePosition(e) 650 | const startCursorRegion = this.cursor.start.inRegion(position) 651 | const endCursorRegion = this.cursor.end.inRegion(position) 652 | if (startCursorRegion.inRegion && endCursorRegion.inRegion) { 653 | this.selectStatus = SelectStatus.SELECTING 654 | this.movingCursor = startCursorRegion.distance < endCursorRegion.distance ? this.cursor.start : this.cursor.end 655 | } else if (endCursorRegion.inRegion) { 656 | this.selectStatus = SelectStatus.SELECTING 657 | this.movingCursor = this.cursor.end 658 | } else if (startCursorRegion.inRegion) { 659 | this.selectStatus = SelectStatus.SELECTING 660 | this.movingCursor = this.cursor.start 661 | } 662 | } 663 | // if (!this.highlight.inRegion(e)) { 664 | // e.preventDefault() 665 | // } 666 | } 667 | 668 | /** 669 | * touchmove event handler 670 | * 671 | * @private 672 | * @param {TouchEvent} e 673 | * @memberof EasyMarker 674 | */ 675 | handleTouchMove(e) { 676 | if (this.selectStatus === SelectStatus.SELECTING) { 677 | e.preventDefault() 678 | } 679 | } 680 | 681 | /** 682 | * Throttle event of touchmove 683 | * 684 | * @private 685 | * @param {TouchEvent} e 686 | * @memberof EasyMarker 687 | */ 688 | handleTouchMoveThrottle(e) { 689 | // 拖着cursor走的逻辑 690 | if (this.selectStatus === SelectStatus.SELECTING) { 691 | const cursorOffset = this.deviceType === DeviceType.MOBILE ? this.movingCursor.height / 2 : 0 692 | const offset = this.movingCursor.offset || { 693 | x: 0, 694 | y: -cursorOffset, 695 | } 696 | const touch = getTouch(e) 697 | const targetY = e.clientY || touch.clientY 698 | if (targetY >= this.windowHeight - this.scrollOffsetBottom) { 699 | if (this.scrollInterval !== null) clearInterval(this.scrollInterval) 700 | const rate = 701 | ((targetY - this.windowHeight + this.scrollOffsetBottom) * this.scrollSpeedLevel) / this.scrollOffsetBottom 702 | this.scrollInterval = setInterval(() => { 703 | this.scrollContainer.scrollTop += rate 704 | document.documentElement.scrollTop += rate 705 | }, 1) 706 | } else { 707 | clearInterval(this.scrollInterval) 708 | } 709 | const { x, y } = getTouchPosition(e, offset) 710 | const target = document.elementFromPoint(x, y) 711 | if (this.isContains(target)) { 712 | this.moveCursor(target, x, y) 713 | } 714 | } 715 | } 716 | 717 | /** 718 | * touchmove event handler 719 | * 720 | * @private 721 | * @param {TouchEvent} e 722 | * @memberof EasyMarker 723 | */ 724 | handleTouchEnd() { 725 | if (this.selectStatus === SelectStatus.SELECTING) { 726 | if (this.scrollInterval) { 727 | clearInterval(this.scrollInterval) 728 | this.scrollInterval = null 729 | } 730 | } 731 | } 732 | 733 | /** 734 | * handleScroll 735 | * 736 | * @private 737 | * @memberof EasyMarker 738 | */ 739 | handleScroll() { 740 | if (this.selectStatus === SelectStatus.FINISH) { 741 | this.menu.handleScroll() 742 | } 743 | } 744 | 745 | showHighlightMenu(selection, options) { 746 | this.setSelection(selection) 747 | this.selectStatus = SelectStatus.FINISH 748 | this.menu.setPosition(this.start, this.end) 749 | this.menu.type = MenuType.HIGHLIGHT 750 | this.menu.options = options 751 | this.menu.show() 752 | } 753 | 754 | // eslint-disable-next-line class-methods-use-this 755 | setSelection() {} 756 | 757 | getTouchRelativePosition(e) { 758 | const cursorOffset = this.deviceType === DeviceType.MOBILE ? this.movingCursor.height / 2 : 0 759 | const offset = { 760 | x: 0, 761 | y: -cursorOffset, 762 | } 763 | const position = getTouchPosition(e, offset) 764 | position.x -= this.screenRelativeOffset.x 765 | position.y -= this.screenRelativeOffset.y 766 | return position 767 | } 768 | 769 | // copy(e) { 770 | // if (this.selectStatus === SelectStatus.FINISH) { 771 | // const text = this.getSelectText() || '' 772 | // e.clipboardData.setData('text/plain', text) 773 | // this.reset() 774 | // e.preventDefault() 775 | // } 776 | // } 777 | 778 | // endregion 779 | } 780 | 781 | export default EasyMarker 782 | 783 | /** 784 | * Menu item click handler 785 | * @callback EasyMarker~menuClickHandler 786 | * @param {*} id menu ID 787 | * @param {Object} selection selection 788 | * @param {Node} selection.anchorNode start node 789 | * @param {number} selection.anchorOffset start node's text offset 790 | * @param {Node} selection.focusNode end node 791 | * @param {number} selection.focusOffset start node's text offset 792 | */ 793 | 794 | /** 795 | * Menu item click handler 796 | * @callback EasyMarker~highlightLineClickHandler 797 | * @param {*} id line ID 798 | * @param {*} meta meta information 799 | * @param {Object} selection selection 800 | * @param {Node} selection.anchorNode start node 801 | * @param {number} selection.anchorOffset start node's text offset 802 | * @param {Node} selection.focusNode end node 803 | * @param {number} selection.focusOffset start node's text offset 804 | */ 805 | --------------------------------------------------------------------------------