diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js index 6fec7a8..59109bd 100644 --- a/client/js/controls/post_edit_sidebar_control.js +++ b/client/js/controls/post_edit_sidebar_control.js @@ -11,13 +11,16 @@ const FileDropperControl = require('../controls/file_dropper_control.js'); const template = views.getTemplate('post-edit-sidebar'); class PostEditSidebarControl extends events.EventTarget { - constructor(hostNode, post, postContentControl) { + constructor(hostNode, post, postContentControl, postNotesOverlayControl) { super(); this._hostNode = hostNode; this._post = post; this._postContentControl = postContentControl; + this._postNotesOverlayControl = postNotesOverlayControl; this._newPostContent = null; + this._postNotesOverlayControl.switchToPassiveEdit(); + views.replaceContent(this._hostNode, template({ post: this._post, canEditPostSafety: api.hasPrivilege('posts:edit:safety'), @@ -39,6 +42,9 @@ class PostEditSidebarControl extends events.EventTarget { new ExpanderControl( 'Tags', this._hostNode.querySelectorAll('.tags')); + new ExpanderControl( + 'Notes', + this._hostNode.querySelectorAll('.notes')); new ExpanderControl( 'Content', this._hostNode.querySelectorAll('.post-content, .post-thumbnail')); @@ -84,6 +90,16 @@ class PostEditSidebarControl extends events.EventTarget { this._post.hasCustomThumbnail ? 'block' : 'none'; } + if (this._addNoteLinkNode) { + this._addNoteLinkNode.addEventListener( + 'click', e => this._evtAddNoteClick(e)); + } + + if (this._deleteNoteLinkNode) { + this._deleteNoteLinkNode.addEventListener( + 'click', e => this._evtDeleteNoteClick(e)); + } + if (this._featureLinkNode) { this._featureLinkNode.addEventListener( 'click', e => this._evtFeatureClick(e)); @@ -94,6 +110,12 @@ class PostEditSidebarControl extends events.EventTarget { 'click', e => this._evtDeleteClick(e)); } + this._postNotesOverlayControl.addEventListener( + 'blur', e => this._evtNoteBlur(e)); + + this._postNotesOverlayControl.addEventListener( + 'focus', e => this._evtNoteFocus(e)); + this._post.addEventListener( 'changeContent', e => this._evtPostContentChange(e)); @@ -110,6 +132,11 @@ class PostEditSidebarControl extends events.EventTarget { }); } } + + if (this._noteTextareaNode) { + this._noteTextareaNode.addEventListener( + 'change', e => this._evtNoteTextChangeRequest(e)); + } } _evtPostContentChange(e) { @@ -146,6 +173,45 @@ class PostEditSidebarControl extends events.EventTarget { } } + _evtNoteTextChangeRequest(e) { + if (this._editedNote) { + this._editedNote.text = this._noteTextareaNode.value; + } + } + + _evtNoteFocus(e) { + this._editedNote = e.detail.note; + this._addNoteLinkNode.classList.remove('inactive'); + this._deleteNoteLinkNode.classList.remove('inactive'); + this._noteTextareaNode.removeAttribute('disabled'); + this._noteTextareaNode.value = e.detail.note.text; + } + + _evtNoteBlur(e) { + this._evtNoteTextChangeRequest(null); + this._addNoteLinkNode.classList.remove('inactive'); + this._deleteNoteLinkNode.classList.add('inactive'); + this._noteTextareaNode.blur(); + this._noteTextareaNode.setAttribute('disabled', 'disabled'); + this._noteTextareaNode.value = ''; + } + + _evtAddNoteClick(e) { + if (e.target.classList.contains('inactive')) { + return; + } + this._addNoteLinkNode.classList.add('inactive'); + this._postNotesOverlayControl.switchToDrawing(); + } + + _evtDeleteNoteClick(e) { + if (e.target.classList.contains('inactive')) { + return; + } + this._post.notes.remove(this._editedNote); + this._postNotesOverlayControl.switchToPassiveEdit(); + } + _evtSubmit(e) { e.preventDefault(); this.dispatchEvent(new CustomEvent('submit', { @@ -226,6 +292,18 @@ class PostEditSidebarControl extends events.EventTarget { return this._formNode.querySelector('.management .delete'); } + get _addNoteLinkNode() { + return this._formNode.querySelector('.notes .add'); + } + + get _deleteNoteLinkNode() { + return this._formNode.querySelector('.notes .delete'); + } + + get _noteTextareaNode() { + return this._formNode.querySelector('.notes textarea'); + } + enableForm() { views.enableForm(this._formNode); } diff --git a/client/js/controls/post_notes_overlay_control.js b/client/js/controls/post_notes_overlay_control.js index 2826236..6b1b7ef 100644 --- a/client/js/controls/post_notes_overlay_control.js +++ b/client/js/controls/post_notes_overlay_control.js @@ -1,63 +1,493 @@ 'use strict'; +const keyboard = require('../util/keyboard.js'); const views = require('../util/views.js'); +const events = require('../events.js'); const misc = require('../util/misc.js'); +const Note = require('../models/note.js'); +const Point = require('../models/point.js'); + const svgNS = 'http://www.w3.org/2000/svg'; +const snapThreshold = 10; +const circleSize = 10; -class PostNotesOverlayControl { - constructor(postOverlayNode, post) { - this._post = post; - this._postOverlayNode = postOverlayNode; - this._install(); +const MOUSE_BUTTON_LEFT = 1; + +const KEY_LEFT = 37; +const KEY_UP = 38; +const KEY_RIGHT = 39; +const KEY_DOWN = 40; +const KEY_ESCAPE = 27; +const KEY_RETURN = 13; + +function _getDistance(point1, point2) { + return Math.sqrt( + Math.pow(point1.x - point2.x, 2) + + Math.pow(point1.y - point2.y, 2)); +} + +function _setNodeState(node, stateName) { + if (node === null) { + return; + } + node.setAttribute('data-state', stateName); +} + +function _clearEditedNote(hostNode) { + const node = hostNode.querySelector('[data-state=\'editing\']'); + _setNodeState(node, null); + return node !== null; +} + +class State { + constructor(control) { + this._control = control; + const stateName = misc.decamelize( + this.constructor.name.replace(/State/, '')); + _setNodeState(control._hostNode, stateName); + _setNodeState(control._textNode, stateName); } - _evtMouseEnter(e) { - const bodyRect = document.body.getBoundingClientRect(); - const svgRect = this._svgNode.getBoundingClientRect(); - const polygonRect = e.target.getBBox(); - this._textNode.querySelector('.wrapper').innerHTML = - misc.formatMarkdown(e.target.getAttribute('data-text')); - const x = ( - -bodyRect.left + svgRect.left + svgRect.width * polygonRect.x); - const y = ( - -bodyRect.top + svgRect.top + svgRect.height * ( - polygonRect.y + polygonRect.height)); - this._textNode.style.left = x + 'px'; - this._textNode.style.top = y + 'px'; - this._textNode.style.display = 'block'; + get canShowNoteText() { + return false; } - _evtMouseLeave(e) { - const newElement = e.relatedTarget; - if (newElement === this._svgNode || - (!this._svgNode.contains(newElement) && - !this._textNode.contains(newElement) && - newElement !== this._textNode)) { - this._textNode.style.display = 'none'; + evtCanvasKeyDown(e) { + } + + evtNoteMouseDown(e, hoveredNote) { + } + + evtCanvasMouseDown(e) { + } + + evtCanvasMouseMove(e) { + } + + evtCanvasMouseUp(e) { + } + + _getScreenPoint(point) { + return new Point( + point.x * this._control.boundingBox.width, + point.y * this._control.boundingBox.height); + } + + _snapPoints(targetPoint, referencePoint) { + const targetScreenPoint = this._getScreenPoint(targetPoint); + const referenceScreenPoint = this._getScreenPoint(referencePoint); + if (_getDistance(targetScreenPoint, referenceScreenPoint) < + snapThreshold) { + targetPoint.x = referencePoint.x; + targetPoint.y = referencePoint.y; } } - _install() { + _createNote() { + const note = new Note(); + this._control._createPolygonNode(note); + return note; + } + + _getPointFromEvent(e) { + return new Point( + (e.clientX - this._control.boundingBox.left) / + this._control.boundingBox.width, + (e.clientY - this._control.boundingBox.top) / + this._control.boundingBox.height); + } +} + +class ReadOnlyState extends State { + constructor(control) { + super(control); + if (_clearEditedNote(control._hostNode)) { + this._control.dispatchEvent(new CustomEvent('blur')); + } + keyboard.unpause(); + } + + get canShowNoteText() { + return true; + } +} + +class PassiveState extends State { + constructor(control) { + super(control); + if (_clearEditedNote(control._hostNode)) { + this._control.dispatchEvent(new CustomEvent('blur')); + } + keyboard.unpause(); + } + + get canShowNoteText() { + return true; + } + + evtNoteMouseDown(e, hoveredNote) { + this._control._state = new SelectedState(this._control, hoveredNote); + } +} + +class ActiveState extends State { + constructor(control, note) { + super(control); + if (_clearEditedNote(control._hostNode)) { + this._control.dispatchEvent(new CustomEvent('blur')); + } + keyboard.pause(); + if (note !== undefined) { + this._note = note; + this._control.dispatchEvent( + new CustomEvent('focus', { + detail: {note: note}, + })); + _setNodeState(this._note.groupNode, 'editing'); + } + } +} + +class SelectedState extends ActiveState { + constructor(control, note) { + super(control, note); + this._clickTimeout = null; + this._control._hideNoteText(); + } + + evtCanvasKeyDown(e) { + const delta = e.ctrlKey ? 10 : 1; + const offsetMap = { + [KEY_LEFT]: [-delta, 0], + [KEY_UP]: [0, -delta], + [KEY_DOWN]: [0, delta], + [KEY_RIGHT]: [delta, 0], + }; + if (offsetMap.hasOwnProperty(e.which)) { + e.stopPropagation(); + e.stopImmediatePropagation(); + e.preventDefault(); + if (e.shiftKey) { + this._scaleEditedNote(...offsetMap[e.which]); + } else { + this._moveEditedNote(...offsetMap[e.which]); + } + } + } + + evtNoteMouseDown(e, hoveredNote) { + if (this._note !== hoveredNote) { + this._control._state = + new SelectedState(this._control, hoveredNote); + return; + } + const mousePoint = this._getPointFromEvent(e); + const mouseScreenPoint = this._getScreenPoint(mousePoint); + this._clickTimeout = window.setTimeout(() => { + for (let polygonPoint of this._note.polygon) { + const distance = _getDistance( + mouseScreenPoint, + this._getScreenPoint(polygonPoint)); + if (distance < circleSize) { + this._control._state = new MovingPointState( + this._control, this._note, polygonPoint, mousePoint); + return; + } + } + this._control._state = new MovingNoteState( + this._control, this._note, mousePoint); + }, 100); + } + + evtCanvasMouseMove(e) { + const mousePoint = this._getPointFromEvent(e); + const mouseScreenPoint = this._getScreenPoint(mousePoint); + for (let polygonPoint of this._note.polygon) { + const distance = _getDistance( + mouseScreenPoint, + this._getScreenPoint(polygonPoint)); + polygonPoint.edgeNode.classList.toggle( + 'nearby', distance < circleSize); + } + } + + evtCanvasMouseDown(e) { + const mousePoint = this._getPointFromEvent(e); + const mouseScreenPoint = this._getScreenPoint(mousePoint); + for (let polygonPoint of this._note.polygon) { + const distance = _getDistance( + mouseScreenPoint, + this._getScreenPoint(polygonPoint)); + if (distance < circleSize) { + this._control._state = new MovingPointState( + this._control, this._note, polygonPoint, mousePoint); + return; + } + } + this._control._state = new PassiveState(this._control); + } + + evtCanvasMouseUp(e) { + window.clearTimeout(this._clickTimeout); + } + + _moveEditedNote(x, y) { + for (let point of this._note.polygon) { + point.x += x / this._control.boundingBox.width; + point.y += y / this._control.boundingBox.height; + } + } + + _scaleEditedNote(x, y) { + const min = new Point(Infinity, Infinity); + const max = new Point(-Infinity, -Infinity); + for (let point of this._note.polygon) { + min.x = Math.min(min.x, point.x); + min.y = Math.min(min.y, point.y); + max.x = Math.max(max.x, point.x); + max.y = Math.max(max.y, point.y); + } + const originalWidth = max.x - min.x; + const originalHeight = max.y - min.y; + const targetWidth = originalWidth + + x / this._control.boundingBox.width; + const targetHeight = originalHeight + + y / this._control.boundingBox.height; + const scaleX = targetWidth / originalWidth; + const scaleY = targetHeight / originalHeight; + for (let point of this._note.polygon) { + point.x = min.x + ((point.x - min.x) * scaleX); + point.y = min.y + ((point.y - min.y) * scaleY); + } + } +} + +class MovingPointState extends ActiveState { + constructor(control, note, notePoint, mousePoint) { + super(control, note); + this._notePoint = notePoint; + this._originalNotePoint = {x: notePoint.x, y: notePoint.y}; + this._originalPosition = mousePoint; + _setNodeState(this._note.groupNode, 'editing'); + } + + evtCanvasKeyDown(e) { + if (e.which == KEY_ESCAPE) { + this._notePoint.x = this._originalNotePoint.x; + this._notePoint.y = this._originalNotePoint.y; + this._control._state = new SelectedState(this._control, this._note); + } + } + + evtCanvasMouseMove(e) { + const mousePoint = this._getPointFromEvent(e); + this._notePoint.x += mousePoint.x - this._originalPosition.x; + this._notePoint.y += mousePoint.y - this._originalPosition.y; + this._originalPosition = mousePoint; + } + + evtCanvasMouseUp(e) { + this._control._state = new SelectedState(this._control, this._note); + } +} + +class MovingNoteState extends ActiveState { + constructor(control, note, mousePoint) { + super(control, note); + this._originalPolygon = [...note.polygon].map( + point => ({x: point.x, y: point.y})); + this._originalPosition = mousePoint; + } + + evtCanvasKeyDown(e) { + if (e.which == KEY_ESCAPE) { + for (let i of misc.range(this._note.polygon.length)) { + this._note.polygon.at(i).x = this._originalPolygon[i].x; + this._note.polygon.at(i).y = this._originalPolygon[i].y; + } + this._control._state = new SelectedState(this._control, this._note); + } + } + + evtCanvasMouseMove(e) { + const mousePoint = this._getPointFromEvent(e); + for (let point of this._note.polygon) { + point.x += mousePoint.x - this._originalPosition.x; + point.y += mousePoint.y - this._originalPosition.y; + } + this._originalPosition = mousePoint; + } + + evtCanvasMouseUp(e) { + this._control._state = new SelectedState(this._control, this._note); + } +} + +class ReadyToDrawState extends ActiveState { + constructor(control) { + super(control); + } + + evtNoteMouseDown(e, hoveredNote) { + this._control._state = new SelectedState(this._control, hoveredNote); + } + + evtCanvasMouseDown(e) { + const mousePoint = this._getPointFromEvent(e); + if (e.shiftKey) { + this._control._state = new DrawingRectangleState( + this._control, mousePoint); + } else { + this._control._state = new DrawingPolygonState( + this._control, mousePoint); + } + } +} + +class DrawingRectangleState extends ActiveState { + constructor(control, mousePoint) { + super(control); + this._note = this._createNote(); + this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); + this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); + this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); + this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); + _setNodeState(this._note.groupNode, 'drawing'); + } + + evtCanvasMouseUp(e) { + const mousePoint = this._getPointFromEvent(e); + const x1 = this._note.polygon.at(0).x; + const y1 = this._note.polygon.at(0).y; + const x2 = this._note.polygon.at(2).x; + const y2 = this._note.polygon.at(2).y; + const width = (x2 - x1) * this._control.boundingBox.width; + const height = (y2 - y1) * this._control.boundingBox.height; + if (width < 20 && height < 20) { + this._control._deletePolygonNode(this._note); + this._control._state = new ReadyToDrawState(this._control); + } else { + this._control._post.notes.add(this._note); + this._control._state = new SelectedState(this._control, this._note); + } + } + + evtCanvasMouseMove(e) { + const mousePoint = this._getPointFromEvent(e); + this._note.polygon.at(1).x = mousePoint.x; + this._note.polygon.at(3).y = mousePoint.y; + this._note.polygon.at(2).x = mousePoint.x; + this._note.polygon.at(2).y = mousePoint.y; + } +} + +class DrawingPolygonState extends ActiveState { + constructor(control, mousePoint) { + super(control); + this._note = this._createNote(); + this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); + this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); + _setNodeState(this._note.groupNode, 'drawing'); + } + + evtCanvasKeyDown(e) { + if (e.which == KEY_ESCAPE) { + this._note.polygon.remove(this._note.polygon.secondLastPoint); + if (this._note.polygon.length === 1) { + this._cancel(); + } + } else if (e.which == KEY_RETURN) { + this._finish(); + } + } + + evtNoteMouseDown(e, hoveredNote) { + this.evtCanvasMouseDown(e); + } + + evtCanvasMouseDown(e) { + const mousePoint = this._getPointFromEvent(e); + const firstPoint = this._note.polygon.firstPoint; + const mouseScreenPoint = this._getScreenPoint(mousePoint); + const firstScreenPoint = this._getScreenPoint(firstPoint); + if (_getDistance(mouseScreenPoint, firstScreenPoint) < snapThreshold) { + this._finish(); + } else { + this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); + } + } + + evtCanvasMouseMove(e) { + const mousePoint = this._getPointFromEvent(e); + const lastPoint = this._note.polygon.lastPoint; + const secondLastPoint = this._note.polygon.secondLastPoint; + const firstPoint = this._note.polygon.firstPoint; + if (!lastPoint) { + return; + } + + if (e.shiftKey && secondLastPoint) { + const direction = (Math.round( + Math.atan2( + secondLastPoint.y - mousePoint.y, + secondLastPoint.x - mousePoint.x) / + (2 * Math.PI / 4)) + 4) % 4; + if (direction === 0 || direction === 2) { + lastPoint.x = mousePoint.x; + lastPoint.y = secondLastPoint.y; + } else if (direction === 1 || direction === 3) { + lastPoint.x = secondLastPoint.x; + lastPoint.y = mousePoint.y; + } + } else { + lastPoint.x = mousePoint.x; + lastPoint.y = mousePoint.y; + } + this._snapPoints(lastPoint, firstPoint); + } + + _cancel() { + this._control._deletePolygonNode(this._note); + this._control._state = new ReadyToDrawState(this._control); + } + + _finish() { + this._note.polygon.remove(this._note.polygon.lastPoint); + if (this._note.polygon.length <= 2) { + this._cancel(); + } else { + this._control._post.notes.add(this._note); + this._control._state = new SelectedState(this._control, this._note); + } + } +} + +class PostNotesOverlayControl extends events.EventTarget { + constructor(hostNode, post) { + super(); + this._post = post; + this._hostNode = hostNode; + this._svgNode = document.createElementNS(svgNS, 'svg'); - this._svgNode.classList.add('notes'); + this._svgNode.classList.add('notes-overlay'); this._svgNode.setAttribute('preserveAspectRatio', 'none'); this._svgNode.setAttribute('viewBox', '0 0 1 1'); for (let note of this._post.notes) { - const polygonNode = document.createElementNS(svgNS, 'polygon'); - polygonNode.setAttribute( - 'vector-effect', 'non-scaling-stroke'); - polygonNode.setAttribute( - 'stroke-alignment', 'inside'); - polygonNode.setAttribute( - 'points', note.polygon.map(point => point.join(',')).join(' ')); - polygonNode.setAttribute('data-text', note.text); - polygonNode.addEventListener( - 'mouseenter', e => this._evtMouseEnter(e)); - polygonNode.addEventListener( - 'mouseleave', e => this._evtMouseLeave(e)); - this._svgNode.appendChild(polygonNode); + this._createPolygonNode(note); } - this._postOverlayNode.appendChild(this._svgNode); + this._hostNode.appendChild(this._svgNode); + this._post.notes.addEventListener('remove', e => { + this._deletePolygonNode(e.detail.note); + }); + + const keyHandler = e => this._evtCanvasKeyDown(e); + document.addEventListener('keydown', keyHandler); + this._svgNode.addEventListener( + 'mousedown', e => this._evtCanvasMouseDown(e)); + this._svgNode.addEventListener( + 'mouseup', e => this._evtCanvasMouseUp(e)); + this._svgNode.addEventListener( + 'mousemove', e => this._evtCanvasMouseMove(e)); const wrapperNode = document.createElement('div'); wrapperNode.classList.add('wrapper'); @@ -65,17 +495,168 @@ class PostNotesOverlayControl { this._textNode.classList.add('note-text'); this._textNode.appendChild(wrapperNode); this._textNode.addEventListener( - 'mouseleave', e => this._evtMouseLeave(e)); + 'mouseleave', e => this._evtNoteMouseLeave(e)); document.body.appendChild(this._textNode); views.monitorNodeRemoval( - this._postOverlayNode, () => { this._uninstall(); }); + this._hostNode, () => { + this._hostNode.removeChild(this._svgNode); + document.removeEventListener('keydown', keyHandler); + document.body.removeChild(this._textNode); + this._state = new ReadOnlyState(this); + }); + + this._state = new ReadOnlyState(this); } - _uninstall() { - this._postOverlayNode.removeChild(this._svgNode); - document.body.removeChild(this._textNode); + switchToPassiveEdit() { + this._state = new PassiveState(this); } -}; + + switchToDrawing() { + this._state = new ReadyToDrawState(this); + } + + get boundingBox() { + return this._hostNode.getBoundingClientRect(); + } + + _evtCanvasKeyDown(e) { + this._state.evtCanvasKeyDown(e); + } + + _evtCanvasMouseDown(e) { + e.preventDefault(); + if (e.which !== MOUSE_BUTTON_LEFT) { + return; + } + const hoveredNode = document.elementFromPoint(e.clientX, e.clientY); + let hoveredNote = null; + for (let note of this._post.notes) { + if (note.polygonNode === hoveredNode) { + hoveredNote = note; + } + } + if (hoveredNote) { + this._state.evtNoteMouseDown(e, hoveredNote); + } else { + this._state.evtCanvasMouseDown(e); + } + } + + _evtCanvasMouseUp(e) { + this._state.evtCanvasMouseUp(e); + } + + _evtCanvasMouseMove(e) { + this._state.evtCanvasMouseMove(e); + } + + _evtNoteMouseEnter(e, note) { + if (this._state.canShowNoteText) { + this._showNoteText(note); + } + } + + _evtNoteMouseLeave(e) { + const newElement = e.relatedTarget; + if (newElement === this._svgNode || + (!this._svgNode.contains(newElement) && + !this._textNode.contains(newElement) && + newElement !== this._textNode)) { + this._hideNoteText(); + } + } + + _showNoteText(note) { + this._textNode.querySelector('.wrapper').innerHTML = + misc.formatMarkdown(note.text); + this._textNode.style.display = 'block'; + const polygonRect = note.polygonNode.getBBox(); + const bodyRect = document.body.getBoundingClientRect(); + const noteRect = this._textNode.getBoundingClientRect(); + const svgRect = this.boundingBox; + const x = ( + -bodyRect.left + + svgRect.left + + svgRect.width * (polygonRect.x + polygonRect.width / 2) - + noteRect.width / 2); + const y = ( + -bodyRect.top + + svgRect.top + + svgRect.height * (polygonRect.y + polygonRect.height / 2) - + noteRect.height / 2); + this._textNode.style.left = x + 'px'; + this._textNode.style.top = y + 'px'; + } + + _hideNoteText() { + this._textNode.style.display = 'none'; + } + + _updatePolygonNotePoints(note) { + note.polygonNode.setAttribute( + 'points', + [...note.polygon].map( + point => [point.x, point.y].join(',')).join(' ')); + } + + _createEdgeNode(point, groupNode) { + const node = document.createElementNS(svgNS, 'ellipse'); + node.setAttribute('cx', point.x); + node.setAttribute('cy', point.y); + node.setAttribute('rx', circleSize / 2 / this.boundingBox.width); + node.setAttribute('ry', circleSize / 2 / this.boundingBox.height); + point.edgeNode = node; + groupNode.appendChild(node); + } + + _deleteEdgeNode(point, note) { + this._updatePolygonNotePoints(note); + point.edgeNode.parentNode.removeChild(point.edgeNode); + } + + _updateEdgeNode(point, note) { + this._updatePolygonNotePoints(note); + point.edgeNode.setAttribute('cx', point.x); + point.edgeNode.setAttribute('cy', point.y); + } + + _deletePolygonNode(note) { + note.polygonNode.parentNode.removeChild(note.polygonNode); + } + + _createPolygonNode(note) { + const groupNode = document.createElementNS(svgNS, 'g'); + note.groupNode = groupNode; + { + const node = document.createElementNS(svgNS, 'polygon'); + note.polygonNode = node; + node.setAttribute('vector-effect', 'non-scaling-stroke'); + node.setAttribute('stroke-alignment', 'inside'); + node.addEventListener( + 'mouseenter', e => this._evtNoteMouseEnter(e, note)); + node.addEventListener( + 'mouseleave', e => this._evtNoteMouseLeave(e)); + this._updatePolygonNotePoints(note); + groupNode.appendChild(node); + } + for (let point of note.polygon) { + this._createEdgeNode(point, groupNode); + } + + note.polygon.addEventListener('change', e => { + this._updateEdgeNode(e.detail.point, note); + }); + note.polygon.addEventListener('remove', e => { + this._deleteEdgeNode(e.detail.point, note); + }); + note.polygon.addEventListener('add', e => { + this._createEdgeNode(e.detail.point, groupNode); + }); + + this._svgNode.appendChild(groupNode); + } +} module.exports = PostNotesOverlayControl; diff --git a/client/js/models/abstract_list.js b/client/js/models/abstract_list.js index ba2a3f4..5e38382 100644 --- a/client/js/models/abstract_list.js +++ b/client/js/models/abstract_list.js @@ -12,18 +12,32 @@ class AbstractList extends events.EventTarget { const ret = new this(); for (let item of response) { const addedItem = this._itemClass.fromResponse(item); - addedItem.addEventListener('delete', e => { - ret.remove(addedItem); - }); + if (addedItem.addEventListener) { + addedItem.addEventListener('delete', e => { + ret.remove(addedItem); + }); + addedItem.addEventListener('change', e => { + this.dispatchEvent(new CustomEvent('change', { + detail: e.detail, + })); + }); + } ret._list.push(addedItem); } return ret; } add(item) { - item.addEventListener('delete', e => { - this.remove(item); - }); + if (item.addEventListener) { + item.addEventListener('delete', e => { + this.remove(item); + }); + item.addEventListener('change', e => { + this.dispatchEvent(new CustomEvent('change', { + detail: e.detail, + })); + }); + } this._list.push(item); const detail = {}; detail[this.constructor._itemName] = item; @@ -32,6 +46,12 @@ class AbstractList extends events.EventTarget { })); } + clear() { + for (let item of [...this._list]) { + this.remove(item); + } + } + remove(itemToRemove) { for (let [index, item] of this._list.entries()) { if (item !== itemToRemove) { @@ -51,6 +71,10 @@ class AbstractList extends events.EventTarget { return this._list.length; } + at(index) { + return this._list[index]; + } + [Symbol.iterator]() { return this._list[Symbol.iterator](); } diff --git a/client/js/models/note.js b/client/js/models/note.js new file mode 100644 index 0000000..877db4e --- /dev/null +++ b/client/js/models/note.js @@ -0,0 +1,34 @@ +'use strict'; + +const events = require('../events.js'); +const Point = require('./point.js'); +const PointList = require('./point_list.js'); + +class Note extends events.EventTarget { + constructor() { + super(); + this._text = '…'; + this._polygon = new PointList(); + } + + get text() { return this._text; } + get polygon() { return this._polygon; } + + set text(value) { this._text = value; } + + static fromResponse(response) { + const note = new Note(); + note._updateFromResponse(response); + return note; + } + + _updateFromResponse(response) { + this._text = response.text; + this._polygon.clear(); + for (let point of response.polygon) { + this._polygon.add(new Point(point[0], point[1])); + } + } +} + +module.exports = Note; diff --git a/client/js/models/note_list.js b/client/js/models/note_list.js new file mode 100644 index 0000000..b54d7f3 --- /dev/null +++ b/client/js/models/note_list.js @@ -0,0 +1,12 @@ +'use strict'; + +const AbstractList = require('./abstract_list.js'); +const Note = require('./note.js'); + +class NoteList extends AbstractList { +} + +NoteList._itemClass = Note; +NoteList._itemName = 'note'; + +module.exports = NoteList; diff --git a/client/js/models/point.js b/client/js/models/point.js new file mode 100644 index 0000000..f1c551e --- /dev/null +++ b/client/js/models/point.js @@ -0,0 +1,26 @@ +'use strict'; + +const events = require('../events.js'); + +class Point extends events.EventTarget { + constructor(x, y) { + super(); + this._x = x; + this._y = y; + } + + get x() { return this._x; } + get y() { return this._y; } + + set x(value) { + this._x = value; + this.dispatchEvent(new CustomEvent('change', {detail: {point: this}})); + } + + set y(value) { + this._y = value; + this.dispatchEvent(new CustomEvent('change', {detail: {point: this}})); + } +}; + +module.exports = Point; diff --git a/client/js/models/point_list.js b/client/js/models/point_list.js new file mode 100644 index 0000000..166e664 --- /dev/null +++ b/client/js/models/point_list.js @@ -0,0 +1,23 @@ +'use strict'; + +const AbstractList = require('./abstract_list.js'); +const Point = require('./point.js'); + +class PointList extends AbstractList { + get firstPoint() { + return this._list[0]; + } + + get secondLastPoint() { + return this._list[this._list.length - 2]; + } + + get lastPoint() { + return this._list[this._list.length - 1]; + } +} + +PointList._itemClass = Point; +PointList._itemName = 'point'; + +module.exports = PointList; diff --git a/client/js/models/post.js b/client/js/models/post.js index 97a4113..609cabe 100644 --- a/client/js/models/post.js +++ b/client/js/models/post.js @@ -3,6 +3,7 @@ const api = require('../api.js'); const tags = require('../tags.js'); const events = require('../events.js'); +const NoteList = require('./note_list.js'); const CommentList = require('./comment_list.js'); const misc = require('../util/misc.js'); @@ -99,6 +100,13 @@ class Post extends events.EventTarget { if (misc.arraysDiffer(this._relations, this._orig._relations)) { detail.relations = this._relations; } + if (misc.arraysDiffer(this._notes, this._orig._notes)) { + detail.notes = [...this._notes].map(note => ({ + polygon: [...note.polygon].map( + point => [point.x, point.y]), + text: note.text, + })); + } if (this._content) { files.content = this._content; } @@ -228,7 +236,7 @@ class Post extends events.EventTarget { } _updateFromResponse(response) { - const map = { + const map = () => ({ _id: response.id, _type: response.type, _mimeType: response.mimeType, @@ -243,7 +251,7 @@ class Post extends events.EventTarget { _flags: response.flags || [], _tags: response.tags || [], - _notes: response.notes || [], + _notes: NoteList.fromResponse(response.notes || []), _comments: CommentList.fromResponse(response.comments || []), _relations: response.relations || [], @@ -252,10 +260,10 @@ class Post extends events.EventTarget { _ownScore: response.ownScore, _ownFavorite: response.ownFavorite, _hasCustomThumbnail: response.hasCustomThumbnail, - }; + }); - Object.assign(this, map); - Object.assign(this._orig, map); + Object.assign(this, map()); + Object.assign(this._orig, map()); } }; diff --git a/client/js/util/keyboard.js b/client/js/util/keyboard.js index 51ee7e2..c3b9543 100644 --- a/client/js/util/keyboard.js +++ b/client/js/util/keyboard.js @@ -3,6 +3,16 @@ const mousetrap = require('mousetrap'); const settings = require('../models/settings.js'); +let paused = false; +const _originalStopCallback = mousetrap.prototype.stopCallback; +mousetrap.prototype.stopCallback = function(...args) { + var self = this; + if (paused) { + return true; + } + return _originalStopCallback.call(self, ...args); +}; + function bind(hotkey, func) { if (settings.get().keyboardShortcuts) { mousetrap.bind(hotkey, func); @@ -18,4 +28,6 @@ function unbind(hotkey) { module.exports = { bind: bind, unbind: unbind, + pause: () => { paused = true; }, + unpause: () => { paused = false; }, }; diff --git a/client/js/util/misc.js b/client/js/util/misc.js index 4bf0e1a..21647c3 100644 --- a/client/js/util/misc.js +++ b/client/js/util/misc.js @@ -2,6 +2,14 @@ const marked = require('marked'); +function decamelize(str, sep) { + sep = sep === undefined ? '-' : sep; + return str + .replace(/([a-z\d])([A-Z])/g, '$1' + sep + '$2') + .replace(/([A-Z]+)([A-Z][a-z\d]+)/g, '$1' + sep + '$2') + .toLowerCase(); +} + function* range(start=0, end=null, step=1) { if (end == null) { end = start; @@ -245,9 +253,11 @@ function escapeHtml(unsafe) { } function arraysDiffer(source1, source2) { + source1 = [...source1]; + source2 = [...source2]; return ( - [...source1].filter(value => !source2.includes(value)).length > 0 || - [...source2].filter(value => !source1.includes(value)).length > 0); + source1.filter(value => !source2.includes(value)).length > 0 || + source2.filter(value => !source1.includes(value)).length > 0); } module.exports = { @@ -266,4 +276,5 @@ module.exports = { makeCssName: makeCssName, splitByWhitespace: splitByWhitespace, arraysDiffer: arraysDiffer, + decamelize: decamelize, }; diff --git a/client/js/views/post_view.js b/client/js/views/post_view.js index 37bf439..1e7fc2f 100644 --- a/client/js/views/post_view.js +++ b/client/js/views/post_view.js @@ -47,7 +47,7 @@ class PostView { ]; }); - new PostNotesOverlayControl( + this._postNotesOverlayControl = new PostNotesOverlayControl( postContainerNode.querySelector('.post-overlay'), ctx.post); @@ -80,7 +80,10 @@ class PostView { if (ctx.editMode) { this.sidebarControl = new PostEditSidebarControl( - sidebarContainerNode, ctx.post, this._postContentControl); + sidebarContainerNode, + ctx.post, + this._postContentControl, + this._postNotesOverlayControl); } else { this.sidebarControl = new PostReadonlySidebarControl( sidebarContainerNode, ctx.post, this._postContentControl);