client/posts: add note editing

This commit is contained in:
rr- 2016-07-22 13:27:52 +02:00
parent 9e2dace73f
commit d5a00fe4b9
15 changed files with 954 additions and 94 deletions

View file

@ -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

63
client/css/notes.styl Normal file
View file

@ -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

View file

@ -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

View file

@ -55,6 +55,14 @@
</section>
<% } %>
<% if (ctx.canEditPostNotes) { %>
<section class='notes'>
<a class='add'>Add a note</a>
<%= ctx.makeTextarea({disabled: true, text: 'Content (supports Markdown)', rows: '8'}) %>
<a class='delete inactive'>Delete selected note</a>
</section>
<% } %>
<% if (ctx.canEditPostContent) { %>
<section class='post-content'>
<label>Content</label>

View file

@ -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);
}

View file

@ -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;

View file

@ -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]();
}

34
client/js/models/note.js Normal file
View file

@ -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;

View file

@ -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;

26
client/js/models/point.js Normal file
View file

@ -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;

View file

@ -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;

View file

@ -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());
}
};

View file

@ -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; },
};

View file

@ -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,
};

View file

@ -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);