Skip to content

Commit

Permalink
Chore: Optimizations for highlightMousemoveEvent (#121)
Browse files Browse the repository at this point in the history
  • Loading branch information
JustinHoldstock authored Jun 6, 2017
1 parent b7217c6 commit 4d31542
Show file tree
Hide file tree
Showing 5 changed files with 311 additions and 131 deletions.
216 changes: 154 additions & 62 deletions src/lib/annotations/doc/DocAnnotator.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import rangyClassApplier from 'rangy/lib/rangy-classapplier';
import rangyHighlight from 'rangy/lib/rangy-highlighter';
import rangySaveRestore from 'rangy/lib/rangy-selectionsaverestore';
/* eslint-enable no-unused-vars */
import throttle from 'lodash.throttle';
import autobind from 'autobind-decorator';
import Annotator from '../Annotator';
import DocHighlightThread from './DocHighlightThread';
Expand All @@ -18,8 +17,62 @@ const MOUSEMOVE_THROTTLE_MS = 50;
const PAGE_PADDING_BOTTOM = 15;
const PAGE_PADDING_TOP = 15;
const HOVER_TIMEOUT_MS = 75;
const MOUSE_MOVE_MIN_DISTANCE = 5;

/**
* For filtering out and only showing the first thread in a list of threads.
*
* @param {Object} thread - The annotation thread to either hide or show
* @param {number} index - The index of the annotation thread
* @return {void}
*/
function showFirstDialogFilter(thread, index) {
if (index === 0) {
thread.show();
} else {
thread.hideDialog();
}
}

/**
* Check if a thread is in a hover state.
*
* @param {Object} thread - The thread to check the state of
* @return {boolean} True if the thread is in a state of hover
*/
function isThreadInHoverState(thread) {
return constants.HOVER_STATES.indexOf(thread.state) > -1;
}

@autobind class DocAnnotator extends Annotator {
/**
* For tracking the most recent event fired by mouse move event.
*
* @property {Event}
*/
mouseMoveEvent;

/**
* Event callback for mouse move events with for highlight annotations.
*
* @property {Function}
*/
highlightMousemoveHandler;

/**
* Handle to RAF used to throttle highlight collision checks.
*
* @property {Function}
*/
highlightThrottleHandle;

/**
* Timer used to throttle highlight event process.
*
* @property {number}
*/
throttleTimer = 0;

//--------------------------------------------------------------------------
// Abstract Implementations
//--------------------------------------------------------------------------
Expand Down Expand Up @@ -245,7 +298,7 @@ const HOVER_TIMEOUT_MS = 75;
this.annotatedElement.addEventListener('dblclick', this.highlightMouseupHandler);
this.annotatedElement.addEventListener('mousedown', this.highlightMousedownHandler);
this.annotatedElement.addEventListener('contextmenu', this.highlightMousedownHandler);
this.annotatedElement.addEventListener('mousemove', this.highlightMousemoveHandler());
this.annotatedElement.addEventListener('mousemove', this.getHighlightMouseMoveHandler());
}
}

Expand All @@ -263,7 +316,12 @@ const HOVER_TIMEOUT_MS = 75;
this.annotatedElement.removeEventListener('dblclick', this.highlightMouseupHandler);
this.annotatedElement.removeEventListener('mousedown', this.highlightMousedownHandler);
this.annotatedElement.removeEventListener('contextmenu', this.highlightMousedownHandler);
this.annotatedElement.removeEventListener('mousemove', this.highlightMousemoveHandler());
this.annotatedElement.removeEventListener('mousemove', this.getHighlightMouseMoveHandler());

if (this.highlightThrottleHandle) {
cancelAnimationFrame(this.highlightThrottleHandle);
this.highlightThrottleHandle = null;
}
}
}

