Skip to content

Commit d6c791f

Browse files
committed
Add support for external undo history
1 parent ba173e7 commit d6c791f

3 files changed

Lines changed: 115 additions & 42 deletions

File tree

src/common/annotation-manager.js

Lines changed: 74 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class AnnotationManager {
2222
this._onChangeFilter = options.onChangeFilter;
2323
this._onSave = options.onSave;
2424
this._onDelete = options.onDelete;
25+
this._onChangeHistory = options.onChangeHistory;
2526
this._adjustTextAnnotationPosition = options.adjustTextAnnotationPosition;
2627
this.render = () => {
2728
options.onRender([...this._annotations]);
@@ -36,6 +37,7 @@ class AnnotationManager {
3637

3738
this._undoStack = [];
3839
this._redoStack = [];
40+
this._lastHistoryPointID = 0;
3941

4042
this._annotations.sort((a, b) => (a.sortIndex > b.sortIndex) - (a.sortIndex < b.sortIndex));
4143

@@ -61,7 +63,14 @@ class AnnotationManager {
6163

6264
// Called when deletions come from the client side
6365
unsetAnnotations(ids) {
66+
// Deletions we haven't applied yet are outside changes that our history
67+
// can no longer be replayed over. Ones we have applied are our own
68+
// deletions coming back to us, and undoing them is still valid.
69+
let externalIDs = ids.filter(id => this._annotations.some(x => x.id === id));
6470
this._annotations = this._annotations.filter(x => !ids.includes(x.id));
71+
if (externalIDs.length) {
72+
this._clearInterferingHistory(externalIDs);
73+
}
6574
this.render();
6675
}
6776

@@ -95,7 +104,7 @@ class AnnotationManager {
95104
annotation.position = roundPositionValues(annotation.position);
96105

97106
let changedAnnotations = new Map([[annotation.id, annotation]]);
98-
this._applyChanges(changedAnnotations);
107+
this._applyChanges(changedAnnotations, 'add-annotations', 1);
99108
return annotation;
100109
}
101110

@@ -170,7 +179,7 @@ class AnnotationManager {
170179
annotation.position = roundPositionValues(annotation.position);
171180
changedAnnotations.set(annotation.id, annotation);
172181
}
173-
this._applyChanges(changedAnnotations);
182+
this._applyChanges(changedAnnotations, 'update-annotations', changedAnnotations.size);
174183
}
175184

176185
deleteAnnotations(ids) {
@@ -182,7 +191,7 @@ class AnnotationManager {
182191
return 0;
183192
}
184193
let changedAnnotations = new Map(ids.map(id => [id, null]));
185-
this._applyChanges(changedAnnotations);
194+
this._applyChanges(changedAnnotations, 'delete-annotations', changedAnnotations.size);
186195
return changedAnnotations.size;
187196
}
188197

@@ -209,7 +218,7 @@ class AnnotationManager {
209218
annotation = { ...annotation, type, dateModified, id: this._generateObjectKey() };
210219
changedAnnotations.set(annotation.id, annotation);
211220
}
212-
this._applyChanges(changedAnnotations);
221+
this._applyChanges(changedAnnotations, 'convert-annotations', annotations.length);
213222
}
214223

215224
mergeAnnotations(ids) {
@@ -289,7 +298,7 @@ class AnnotationManager {
289298

290299
let changedAnnotations = new Map(annotations.map(x => [x.id, null]));
291300
changedAnnotations.set(annotation.id, annotation);
292-
this._applyChanges(changedAnnotations);
301+
this._applyChanges(changedAnnotations, 'merge-annotations', annotations.length);
293302

294303
return annotation;
295304
}
@@ -307,7 +316,12 @@ class AnnotationManager {
307316
return randomstring;
308317
}
309318

310-
_applyChanges(changedAnnotations) {
319+
/**
320+
* @param {Map | null} changedAnnotations Annotation ID -> new annotation, or null to delete
321+
* @param {string} action Action that produced the changes, for the history point
322+
* @param {number} count Number of annotations the action was performed on
323+
*/
324+
_applyChanges(changedAnnotations, action, count) {
311325
if (!changedAnnotations.size) {
312326
return;
313327
}
@@ -320,7 +334,7 @@ class AnnotationManager {
320334
}
321335
this._unsavedAnnotations.set(id, changedAnnotation);
322336
}
323-
this._historySave(changedAnnotations);
337+
this._historySave(changedAnnotations, action, count);
324338
annotations = new Map([...annotations, ...changedAnnotations]);
325339
this._annotations = [...annotations.values()].filter(x => x);
326340
this._annotations.sort((a, b) => (a.sortIndex > b.sortIndex) - (a.sortIndex < b.sortIndex));
@@ -458,7 +472,7 @@ class AnnotationManager {
458472
this.render();
459473
}
460474

461-
_historySave(changedAnnotations) {
475+
_historySave(changedAnnotations, action, count) {
462476
if (!changedAnnotations.size) {
463477
return;
464478
}
@@ -478,10 +492,10 @@ class AnnotationManager {
478492
let disableTextualJoin = true;
479493
if (
480494
prevPoint && point
481-
&& prevPoint.size === 1 && point.size === 1 && oldAnnotations.size === 1
495+
&& prevPoint.annotations.size === 1 && point.annotations.size === 1 && oldAnnotations.size === 1
482496
) {
483-
let [id1, annotation1] = [...prevPoint][0];
484-
let [id2, annotation2] = [...point][0];
497+
let [id1, annotation1] = [...prevPoint.annotations][0];
498+
let [id2, annotation2] = [...point.annotations][0];
485499
let [id3, annotation3] = [...oldAnnotations][0];
486500
if (id1 === id2 && id2 === id3) {
487501
disableJoin = false;
@@ -502,45 +516,66 @@ class AnnotationManager {
502516
}
503517

504518
if (!point || disableJoin || Date.now() - this._lastChange > 500 && disableTextualJoin) {
505-
point = new Map();
519+
point = {
520+
id: ++this._lastHistoryPointID,
521+
// Bumped whenever an additional change is joined into the point,
522+
// so an external history can tell that it moved to the top
523+
revision: 0,
524+
action,
525+
count,
526+
annotations: new Map()
527+
};
506528
this._undoStack.push(point);
507529
}
530+
else {
531+
// The point keeps the action it was created with and now covers this
532+
// change as well
533+
point.revision++;
534+
}
508535
for (let [id, annotation] of oldAnnotations) {
509536
if (annotation) {
510537
annotation = JSON.parse(JSON.stringify(annotation));
511538
delete annotation.image;
512539
}
513-
point.set(id, annotation);
540+
point.annotations.set(id, annotation);
514541
}
515542

516543
this._lastChange = Date.now();
517544
this._redoStack = [];
545+
this._notifyChangeHistory();
518546
}
519547

520548
remapHistory(mapping) {
521549
for (let [oldID, newID] of mapping) {
522-
for (let point of this._undoStack) {
523-
if (point.has(oldID)) {
524-
let annotation = point.get(oldID);
550+
for (let { annotations } of [...this._undoStack, ...this._redoStack]) {
551+
if (annotations.has(oldID)) {
552+
let annotation = annotations.get(oldID);
525553
if (annotation) {
526554
annotation.id = newID;
527555
}
528-
point.delete(oldID);
529-
point.set(newID, annotation);
556+
annotations.delete(oldID);
557+
annotations.set(newID, annotation);
530558
}
531559
}
560+
}
561+
}
532562

533-
for (let point of this._redoStack) {
534-
if (point.has(oldID)) {
535-
let annotation = point.get(oldID);
536-
if (annotation) {
537-
annotation.id = newID;
538-
}
539-
point.delete(oldID);
540-
point.set(newID, annotation);
541-
}
542-
}
563+
/**
564+
* Report the current history to the client, which can then present the
565+
* reader's actions as part of the application-wide undo history, and
566+
* step through them with undo() and redo()
567+
*/
568+
_notifyChangeHistory() {
569+
if (!this._onChangeHistory) {
570+
return;
543571
}
572+
let describe = stack => stack.map(
573+
({ id, revision, action, count }) => ({ id, revision, action, count })
574+
);
575+
this._onChangeHistory({
576+
undoSteps: describe(this._undoStack),
577+
redoSteps: describe(this._redoStack)
578+
});
544579
}
545580

546581
get canUndo() {
@@ -557,12 +592,12 @@ class AnnotationManager {
557592
return false;
558593
}
559594
let mapping = new Map();
560-
let redoPoint = new Map();
595+
let redoPoint = { ...undoPoint, annotations: new Map() };
561596
let allAnnotations = new Map(this._annotations.map(x => [x.id, x]));
562-
for (let [id, annotation] of undoPoint) {
597+
for (let [id, annotation] of undoPoint.annotations) {
563598
annotation = annotation && { ...annotation };
564599
let prevAnnotation = allAnnotations.get(id);
565-
redoPoint.set(id, prevAnnotation);
600+
redoPoint.annotations.set(id, prevAnnotation);
566601
if (annotation) {
567602
annotation.dateModified = (new Date()).toISOString();
568603
}
@@ -581,6 +616,7 @@ class AnnotationManager {
581616
this._annotations.sort((a, b) => (a.sortIndex > b.sortIndex) - (a.sortIndex < b.sortIndex));
582617
this._triggerSaving();
583618
this.render();
619+
this._notifyChangeHistory();
584620
return true;
585621
}
586622

@@ -590,12 +626,12 @@ class AnnotationManager {
590626
return false;
591627
}
592628
let mapping = new Map();
593-
let undoPoint = new Map();
629+
let undoPoint = { ...redoPoint, annotations: new Map() };
594630
let allAnnotations = new Map(this._annotations.map(x => [x.id, x]));
595-
for (let [id, annotation] of redoPoint) {
631+
for (let [id, annotation] of redoPoint.annotations) {
596632
annotation = annotation && { ...annotation };
597633
let prevAnnotation = allAnnotations.get(id);
598-
undoPoint.set(id, prevAnnotation);
634+
undoPoint.annotations.set(id, prevAnnotation);
599635
if (annotation) {
600636
annotation.dateModified = (new Date()).toISOString();
601637
}
@@ -614,22 +650,24 @@ class AnnotationManager {
614650
this._annotations.sort((a, b) => (a.sortIndex > b.sortIndex) - (a.sortIndex < b.sortIndex));
615651
this._triggerSaving();
616652
this.render();
653+
this._notifyChangeHistory();
617654
return true;
618655
}
619656

620657
_clearInterferingHistory(affectedAnnotationIDs) {
621658
for (let i = this._undoStack.length - 1; i >= 0; i--) {
622-
if (affectedAnnotationIDs.some(id => this._undoStack[i].has(id))) {
659+
if (affectedAnnotationIDs.some(id => this._undoStack[i].annotations.has(id))) {
623660
this._undoStack = this._undoStack.slice(i + 1);
624661
break;
625662
}
626663
}
627664
for (let i = 0; i < this._redoStack.length; i++) {
628-
if (affectedAnnotationIDs.some(id => this._redoStack[i].has(id))) {
665+
if (affectedAnnotationIDs.some(id => this._redoStack[i].annotations.has(id))) {
629666
this._redoStack = this._redoStack.slice(0, Math.max(0, i - 1));
630667
break;
631668
}
632669
}
670+
this._notifyChangeHistory();
633671
}
634672
}
635673

src/common/keyboard-manager.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -240,15 +240,17 @@ export class KeyboardManager {
240240
}
241241
}
242242
}
243-
else if ((view || sidebarAnnotationFocused) && key === `${pm}-z`) {
243+
// Only handle undo/redo shortcuts internally if the embedding
244+
// Zotero instance isn't tracking undo history itself
245+
else if ((view || sidebarAnnotationFocused) && key === `${pm}-z`
246+
&& !this._reader._externalUndoHistory) {
244247
event.preventDefault();
245-
this._reader._annotationManager.undo();
246-
this._reader.setSelectedAnnotations([]);
248+
this._reader.undo();
247249
}
248-
else if ((view || sidebarAnnotationFocused) && key === `${pm}-Shift-z`) {
250+
else if ((view || sidebarAnnotationFocused) && key === `${pm}-Shift-z`
251+
&& !this._reader._externalUndoHistory) {
249252
event.preventDefault();
250-
this._reader._annotationManager.redo();
251-
this._reader.setSelectedAnnotations([]);
253+
this._reader.redo();
252254
}
253255
else if (key === `${pm}-f`) {
254256
event.preventDefault();

src/common/reader.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ class Reader {
128128
this._onLogIn = options.onLogIn;
129129
this._onOpenReadAloudFirstRunPopup = options.onOpenReadAloudFirstRunPopup;
130130
this._onSetPopupPosition = options.onSetPopupPosition;
131+
this._onChangeUndoHistory = options.onChangeUndoHistory;
132+
this._externalUndoHistory = !!options.onChangeUndoHistory;
131133

132134
for (let ftl of options.ftl) {
133135
addFTL(ftl);
@@ -358,6 +360,7 @@ class Reader {
358360
onChangeFilter: (filter) => {
359361
this._updateState({ filter });
360362
},
363+
onChangeHistory: this._onChangeUndoHistory,
361364
adjustTextAnnotationPosition: (annotation, option) => {
362365
return this._primaryView.adjustTextAnnotationPosition(annotation, option);
363366
}
@@ -2238,6 +2241,36 @@ class Reader {
22382241
return this._annotationManager.mergeAnnotations(ids);
22392242
}
22402243

2244+
get canUndo() {
2245+
return this._annotationManager.canUndo;
2246+
}
2247+
2248+
get canRedo() {
2249+
return this._annotationManager.canRedo;
2250+
}
2251+
2252+
/**
2253+
* @returns {boolean} Whether a change was reverted
2254+
*/
2255+
undo() {
2256+
if (!this._annotationManager.undo()) {
2257+
return false;
2258+
}
2259+
this.setSelectedAnnotations([]);
2260+
return true;
2261+
}
2262+
2263+
/**
2264+
* @returns {boolean} Whether a change was reapplied
2265+
*/
2266+
redo() {
2267+
if (!this._annotationManager.redo()) {
2268+
return false;
2269+
}
2270+
this.setSelectedAnnotations([]);
2271+
return true;
2272+
}
2273+
22412274
/**
22422275
* @param {BufferSource} metadata
22432276
* @returns {{ count: number, lastModified?: Date }}

0 commit comments

Comments
 (0)