From 3960927274c018182987ca1962dd995225ad901e Mon Sep 17 00:00:00 2001 From: MinhHNguyen Date: Mon, 24 Jul 2017 10:52:45 -0700 Subject: [PATCH] Feature: DrawingAnnotations starting code (#224) * New: starting to add drawing annotation classes * Update: restructure the class structure of drawing annotations * Update: restructure annotator class hierarchy * Update: clean up jsdoc * Fix: fixing tests on changed function names for drawing annotations * Update: annotation confirm and cancel buttons * Update: remove entry into drawing annotations * Fix: prettier on all files * Fix: remove drawing listener from load annotations test * Update: annotation variable updates and code clarity fixes from reviews * Chore: drawing annotation tests * Update: changes pertaining to latest code review * Update: change test variable names to match camelCasing * Fix: annotator test stubs --- CHANGELOG.md | 2 +- src/i18n/da-DK.properties | 2 +- src/i18n/de-DE.properties | 2 +- src/i18n/en-AU.properties | 2 +- src/i18n/en-CA.properties | 2 +- src/i18n/en-GB.properties | 2 +- src/i18n/en-US.properties | 8 +- src/i18n/es-ES.properties | 2 +- src/i18n/fi-FI.properties | 2 +- src/i18n/fr-CA.properties | 2 +- src/i18n/fr-FR.properties | 2 +- src/i18n/it-IT.properties | 2 +- src/i18n/ja-JP.properties | 2 +- src/i18n/ko-KR.properties | 2 +- src/i18n/nb-NO.properties | 2 +- src/i18n/nl-NL.properties | 2 +- src/i18n/pl-PL.properties | 2 +- src/i18n/pt-BR.properties | 2 +- src/i18n/ru-RU.properties | 2 +- src/i18n/sv-SE.properties | 2 +- src/i18n/tr-TR.properties | 2 +- src/i18n/zh-CN.properties | 2 +- src/i18n/zh-TW.properties | 2 +- src/lib/PreviewUI.js | 8 +- src/lib/__tests__/PreviewUI-test.js | 3 +- src/lib/annotations/AnnotationService.js | 1 + src/lib/annotations/AnnotationThread.js | 4 +- src/lib/annotations/Annotator.js | 190 +++++++++++++++--- src/lib/annotations/Annotator.scss | 13 +- .../__tests__/AnnotationThread-test.js | 6 +- .../annotations/__tests__/Annotator-test.js | 187 +++++++++++++++-- .../__tests__/annotatorUtil-test.js | 37 +++- src/lib/annotations/annotationConstants.js | 25 ++- src/lib/annotations/annotatorUtil.js | 23 +++ src/lib/annotations/doc/DocAnnotator.js | 11 +- src/lib/annotations/doc/DocDrawingThread.js | 93 +++++++++ src/lib/annotations/doc/DocHighlightThread.js | 49 ++--- .../doc/__tests__/DocAnnotator-test.js | 8 + .../doc/__tests__/DocDrawingThread-test.html | 1 + .../doc/__tests__/DocDrawingThread-test.js | 109 ++++++++++ .../doc/__tests__/DocHighlightThread-test.js | 74 +------ .../doc/__tests__/docAnnotatorUtil-test.js | 67 +++++- src/lib/annotations/doc/docAnnotatorUtil.js | 44 ++++ src/lib/annotations/drawing/DrawingPath.js | 104 ++++++++++ src/lib/annotations/drawing/DrawingThread.js | 175 ++++++++++++++++ .../drawing/__tests__/DrawingPath-test.js | 91 +++++++++ .../drawing/__tests__/DrawingThread-test.js | 154 ++++++++++++++ src/lib/annotations/image/ImageAnnotator.js | 10 +- .../image/__tests__/ImageAnnotator-test.html | 2 +- .../__tests__/ImagePointThread-test.html | 2 +- .../__tests__/imageAnnotatorUtil-test.html | 2 +- src/lib/constants.js | 3 +- src/lib/shell.html | 15 +- src/lib/viewers/BaseViewer.js | 73 +++++-- src/lib/viewers/__tests__/BaseViewer-test.js | 50 ++++- src/lib/viewers/doc/DocBaseViewer.js | 7 +- .../doc/__tests__/DocBaseViewer-test.js | 8 +- src/lib/viewers/doc/docAssets.js | 3 +- .../image/__tests__/ImageViewer-test.js | 2 +- src/third-party/doc/0.130.0/rbush.min.js | 2 + 60 files changed, 1468 insertions(+), 240 deletions(-) create mode 100644 src/lib/annotations/doc/DocDrawingThread.js create mode 100644 src/lib/annotations/doc/__tests__/DocDrawingThread-test.html create mode 100644 src/lib/annotations/doc/__tests__/DocDrawingThread-test.js create mode 100644 src/lib/annotations/drawing/DrawingPath.js create mode 100644 src/lib/annotations/drawing/DrawingThread.js create mode 100644 src/lib/annotations/drawing/__tests__/DrawingPath-test.js create mode 100644 src/lib/annotations/drawing/__tests__/DrawingThread-test.js create mode 100644 src/third-party/doc/0.130.0/rbush.min.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b91fc9b02..3e1592f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -390,4 +390,4 @@ - Setting up Travis [\#1](https://github.com/box/box-content-preview/pull/1) ([tonyjin](https://github.com/tonyjin)) -\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* \ No newline at end of file +\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* diff --git a/src/i18n/da-DK.properties b/src/i18n/da-DK.properties index 26b03c27f..faab20faf 100644 --- a/src/i18n/da-DK.properties +++ b/src/i18n/da-DK.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Din session er udløbet. Genindlæs siden. # Default text for notification button that dismisses notification notification_button_default_text=OK # Notification message shown when user enters annotation mode -notification_annotation_mode=Klik et vilkårligt sted for at føje en kommentar til dokumentet +notification_annotation_point_mode=Klik et vilkårligt sted for at føje en kommentar til dokumentet # File Types # 360 degree video file type diff --git a/src/i18n/de-DE.properties b/src/i18n/de-DE.properties index 64400ab21..5e358be13 100644 --- a/src/i18n/de-DE.properties +++ b/src/i18n/de-DE.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Ihre Sitzung ist abgelaufen. Bitte aktualisieren # Default text for notification button that dismisses notification notification_button_default_text=OK # Notification message shown when user enters annotation mode -notification_annotation_mode=Klicken Sie an einer beliebigen Stelle, um einen Kommentar im Dokument hinzuzufügen +notification_annotation_point_mode=Klicken Sie an einer beliebigen Stelle, um einen Kommentar im Dokument hinzuzufügen # File Types # 360 degree video file type diff --git a/src/i18n/en-AU.properties b/src/i18n/en-AU.properties index 1bfcb5ee6..270999c5a 100644 --- a/src/i18n/en-AU.properties +++ b/src/i18n/en-AU.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Your session has expired. Please refresh the pag # Default text for notification button that dismisses notification notification_button_default_text=Okay # Notification message shown when user enters annotation mode -notification_annotation_mode=Click anywhere to add a comment to the document +notification_annotation_point_mode=Click anywhere to add a comment to the document # File Types # 360 degree video file type diff --git a/src/i18n/en-CA.properties b/src/i18n/en-CA.properties index 1bfcb5ee6..270999c5a 100644 --- a/src/i18n/en-CA.properties +++ b/src/i18n/en-CA.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Your session has expired. Please refresh the pag # Default text for notification button that dismisses notification notification_button_default_text=Okay # Notification message shown when user enters annotation mode -notification_annotation_mode=Click anywhere to add a comment to the document +notification_annotation_point_mode=Click anywhere to add a comment to the document # File Types # 360 degree video file type diff --git a/src/i18n/en-GB.properties b/src/i18n/en-GB.properties index 1bfcb5ee6..270999c5a 100644 --- a/src/i18n/en-GB.properties +++ b/src/i18n/en-GB.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Your session has expired. Please refresh the pag # Default text for notification button that dismisses notification notification_button_default_text=Okay # Notification message shown when user enters annotation mode -notification_annotation_mode=Click anywhere to add a comment to the document +notification_annotation_point_mode=Click anywhere to add a comment to the document # File Types # 360 degree video file type diff --git a/src/i18n/en-US.properties b/src/i18n/en-US.properties index 1bfcb5ee6..549e83a61 100644 --- a/src/i18n/en-US.properties +++ b/src/i18n/en-US.properties @@ -123,6 +123,8 @@ annotation_anonymous_user_name=Some User annotation_posting_message=Posting... # Accessibilty message for button that toggles point annotation mode annotation_point_toggle=Point annotation mode +# Accessibilty message for button that toggles drawing annotation mode +annotation_draw_toggle=Drawing annotation mode # Accessibilty text for button that adds and removes highlights on text annotation_highlight_toggle=Highlight text # Accessibilty text for button that adds comments to text highlights @@ -142,8 +144,10 @@ annotations_authorization_error=Your session has expired. Please refresh the pag # Notifications # Default text for notification button that dismisses notification notification_button_default_text=Okay -# Notification message shown when user enters annotation mode -notification_annotation_mode=Click anywhere to add a comment to the document +# Notification message shown when user enters point annotation mode +notification_annotation_point_mode=Click anywhere to add a comment to the document +# Notification message shown when user enters drawing annotation mode +notification_annotation_draw_mode=Press down and drag the pointer to draw on the document # File Types # 360 degree video file type diff --git a/src/i18n/es-ES.properties b/src/i18n/es-ES.properties index 87cf7797c..dcbc13b2b 100644 --- a/src/i18n/es-ES.properties +++ b/src/i18n/es-ES.properties @@ -143,7 +143,7 @@ annotations_authorization_error=La sesión ha finalizado. Actualice la página. # Default text for notification button that dismisses notification notification_button_default_text=Aceptar # Notification message shown when user enters annotation mode -notification_annotation_mode=Haga clic en cualquier parte para añadir un comentario al documento +notification_annotation_point_mode=Haga clic en cualquier parte para añadir un comentario al documento # File Types # 360 degree video file type diff --git a/src/i18n/fi-FI.properties b/src/i18n/fi-FI.properties index 77bca198d..816620087 100644 --- a/src/i18n/fi-FI.properties +++ b/src/i18n/fi-FI.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Istunto on vanhentunut. Päivitä sivu. # Default text for notification button that dismisses notification notification_button_default_text=OK # Notification message shown when user enters annotation mode -notification_annotation_mode=Lisää kommentti asiakirjaan napsauttamalla mitä tahansa kohtaa +notification_annotation_point_mode=Lisää kommentti asiakirjaan napsauttamalla mitä tahansa kohtaa # File Types # 360 degree video file type diff --git a/src/i18n/fr-CA.properties b/src/i18n/fr-CA.properties index 38bacabac..7445c8d2e 100644 --- a/src/i18n/fr-CA.properties +++ b/src/i18n/fr-CA.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Votre session a expiré. Veuillez actualiser la # Default text for notification button that dismisses notification notification_button_default_text=OK # Notification message shown when user enters annotation mode -notification_annotation_mode=Cliquez n'importe où dans le document pour y ajouter un commentaire +notification_annotation_point_mode=Cliquez n'importe où dans le document pour y ajouter un commentaire # File Types # 360 degree video file type diff --git a/src/i18n/fr-FR.properties b/src/i18n/fr-FR.properties index 38bacabac..7445c8d2e 100644 --- a/src/i18n/fr-FR.properties +++ b/src/i18n/fr-FR.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Votre session a expiré. Veuillez actualiser la # Default text for notification button that dismisses notification notification_button_default_text=OK # Notification message shown when user enters annotation mode -notification_annotation_mode=Cliquez n'importe où dans le document pour y ajouter un commentaire +notification_annotation_point_mode=Cliquez n'importe où dans le document pour y ajouter un commentaire # File Types # 360 degree video file type diff --git a/src/i18n/it-IT.properties b/src/i18n/it-IT.properties index 9cbe5f3e2..afea3b09a 100644 --- a/src/i18n/it-IT.properties +++ b/src/i18n/it-IT.properties @@ -143,7 +143,7 @@ annotations_authorization_error=La sessione è scaduta. Aggiorna la pagina. # Default text for notification button that dismisses notification notification_button_default_text=OK # Notification message shown when user enters annotation mode -notification_annotation_mode=Fai clic in un punto qualsiasi per aggiungere un commento al documento +notification_annotation_point_mode=Fai clic in un punto qualsiasi per aggiungere un commento al documento # File Types # 360 degree video file type diff --git a/src/i18n/ja-JP.properties b/src/i18n/ja-JP.properties index 5c2495992..6e7cddf9f 100644 --- a/src/i18n/ja-JP.properties +++ b/src/i18n/ja-JP.properties @@ -143,7 +143,7 @@ annotations_authorization_error=セッションが期限切れです。ページ # Default text for notification button that dismisses notification notification_button_default_text=OK # Notification message shown when user enters annotation mode -notification_annotation_mode=ドキュメントにコメントをつけるには任意の場所をクリックします +notification_annotation_point_mode=ドキュメントにコメントをつけるには任意の場所をクリックします # File Types # 360 degree video file type diff --git a/src/i18n/ko-KR.properties b/src/i18n/ko-KR.properties index 4eeeb49a7..a008d7a88 100644 --- a/src/i18n/ko-KR.properties +++ b/src/i18n/ko-KR.properties @@ -143,7 +143,7 @@ annotations_authorization_error=세션이 만료되었습니다. 페이지를 # Default text for notification button that dismisses notification notification_button_default_text=확인 # Notification message shown when user enters annotation mode -notification_annotation_mode=아무 곳이나 클릭하여 문서에 코멘트를 추가하십시오. +notification_annotation_point_mode=아무 곳이나 클릭하여 문서에 코멘트를 추가하십시오. # File Types # 360 degree video file type diff --git a/src/i18n/nb-NO.properties b/src/i18n/nb-NO.properties index d92ae60ef..8a10e5257 100644 --- a/src/i18n/nb-NO.properties +++ b/src/i18n/nb-NO.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Økten er utløpt. Oppdater siden. # Default text for notification button that dismisses notification notification_button_default_text=OK # Notification message shown when user enters annotation mode -notification_annotation_mode=Klikk hvor som helst for å legge til en kommentar til dokumentet +notification_annotation_point_mode=Klikk hvor som helst for å legge til en kommentar til dokumentet # File Types # 360 degree video file type diff --git a/src/i18n/nl-NL.properties b/src/i18n/nl-NL.properties index c8b8a6155..023d31553 100644 --- a/src/i18n/nl-NL.properties +++ b/src/i18n/nl-NL.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Uw sessie is verlopen. Vernieuw de pagina. # Default text for notification button that dismisses notification notification_button_default_text=OK # Notification message shown when user enters annotation mode -notification_annotation_mode=Klik ergens om een opmerking aan het document toe te voegen +notification_annotation_point_mode=Klik ergens om een opmerking aan het document toe te voegen # File Types # 360 degree video file type diff --git a/src/i18n/pl-PL.properties b/src/i18n/pl-PL.properties index 0af5ce4b6..4473cca52 100644 --- a/src/i18n/pl-PL.properties +++ b/src/i18n/pl-PL.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Sesja wygasła. Odśwież stronę. # Default text for notification button that dismisses notification notification_button_default_text=OK # Notification message shown when user enters annotation mode -notification_annotation_mode=Aby dodać komentarz do dokumentu, kliknij w dowolnym miejscu +notification_annotation_point_mode=Aby dodać komentarz do dokumentu, kliknij w dowolnym miejscu # File Types # 360 degree video file type diff --git a/src/i18n/pt-BR.properties b/src/i18n/pt-BR.properties index 49e2ca0fe..aaf09dce8 100644 --- a/src/i18n/pt-BR.properties +++ b/src/i18n/pt-BR.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Sua sessão expirou. Atualize a página. # Default text for notification button that dismisses notification notification_button_default_text=Ok # Notification message shown when user enters annotation mode -notification_annotation_mode=Clique em qualquer lugar para fazer um comentário no documento +notification_annotation_point_mode=Clique em qualquer lugar para fazer um comentário no documento # File Types # 360 degree video file type diff --git a/src/i18n/ru-RU.properties b/src/i18n/ru-RU.properties index b693fca5c..990b45c46 100644 --- a/src/i18n/ru-RU.properties +++ b/src/i18n/ru-RU.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Время сеанса истекло. Обно # Default text for notification button that dismisses notification notification_button_default_text=ОК # Notification message shown when user enters annotation mode -notification_annotation_mode=Нажмите на любую часть документа, чтобы добавить комментарий. +notification_annotation_point_mode=Нажмите на любую часть документа, чтобы добавить комментарий. # File Types # 360 degree video file type diff --git a/src/i18n/sv-SE.properties b/src/i18n/sv-SE.properties index e2ff0f572..de0b15ebe 100644 --- a/src/i18n/sv-SE.properties +++ b/src/i18n/sv-SE.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Din session har gått ut. Uppdatera sidan. # Default text for notification button that dismisses notification notification_button_default_text=OK # Notification message shown when user enters annotation mode -notification_annotation_mode=Klicka var som helst för att lägga till en kommentar till dokumentet +notification_annotation_point_mode=Klicka var som helst för att lägga till en kommentar till dokumentet # File Types # 360 degree video file type diff --git a/src/i18n/tr-TR.properties b/src/i18n/tr-TR.properties index bad00ca4c..70f9fa9fc 100644 --- a/src/i18n/tr-TR.properties +++ b/src/i18n/tr-TR.properties @@ -143,7 +143,7 @@ annotations_authorization_error=Oturumunuzun süresi sona erdi. Lütfen sayfayı # Default text for notification button that dismisses notification notification_button_default_text=Tamam # Notification message shown when user enters annotation mode -notification_annotation_mode=Belgeye bir yorum eklemek için herhangi bir yere tıklayın +notification_annotation_point_mode=Belgeye bir yorum eklemek için herhangi bir yere tıklayın # File Types # 360 degree video file type diff --git a/src/i18n/zh-CN.properties b/src/i18n/zh-CN.properties index 7cbae1e3a..062bab8f4 100644 --- a/src/i18n/zh-CN.properties +++ b/src/i18n/zh-CN.properties @@ -143,7 +143,7 @@ annotations_authorization_error=您的会话已过期。请刷新页面。 # Default text for notification button that dismisses notification notification_button_default_text=确定 # Notification message shown when user enters annotation mode -notification_annotation_mode=点击任意位置均可向该文档添加评论 +notification_annotation_point_mode=点击任意位置均可向该文档添加评论 # File Types # 360 degree video file type diff --git a/src/i18n/zh-TW.properties b/src/i18n/zh-TW.properties index a865892e6..0f2d5c908 100644 --- a/src/i18n/zh-TW.properties +++ b/src/i18n/zh-TW.properties @@ -143,7 +143,7 @@ annotations_authorization_error=您的工作階段已到期。請重新整理頁 # Default text for notification button that dismisses notification notification_button_default_text=確定 # Notification message shown when user enters annotation mode -notification_annotation_mode=在任何位置上按一下,即可新增留言至文件 +notification_annotation_point_mode=在任何位置上按一下,即可新增留言至文件 # File Types # 360 degree video file type diff --git a/src/lib/PreviewUI.js b/src/lib/PreviewUI.js index cbebdcc40..e8c26973d 100644 --- a/src/lib/PreviewUI.js +++ b/src/lib/PreviewUI.js @@ -10,7 +10,6 @@ import { CLASS_PREVIEW_LOADED, SELECTOR_BOX_PREVIEW_CONTAINER, SELECTOR_BOX_PREVIEW, - SELECTOR_BOX_PREVIEW_BTN_ANNOTATE, SELECTOR_BOX_PREVIEW_BTN_PRINT, SELECTOR_BOX_PREVIEW_BTN_DOWNLOAD, SELECTOR_BOX_PREVIEW_BTN_LOADING_DOWNLOAD, @@ -261,10 +260,11 @@ class PreviewUI { /** * Gets the annotation button element. * - * @return {HTMLElement} Annotate button element + * @param {string} annotatorSelector - Class selector for a custom annotation button. + * @return {HTMLElement|null} Annotate button element or null if the selector did not find an element. */ - getAnnotateButton() { - return this.container.querySelector(SELECTOR_BOX_PREVIEW_BTN_ANNOTATE); + getAnnotateButton(annotatorSelector) { + return this.container.querySelector(annotatorSelector); } /** diff --git a/src/lib/__tests__/PreviewUI-test.js b/src/lib/__tests__/PreviewUI-test.js index 5c8807f5f..9a6779e05 100644 --- a/src/lib/__tests__/PreviewUI-test.js +++ b/src/lib/__tests__/PreviewUI-test.js @@ -204,7 +204,8 @@ describe('lib/PreviewUI', () => { describe('getAnnotateButton()', () => { it('should return the annotate button', () => { containerEl = ui.setup(options); - expect(ui.getAnnotateButton()).to.equal(containerEl.querySelector(constants.SELECTOR_BOX_PREVIEW_BTN_ANNOTATE)); + const buttonEl = ui.getAnnotateButton(constants.SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_POINT); + expect(buttonEl).to.equal(containerEl.querySelector(constants.SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_POINT)); }); }); diff --git a/src/lib/annotations/AnnotationService.js b/src/lib/annotations/AnnotationService.js index 75f83b08b..25de39974 100644 --- a/src/lib/annotations/AnnotationService.js +++ b/src/lib/annotations/AnnotationService.js @@ -81,6 +81,7 @@ class AnnotationService extends EventEmitter { }, details: { type: annotation.type, + drawingPaths: annotation.drawingPaths, location: annotation.location, threadID: annotation.threadID }, diff --git a/src/lib/annotations/AnnotationThread.js b/src/lib/annotations/AnnotationThread.js index 4ce7e61a4..6b8d5375d 100644 --- a/src/lib/annotations/AnnotationThread.js +++ b/src/lib/annotations/AnnotationThread.js @@ -4,7 +4,7 @@ import Annotation from './Annotation'; import AnnotationService from './AnnotationService'; import * as annotatorUtil from './annotatorUtil'; import { ICON_PLACED_ANNOTATION } from '../icons/icons'; -import { STATES, TYPES, CLASS_ANNOTATION_POINT_BUTTON, DATA_TYPE_ANNOTATION_INDICATOR } from './annotationConstants'; +import { STATES, TYPES, CLASS_ANNOTATION_POINT_MARKER, DATA_TYPE_ANNOTATION_INDICATOR } from './annotationConstants'; @autobind class AnnotationThread extends EventEmitter { @@ -404,7 +404,7 @@ class AnnotationThread extends EventEmitter { */ createElement() { const indicatorEl = document.createElement('button'); - indicatorEl.classList.add(CLASS_ANNOTATION_POINT_BUTTON); + indicatorEl.classList.add(CLASS_ANNOTATION_POINT_MARKER); indicatorEl.setAttribute('data-type', DATA_TYPE_ANNOTATION_INDICATOR); indicatorEl.innerHTML = ICON_PLACED_ANNOTATION; return indicatorEl; diff --git a/src/lib/annotations/Annotator.js b/src/lib/annotations/Annotator.js index 60e3edf8f..060ee901f 100644 --- a/src/lib/annotations/Annotator.js +++ b/src/lib/annotations/Annotator.js @@ -3,20 +3,28 @@ import autobind from 'autobind-decorator'; import Notification from '../Notification'; import AnnotationService from './AnnotationService'; import * as annotatorUtil from './annotatorUtil'; -import { CLASS_ACTIVE, CLASS_HIDDEN } from '../constants'; +import { + CLASS_ACTIVE, + CLASS_HIDDEN, + SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_POINT, + SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_DRAW +} from '../constants'; import { ICON_CLOSE } from '../icons/icons'; import './Annotator.scss'; import { DATA_TYPE_ANNOTATION_DIALOG, CLASS_MOBILE_ANNOTATION_DIALOG, CLASS_ANNOTATION_DIALOG, + CLASS_ANNOTATION_DRAW_MODE, + CLASS_ANNOTATION_POINT_MODE, CLASS_MOBILE_DIALOG_HEADER, CLASS_DIALOG_CLOSE, + SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL, + SELECTOR_ANNOTATION_BUTTON_DRAW_ENTER, + SELECTOR_ANNOTATION_BUTTON_DRAW_POST, TYPES } from './annotationConstants'; -const CLASS_ANNOTATION_POINT_MODE = 'bp-point-annotation-mode'; - @autobind class Annotator extends EventEmitter { //-------------------------------------------------------------------------- @@ -52,6 +60,7 @@ class Annotator extends EventEmitter { this.validationErrorDisplayed = false; this.isMobile = data.isMobile; this.previewUI = data.previewUI; + this.annotationModeHandlers = []; } /** @@ -60,6 +69,8 @@ class Annotator extends EventEmitter { * @return {void} */ destroy() { + this.unbindModeListeners(); + if (this.threads) { Object.keys(this.threads).forEach((page) => { this.threads[page].forEach((thread) => { @@ -212,12 +223,12 @@ class Annotator extends EventEmitter { } // Hide create annotations button if image is rotated - const annotateButton = this.previewUI.getAnnotateButton(); + const pointAnnotateButton = this.previewUI.getAnnotateButton(SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_POINT); if (rotationAngle !== 0) { - annotatorUtil.hideElement(annotateButton); + annotatorUtil.hideElement(pointAnnotateButton); } else { - annotatorUtil.showElement(annotateButton); + annotatorUtil.showElement(pointAnnotateButton); } } @@ -238,9 +249,13 @@ class Annotator extends EventEmitter { * @param {HTMLEvent} event - DOM event * @return {void} */ - togglePointModeHandler(event = {}) { + togglePointAnnotationHandler(event = {}) { this.destroyPendingThreads(); - const buttonEl = event.target || this.previewUI.getAnnotateButton(); + const buttonEl = event.target || this.previewUI.getAnnotateButton(SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_POINT); + + if (this.isInDrawMode()) { + this.toggleDrawAnnotationHandler(); + } // If in annotation mode, turn it off if (this.isInPointMode()) { @@ -252,13 +267,12 @@ class Annotator extends EventEmitter { buttonEl.classList.remove(CLASS_ACTIVE); } - this.unbindPointModeListeners(); // Disable point mode + this.unbindModeListeners(); // Disable point mode this.bindDOMListeners(); // Re-enable other annotations // Otherwise, enable annotation mode } else { - this.notification.show(__('notification_annotation_mode')); - + this.notification.show(__('notification_annotation_point_mode')); this.emit('annotationmodeenter'); this.annotatedElement.classList.add(CLASS_ANNOTATION_POINT_MODE); if (buttonEl) { @@ -270,6 +284,57 @@ class Annotator extends EventEmitter { } } + /** + * Toggles draw annotation mode on and off. When draw annotation mode is + * on, a click and draw + * + * @param {HTMLEvent} event - DOM event + * @return {void} + */ + toggleDrawAnnotationHandler(event = {}) { + this.destroyPendingThreads(); + if (this.isInPointMode()) { + this.togglePointAnnotationHandler(); + } + + const buttonEl = event.target || this.previewUI.getAnnotateButton(SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_DRAW); + const postButtonEl = this.previewUI.getAnnotateButton(SELECTOR_ANNOTATION_BUTTON_DRAW_POST); + + // Exit if in draw mode + if (this.isInDrawMode()) { + this.notification.hide(); + this.emit('annotationmodeexit'); + this.annotatedElement.classList.remove(CLASS_ANNOTATION_DRAW_MODE); + + if (buttonEl) { + buttonEl.classList.remove(CLASS_ACTIVE); + buttonEl.querySelector(SELECTOR_ANNOTATION_BUTTON_DRAW_ENTER).classList.remove(CLASS_HIDDEN); + buttonEl.querySelector(SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL).classList.add(CLASS_HIDDEN); + postButtonEl.classList.add(CLASS_HIDDEN); + } + + this.unbindModeListeners(); // Disable draw mode + this.bindDOMListeners(); // Re-enable other annotations + + // Otherwise enter draw mode + } else { + this.notification.show(__('notification_annotation_draw_mode')); + this.emit('annotationmodeenter'); + this.annotatedElement.classList.add(CLASS_ANNOTATION_DRAW_MODE); + + if (buttonEl) { + buttonEl.classList.add(CLASS_ACTIVE); + buttonEl.querySelector(SELECTOR_ANNOTATION_BUTTON_DRAW_ENTER).classList.add(CLASS_HIDDEN); + buttonEl.querySelector(SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL).classList.remove(CLASS_HIDDEN); + postButtonEl.classList.remove(CLASS_HIDDEN); + } + + const thread = this.createAnnotationThread([], {}, TYPES.draw); + this.unbindDOMListeners(); + this.bindDrawModeListeners(thread, postButtonEl); + } + } + //-------------------------------------------------------------------------- // Abstract //-------------------------------------------------------------------------- @@ -473,17 +538,15 @@ class Annotator extends EventEmitter { * @return {void} */ bindPointModeListeners() { - this.annotatedElement.addEventListener('click', this.pointClickHandler); - } - - /** - * Unbinds event listeners for point annotation mode. - * - * @protected - * @return {void} - */ - unbindPointModeListeners() { - this.annotatedElement.removeEventListener('click', this.pointClickHandler); + const pointFunc = this.pointClickHandler.bind(this.annotatedElement); + const handler = { + type: 'click', + func: pointFunc, + eventObj: this.annotatedElement + }; + + handler.eventObj.addEventListener(handler.type, handler.func); + this.annotationModeHandlers.push(handler); } /** @@ -504,16 +567,15 @@ class Annotator extends EventEmitter { return; } + // Exits point annotation mode on first click + this.togglePointAnnotationHandler(); + // Get annotation location from click event, ignore click if location is invalid const location = this.getLocationFromEvent(event, TYPES.point); if (!location) { - this.togglePointModeHandler(); return; } - // Exits point annotation mode on first click - this.togglePointModeHandler(); - // Create new thread with no annotations, show indicator, and show dialog const thread = this.createAnnotationThread([], location, TYPES.point); @@ -525,6 +587,72 @@ class Annotator extends EventEmitter { } } + /** + * Binds event listeners for draw annotation mode. + * + * @param {DrawingThread} drawingThread - The drawing thread to bind event listeners to. + * @param {HTMLElement} postButtonEl - The HTML element that will save the DrawingThread on click. + * @return {void} + */ + bindDrawModeListeners(drawingThread, postButtonEl) { + if (!drawingThread || !postButtonEl) { + return; + } + + const startCallback = drawingThread.handleStart.bind(drawingThread); + const stopCallback = drawingThread.handleStop.bind(drawingThread); + const moveCallback = drawingThread.handleMove.bind(drawingThread); + /* eslint-disable require-jsdoc */ + const locationFunction = (event) => this.getLocationFromEvent(event, TYPES.point); + /* eslint-enable require-jsdoc */ + const handlers = [ + { + type: 'mousemove', + func: annotatorUtil.eventToLocationHandler(locationFunction, moveCallback), + eventObj: this.annotatedElement + }, + { + type: 'mousedown', + func: annotatorUtil.eventToLocationHandler(locationFunction, startCallback), + eventObj: this.annotatedElement + }, + { + type: 'mouseup', + func: annotatorUtil.eventToLocationHandler(locationFunction, stopCallback), + eventObj: this.annotatedElement + } + ]; + + if (postButtonEl) { + handlers.push({ + type: 'click', + func: () => { + drawingThread.saveAnnotation(TYPES.draw); + this.toggleDrawAnnotationHandler(); + }, + eventObj: postButtonEl + }); + } + + handlers.forEach((handler) => { + handler.eventObj.addEventListener(handler.type, handler.func); + this.annotationModeHandlers.push(handler); + }); + } + + /** + * Unbinds event listeners for annotation modes. + * + * @protected + * @return {void} + */ + unbindModeListeners() { + while (this.annotationModeHandlers.length > 0) { + const handler = this.annotationModeHandlers.pop(); + handler.eventObj.removeEventListener(handler.type, handler.func); + } + } + /** * Adds thread to in-memory map. * @@ -549,6 +677,16 @@ class Annotator extends EventEmitter { return this.annotatedElement.classList.contains(CLASS_ANNOTATION_POINT_MODE); } + /** + * Returns whether or not annotator is in drawing mode. + * + * @protected + * @return {boolean} True if drawing mode is on, otherwise returns false. + */ + isInDrawMode() { + return this.annotatedElement.classList.contains(CLASS_ANNOTATION_DRAW_MODE); + } + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- diff --git a/src/lib/annotations/Annotator.scss b/src/lib/annotations/Annotator.scss index 725a93eea..faa9fdd11 100644 --- a/src/lib/annotations/Annotator.scss +++ b/src/lib/annotations/Annotator.scss @@ -256,7 +256,7 @@ $avatar-color-9: #f22c44; } } -.bp-point-annotation-btn { +.bp-point-annotation-marker { background-color: transparent; border-style: none; cursor: pointer; @@ -324,8 +324,8 @@ $avatar-color-9: #f22c44; //------------------------------------------------------------------------------ // CSS for highlights //------------------------------------------------------------------------------ - -.bp-annotation-layer { +.bp-annotation-layer-draw, +.bp-annotation-layer-highlight { cursor: text; left: 0; mix-blend-mode: multiply; @@ -425,9 +425,12 @@ $avatar-color-9: #f22c44; //------------------------------------------------------------------------------ // Annotation mode //------------------------------------------------------------------------------ - +.bp-draw-annotation-mode .page, +.bp-draw-annotation-mode .bp-annotation-layer-highlight, +.bp-draw-annotation-mode .textLayer > div, +.bp-draw-annotation-mode > img, .bp-point-annotation-mode .page, -.bp-point-annotation-mode .bp-annotation-layer, +.bp-point-annotation-mode .bp-annotation-layer-highlight, .bp-point-annotation-mode .textLayer > div, .bp-point-annotation-mode > img { cursor: crosshair; diff --git a/src/lib/annotations/__tests__/AnnotationThread-test.js b/src/lib/annotations/__tests__/AnnotationThread-test.js index 560167978..8ff93f182 100644 --- a/src/lib/annotations/__tests__/AnnotationThread-test.js +++ b/src/lib/annotations/__tests__/AnnotationThread-test.js @@ -6,7 +6,7 @@ import { CLASS_HIDDEN } from '../../constants'; import { STATES, TYPES, - CLASS_ANNOTATION_POINT_BUTTON, + CLASS_ANNOTATION_POINT_MARKER, DATA_TYPE_ANNOTATION_INDICATOR } from '../annotationConstants'; @@ -371,7 +371,7 @@ describe('lib/annotations/AnnotationThread', () => { thread.setupElement(); expect(thread.element instanceof HTMLElement).to.be.true; - expect(thread.element).to.have.class(CLASS_ANNOTATION_POINT_BUTTON); + expect(thread.element).to.have.class(CLASS_ANNOTATION_POINT_MARKER); expect(stubs.bind).to.be.called; }); }); @@ -489,7 +489,7 @@ describe('lib/annotations/AnnotationThread', () => { describe('createElement()', () => { it('should create an element with the right class and attribute', () => { const element = thread.createElement(); - expect(element).to.have.class(CLASS_ANNOTATION_POINT_BUTTON); + expect(element).to.have.class(CLASS_ANNOTATION_POINT_MARKER); expect(element).to.have.attribute('data-type', DATA_TYPE_ANNOTATION_INDICATOR); }); }); diff --git a/src/lib/annotations/__tests__/Annotator-test.js b/src/lib/annotations/__tests__/Annotator-test.js index c9ec5ff7d..fe8d45c92 100644 --- a/src/lib/annotations/__tests__/Annotator-test.js +++ b/src/lib/annotations/__tests__/Annotator-test.js @@ -2,7 +2,11 @@ import Annotator from '../Annotator'; import * as annotatorUtil from '../annotatorUtil'; import AnnotationService from '../AnnotationService'; -import { STATES, CLASS_ANNOTATION_POINT_MODE } from '../annotationConstants'; +import { + STATES, + CLASS_ANNOTATION_POINT_MODE, + CLASS_ANNOTATION_DRAW_MODE +} from '../annotationConstants'; let annotator; let stubs = {}; @@ -249,7 +253,7 @@ describe('lib/annotations/Annotator', () => { }); }); - describe('togglePointModeHandler()', () => { + describe('togglePointAnnotationHandler()', () => { beforeEach(() => { stubs.pointMode = sandbox.stub(annotator, 'isInPointMode'); sandbox.stub(annotator.notification, 'show'); @@ -257,14 +261,14 @@ describe('lib/annotations/Annotator', () => { sandbox.stub(annotator, 'unbindDOMListeners'); sandbox.stub(annotator, 'bindDOMListeners'); sandbox.stub(annotator, 'bindPointModeListeners'); - sandbox.stub(annotator, 'unbindPointModeListeners'); + sandbox.stub(annotator, 'unbindModeListeners'); }); - it('should turn annotation mode on if it is off', () => { + it('should turn point annotation mode on if it is off', () => { const destroyStub = sandbox.stub(annotator, 'destroyPendingThreads'); stubs.pointMode.returns(false); - annotator.togglePointModeHandler(); + annotator.togglePointAnnotationHandler(); const annotatedEl = document.querySelector('.annotated-element'); expect(destroyStub).to.be.called; @@ -275,18 +279,62 @@ describe('lib/annotations/Annotator', () => { expect(annotator.bindPointModeListeners).to.be.called; }); - it('should turn annotation mode off if it is on', () => { + it('should turn point annotation mode off if it is on', () => { const destroyStub = sandbox.stub(annotator, 'destroyPendingThreads'); stubs.pointMode.returns(true); - annotator.togglePointModeHandler(); + annotator.togglePointAnnotationHandler(); const annotatedEl = document.querySelector('.annotated-element'); expect(destroyStub).to.be.called; expect(annotator.notification.hide).to.be.called; expect(annotator.emit).to.be.calledWith('annotationmodeexit'); expect(annotatedEl).to.not.have.class(CLASS_ANNOTATION_POINT_MODE); - expect(annotator.unbindPointModeListeners).to.be.called; + expect(annotator.unbindModeListeners).to.be.called; + expect(annotator.bindDOMListeners).to.be.called; + }); + }); + + describe('toggleDrawAnnotationHandler()', () => { + beforeEach(() => { + stubs.drawMode = sandbox.stub(annotator, 'isInDrawMode'); + sandbox.stub(annotator.notification, 'show'); + sandbox.stub(annotator.notification, 'hide'); + sandbox.stub(annotator, 'unbindDOMListeners'); + sandbox.stub(annotator, 'bindDOMListeners'); + sandbox.stub(annotator, 'bindDrawModeListeners'); + sandbox.stub(annotator, 'unbindModeListeners'); + sandbox.stub(annotator, 'createAnnotationThread'); + }); + + it('should turn draw annotation mode on if it is off', () => { + const destroyStub = sandbox.stub(annotator, 'destroyPendingThreads'); + stubs.drawMode.returns(false); + + annotator.toggleDrawAnnotationHandler(); + + const annotatedEl = document.querySelector('.annotated-element'); + expect(destroyStub).to.be.called; + expect(annotator.notification.show).to.be.called; + expect(annotator.emit).to.be.calledWith('annotationmodeenter'); + expect(annotatedEl).to.have.class(CLASS_ANNOTATION_DRAW_MODE); + expect(annotator.unbindDOMListeners).to.be.called; + expect(annotator.bindDrawModeListeners).to.be.called; + expect(annotator.createAnnotationThread).to.be.calledWith([], {}, 'draw'); + }); + + it('should turn annotation mode off if it is on', () => { + const destroyStub = sandbox.stub(annotator, 'destroyPendingThreads'); + stubs.drawMode.returns(true); + + annotator.toggleDrawAnnotationHandler(); + + const annotatedEl = document.querySelector('.annotated-element'); + expect(destroyStub).to.be.called; + expect(annotator.notification.hide).to.be.called; + expect(annotator.emit).to.be.calledWith('annotationmodeexit'); + expect(annotatedEl).to.not.have.class(CLASS_ANNOTATION_DRAW_MODE); + expect(annotator.unbindModeListeners).to.be.called; expect(annotator.bindDOMListeners).to.be.called; }); }); @@ -398,7 +446,11 @@ describe('lib/annotations/Annotator', () => { describe('bindPointModeListeners', () => { it('should bind point mode click handler', () => { sandbox.stub(annotator.annotatedElement, 'addEventListener'); + sandbox.stub(annotator.annotatedElement, 'removeEventListener'); + sandbox.stub(annotator.pointClickHandler, 'bind', () => annotator.pointClickHandler); + annotator.bindPointModeListeners(); + expect(annotator.pointClickHandler.bind).to.be.called; expect(annotator.annotatedElement.addEventListener).to.be.calledWith( 'click', annotator.pointClickHandler @@ -406,18 +458,53 @@ describe('lib/annotations/Annotator', () => { }); }); - describe('unbindPointModeListeners', () => { + describe('unbindModeListeners()', () => { it('should unbind point mode click handler', () => { sandbox.stub(annotator.annotatedElement, 'removeEventListener'); - annotator.unbindPointModeListeners(); + sandbox.stub(annotator.pointClickHandler, 'bind', () => annotator.pointClickHandler); + + annotator.bindPointModeListeners(); + annotator.unbindModeListeners(); + expect(annotator.pointClickHandler.bind).to.be.called; expect(annotator.annotatedElement.removeEventListener).to.be.calledWith( 'click', annotator.pointClickHandler ); }); + + it('should unbind draw mode click handler', () => { + const drawingThread = { + handleStart: () => { + bind: handleStart + }, + handleStop: () => { + bind: handleStop + }, + handleMove: () => { + bind: handleMove + } + }; + const postButtonEl = { + addEventListener: sandbox.stub(), + removeEventListener: sandbox.stub() + }; + + sandbox.stub(annotator.annotatedElement, 'addEventListener'); + sandbox.stub(annotator.annotatedElement, 'removeEventListener'); + + annotator.bindDrawModeListeners(drawingThread, postButtonEl); + annotator.unbindModeListeners(); + expect(annotator.annotatedElement.addEventListener).to.be.called.thrice; + expect(postButtonEl.addEventListener).to.be.called; + expect(annotator.annotatedElement.removeEventListener).to.be.calledWith( + sinon.match.string, + sinon.match.func + ).thrice; + expect(postButtonEl.removeEventListener).to.be.called; + }); }); - describe('pointClickHandler', () => { + describe('pointClickHandler()', () => { const event = { stopPropagation: () => {} }; @@ -427,7 +514,7 @@ describe('lib/annotations/Annotator', () => { stubs.create = sandbox.stub(annotator, 'createAnnotationThread'); stubs.getLocation = sandbox.stub(annotator, 'getLocationFromEvent'); sandbox.stub(annotator, 'bindCustomListenersOnThread'); - sandbox.stub(annotator, 'togglePointModeHandler'); + sandbox.stub(annotator, 'togglePointAnnotationHandler'); }); it('should not do anything if there are pending threads', () => { @@ -439,7 +526,7 @@ describe('lib/annotations/Annotator', () => { expect(annotator.getLocationFromEvent).to.not.be.called; expect(annotator.bindCustomListenersOnThread).to.not.be.called; - expect(annotator.togglePointModeHandler).to.not.be.called; + expect(annotator.togglePointAnnotationHandler).to.not.be.called; }); it('should not do anything if thread is invalid', () => { @@ -449,7 +536,7 @@ describe('lib/annotations/Annotator', () => { annotator.pointClickHandler(event); expect(annotator.getLocationFromEvent).to.be.called; - expect(annotator.togglePointModeHandler).to.be.called; + expect(annotator.togglePointAnnotationHandler).to.be.called; expect(annotator.bindCustomListenersOnThread).to.not.be.called; }); @@ -463,7 +550,7 @@ describe('lib/annotations/Annotator', () => { expect(annotator.getLocationFromEvent).to.be.called; expect(annotator.bindCustomListenersOnThread).to.not.be.called; - expect(annotator.togglePointModeHandler).to.be.called; + expect(annotator.togglePointAnnotationHandler).to.be.called; }); it('should create, show, and bind listeners to a thread', () => { @@ -476,7 +563,65 @@ describe('lib/annotations/Annotator', () => { expect(annotator.getLocationFromEvent).to.be.called; expect(annotator.bindCustomListenersOnThread).to.be.called; - expect(annotator.togglePointModeHandler).to.be.called; + expect(annotator.togglePointAnnotationHandler).to.be.called; + }); + }); + + describe('bindDrawModeListeners', () => { + it('should do nothing if neither a thread nor a post button is not provided', () => { + const drawingThread = { + handleStart: () => {}, + handleStop: () => { + bind: handleStop + }, + handleMove: () => { + bind: handleMove + } + }; + + sandbox.stub(drawingThread.handleStart, 'bind').returns(drawingThread.handleStart) + sandbox.stub(annotator, 'getLocationFromEvent'); + + annotator.bindDrawModeListeners(null, 'A real button'); + expect(annotator.getLocationFromEvent).to.not.be.called; + + annotator.bindDrawModeListeners(drawingThread, null); + expect(drawingThread.handleStart.bind).to.not.be.called; + }); + + it('should bind draw mode click handler', () => { + const drawingThread = { + handleStart: () => {}, + handleStop: () => {}, + handleMove: () => {} + }; + const postButtonEl = { + addEventListener: sandbox.stub(), + removeEventListener: sandbox.stub() + }; + const locationHandler = (() => {}); + + sandbox.stub(annotator.annotatedElement, 'addEventListener'); + sandbox.stub(annotator.annotatedElement, 'removeEventListener'); + sandbox.stub(annotator, 'isInDrawMode').returns(true); + sandbox.stub(drawingThread.handleStart, 'bind', () => drawingThread.pointClickHandler); + sandbox.stub(drawingThread.handleStop, 'bind', () => drawingThread.pointClickHandler); + sandbox.stub(drawingThread.handleMove, 'bind', () => drawingThread.pointClickHandler); + sandbox.stub(annotatorUtil, 'eventToLocationHandler').returns(locationHandler); + + annotator.bindDrawModeListeners(drawingThread, postButtonEl); + + expect(drawingThread.handleStart.bind).to.be.called; + expect(drawingThread.handleStop.bind).to.be.called; + expect(drawingThread.handleMove.bind).to.be.called; + expect(annotator.annotatedElement.addEventListener).to.be.calledWith( + sinon.match.string, + locationHandler + ).thrice; + expect(postButtonEl.addEventListener).to.be.calledWith( + 'click', + sinon.match.func + ); }); }); @@ -513,6 +658,16 @@ describe('lib/annotations/Annotator', () => { }); }); + describe('isInDrawMode', () => { + it('should return whether the annotator is in draw mode or not', () => { + annotator.annotatedElement.classList.add(CLASS_ANNOTATION_DRAW_MODE); + expect(annotator.isInDrawMode()).to.be.true; + + annotator.annotatedElement.classList.remove(CLASS_ANNOTATION_DRAW_MODE); + expect(annotator.isInDrawMode()).to.be.false; + }); + }); + describe('destroyPendingThreads', () => { beforeEach(() => { stubs.thread = { diff --git a/src/lib/annotations/__tests__/annotatorUtil-test.js b/src/lib/annotations/__tests__/annotatorUtil-test.js index 0b029ed06..86776dfcc 100644 --- a/src/lib/annotations/__tests__/annotatorUtil-test.js +++ b/src/lib/annotations/__tests__/annotatorUtil-test.js @@ -17,7 +17,8 @@ import { htmlEscape, repositionCaret, isPending, - validateThreadParams + validateThreadParams, + eventToLocationHandler } from '../annotatorUtil'; import { STATES, @@ -365,4 +366,38 @@ describe('lib/annotations/annotatorUtil', () => { expect(validateThreadParams(threadParams)).to.be.true; }); }); + + describe('eventToLocationHandler()', () => { + it('should not call the callback when the location is valid', () => { + const annotator = { + isChanged: false + } + const getLocation = ((event) => 'location'); + const callback = ((location) => { + annotator.isChanged = true + }); + const locationHandler = eventToLocationHandler(getLocation, callback); + + locationHandler(undefined); + expect(annotator.isChanged).to.be.false; + }); + + it('should call the callback when the location is valid', () => { + const annotator = { + isChanged: false + } + const getLocation = ((event) => 'location'); + const callback = ((location) => { + annotator.isChanged = true + }); + const locationHandler = eventToLocationHandler(getLocation, callback); + const event = { + preventDefault: () => {}, + stopPropagation: () => {} + }; + + locationHandler(event); + expect(annotator.isChanged).to.be.true; + }); + }); }); diff --git a/src/lib/annotations/annotationConstants.js b/src/lib/annotations/annotationConstants.js index 7903de518..fc79ed8ef 100644 --- a/src/lib/annotations/annotationConstants.js +++ b/src/lib/annotations/annotationConstants.js @@ -2,8 +2,9 @@ export const CLASS_ANNOTATION_BUTTON_CANCEL = 'cancel-annotation-btn'; export const CLASS_ANNOTATION_BUTTON_POST = 'post-annotation-btn'; export const CLASS_ANNOTATION_DIALOG = 'bp-annotation-dialog'; export const CLASS_ANNOTATION_HIGHLIGHT_DIALOG = 'bp-annotation-highlight-dialog'; -export const CLASS_ANNOTATION_POINT_BUTTON = 'bp-point-annotation-btn'; +export const CLASS_ANNOTATION_POINT_MARKER = 'bp-point-annotation-marker'; export const CLASS_ANNOTATION_POINT_MODE = 'bp-point-annotation-mode'; +export const CLASS_ANNOTATION_DRAW_MODE = 'bp-draw-annotation-mode'; export const CLASS_ANNOTATION_CARET = 'bp-annotation-caret'; export const CLASS_ANNOTATION_TEXTAREA = 'annotation-textarea'; export const CLASS_BUTTON_CONTAINER = 'button-container'; @@ -15,7 +16,12 @@ export const CLASS_TEXTAREA = 'bp-textarea'; export const CLASS_HIGHLIGHT_BTNS = 'bp-annotation-highlight-btns'; export const CLASS_ADD_HIGHLIGHT_BTN = 'bp-add-highlight-btn'; export const CLASS_ADD_HIGHLIGHT_COMMENT_BTN = 'bp-highlight-comment-btn'; -export const CLASS_ANNOTATION_LAYER = 'bp-annotation-layer'; +export const CLASS_ANNOTATION_LAYER_HIGHLIGHT = 'bp-annotation-layer-highlight'; +export const CLASS_ANNOTATION_LAYER_DRAW = 'bp-annotation-layer-draw'; +export const CLASS_ANNOTATION_BUTTON_POINT = 'bp-btn-annotate-point'; +export const CLASS_ANNOTATION_BUTTON_DRAW_POST = 'bp-btn-annotate-draw-post'; +export const CLASS_ANNOTATION_BUTTON_DRAW_CANCEL = 'bp-btn-annotate-draw-cancel'; +export const CLASS_ANNOTATION_BUTTON_DRAW_ENTER = 'bp-btn-annotate-draw-enter'; export const DATA_TYPE_ANNOTATION_DIALOG = 'annotation-dialog'; export const DATA_TYPE_ANNOTATION_INDICATOR = 'annotation-indicator'; @@ -23,11 +29,15 @@ export const DATA_TYPE_ANNOTATION_INDICATOR = 'annotation-indicator'; export const SECTION_CREATE = '[data-section="create"]'; export const SECTION_SHOW = '[data-section="show"]'; +export const SELECTOR_ANNOTATION_BUTTON_POINT = `.${CLASS_ANNOTATION_BUTTON_POINT}`; +export const SELECTOR_ANNOTATION_BUTTON_DRAW_POST = `.${CLASS_ANNOTATION_BUTTON_DRAW_POST}`; +export const SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL = `.${CLASS_ANNOTATION_BUTTON_DRAW_CANCEL}`; +export const SELECTOR_ANNOTATION_BUTTON_DRAW_ENTER = `.${CLASS_ANNOTATION_BUTTON_DRAW_ENTER}`; export const SELECTOR_ANNOTATION_BUTTON_CANCEL = `.${CLASS_ANNOTATION_BUTTON_CANCEL}`; export const SELECTOR_ANNOTATION_BUTTON_POST = `.${CLASS_ANNOTATION_BUTTON_POST}`; export const SELECTOR_ANNOTATION_DIALOG = `.${CLASS_ANNOTATION_DIALOG}`; export const SELECTOR_ANNOTATION_HIGHLIGHT_DIALOG = `.${CLASS_ANNOTATION_HIGHLIGHT_DIALOG}`; -export const SELECTOR_ANNOTATION_POINT_BUTTON = `.${CLASS_ANNOTATION_POINT_BUTTON}`; +export const SELECTOR_ANNOTATION_POINT_BUTTON = `.${CLASS_ANNOTATION_POINT_MARKER}`; export const SELECTOR_ANNOTATION_POINT_MODE = `.${CLASS_ANNOTATION_POINT_MODE}`; export const SELECTOR_ANNOTATION_CARET = `.${CLASS_ANNOTATION_CARET}`; export const SELECTOR_ANNOTATION_TEXTAREA = `.${CLASS_ANNOTATION_TEXTAREA}`; @@ -39,6 +49,12 @@ export const SELECTOR_DIALOG_CLOSE = `.${CLASS_DIALOG_CLOSE}`; export const SELECTOR_HIGHLIGHT_BTNS = `.${CLASS_HIGHLIGHT_BTNS}`; export const SELECTOR_ADD_HIGHLIGHT_BTN = `.${CLASS_ADD_HIGHLIGHT_BTN}`; +export const STATES_DRAW = { + draw: 'draw', + idle: 'idle', + erase: 'erase' +}; + export const STATES = { hover: 'hover', // mouse is over inactive: 'inactive', // not clicked and mouse is not over @@ -50,6 +66,7 @@ export const PENDING_STATES = [STATES.pending, STATES.pending_active]; export const TYPES = { point: 'point', highlight: 'highlight', + draw: 'draw', highlight_comment: 'highlight-comment' }; @@ -61,3 +78,5 @@ export const HIGHLIGHT_FILL = { export const PAGE_PADDING_TOP = 15; export const PAGE_PADDING_BOTTOM = 15; + +export const DRAW_RENDER_THRESHOLD = 16.67; // 60 FPS target using 16.667ms/frame diff --git a/src/lib/annotations/annotatorUtil.js b/src/lib/annotations/annotatorUtil.js index 804912d24..5070db25a 100644 --- a/src/lib/annotations/annotatorUtil.js +++ b/src/lib/annotations/annotatorUtil.js @@ -356,3 +356,26 @@ export function validateThreadParams(thread) { } return false; } + +/** + * Returns a function that passes a callback a location when given an event + * + * @param {Function} locationFunction - The function to get a location from an event + * @param {Function} callback - Callback to be called upon receiving an event + * @return {Function} Event listener to convert to document location + */ +export function eventToLocationHandler(locationFunction, callback) { + return (event) => { + const evt = event || window.event; + if (!evt) { + return; + } + + evt.stopPropagation(); + evt.preventDefault(); + const location = locationFunction(evt); + if (location) { + callback(location); + } + }; +} diff --git a/src/lib/annotations/doc/DocAnnotator.js b/src/lib/annotations/doc/DocAnnotator.js index b03b8a507..246c8e0d8 100644 --- a/src/lib/annotations/doc/DocAnnotator.js +++ b/src/lib/annotations/doc/DocAnnotator.js @@ -9,6 +9,7 @@ import autobind from 'autobind-decorator'; import Annotator from '../Annotator'; import DocHighlightThread from './DocHighlightThread'; import DocPointThread from './DocPointThread'; +import DocDrawingThread from './DocDrawingThread'; import CreateHighlightDialog, { CreateEvents } from './CreateHighlightDialog'; import * as annotatorUtil from '../annotatorUtil'; import * as docAnnotatorUtil from './docAnnotatorUtil'; @@ -19,7 +20,7 @@ import { DATA_TYPE_ANNOTATION_INDICATOR, PAGE_PADDING_TOP, PAGE_PADDING_BOTTOM, - CLASS_ANNOTATION_LAYER, + CLASS_ANNOTATION_LAYER_HIGHLIGHT, PENDING_STATES } from '../annotationConstants'; @@ -294,8 +295,12 @@ class DocAnnotator extends Annotator { if (annotatorUtil.isHighlightAnnotation(type)) { thread = new DocHighlightThread(threadParams); - } else { + } else if (type === TYPES.draw) { + thread = new DocDrawingThread(threadParams); + } else if (type === TYPES.point) { thread = new DocPointThread(threadParams); + } else { + throw new Error(`DocAnnotator: Unknown Annotation Type: ${type}`); } this.addThreadToMap(thread); @@ -830,7 +835,7 @@ class DocAnnotator extends Annotator { showHighlightsOnPage(page) { // Clear context if needed const pageEl = this.annotatedElement.querySelector(`[data-page-number="${page}"]`); - const annotationLayerEl = pageEl.querySelector(`.${CLASS_ANNOTATION_LAYER}`); + const annotationLayerEl = pageEl.querySelector(`.${CLASS_ANNOTATION_LAYER_HIGHLIGHT}`); if (annotationLayerEl) { const context = annotationLayerEl.getContext('2d'); context.clearRect(0, 0, annotationLayerEl.width, annotationLayerEl.height); diff --git a/src/lib/annotations/doc/DocDrawingThread.js b/src/lib/annotations/doc/DocDrawingThread.js new file mode 100644 index 000000000..bd7fb5dee --- /dev/null +++ b/src/lib/annotations/doc/DocDrawingThread.js @@ -0,0 +1,93 @@ +import { + STATES_DRAW, + PAGE_PADDING_TOP, + PAGE_PADDING_BOTTOM, + CLASS_ANNOTATION_LAYER_DRAW +} from '../annotationConstants'; +import { getScale } from '../annotatorUtil'; +import DrawingPath from '../drawing/DrawingPath'; +import DrawingThread from '../drawing/DrawingThread'; +import * as docAnnotatorUtil from './docAnnotatorUtil'; + +class DocDrawingThread extends DrawingThread { + /** @property {number} - Drawing state */ + lastPage; + + /** @property {HTMLElement} - Page element being observed */ + pageEl; + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * Handle a pointer movement + * + * @param {Object} location - The location information of the pointer + * @return {void} + */ + handleMove(location) { + const pageChanged = this.lastPage && this.lastPage !== location.page; + + this.lastPage = location.page; + if (!this.pageEl || pageChanged) { + this.pageEl = docAnnotatorUtil.getPageEl(this.annotatedElement, location.page); + if (pageChanged) { + this.handleStop(location); + } + return; + } + + const [x, y] = docAnnotatorUtil.getBrowserCoordinatesFromLocation(location, this.pageEl); + if (this.drawingFlag === STATES_DRAW.draw) { + this.pendingPath.addCoordinate(x, y); + + // Cancel any pending animation to a new request. + if (this.lastAnimationRequestId) { + window.cancelAnimationFrame(this.lastAnimationRequestId); + } + // Keep animating while the drawing flag is down + this.lastAnimationRequestId = window.requestAnimationFrame(this.render); + } + } + + /** + * Start a drawing stroke + * + * @return {void} + */ + handleStart() { + this.drawingFlag = STATES_DRAW.draw; + const scale = getScale(this.annotatedElement); + const context = docAnnotatorUtil.getContext( + this.pageEl, + CLASS_ANNOTATION_LAYER_DRAW, + PAGE_PADDING_TOP, + PAGE_PADDING_BOTTOM + ); + + if (!this.pendingPath) { + this.pendingPath = new DrawingPath(); + } + + if (!this.drawingContext || context !== this.drawingContext) { + this.drawingContext = context; + this.setContextStyles(scale); + } + } + + /** + * End a drawing stroke + * + * @return {void} + */ + handleStop() { + this.drawingFlag = STATES_DRAW.idle; + + if (this.pendingPath && !this.pendingPath.isEmpty()) { + this.pathContainer.insert(this.pendingPath); + this.pendingPath = null; + } + } +} + +export default DocDrawingThread; diff --git a/src/lib/annotations/doc/DocHighlightThread.js b/src/lib/annotations/doc/DocHighlightThread.js index 020e97bc2..8f6cc1b85 100644 --- a/src/lib/annotations/doc/DocHighlightThread.js +++ b/src/lib/annotations/doc/DocHighlightThread.js @@ -6,13 +6,13 @@ import * as docAnnotatorUtil from './docAnnotatorUtil'; import { STATES, TYPES, - CLASS_ANNOTATION_LAYER, SELECTOR_ADD_HIGHLIGHT_BTN, - HIGHLIGHT_FILL + HIGHLIGHT_FILL, + CLASS_ANNOTATION_LAYER_HIGHLIGHT, + PAGE_PADDING_TOP, + PAGE_PADDING_BOTTOM } from '../annotationConstants'; -const PAGE_PADDING_BOTTOM = 15; -const PAGE_PADDING_TOP = 15; const HOVER_TIMEOUT_MS = 75; @autobind @@ -369,12 +369,18 @@ class DocHighlightThread extends AnnotationThread { */ /* istanbul ignore next */ draw(fillStyle) { - const context = this.getContext(); + const pageEl = this.getPageEl(); + const context = docAnnotatorUtil.getContext( + pageEl, + CLASS_ANNOTATION_LAYER_HIGHLIGHT, + PAGE_PADDING_TOP, + PAGE_PADDING_BOTTOM + ); if (!context) { return; } - const pageDimensions = this.getPageEl().getBoundingClientRect(); + const pageDimensions = pageEl.getBoundingClientRect(); const pageHeight = pageDimensions.height - PAGE_PADDING_TOP - PAGE_PADDING_BOTTOM; const zoomScale = annotatorUtil.getScale(this.annotatedElement); const dimensionScale = annotatorUtil.getDimensionScale( @@ -502,39 +508,10 @@ class DocHighlightThread extends AnnotationThread { */ getPageEl() { if (!this.pageEl) { - this.pageEl = this.annotatedElement.querySelector(`[data-page-number="${this.location.page}"]`); + this.pageEl = docAnnotatorUtil.getPageEl(this.annotatedElement, this.location.page); } - return this.pageEl; } - - /** - * Gets the context this highlight should be drawn on. - * - * @private - * @return {RenderingContext|null} Context - */ - getContext() { - // Create annotation layer if one does not exist (e.g. first load or page resize) - const pageEl = this.getPageEl(); - if (!pageEl) { - return null; - } - - let annotationLayerEl = pageEl.querySelector(`.${CLASS_ANNOTATION_LAYER}`); - if (!annotationLayerEl) { - annotationLayerEl = document.createElement('canvas'); - annotationLayerEl.classList.add(CLASS_ANNOTATION_LAYER); - const pageDimensions = pageEl.getBoundingClientRect(); - annotationLayerEl.width = pageDimensions.width; - annotationLayerEl.height = pageDimensions.height - PAGE_PADDING_TOP - PAGE_PADDING_BOTTOM; - - const textLayerEl = pageEl.querySelector('.textLayer'); - pageEl.insertBefore(annotationLayerEl, textLayerEl); - } - - return annotationLayerEl.getContext('2d'); - } } export default DocHighlightThread; diff --git a/src/lib/annotations/doc/__tests__/DocAnnotator-test.js b/src/lib/annotations/doc/__tests__/DocAnnotator-test.js index 2fab3442d..aaaa2bdea 100644 --- a/src/lib/annotations/doc/__tests__/DocAnnotator-test.js +++ b/src/lib/annotations/doc/__tests__/DocAnnotator-test.js @@ -6,6 +6,7 @@ import AnnotationThread from '../../AnnotationThread'; import Browser from '../../../Browser'; import DocAnnotator from '../DocAnnotator'; import DocHighlightThread from '../DocHighlightThread'; +import DocDrawingThread from '../DocDrawingThread'; import DocPointThread from '../DocPointThread'; import * as annotatorUtil from '../../annotatorUtil'; import * as docAnnotatorUtil from '../docAnnotatorUtil'; @@ -271,6 +272,13 @@ describe('lib/annotations/doc/DocAnnotator', () => { expect(annotator.handleValidationError).to.not.be.called; }); + it('should create, add drawing thread to internal map, and return it', () => { + const thread = annotator.createAnnotationThread([], {}, TYPES.draw); + expect(stubs.addThread).to.have.been.called; + expect(thread instanceof DocDrawingThread).to.be.true; + expect(annotator.handleValidationError).to.not.be.called; + }); + it('should emit error and return undefined if thread params are invalid', () => { stubs.validateThread.returns(false); sandbox.stub(annotator, 'emit'); diff --git a/src/lib/annotations/doc/__tests__/DocDrawingThread-test.html b/src/lib/annotations/doc/__tests__/DocDrawingThread-test.html new file mode 100644 index 000000000..5cd0158bb --- /dev/null +++ b/src/lib/annotations/doc/__tests__/DocDrawingThread-test.html @@ -0,0 +1 @@ +
diff --git a/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js b/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js new file mode 100644 index 000000000..40de53cf0 --- /dev/null +++ b/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js @@ -0,0 +1,109 @@ +import * as docAnnotatorUtil from '../docAnnotatorUtil'; +import * as annotatorUtil from '../../annotatorUtil'; +import DocDrawingThread from '../DocDrawingThread'; +import DrawingPath from '../../drawing/DrawingPath'; +import { + STATES_DRAW +} from '../../annotationConstants'; + +let docDrawingThread; +const sandbox = sinon.sandbox.create(); + +describe('lib/annotations/doc/DocDrawingThread', () => { + before(() => { + fixture.setBase('src/lib'); + }); + + beforeEach(() => { + fixture.load('annotations/doc/__tests__/DocDrawingThread-test.html'); + docDrawingThread = new DocDrawingThread({}); + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + docDrawingThread.destroy(); + docDrawingThread = null; + }); + + describe('handleMove()', () => { + beforeEach(() => { + docDrawingThread.pageEl = document.querySelector('.page-element'); + docDrawingThread.lastPage = docDrawingThread.pageEl.getAttribute('page'); + docDrawingThread.pendingPath = { + addCoordinate: sandbox.stub(), + isEmpty: sandbox.stub() + }; + + sandbox.stub(window, 'requestAnimationFrame'); + sandbox.stub(docAnnotatorUtil, 'getPageEl') + .returns(docDrawingThread.pageEl); + sandbox.stub(docAnnotatorUtil, 'getBrowserCoordinatesFromLocation') + .returns([location.x, location.y]); + }); + + it("should not request an animation frame when the state is not 'draw'", () => { + const location = { + x: 0, + y: 0, + page: docDrawingThread.lastPage + }; + docDrawingThread.drawingFlag = STATES_DRAW.idle; + docDrawingThread.handleMove(location); + + expect(window.requestAnimationFrame).to.not.be.called; + }); + + it("should request an animation frame when the state is 'draw'", () => { + const location = { + x: 0, + y: 0, + page: docDrawingThread.lastPage + }; + docDrawingThread.drawingFlag = STATES_DRAW.draw; + docDrawingThread.handleMove(location); + + expect(window.requestAnimationFrame).to.be.called; + }); + }); + + + describe('handleStart()', () => { + it('should set the drawingFlag, pendingPath, and context if they do not exist', () => { + const context = "I'm a real context"; + + sandbox.stub(annotatorUtil, 'getScale'); + sandbox.stub(docDrawingThread, 'setContextStyles'); + sandbox.stub(docAnnotatorUtil, 'getContext') + .returns(context); + + docDrawingThread.drawingFlag = STATES_DRAW.idle; + docDrawingThread.pendingPath = undefined; + docDrawingThread.context = undefined; + docDrawingThread.handleStart(); + + expect(docDrawingThread.drawingFlag).to.equal(STATES_DRAW.draw); + expect(docDrawingThread.drawingContext).to.equal(context); + expect(docDrawingThread.pendingPath).to.be.an.instanceof(DrawingPath); + expect(annotatorUtil.getScale).to.be.called; + expect(docAnnotatorUtil.getContext).to.be.called; + expect(docDrawingThread.setContextStyles).to.be.called; + }); + }); + + describe('handleStop()', () => { + it("should set the state to 'idle' and clear the pendingPath", () => { + docDrawingThread.drawingFlag = STATES_DRAW.draw; + docDrawingThread.pendingPath = { + isEmpty: () => false + }; + docDrawingThread.pathContainer = { + insert: sandbox.stub() + } + + docDrawingThread.handleStop(); + + expect(docDrawingThread.drawingFlag).to.equal(STATES_DRAW.idle); + expect(docDrawingThread.pendingPath).to.be.null; + }); + }); +}); diff --git a/src/lib/annotations/doc/__tests__/DocHighlightThread-test.js b/src/lib/annotations/doc/__tests__/DocHighlightThread-test.js index 52d8bbcbc..009b9fe3b 100644 --- a/src/lib/annotations/doc/__tests__/DocHighlightThread-test.js +++ b/src/lib/annotations/doc/__tests__/DocHighlightThread-test.js @@ -432,9 +432,12 @@ describe('lib/annotations/doc/DocHighlightThread', () => { describe('draw()', () => { it('should not draw if no context exists', () => { sandbox.stub(highlightThread, 'getPageEl'); - sandbox.stub(highlightThread, 'getContext'); + sandbox.stub(docAnnotatorUtil, 'getContext').returns(null); + sandbox.stub(annotatorUtil, 'getScale'); + highlightThread.draw('fill'); - expect(highlightThread.getPageEl).to.not.be.called; + expect(highlightThread.pageEl).to.be.undefined; + expect(annotatorUtil.getScale).to.not.be.called; }); }); @@ -501,71 +504,4 @@ describe('lib/annotations/doc/DocHighlightThread', () => { expect(pointInPolyStub).to.be.called; }); }); - - describe('getPageEl()', () => { - it('should return the result of querySelector', () => { - const queryStub = sandbox.stub(highlightThread.annotatedElement, 'querySelector'); - - highlightThread.getPageEl(); - expect(queryStub).to.be.called; - }); - }); - - describe('getContext()', () => { - it('should return null if there is no pageEl', () => { - const pageElStub = sandbox.stub(highlightThread, 'getPageEl').returns(false); - const result = highlightThread.getContext(); - - expect(pageElStub).to.be.called; - expect(result).to.equal(null); - }); - - it('should not insert the pageEl if the annotationLayerEl already exists', () => { - const pageEl = { - querySelector: sandbox.stub(), - getBoundingClientRect: sandbox.stub(), - insertBefore: sandbox.stub() - }; - const annotationLayer = { - width: 0, - height: 0, - getContext: sandbox.stub() - }; - const pageElStub = sandbox.stub(highlightThread, 'getPageEl').returns(pageEl); - pageEl.querySelector.returns(annotationLayer); - annotationLayer.getContext.returns('2d context'); - - highlightThread.getContext(); - expect(pageElStub).to.be.called; - expect(annotationLayer.getContext).to.be.called; - expect(pageEl.insertBefore).to.not.be.called; - }); - - it('should insert the pageEl if the annotationLayerEl does not exist', () => { - const pageEl = { - querySelector: sandbox.stub(), - getBoundingClientRect: sandbox.stub(), - insertBefore: sandbox.stub() - }; - const annotationLayer = { - width: 0, - height: 0, - getContext: sandbox.stub(), - classList: { - add: sandbox.stub() - } - }; - const pageElStub = sandbox.stub(highlightThread, 'getPageEl').returns(pageEl); - pageEl.querySelector.returns(undefined); - const docStub = sandbox.stub(document, 'createElement').returns(annotationLayer); - annotationLayer.getContext.returns('2d context'); - pageEl.getBoundingClientRect.returns({ width: 0, height: 0 }); - - highlightThread.getContext(); - expect(pageElStub).to.be.called; - expect(docStub).to.be.called; - expect(annotationLayer.getContext).to.be.called; - expect(pageEl.insertBefore).to.be.called; - }); - }); }); diff --git a/src/lib/annotations/doc/__tests__/docAnnotatorUtil-test.js b/src/lib/annotations/doc/__tests__/docAnnotatorUtil-test.js index a5a82af1f..1498382bd 100644 --- a/src/lib/annotations/doc/__tests__/docAnnotatorUtil-test.js +++ b/src/lib/annotations/doc/__tests__/docAnnotatorUtil-test.js @@ -9,7 +9,9 @@ import { convertPDFSpaceToDOMSpace, convertDOMSpaceToPDFSpace, getBrowserCoordinatesFromLocation, - getLowerRightCornerOfLastQuadPoint + getLowerRightCornerOfLastQuadPoint, + getContext, + getPageEl } from '../docAnnotatorUtil'; import { SELECTOR_ANNOTATION_DIALOG, @@ -17,6 +19,8 @@ import { CLASS_ANNOTATION_DIALOG } from '../../annotationConstants'; +const sandbox = sinon.sandbox.create(); + describe('lib/annotations/doc/docAnnotatorUtil', () => { before(() => { fixture.setBase('src/lib'); @@ -27,6 +31,7 @@ describe('lib/annotations/doc/docAnnotatorUtil', () => { }); afterEach(() => { + sandbox.verifyAndRestore(); fixture.cleanup(); }); @@ -174,4 +179,64 @@ describe('lib/annotations/doc/docAnnotatorUtil', () => { assert.equal(getLowerRightCornerOfLastQuadPoint(quadPoints).toString(), [10, 0].toString()); }); + + + describe('getContext()', () => { + it('should return null if there is no pageEl', () => { + const result = getContext(null, 'random-class-name', 0, 0); + expect(result).to.equal(null); + }); + + it('should not insert into the pageEl if the annotationLayerEl already exists', () => { + const annotationLayer = { + width: 0, + height: 0, + getContext: sandbox.stub().returns('2d context') + }; + const pageEl = { + querySelector: sandbox.stub().returns(annotationLayer), + getBoundingClientRect: sandbox.stub(), + insertBefore: sandbox.stub() + }; + + getContext(pageEl, 'random-class-name'); + expect(annotationLayer.getContext).to.be.called; + expect(pageEl.insertBefore).to.not.be.called; + }); + + it('should insert into the pageEl if the annotationLayerEl does not exist', () => { + const annotationLayer = { + width: 0, + height: 0, + getContext: sandbox.stub().returns('2d context'), + classList: { + add: sandbox.stub() + } + }; + const pageEl = { + querySelector: sandbox.stub().returns(undefined), + getBoundingClientRect: sandbox.stub().returns({ width: 0, height: 0 }), + insertBefore: sandbox.stub() + }; + const docStub = sandbox.stub(document, 'createElement').returns(annotationLayer); + + getContext(pageEl, 'random-class-name', 0, 0); + expect(docStub).to.be.called; + expect(annotationLayer.getContext).to.be.called; + expect(annotationLayer.classList.add).to.be.called; + expect(pageEl.insertBefore).to.be.called; + }); + }); + + describe('getPageEl()', () => { + it('should return the result of querySelector', () => { + const page = 2; + const docEl = document.querySelector('.annotatedElement'); + const truePageEl = document.querySelector(`.page[data-page-number="${page}"]`); + docEl.appendChild(truePageEl); + + const pageEl = getPageEl(docEl, page); + assert.equal(pageEl, truePageEl); + }); + }); }); diff --git a/src/lib/annotations/doc/docAnnotatorUtil.js b/src/lib/annotations/doc/docAnnotatorUtil.js index a89080c50..6307c2a05 100644 --- a/src/lib/annotations/doc/docAnnotatorUtil.js +++ b/src/lib/annotations/doc/docAnnotatorUtil.js @@ -315,3 +315,47 @@ export function getLowerRightCornerOfLastQuadPoint(quadPoints) { const [x1, y1, x2, y2, x3, y3, x4, y4] = quadPoints[quadPoints.length - 1]; return [Math.max(x1, x2, x3, x4), Math.min(y1, y2, y3, y4)]; } + +/** + * Gets the context an annotation should be drawn on. + * + * @param {HTMLElement} pageEl The DOM element for the current page + * @param {string} annotationLayerClass The class name for the annotation layer + * @param {number} [paddingTop] The top padding of each page element + * @param {number} [paddingBottom] The bottom padding of each page element + * @return {RenderingContext|null} Context or null if no page element was given + */ +export function getContext(pageEl, annotationLayerClass, paddingTop, paddingBottom) { + if (!pageEl) { + return null; + } + + let annotationLayerEl = pageEl.querySelector(`.${annotationLayerClass}`); + // Create annotation layer if one does not exist (e.g. first load or page resize) + if (!annotationLayerEl) { + annotationLayerEl = document.createElement('canvas'); + annotationLayerEl.classList.add(annotationLayerClass); + const pageDimensions = pageEl.getBoundingClientRect(); + const pagePaddingTop = paddingTop || 0; + const pagePaddingBottom = paddingBottom || 0; + annotationLayerEl.width = pageDimensions.width; + annotationLayerEl.height = pageDimensions.height - pagePaddingTop - pagePaddingBottom; + + const textLayerEl = pageEl.querySelector('.textLayer'); + pageEl.insertBefore(annotationLayerEl, textLayerEl); + } + + return annotationLayerEl.getContext('2d'); +} + +/** + * Gets the current page element. + * + * @private + * @param {HTMLElement} annotatedEl HTML Element being annotated on + * @param {number} pageNum Page number + * @return {HTMLElement|null} Page element if it exists, otherwise null + */ +export function getPageEl(annotatedEl, pageNum) { + return annotatedEl.querySelector(`[data-page-number="${pageNum}"]`); +} diff --git a/src/lib/annotations/drawing/DrawingPath.js b/src/lib/annotations/drawing/DrawingPath.js new file mode 100644 index 000000000..6ae805652 --- /dev/null +++ b/src/lib/annotations/drawing/DrawingPath.js @@ -0,0 +1,104 @@ +class DrawingPath { + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** @property {Array} - The array of coordinates that form the path */ + path = []; + + /** @property {number} - The maximum X position of all coordinates */ + maxX = -Infinity; + + /** @property {number} - The maximum Y position of all coordinates */ + maxY = -Infinity; + + /** @property {number} - The minimum X position of all coordinates */ + minX = Infinity; + + /** @property {number} - The minimum Y position of all coordinates */ + minY = Infinity; + + /** + * Add position to coordinates and update the bounding box + * + * @param {number} xPos - xPosition to be part of the drawing + * @param {number} yPos - yPosition to be part of the drawing + * @return {void} + */ + addCoordinate(xPos, yPos) { + if (!xPos || !yPos) { + return; + } + + // OPTIMIZE (@minhnguyen): We convert a number to a string using toFixed and then back a number. + // As a result, it might be better to truncate only on annotation save. + const x = parseFloat(xPos.toFixed(2)); + const y = parseFloat(yPos.toFixed(2)); + + if (x < this.minX) { + this.minX = x; + } + + if (y < this.minY) { + this.minY = y; + } + + if (y > this.maxY) { + this.maxY = y; + } + + if (x > this.maxX) { + this.maxX = x; + } + + this.path.push({ + x, + y + }); + } + + /** + * Determine if any coordinates are contained in the DrawingPath + * + * @return {boolean} Whether or not any coordinates have been recorded + */ + isEmpty() { + return this.path.length === 0; + } + + /** + * Draw the recorded coordinates onto a CanvasContext + * + * @param {CanvasContext} drawingContext - Context to draw the recorded path on + * @return {void} + */ + drawPath(drawingContext) { + const ctx = drawingContext; + if (!ctx) { + return; + } + + ctx.beginPath(); + const pathLen = this.path.length; + for (let i = 0; i < pathLen; i++) { + let xLast; + let yLast; + + if (i > 0) { + xLast = this.path[i - 1].x; + yLast = this.path[i - 1].y; + } else { + xLast = this.path[i].x; + yLast = this.path[i].y; + ctx.moveTo(xLast, yLast); + } + + const xMid = (this.path[i].x + xLast) / 2; + const yMid = (this.path[i].y + yLast) / 2; + ctx.quadraticCurveTo(xLast, yLast, xMid, yMid); + } + + ctx.stroke(); + } +} + +export default DrawingPath; diff --git a/src/lib/annotations/drawing/DrawingThread.js b/src/lib/annotations/drawing/DrawingThread.js new file mode 100644 index 000000000..87d3ae21f --- /dev/null +++ b/src/lib/annotations/drawing/DrawingThread.js @@ -0,0 +1,175 @@ +/* global Rbush */ +import AnnotationThread from '../AnnotationThread'; +import { STATES_DRAW, DRAW_RENDER_THRESHOLD } from '../annotationConstants'; + +const RTREE_WIDTH = 5; // Lower number - faster search, higher - faster insert +const BASE_LINE_WIDTH = 3; + +class DrawingThread extends AnnotationThread { + /** @property {number} - Drawing state */ + drawingFlag = STATES_DRAW.idle; + + /** @property {Rbush} - Rtree path container */ + pathContainer = new Rbush(RTREE_WIDTH); + + /** @property {CanvasContext} - A canvas for drawing new strokes */ + memoryCanvas; + + /** @property {DrawingPath} - The path being drawn but not yet finalized */ + pendingPath; + + /** @property {CanvasContext} - The context to be drawn on */ + drawingContext; + + /** @property {number} - Timestamp of the last render */ + lastRenderTimestamp; + + /** @property {number} - The the last animation frame request id */ + lastAnimationRequestId; + + /** + * [constructor] + * + * @inheritdoc + * @param {AnnotationThreadData} data - Data for constructing thread + * @return {DrawingThread} Drawing annotation thread instance + */ + constructor(data) { + super(data); + this.render = this.render.bind(this); + } + + /** + * Soft destructor for a drawingthread object + * + * [destructor] + * @inheritdoc + * @return {void} + */ + destroy() { + if (this.lastAnimationRequestId) { + window.cancelAnimationFrame(this.lastAnimationRequestId); + } + + this.removeAllListeners(); + this.reset(); + super.destroy(); + } + + /** + * Get all of the DrawingPaths in the current thread. + * + * @return {void} + */ + getDrawings() { + return this.pathContainer.all(); + } + + /* eslint-disable no-unused-vars */ + /** + * Handle a pointer movement + * + * @param {Object} location - The location information of the pointer + * @return {void} + */ + handleMove(location) {} + + /** + * Start a drawing stroke * + * + * @param {Object} location - The location information of the pointer + * @return {void} + */ + handleStart(location) {} + + /** + * End a drawing stroke + * + * @param {Object} location - The location information of the pointer + * @return {void} + */ + handleStop(location) {} + /* eslint-disable no-unused-vars */ + + //-------------------------------------------------------------------------- + // Protected + //-------------------------------------------------------------------------- + + /** + * Set the drawing styles + * + * @protected + * @param {Object} config - The configuration Object + * @param {number} config.scale - The document scale + * @param {string} config.color - The brush color + * @return {void} + */ + setContextStyles(config) { + if (!this.drawingContext) { + return; + } + const { scale, color } = config; + + this.drawingContext.lineCap = 'round'; + this.drawingContext.lineJoin = 'round'; + this.drawingContext.strokeStyle = color || 'black'; + this.drawingContext.lineWidth = BASE_LINE_WIDTH * (scale || 1); + } + + /** + * Draw the pending path onto the DrawingThread CanvasContext. Should be used + * in conjunction with requestAnimationFrame. + * + * @protected + * @param {number} timestamp - The time when the function was called; + * @return {void} + */ + render(timestamp) { + const elapsed = timestamp - (this.lastRenderTimestamp || 0); + if (elapsed < DRAW_RENDER_THRESHOLD || !this.drawingContext) { + return; + } + + this.lastRenderTimestamp = timestamp; + const canvas = this.drawingContext.canvas; + const drawings = this.getDrawings(); + + /* OPTIMIZE (@minhnguyen): Render only what has been obstructed by the new drawing + * rather than every single line in the thread. If we do end + * up splitting saves into multiple requests, we can buffer + * the amount of re-renders onto a temporary memory canvas. + */ + this.drawingContext.clearRect(0, 0, canvas.width, canvas.height); + drawings.forEach((drawing) => drawing.drawPath(this.drawingContext)); + + if (this.pendingPath) { + this.pendingPath.drawPath(context); + } + } + + //-------------------------------------------------------------------------- + // Protected + //-------------------------------------------------------------------------- + + /** + * Create an annotation data object to pass to annotation service. + * + * @inheritdoc + * @private + * @param {string} type - Type of annotation + * @param {string} text - Annotation text + * @return {Object} Annotation data + */ + createAnnotationData(type, text) { + return { + type, + drawingPaths: this.getDrawings(), + fileVersionId: this.fileVersionId, + user: this.annotationService.user, + threadID: this.threadID, + thread: this.thread + }; + } +} + +export default DrawingThread; diff --git a/src/lib/annotations/drawing/__tests__/DrawingPath-test.js b/src/lib/annotations/drawing/__tests__/DrawingPath-test.js new file mode 100644 index 000000000..306525f23 --- /dev/null +++ b/src/lib/annotations/drawing/__tests__/DrawingPath-test.js @@ -0,0 +1,91 @@ +import DrawingPath from '../DrawingPath'; + +let drawingPath; +const sandbox = sinon.sandbox.create(); + +describe('lib/annotations/drawing/DrawingPath', () => { + before(() => { + fixture.setBase('src/lib'); + }); + + beforeEach(() => { + drawingPath = new DrawingPath(); + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + drawingPath = null; + }); + + describe('addCoordinate()', () => { + it('should do nothing if x or y is empty', () => { + const lengthBefore = drawingPath.path.length; + drawingPath.addCoordinate(null, 2); + drawingPath.addCoordinate(2, null); + const lengthAfter = drawingPath.path.length; + + expect(lengthAfter).to.equal(lengthBefore); + }); + + it('should insert the new coordinate into its path container', () => { + const lengthBefore = drawingPath.path.length; + drawingPath.addCoordinate(1,2); + const lengthAfter = drawingPath.path.length; + + expect(lengthAfter).to.equal(lengthBefore + 1); + expect(drawingPath.path[lengthAfter - 1]).to.deep.equal({ + x: 1, + y: 2 + }); + }); + + it('should update the bounding rectangle', () => { + const rectBounds = { + x1: 1, + x2: 5, + y1: 2, + y2: 6 + }; + + drawingPath.addCoordinate(rectBounds.x1, rectBounds.y1); + drawingPath.addCoordinate(rectBounds.x2, rectBounds.y2); + + expect(drawingPath.minY).to.equal(rectBounds.y1); + expect(drawingPath.minX).to.equal(rectBounds.x1); + expect(drawingPath.maxY).to.equal(rectBounds.y2); + expect(drawingPath.maxX).to.equal(rectBounds.x2); + }); + }); + + describe('isEmpty()', () => { + it('should return true when nothing has been inserted', () => { + expect(drawingPath.isEmpty()).to.be.true; + }); + + + it('should return false when a coordinate has been inserted', () => { + drawingPath.addCoordinate(1,1); + expect(drawingPath.isEmpty()).to.be.false; + }); + + }); + + describe('drawPath()', () => { + it('should call context->quadraticCurveTo and stroke when there are coordinates', () => { + const context = { + quadraticCurveTo: sandbox.stub(), + beginPath: sandbox.stub(), + stroke: sandbox.stub(), + moveTo: sandbox.stub() + }; + + drawingPath.addCoordinate(1,1); + drawingPath.drawPath(context); + + expect(context.quadraticCurveTo).to.be.called; + expect(context.beginPath).to.be.called; + expect(context.stroke).to.be.called; + expect(context.moveTo).to.be.called; + }); + }) +}); diff --git a/src/lib/annotations/drawing/__tests__/DrawingThread-test.js b/src/lib/annotations/drawing/__tests__/DrawingThread-test.js new file mode 100644 index 000000000..6b222112f --- /dev/null +++ b/src/lib/annotations/drawing/__tests__/DrawingThread-test.js @@ -0,0 +1,154 @@ +import DrawingThread from '../DrawingThread'; +import AnnotationService from '../../AnnotationService'; +import { + STATES +} from '../../annotationConstants' + +let drawingThread; +const sandbox = sinon.sandbox.create(); + +describe('lib/annotations/drawing/DrawingThread', () => { + before(() => { + fixture.setBase('src/lib'); + }); + + beforeEach(() => { + drawingThread = new DrawingThread({ + annotatedElement: document.querySelector('.annotated-element'), + annotations: [], + annotationService: new AnnotationService({ + apiHost: 'https://app.box.com/api', + fileId: 1, + token: 'someToken', + canAnnotate: true, + user: 'completelyRealUser', + }), + fileVersionId: 1, + location: {}, + threadID: 2, + type: 'draw' + }); + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + drawingThread = null; + }); + + describe('destroy()', () => { + it('should destroy the thread', () => { + drawingThread.state = STATES.pending; + assert.notEqual(drawingThread.element, null); + + // This stubs out a parent method by forcing the method we care about + // in the prototype of the prototype of DrawingThread (ie + // AnnotationThread's prototype) to be a stub + Object.defineProperty(Object.getPrototypeOf(DrawingThread.prototype), 'destroy', { + value: (() => drawingThread.element = null) + }); + + drawingThread.destroy(); + + assert.equal(drawingThread.element, null); + }) + }); + + describe('getDrawings()', () => { + it('should return all items inserted into the container', () => { + drawingThread.pathContainer.insert('not a test'); + drawingThread.pathContainer.insert('not a secondary test'); + + const allDrawings = drawingThread.getDrawings(); + + assert.ok(allDrawings instanceof Array); + assert.equal(allDrawings.length, 2); + }); + + it('should return an empty array when no items are inserted into the container', () => { + const allDrawings = drawingThread.getDrawings(); + + assert.ok(allDrawings instanceof Array); + assert.equal(allDrawings.length, 0); + }) + }); + + describe('setContextStyles()', () => { + it('should set configurable context properties', () => { + drawingThread.drawingContext = { + lineCap: 'not set', + lineJoin: 'not set', + strokeStyle: 'no color', + lineWidth: 'no width' + }; + + const config = { + scale: 2, + color: 'blue' + }; + + drawingThread.setContextStyles(config); + + assert.deepEqual(drawingThread.drawingContext, { + lineCap: 'round', + lineJoin: 'round', + strokeStyle: 'blue', + lineWidth: drawingThread.drawingContext.lineWidth + }); + + assert.ok(drawingThread.drawingContext.lineWidth % config.scale == 0); + }) + }); + + describe('render()', () => { + it('should draw the pending path when the context is not empty', () => { + const timeElapsed = 20000; + const drawingArray = { + forEach: sandbox.stub() + }; + + sandbox.stub(drawingThread, 'getDrawings') + .returns(drawingArray); + drawingThread.pendingPath = { + drawPath: sandbox.stub() + }; + drawingThread.drawingContext = { + clearRect: sandbox.stub(), + canvas: { + width: 2, + height: 2 + } + }; + drawingThread.render(timeElapsed); + + expect(drawingThread.getDrawings).to.be.called; + expect(drawingArray.forEach).to.be.called; + expect(drawingThread.drawingContext.clearRect).to.be.called; + expect(drawingThread.pendingPath.drawPath).to.be.called; + }); + + it('should do nothing when the context is empty', () => { + const timeElapsed = 20000; + + sandbox.stub(drawingThread, 'getDrawings'); + drawingThread.context = null; + drawingThread.render(timeElapsed); + + expect(drawingThread.getDrawings).to.not.be.called; + }); + }); + + describe('createAnnotationData()', () => { + it('should create a valid annotation data object', () => { + drawingThread.annotationService = { + user: { id: '1' } + }; + + const placeholder = "String here so string doesn't get fined"; + const annotationData = drawingThread.createAnnotationData('draw', placeholder); + + expect(annotationData.fileVersionId).to.equal(drawingThread.fileVersionId); + expect(annotationData.threadID).to.equal(drawingThread.threadID); + expect(annotationData.user.id).to.equal('1'); + }); + }); +}); diff --git a/src/lib/annotations/image/ImageAnnotator.js b/src/lib/annotations/image/ImageAnnotator.js index f30260523..644948ce3 100644 --- a/src/lib/annotations/image/ImageAnnotator.js +++ b/src/lib/annotations/image/ImageAnnotator.js @@ -3,7 +3,7 @@ import Annotator from '../Annotator'; import ImagePointThread from './ImagePointThread'; import * as annotatorUtil from '../annotatorUtil'; import * as imageAnnotatorUtil from './imageAnnotatorUtil'; -import { CLASS_ANNOTATION_POINT_BUTTON } from '../annotationConstants'; +import { CLASS_ANNOTATION_POINT_MARKER, SELECTOR_ANNOTATION_BUTTON_POINT } from '../annotationConstants'; const IMAGE_NODE_NAME = 'img'; // Selector for image container OR multi-image container @@ -123,8 +123,8 @@ class ImageAnnotator extends Annotator { * @return {void} */ hideAllAnnotations() { - const annotateButton = this.previewUI.getAnnotateButton(); - const annotations = this.annotatedElement.getElementsByClassName(CLASS_ANNOTATION_POINT_BUTTON); + const annotateButton = this.previewUI.getAnnotateButton(SELECTOR_ANNOTATION_BUTTON_POINT); + const annotations = this.annotatedElement.getElementsByClassName(CLASS_ANNOTATION_POINT_MARKER); for (let i = 0; i < annotations.length; i++) { annotatorUtil.hideElement(annotations[i]); } @@ -138,8 +138,8 @@ class ImageAnnotator extends Annotator { * @return {void} */ showAllAnnotations() { - const annotateButton = this.previewUI.getAnnotateButton(); - const annotations = this.annotatedElement.getElementsByClassName(CLASS_ANNOTATION_POINT_BUTTON); + const annotateButton = this.previewUI.getAnnotateButton(SELECTOR_ANNOTATION_BUTTON_POINT); + const annotations = this.annotatedElement.getElementsByClassName(CLASS_ANNOTATION_POINT_MARKER); for (let i = 0; i < annotations.length; i++) { annotatorUtil.showElement(annotations[i]); } diff --git a/src/lib/annotations/image/__tests__/ImageAnnotator-test.html b/src/lib/annotations/image/__tests__/ImageAnnotator-test.html index 6d621b0db..f306993fa 100644 --- a/src/lib/annotations/image/__tests__/ImageAnnotator-test.html +++ b/src/lib/annotations/image/__tests__/ImageAnnotator-test.html @@ -1,4 +1,4 @@
- +
diff --git a/src/lib/annotations/image/__tests__/ImagePointThread-test.html b/src/lib/annotations/image/__tests__/ImagePointThread-test.html index 5c2c3bab2..e3f72d217 100644 --- a/src/lib/annotations/image/__tests__/ImagePointThread-test.html +++ b/src/lib/annotations/image/__tests__/ImagePointThread-test.html @@ -1,4 +1,4 @@
- +
diff --git a/src/lib/annotations/image/__tests__/imageAnnotatorUtil-test.html b/src/lib/annotations/image/__tests__/imageAnnotatorUtil-test.html index bd507d440..bb759ec53 100644 --- a/src/lib/annotations/image/__tests__/imageAnnotatorUtil-test.html +++ b/src/lib/annotations/image/__tests__/imageAnnotatorUtil-test.html @@ -1,5 +1,5 @@
- +
diff --git a/src/lib/constants.js b/src/lib/constants.js index 20eb3eede..d196ae208 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -42,7 +42,8 @@ export const SELECTOR_BOX_PREVIEW_CRAWLER_WRAPPER = '.bp-crawler-wrapper'; export const SELECTOR_BOX_PREVIEW_HEADER_BTNS = `.${CLASS_BOX_PREVIEW_HEADER_BTNS}`; export const SELECTOR_NAVIGATION_LEFT = '.bp-navigate-left'; export const SELECTOR_NAVIGATION_RIGHT = '.bp-navigate-right'; -export const SELECTOR_BOX_PREVIEW_BTN_ANNOTATE = '.bp-btn-annotate'; +export const SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_POINT = '.bp-btn-annotate-point'; +export const SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_DRAW = '.bp-btn-annotate-draw'; export const SELECTOR_BOX_PREVIEW_BTN_PRINT = '.bp-btn-print'; export const SELECTOR_BOX_PREVIEW_BTN_DOWNLOAD = '.bp-btn-download'; export const SELECTOR_BOX_PREVIEW_BTN_LOADING_DOWNLOAD = '.bp-btn-loading-download'; diff --git a/src/lib/shell.html b/src/lib/shell.html index 6d468d9d4..985449c11 100644 --- a/src/lib/shell.html +++ b/src/lib/shell.html @@ -7,7 +7,20 @@
- + +