diff --git a/client/css/colors.styl b/client/css/colors.styl
index da5f72e..25f8bcc 100644
--- a/client/css/colors.styl
+++ b/client/css/colors.styl
@@ -38,8 +38,13 @@ $tag-suggestions-header-color = #EEE
$tag-suggestions-border-color = #AAA
$duplicate-tag-background-color = #FDC
$duplicate-tag-text-color = black
-$note-overlay-background-color = rgba(255, 255, 255, 0.3)
-$note-overlay-border-color = rgba(0, 0, 0, 0.3)
+$active-note-overlay-background-color = rgba(255, 255, 255, 0.3)
+$active-note-overlay-border-color = rgba(62, 255, 62, 0.8)
+$note-background-color = rgba(255, 255, 205, 0.3)
+$note-border-color = rgba(0, 0, 0, 0.2)
+$edited-note-background-color = rgba(222, 255, 222, 0.3)
+$edited-note-border-color = rgba(0, 200, 0, 0.9)
$note-text-background-color = lemonchiffon
$note-text-border-color = black
$note-text-text-color = black
+$hovered-note-point-color = red
diff --git a/client/css/notes.styl b/client/css/notes.styl
new file mode 100644
index 0000000..717bb16
--- /dev/null
+++ b/client/css/notes.styl
@@ -0,0 +1,63 @@
+@import colors
+
+.post-overlay
+ &[data-state=ready-to-draw],
+ &[data-state=drawing-rectangle],
+ &[data-state=drawing-polygon]
+ &:after
+ box-sizing: border-box
+ border: 0.3em dashed $active-note-overlay-border-color
+ background: $active-note-overlay-background-color
+ display: block
+ content: ' '
+ pointer-events: none
+ position: absolute
+ width: 100%
+ height: 100%
+ left: 0
+ right: 0
+ top: 0
+ bottom: 0
+
+.notes-overlay
+ g
+ stroke-width: 1px
+
+ polygon
+ fill: $note-background-color
+ stroke: $note-border-color
+ pointer-events: auto
+ ellipse
+ display: none
+
+ g[data-state=editing], g[data-state=drawing]
+ stroke-width: 2px
+
+ polygon
+ fill: $edited-note-background-color
+ stroke: $edited-note-border-color
+ ellipse
+ fill: $edited-note-border-color
+ display: block
+ &.nearby
+ fill: $hovered-note-point-color
+
+.note-text
+ position: absolute
+ max-width: 22.5em
+ display: none
+
+ &:not([data-state=read-only])
+ pointer-events: none
+
+ &>.wrapper
+ background: $note-text-background-color
+ padding: 0.3em 0.6em
+ border: 1px solid $note-text-border-color
+ color: $note-text-text-color
+ box-sizing: border-box
+
+ p:last-of-type
+ margin-bottom: 0
+ p:first-of-type
+ margin-top: 0
diff --git a/client/css/posts.styl b/client/css/posts.styl
index 2312708..b2945c9 100644
--- a/client/css/posts.styl
+++ b/client/css/posts.styl
@@ -204,9 +204,6 @@ $safety-unsafe = #F3985F
top: 0
bottom: 0
- .post-overlay
- pointer-events: none
-
.post-overlay>*
position: absolute
left: 0
@@ -216,31 +213,6 @@ $safety-unsafe = #F3985F
width: 100%
height: 100%
- .notes
- stroke-width: 1px
- polygon
- fill: $note-overlay-background-color
- stroke: $note-overlay-border-color
- pointer-events: auto
-
-.note-text
- position: absolute
- max-width: 22.5em
- margin-top: -0.5em
- display: none
-
- &>.wrapper
- background: $note-text-background-color
- padding: 0.5em
- border: 1px solid $note-text-border-color
- color: $note-text-text-color
- margin-top: 1em
-
- p:last-of-type
- margin-bottom: 0
- p:first-of-type
- margin-top: 0
-
.post-view .readonly-sidebar
.details
i
diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl
index ab28386..18e7e87 100644
--- a/client/html/post_edit_sidebar.tpl
+++ b/client/html/post_edit_sidebar.tpl
@@ -55,6 +55,14 @@
<% } %>
+ <% if (ctx.canEditPostNotes) { %>
+
+ <% } %>
+
<% if (ctx.canEditPostContent) { %>
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);