Expand Down Expand Up @@ -352,76 +410,110 @@ const HOVER_TIMEOUT_MS = 75;
* @private
* @return {Function} mousemove handler
*/
highlightMousemoveHandler() {
if (this.throttledHighlightMousemoveHandler) {
return this.throttledHighlightMousemoveHandler;
getHighlightMouseMoveHandler() {
if (this.highlightMousemoveHandler) {
return this.highlightMousemoveHandler;
}

this.throttledHighlightMousemoveHandler = throttle((event) => {
// Only filter through highlight threads on the current page
const page = annotatorUtil.getPageElAndPageNumber(event.target).page;
const pageThreads = this.getHighlightThreadsOnPage(page);
const delayThreads = [];

pageThreads.forEach((thread) => {
// Determine if any highlight threads on page are pending or active
// and ignore hover events of any highlights below
if (
thread.state === constants.ANNOTATION_STATE_PENDING ||
thread.state === constants.ANNOTATION_STATE_ACTIVE
) {
return;
}
this.highlightMousemoveHandler = this.onHighlightMouseMove.bind(this);

// Determine if the mouse is hovering over any highlight threads
const shouldDelay = thread.onMousemove(event);
if (shouldDelay) {
delayThreads.push(thread);
}
});
const highlightLoop = () => {
this.highlightThrottleHandle = requestAnimationFrame(highlightLoop);
this.onHighlightCheck();
};

// Ignore small mouse movements when figuring out if a mousedown
// and mouseup was a click
if (Math.abs(event.clientX - this.mouseX) > 5 || Math.abs(event.clientY - this.mouseY) > 5) {
this.didMouseMove = true;
}
// Kickstart event process loop.
highlightLoop();

// Determine if the user is creating a new overlapping highlight
// and ignore hover events of any highlights below
if (this.isCreatingHighlight) {
return;
}
return this.highlightMousemoveHandler;
}

/**
* Throttled processing of the most recent mouse move event.
*
* @return {void}
*/
onHighlightCheck() {
const dt = performance.now() - this.throttleTimer;
// Bail if no mouse events have occurred OR the throttle delay has not been met.
if (!this.mouseMoveEvent || dt < MOUSEMOVE_THROTTLE_MS) {
return;
}

const event = this.mouseMoveEvent;
this.mouseMoveEvent = null;
this.throttleTimer = performance.now();
// Only filter through highlight threads on the current page
const { page } = annotatorUtil.getPageElAndPageNumber(event.target);
const pageThreads = this.getHighlightThreadsOnPage(page);
const delayThreads = [];
let hoverActive = false;

// If we are hovering over a highlight, we should use a hand cursor
const threadLength = pageThreads.length;
for (let i = 0; i < threadLength; ++i) {
const thread = pageThreads[i];
// Determine if any highlight threads on page are pending or active
// and ignore hover events of any highlights below
if (
delayThreads.some((thread) => {
return constants.HOVER_STATES.indexOf(thread.state) > 1;
})
thread.state === constants.ANNOTATION_STATE_PENDING ||
thread.state === constants.ANNOTATION_STATE_ACTIVE
) {
this.useDefaultCursor();
clearTimeout(this.cursorTimeout);
} else {
// Setting timeout on cursor change so cursor doesn't
// flicker when hovering on line spacing
this.cursorTimeout = setTimeout(() => {
this.removeDefaultCursor();
}, HOVER_TIMEOUT_MS);
return;
}

// Delayed threads (threads that should be in active or hover
// state) should be drawn last. If multiple highlights are
// hovered over at the same time, only the top-most highlight
// dialog will be displayed and the others will be hidden
// without delay
delayThreads.forEach((thread, index) => {
if (index === 0) {
thread.show();
} else {
thread.hideDialog();
// Determine if the mouse is hovering over any highlight threads
const shouldDelay = thread.onMousemove(event);
if (shouldDelay) {
delayThreads.push(thread);

if (!hoverActive) {
hoverActive = isThreadInHoverState(thread);
}
});
}, MOUSEMOVE_THROTTLE_MS);
return this.throttledHighlightMousemoveHandler;
}
}

// If we are hovering over a highlight, we should use a hand cursor
if (hoverActive) {
this.useDefaultCursor();
clearTimeout(this.cursorTimeout);
} else {
// Setting timeout on cursor change so cursor doesn't
// flicker when hovering on line spacing
this.cursorTimeout = setTimeout(() => {
this.removeDefaultCursor();
}, HOVER_TIMEOUT_MS);
}

// Delayed threads (threads that should be in active or hover
// state) should be drawn last. If multiple highlights are
// hovered over at the same time, only the top-most highlight
// dialog will be displayed and the others will be hidden
// without delay
delayThreads.forEach(showFirstDialogFilter);
}

/**
* Mouse move handler. Paired with throttle mouse move handler to check for annotation highlights.
*
* @param {Event} event - DDOM event fired by mouse move event
* @return {void}
*/
onHighlightMouseMove(event) {
if (
!this.didMouseMove &&
(Math.abs(event.clientX - this.mouseX) > MOUSE_MOVE_MIN_DISTANCE ||
Math.abs(event.clientY - this.mouseY) > MOUSE_MOVE_MIN_DISTANCE)
) {
this.didMouseMove = true;
}

// Determine if the user is creating a new overlapping highlight
// and ignore hover events of any highlights below
if (this.isCreatingHighlight) {
return;
}

this.mouseMoveEvent = event;
}

/**
Expand Down
48 changes: 40 additions & 8 deletions src/lib/annotations/doc/DocHighlightThread.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const PAGE_PADDING_TOP = 15;
const HOVER_TIMEOUT_MS = 75;

@autobind class DocHighlightThread extends AnnotationThread {
/**
* Cached page element for the document.
*
* @property {HTMLElement}
*/
pageEl;

//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
Expand Down Expand Up @@ -450,24 +457,45 @@ const HOVER_TIMEOUT_MS = 75;
PAGE_PADDING_TOP + PAGE_PADDING_BOTTOM
);

const scaleVertices = (val, index) => {
return index % 2 ? val * dimensionScale.y : val * dimensionScale.x;
};

// DOM coordinates with respect to the page
const x = event.clientX - pageDimensions.left;
const y = event.clientY - pageTop;

return this.location.quadPoints.some((quadPoint) => {
let eventOccurredInHighlight = false;

const points = this.location.quadPoints;
const length = points.length;

let index = 0;
while (index < length && !eventOccurredInHighlight) {
const quadPoint = points[index];
// If needed, scale quad points comparing current dimensions with saved dimensions
let scaledQuadPoint = quadPoint;
const scaledQuadPoint = [...quadPoint];
if (dimensionScale) {
scaledQuadPoint = quadPoint.map((val, index) => {
return index % 2 ? val * dimensionScale.y : val * dimensionScale.x;
});
const qLength = quadPoint.length;
for (let i = 0; i < qLength; i++) {
scaledQuadPoint[i] = scaleVertices(quadPoint[i], i);
}
}

const browserQuadPoint = docAnnotatorUtil.convertPDFSpaceToDOMSpace(scaledQuadPoint, pageHeight, zoomScale);

const [x1, y1, x2, y2, x3, y3, x4, y4] = browserQuadPoint;

return docAnnotatorUtil.isPointInPolyOpt([[x1, y1], [x2, y2], [x3, y3], [x4, y4]], x, y);
});
eventOccurredInHighlight = docAnnotatorUtil.isPointInPolyOpt(
[[x1, y1], [x2, y2], [x3, y3], [x4, y4]],
x,
y
);

index += 1;
}

return eventOccurredInHighlight;
}

/**
Expand All @@ -477,7 +505,11 @@ const HOVER_TIMEOUT_MS = 75;
* @return {HTMLElement} Page element
*/
getPageEl() {
return this.annotatedElement.querySelector(`[data-page-number="${this.location.page}"]`);
if (!this.pageEl) {
this.pageEl = this.annotatedElement.querySelector(`[data-page-number="${this.location.page}"]`);
}

return this.pageEl;
}

/**
Expand Down
Loading

0 comments on commit 4d31542

Please sign in to comment.