diff --git a/examples/highlights.html b/examples/highlights.html index cf03dd8..e5c3fe7 100644 --- a/examples/highlights.html +++ b/examples/highlights.html @@ -112,17 +112,25 @@ // console.log(location); }); - // Apply a class to selected text rendition.on("selected", function(cfiRange, contents) { - rendition.annotations.highlight(cfiRange, {}, - (e) => { - console.log("highlight clicked", e.target); - }, - (e) => { - console.log("highlight mouseover", e.target); + rendition.annotations.highlight( + cfiRange, + {}, + null, + "epubjs-hl", + {}, + { + onClick : (e) => { + console.log("highlight-click", e.target); + }, + onMouseOver : (e) => { + console.log("highlight-mouseover", e.target); + }, + onMouseOut : (e) => { + console.log("highlight-mouseout", e.target); + } } - ); contents.window.getSelection().removeAllRanges(); diff --git a/src/annotations.js b/src/annotations.js index ecbcb56..4158db0 100644 --- a/src/annotations.js +++ b/src/annotations.js @@ -31,7 +31,7 @@ class Annotations { * @param {object} styles CSS styles to assign to annotation * @returns {Annotation} annotation */ - add (type, cfiRange, data, cb, className, styles) { + add (type, cfiRange, data, cb, className, styles, cbOptions) { let hash = encodeURI(cfiRange + type); let cfi = new EpubCFI(cfiRange); let sectionIndex = cfi.spinePos; @@ -42,7 +42,8 @@ class Annotations { sectionIndex, cb, className, - styles + styles, + cbOptions, }); this._annotations[hash] = annotation; @@ -116,8 +117,8 @@ class Annotations { * @param {string} className CSS class to assign to annotation * @param {object} styles CSS styles to assign to annotation */ - highlight (cfiRange, data, cb, className, styles) { - return this.add("highlight", cfiRange, data, cb, className, styles); + highlight (cfiRange, data, cb, className, styles, cbOptions) { + return this.add("highlight", cfiRange, data, cb, className, styles, cbOptions); } /** @@ -221,7 +222,8 @@ class Annotation { sectionIndex, cb, className, - styles + styles, + cbOptions, }) { this.type = type; this.cfiRange = cfiRange; @@ -231,6 +233,7 @@ class Annotation { this.cb = cb; this.className = className; this.styles = styles; + this.cbOptions = cbOptions; } /** @@ -246,11 +249,11 @@ class Annotation { * @param {View} view */ attach (view) { - let {cfiRange, data, type, mark, cb, className, styles} = this; + let {cfiRange, data, type, mark, cb, cbOptions, className, styles} = this; let result; if (type === "highlight") { - result = view.highlight(cfiRange, data, cb, className, styles); + result = view.highlight(cfiRange, data, cb, className, styles, cbOptions); } else if (type === "underline") { result = view.underline(cfiRange, data, cb, className, styles); } else if (type === "mark") { diff --git a/src/libs/marks/events.js b/src/libs/marks/events.js new file mode 100644 index 0000000..9f5c4c0 --- /dev/null +++ b/src/libs/marks/events.js @@ -0,0 +1,124 @@ +// import 'babelify/polyfill'; // needed for Object.assign + +export default { + proxyMouse: proxyMouse +}; + + +/** +* Start proxying all mouse events that occur on the target node to each node in +* a set of tracked nodes. +* +* The items in tracked do not strictly have to be DOM Nodes, but they do have +* to have dispatchEvent, getBoundingClientRect, and getClientRects methods. +* +* @param target {Node} The node on which to listen for mouse events. +* @param tracked {Node[]} A (possibly mutable) array of nodes to which to proxy +* events. +*/ +export function proxyMouse(target, tracked) { + function dispatch(e) { + // We walk through the set of tracked elements in reverse order so that + // events are sent to those most recently added first. + // + // This is the least surprising behaviour as it simulates the way the + // browser would work if items added later were drawn "on top of" + // earlier ones. + for (var i = tracked.length - 1; i >= 0; i--) { + var t = tracked[i]; + var x = e.clientX + var y = e.clientY; + + if (e.touches && e.touches.length) { + x = e.touches[0].clientX; + y = e.touches[0].clientY; + } + + if (!contains(t, target, x, y)) { + continue; + } + + // The event targets this mark, so dispatch a cloned event: + t.dispatchEvent(clone(e)); + // We only dispatch the cloned event to the first matching mark. + break; + } + } + + if (target.nodeName === "iframe" || target.nodeName === "IFRAME") { + + try { + // Try to get the contents if same domain + this.target = target.contentDocument; + } catch(err){ + this.target = target; + } + + } else { + this.target = target; + } + + for (var ev of ['mouseup', 'mousedown', 'click', 'touchstart']) { + this.target.addEventListener(ev, (e) => dispatch(e), false); + } + +} + + +/** +* Clone a mouse event object. +* +* @param e {MouseEvent} A mouse event object to clone. +* @returns {MouseEvent} +*/ +export function clone(e) { + var opts = Object.assign({}, e, {bubbles: false}); + try { + return new MouseEvent(e.type, opts); + } catch(err) { // compat: webkit + var copy = document.createEvent('MouseEvents'); + copy.initMouseEvent(e.type, false, opts.cancelable, opts.view, + opts.detail, opts.screenX, opts.screenY, + opts.clientX, opts.clientY, opts.ctrlKey, + opts.altKey, opts.shiftKey, opts.metaKey, + opts.button, opts.relatedTarget); + return copy; + } +} + + +/** +* Check if the item contains the point denoted by the passed coordinates +* @param item {Object} An object with getBoundingClientRect and getClientRects +* methods. +* @param x {Number} +* @param y {Number} +* @returns {Boolean} +*/ +function contains(item, target, x, y) { + // offset + var offset = target.getBoundingClientRect(); + + function rectContains(r, x, y) { + var top = r.top - offset.top; + var left = r.left - offset.left; + var bottom = top + r.height; + var right = left + r.width; + return (top <= y && left <= x && bottom > y && right > x); + } + + // Check overall bounding box first + var rect = item.getBoundingClientRect(); + if (!rectContains(rect, x, y)) { + return false; + } + + // Then continue to check each child rect + var rects = item.getClientRects(); + for (var i = 0, len = rects.length; i < len; i++) { + if (rectContains(rects[i], x, y)) { + return true; + } + } + return false; +} \ No newline at end of file diff --git a/src/libs/marks/marks.js b/src/libs/marks/marks.js new file mode 100644 index 0000000..62e633e --- /dev/null +++ b/src/libs/marks/marks.js @@ -0,0 +1,237 @@ +import svg from './svg'; +import events from './events'; + +export class Pane { + constructor(target, container = document.body) { + this.target = target; + this.element = svg.createElement('svg'); + this.marks = []; + + // Match the coordinates of the target element + this.element.style.position = 'absolute'; + // Disable pointer events + this.element.setAttribute('pointer-events', 'none'); + + // Set up mouse event proxying between the target element and the marks + events.proxyMouse(this.target, this.marks); + + this.container = container; + this.container.appendChild(this.element); + this.render(); + } + + addMark(mark) { + var g = svg.createElement('g'); + // enable pointer events for highlight so that we can capture mouse events + g.setAttribute('pointer-events', 'all'); + + this.element.appendChild(g); + mark.bind(g, this.container); + + this.marks.push(mark); + + mark.render(); + return mark; + } + + removeMark(mark) { + var idx = this.marks.indexOf(mark); + if (idx === -1) { + return; + } + var el = mark.unbind(); + this.element.removeChild(el); + this.marks.splice(idx, 1); + } + + render() { + setCoords(this.element, coords(this.target, this.container)); + for (var m of this.marks) { + m.render(); + } + } +} + + +export class Mark { + constructor() { + this.element = null; + } + + bind(element, container) { + this.element = element; + this.container = container; + } + + unbind() { + var el = this.element; + this.element = null; + return el; + } + + render() {} + + dispatchEvent(e) { + if (!this.element) return; + this.element.dispatchEvent(e); + } + + getBoundingClientRect() { + return this.element.getBoundingClientRect(); + } + + getClientRects() { + var rects = []; + var el = this.element.firstChild; + while (el) { + rects.push(el.getBoundingClientRect()); + el = el.nextSibling; + } + return rects; + } + + filteredRanges() { + if (!this.range) { + return []; + } + + // De-duplicate the boxes + const rects = Array.from(this.range.getClientRects()); + const stringRects = rects.map((r) => JSON.stringify(r)); + const setRects = new Set(stringRects); + return Array.from(setRects).map((sr) => JSON.parse(sr)); + } + +} + +export class Highlight extends Mark { + constructor(range, className, data, attributes) { + super(); + this.range = range; + this.className = className; + this.data = data || {}; + this.attributes = attributes || {}; + } + + bind(element, container) { + super.bind(element, container); + + for (var attr in this.data) { + if (this.data.hasOwnProperty(attr)) { + this.element.dataset[attr] = this.data[attr]; + } + } + + for (var attr in this.attributes) { + if (this.attributes.hasOwnProperty(attr)) { + this.element.setAttribute(attr, this.attributes[attr]); + } + } + + if (this.className) { + this.element.classList.add(this.className); + } + } + + render() { + // Empty element + while (this.element.firstChild) { + this.element.removeChild(this.element.firstChild); + } + + var docFrag = this.element.ownerDocument.createDocumentFragment(); + var filtered = this.filteredRanges(); + var offset = this.element.getBoundingClientRect(); + var container = this.container.getBoundingClientRect(); + + for (var i = 0, len = filtered.length; i < len; i++) { + var r = filtered[i]; + var el = svg.createElement('rect'); + el.setAttribute('x', r.left - offset.left + container.left); + el.setAttribute('y', r.top - offset.top + container.top); + el.setAttribute('height', r.height); + el.setAttribute('width', r.width); + docFrag.appendChild(el); + } + + this.element.appendChild(docFrag); + + } +} + +export class Underline extends Highlight { + constructor(range, className, data, attributes) { + super(range, className, data, attributes); + } + + render() { + // Empty element + while (this.element.firstChild) { + this.element.removeChild(this.element.firstChild); + } + + var docFrag = this.element.ownerDocument.createDocumentFragment(); + var filtered = this.filteredRanges(); + var offset = this.element.getBoundingClientRect(); + var container = this.container.getBoundingClientRect(); + + for (var i = 0, len = filtered.length; i < len; i++) { + var r = filtered[i]; + + var rect = svg.createElement('rect'); + rect.setAttribute('x', r.left - offset.left + container.left); + rect.setAttribute('y', r.top - offset.top + container.top); + rect.setAttribute('height', r.height); + rect.setAttribute('width', r.width); + rect.setAttribute('fill', 'none'); + + + var line = svg.createElement('line'); + line.setAttribute('x1', r.left - offset.left + container.left); + line.setAttribute('x2', r.left - offset.left + container.left + r.width); + line.setAttribute('y1', r.top - offset.top + container.top + r.height - 1); + line.setAttribute('y2', r.top - offset.top + container.top + r.height - 1); + + line.setAttribute('stroke-width', 1); + line.setAttribute('stroke', 'black'); //TODO: match text color? + line.setAttribute('stroke-linecap', 'square'); + + docFrag.appendChild(rect); + + docFrag.appendChild(line); + } + + this.element.appendChild(docFrag); + + } +} + + +function coords(el, container) { + var offset = container.getBoundingClientRect(); + var rect = el.getBoundingClientRect(); + + return { + top: rect.top - offset.top, + left: rect.left - offset.left, + height: el.scrollHeight, + width: el.scrollWidth + }; +} + + +function setCoords(el, coords) { + el.style.setProperty('top', `${coords.top}px`, 'important'); + el.style.setProperty('left', `${coords.left}px`, 'important'); + el.style.setProperty('height', `${coords.height}px`, 'important'); + el.style.setProperty('width', `${coords.width}px`, 'important'); +} + +function contains(rect1, rect2) { + return ( + (rect2.right <= rect1.right) && + (rect2.left >= rect1.left) && + (rect2.top >= rect1.top) && + (rect2.bottom <= rect1.bottom) + ); +} \ No newline at end of file diff --git a/src/libs/marks/svg.js b/src/libs/marks/svg.js new file mode 100644 index 0000000..45b3973 --- /dev/null +++ b/src/libs/marks/svg.js @@ -0,0 +1,7 @@ +export function createElement(name) { + return document.createElementNS('http://www.w3.org/2000/svg', name); +} + +export default { + createElement: createElement +}; \ No newline at end of file diff --git a/src/managers/views/iframe.js b/src/managers/views/iframe.js index 72fdd0a..0db71c5 100644 --- a/src/managers/views/iframe.js +++ b/src/managers/views/iframe.js @@ -3,7 +3,7 @@ import {extend, borders, uuid, isNumber, bounds, defer, createBlobUrl, revokeBlo import EpubCFI from "../../epubcfi"; import Contents from "../../contents"; import { EVENTS } from "../../utils/constants"; -import { Pane, Highlight, Underline } from "marks-pane"; +import { Pane, Highlight, Underline } from "../../libs/marks/marks"; class IframeView { constructor(section, options) { @@ -603,32 +603,39 @@ class IframeView { return this.elementBounds; } - highlight(cfiRange, data={}, cb, cb2, className = "epubjs-hl", styles = {}) { + highlight(cfiRange, data={}, cb, className = "epubjs-hl", styles = {}, cbOptions = {}) { if (!this.contents) { return; } + + // ensuring backward compatibility + const onClickCallback = cb || cbOptions.onClick; + + const onMouseOverCallback = cbOptions.onMouseOver; + const onMouseOutCallback = cbOptions.onMouseOut; + const attributes = Object.assign({"fill": "yellow", "fill-opacity": "0.3", "mix-blend-mode": "multiply"}, styles); let range = this.contents.range(cfiRange); - const onMount = (container) => { - container.style.pointerEvents = "all"; - }; + // const onMount = (container) => { + // container.style.pointerEvents = "all"; + // }; - const onEject = (container) => { - // container.style.pointerEvents = "none"; - }; + // const onEject = (container) => { + // container.style.pointerEvents = "none"; + // }; const emitOnClick = () => { this.emit(EVENTS.VIEWS.MARK_CLICKED, cfiRange, data); }; - const emitOnMouseOver = (el) => { - this.emit(EVENTS.VIEWS.MARK_HOVERED, cfiRange, data); - onMount(el); + const emitOnMouseOver = () => { + this.emit(EVENTS.VIEWS.MARK_MOUSEOVER, cfiRange, data); + }; - const emitOnMouseOut = (el) => { - onEject(el); + const emitOnMouseOut = () => { + this.emit(EVENTS.VIEWS.MARK_MOUSEOUT, cfiRange, data); }; data["epubcfi"] = cfiRange; @@ -640,20 +647,48 @@ class IframeView { let m = new Highlight(range, className, data, attributes); let h = this.pane.addMark(m); - this.highlights[cfiRange] = { "mark": h, "element": h.element, "listeners": [emitOnClick, cb, cb2] }; + const getListeners = () => { + let listeners = [ + emitOnClick, + emitOnMouseOver, + emitOnMouseOut]; + + if(onClickCallback) { + listeners.push(onClickCallback); + } + + if(onMouseOverCallback) { + listeners.push(onMouseOverCallback); + } + + if(onMouseOutCallback) { + listeners.push(onMouseOutCallback); + } + + return listeners; + }; + + this.highlights[cfiRange] = { "mark": h, "element": h.element, "listeners": getListeners() }; h.element.setAttribute("ref", className); h.element.addEventListener("click", emitOnClick); h.element.addEventListener("touchstart", emitOnClick); - h.element.addEventListener("mouseover", emitOnMouseOver(this.pane.element)); - h.element.addEventListener("mouseout", emitOnMouseOut(this.pane.element)); + h.element.addEventListener("mouseover", emitOnMouseOver); + h.element.addEventListener("mouseout", emitOnMouseOut); - if (cb) { - h.element.addEventListener("click", cb); - h.element.addEventListener("touchstart", cb); - h.element.addEventListener("mouseover", cb2); - h.element.addEventListener("mouseout", ()=>{console.log('mouse is out');}); + if (onClickCallback) { + h.element.addEventListener("click", onClickCallback); + h.element.addEventListener("touchstart", onClickCallback); } + + if(onMouseOverCallback) { + h.element.addEventListener("mouseover", onMouseOverCallback); + } + + if(onMouseOutCallback) { + h.element.addEventListener("mouseout", onMouseOutCallback); + } + return h; } diff --git a/src/utils/constants.js b/src/utils/constants.js index b4d5d33..0d3f423 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -1,7 +1,7 @@ export const EPUBJS_VERSION = "0.3"; // Dom events to listen for -export const DOM_EVENTS = ["keydown", "keyup", "keypressed", "mouseup", "mousedown", "mousemove", "click", "touchend", "touchstart", "touchmove", "mouseover", "mouseenter"]; +export const DOM_EVENTS = ["keydown", "keyup", "keypressed", "mouseup", "mousedown", "mousemove", "click", "touchend", "touchstart", "touchmove", "mouseover", "mouseout"]; export const EVENTS = { BOOK : { @@ -36,7 +36,8 @@ export const EVENTS = { SHOWN : "shown", HIDDEN : "hidden", MARK_CLICKED : "markClicked", - MARK_HOVERED: "markHovered" + MARK_MOUSEOVER: "markMouseover", + MARK_MOUSEOUT: "markMouseout", }, RENDITION : { STARTED : "started",