Improve note editor and backups

This commit is contained in:
Martynas Bagdonas 2020-08-28 20:33:35 +03:00 committed by Dan Stillman
parent bce50e8e9c
commit 05318b3021
7 changed files with 165 additions and 101 deletions

View file

@ -39,7 +39,6 @@
Public properties Public properties
--> -->
<field name="editable">false</field> <field name="editable">false</field>
<field name="saveOnEdit">false</field>
<field name="displayTags">false</field> <field name="displayTags">false</field>
<field name="displayRelated">false</field> <field name="displayRelated">false</field>
<field name="displayButton">false</field> <field name="displayButton">false</field>
@ -55,15 +54,25 @@
this._noteEditorID = Zotero.Utilities.randomString(); this._noteEditorID = Zotero.Utilities.randomString();
this._iframe = document.getAnonymousElementByAttribute(this, 'anonid', 'editor-view'); this._iframe = document.getAnonymousElementByAttribute(this, 'anonid', 'editor-view');
this._iframe.addEventListener('DOMContentLoaded', (e) => { this._iframe.addEventListener('DOMContentLoaded', (e) => {
// For iframes without chrome priviledges, for unknown reasons,
// dataTransfer.getData() returns empty value for `drop` event
// when dragging something from the outside of Zotero
this._iframe.contentWindow.addEventListener('drop', (event) => {
this._iframe.contentWindow.wrappedJSObject.droppedData = Components.utils.cloneInto({
'text/plain': event.dataTransfer.getData('text/plain'),
'text/html': event.dataTransfer.getData('text/html'),
'zotero/annotation': event.dataTransfer.getData('zotero/annotation'),
'zotero/item': event.dataTransfer.getData('zotero/item')
}, this._iframe.contentWindow);
}, true);
this._initialized = true; this._initialized = true;
}); });
window.fillTooltip = (tooltip) => { window.fillTooltip = (tooltip) => {
let node = window.document.tooltipNode.closest('*[title]'); let node = window.document.tooltipNode.closest('*[title]');
if (!node) { if (!node || !node.getAttribute('title')) {
return false; return false;
} }
tooltip.setAttribute('label', node.getAttribute('title')); tooltip.setAttribute('label', node.getAttribute('title'));
return true; return true;
} }
@ -84,7 +93,8 @@
item: this._item, item: this._item,
iframeWindow: document.getAnonymousElementByAttribute(this, 'anonid', 'editor-view').contentWindow, iframeWindow: document.getAnonymousElementByAttribute(this, 'anonid', 'editor-view').contentWindow,
popup: document.getAnonymousElementByAttribute(this, 'anonid', 'editor-menu'), popup: document.getAnonymousElementByAttribute(this, 'anonid', 'editor-menu'),
onNavigate: this._navigateHandler onNavigate: this._navigateHandler,
readOnly: !this.editable
}); });
} }
@ -138,7 +148,6 @@
<![CDATA[ <![CDATA[
// Duplicate default property settings here // Duplicate default property settings here
this.editable = false; this.editable = false;
this.saveOnEdit = false;
this.displayTags = false; this.displayTags = false;
this.displayRelated = false; this.displayRelated = false;
this.displayButton = false; this.displayButton = false;
@ -151,7 +160,6 @@
case 'edit': case 'edit':
this.editable = true; this.editable = true;
this.saveOnEdit = true;
this.parentClickHandler = this.selectParent; this.parentClickHandler = this.selectParent;
this.keyDownHandler = this.handleKeyDown; this.keyDownHandler = this.handleKeyDown;
this.commandHandler = this.save; this.commandHandler = this.save;
@ -186,8 +194,8 @@
<setter><![CDATA[ <setter><![CDATA[
return (async () => { return (async () => {
// `item` field can be set before the constructor is called // `item` field can be set before the constructor is called
// (which happens in the merge dialog i.e.), therefore we wait for // or noteditor is attached to dom (which happens in the
// the initialization // merge dialog i.e.), therefore we wait for the initialization
let n = 0; let n = 0;
while (!this._initialized && !this._destroyed) { while (!this._initialized && !this._destroyed) {
if (n >= 1000) { if (n >= 1000) {
@ -197,8 +205,8 @@
n++; n++;
} }
// The binding can also be immediately destrcutred // The binding can also be immediately destructed
// (which also happens in the marge dialog) // (which also happens in the merge dialog)
if (this._destroyed) { if (this._destroyed) {
return; return;
} }
@ -207,17 +215,10 @@
if (this._item && this._item.id === val.id) return; if (this._item && this._item.id === val.id) return;
this._lastHtmlValue = val.note; this._lastHtmlValue = val.note;
this._editorInstance = new Zotero.EditorInstance();
this._editorInstance.init({
item: val,
iframeWindow: document.getAnonymousElementByAttribute(this, "anonid", "editor-view").contentWindow,
popup: document.getAnonymousElementByAttribute(this, "anonid", "editor-menu"),
readOnly: !this.editable,
onNavigate: this._navigateHandler
});
this._item = val; this._item = val;
this.initEditor();
var parentKey = this._item.parentKey; var parentKey = this._item.parentKey;
if (parentKey) { if (parentKey) {

View file

@ -422,7 +422,6 @@ const ZoteroStandalone = new function() {
this.updateNoteFontSize = function (event) { this.updateNoteFontSize = function (event) {
var size = event.originalTarget.getAttribute('label'); var size = event.originalTarget.getAttribute('label');
Zotero.Prefs.set('note.fontSize', size); Zotero.Prefs.set('note.fontSize', size);
this.promptForRestart();
}; };

View file

@ -27,7 +27,6 @@ class EditorInstance {
constructor() { constructor() {
this.instanceID = Zotero.Utilities.randomString(); this.instanceID = Zotero.Utilities.randomString();
Zotero.Notes.registerEditorInstance(this); Zotero.Notes.registerEditorInstance(this);
Zotero.debug('Creating a new editor instance');
} }
async init(options) { async init(options) {
@ -37,13 +36,16 @@ class EditorInstance {
this._iframeWindow = options.iframeWindow; this._iframeWindow = options.iframeWindow;
this._popup = options.popup; this._popup = options.popup;
this._state = options.state; this._state = options.state;
this._saveOnEdit = true;
this._disableSaving = false; this._disableSaving = false;
this._subscriptions = []; this._subscriptions = [];
this._deletedImages = {};
this._quickFormatWindow = null; this._quickFormatWindow = null;
this._isAttachment = this._item.isAttachment();
await this._waitForEditor(); this._prefObserverIDs = [
Zotero.Prefs.registerObserver('note.fontSize', this._handleFontChange),
Zotero.Prefs.registerObserver('note.fontFamily', this._handleFontChange)
];
// Run Cut/Copy/Paste with chrome privileges // Run Cut/Copy/Paste with chrome privileges
this._iframeWindow.wrappedJSObject.zoteroExecCommand = function (doc, command, ui, value) { this._iframeWindow.wrappedJSObject.zoteroExecCommand = function (doc, command, ui, value) {
// Is that safe enough? // Is that safe enough?
@ -53,19 +55,40 @@ class EditorInstance {
return doc.execCommand(command, ui, value); return doc.execCommand(command, ui, value);
}; };
this._iframeWindow.addEventListener('message', this._listener); this._iframeWindow.addEventListener('message', this._messageHandler);
let note = this._item.note;
this._postMessage({ this._postMessage({
action: 'init', action: 'init',
value: this._state || this._item.note, value: this._state || this._item.note,
schemaVersion: this._item.noteSchemaVersion, readOnly: this._readOnly,
readOnly: this._readOnly dir: Zotero.dir,
font: this._getFont(),
// TODO: We should avoid hitting `data-schema-version` in note text
hasBackup: note && note.toLowerCase().indexOf('data-schema-version') < 0
|| !!await Zotero.NoteBackups.getNote(this._item.id)
}); });
} }
uninit() { uninit() {
this._iframeWindow.removeEventListener('message', this._listener); this._prefObserverIDs.forEach(id => Zotero.Prefs.unregisterObserver(id));
if (this._quickFormatWindow) {
this._quickFormatWindow.close();
this._quickFormatWindow = null;
}
// TODO: Allow editor instance to finish its work before
// the uninitialization. I.e. to finish image importing
// As long as the message listeners are attached on
// both sides, editor instance can continue its work
// in the backstage. Although the danger here is that
// multiple editor instances of the same note can start
// compeating
this._iframeWindow.removeEventListener('message', this._messageHandler);
Zotero.Notes.unregisterEditorInstance(this); Zotero.Notes.unregisterEditorInstance(this);
this.saveSync();
} }
focus() { focus() {
@ -84,27 +107,32 @@ class EditorInstance {
saveSync() { saveSync() {
if (!this._readOnly && !this._disableSaving && this._iframeWindow) { if (!this._readOnly && !this._disableSaving && this._iframeWindow) {
let noteData = this._iframeWindow.wrappedJSObject.getDataSync(); let noteData = this._iframeWindow.wrappedJSObject.getDataSync();
noteData = JSON.parse(JSON.stringify(noteData)); if (noteData) {
noteData = JSON.parse(JSON.stringify(noteData));
}
this._save(noteData); this._save(noteData);
} }
} }
async _waitForEditor() {
let n = 0;
while (!this._iframeWindow) {
if (n >= 1000) {
throw new Error('Waiting for editor failed');
}
await Zotero.Promise.delay(10);
n++;
}
}
_postMessage(message) { _postMessage(message) {
this._iframeWindow.postMessage({ instanceId: this.instanceID, message }, '*'); this._iframeWindow.postMessage({ instanceId: this.instanceID, message }, '*');
} }
_listener = async (e) => { _getFont() {
let fontSize = Zotero.Prefs.get('note.fontSize');
// Fix empty old font prefs before a value was enforced
if (fontSize < 6) {
fontSize = 11;
}
let fontFamily = Zotero.Prefs.get('note.fontFamily');
return { fontSize, fontFamily };
}
_handleFontChange = () => {
this._postMessage({ action: 'updateFont', font: this._getFont() });
}
_messageHandler = async (e) => {
if (e.data.instanceId !== this.instanceID) { if (e.data.instanceId !== this.instanceID) {
return; return;
} }
@ -202,8 +230,7 @@ class EditorInstance {
return; return;
} }
case 'subscribeProvider': { case 'subscribeProvider': {
let { id, type, data } = message; let { subscription } = message;
let subscription = { id, type, data };
this._subscriptions.push(subscription); this._subscriptions.push(subscription);
await this._feedSubscription(subscription); await this._feedSubscription(subscription);
return; return;
@ -230,32 +257,44 @@ class EditorInstance {
} }
case 'importImages': { case 'importImages': {
let { images } = message; let { images } = message;
if (this._isAttachment) {
return;
}
for (let image of images) { for (let image of images) {
let { nodeId, src } = image; let { nodeId, src } = image;
let attachmentKey = await this._importImage(src); let attachmentKey = await this._importImage(src);
// TODO: Inform editor about the failed to import images
this._postMessage({ action: 'attachImportedImage', nodeId, attachmentKey }); this._postMessage({ action: 'attachImportedImage', nodeId, attachmentKey });
} }
return; return;
} }
case 'syncAttachmentKeys': { case 'syncAttachmentKeys': {
let { attachmentKeys } = message; let { attachmentKeys } = message;
if (this._isAttachment) {
return;
}
// TODO: Remove when fixed
this._item._loaded.childItems = true;
let attachmentItems = this._item.getAttachments().map(id => Zotero.Items.get(id)); let attachmentItems = this._item.getAttachments().map(id => Zotero.Items.get(id));
let abandonedItems = attachmentItems.filter(item => !attachmentKeys.includes(item.key)); let abandonedItems = attachmentItems.filter(item => !attachmentKeys.includes(item.key));
for (let item of abandonedItems) { for (let item of abandonedItems) {
// Store image data in case it will be necessary for undo,
// which is not ideal
this._deletedImages[item.key] = await this._getDataURL(item);
await item.eraseTx(); await item.eraseTx();
} }
return; return;
} }
case 'popup': { case 'openContextMenu': {
let { x, y, pos, items } = message; let { x, y, pos, itemGroups } = message;
this._openPopup(x, y, pos, items); this._openPopup(x, y, pos, itemGroups);
return; return;
} }
} }
} }
async _feedSubscription(subscription) { async _feedSubscription(subscription) {
let { id, type, data } = subscription; let { id, type, nodeId, data } = subscription;
if (type === 'citation') { if (type === 'citation') {
let formattedCitation = await this._getFormattedCitation(data.citation); let formattedCitation = await this._getFormattedCitation(data.citation);
this._postMessage({ action: 'notifyProvider', id, type, data: { formattedCitation } }); this._postMessage({ action: 'notifyProvider', id, type, data: { formattedCitation } });
@ -263,11 +302,22 @@ class EditorInstance {
else if (type === 'image') { else if (type === 'image') {
let { attachmentKey } = data; let { attachmentKey } = data;
let item = Zotero.Items.getByLibraryAndKey(this._item.libraryID, attachmentKey); let item = Zotero.Items.getByLibraryAndKey(this._item.libraryID, attachmentKey);
if (!item) return; if (!item) {
let path = await item.getFilePathAsync(); // TODO: Find a better way to undo image deletion,
let buf = await OS.File.read(path, {}); // probably just keep it in a trash until the note is closed
buf = new Uint8Array(buf).buffer; // This recreates the attachment as a completely new item
let src = 'data:' + item.attachmentContentType + ';base64,' + this._arrayBufferToBase64(buf); let dataURL = this._deletedImages[attachmentKey];
if (dataURL) {
// delete this._deletedImages[attachmentKey];
// TODO: Fix every repeated undo-redo cycle caching a
// new image copy in memory
let newAttachmentKey = await this._importImage(dataURL);
// TODO: Inform editor about the failed to import images
this._postMessage({ action: 'attachImportedImage', nodeId, attachmentKey: newAttachmentKey });
}
return;
}
let src = await this._getDataURL(item);
this._postMessage({ action: 'notifyProvider', id, type, data: { src } }); this._postMessage({ action: 'notifyProvider', id, type, data: { src } });
} }
} }
@ -303,25 +353,35 @@ class EditorInstance {
return attachment.key; return attachment.key;
} }
_openPopup(x, y, pos, items) { _openPopup(x, y, pos, itemGroups) {
this._popup.hidePopup(); this._popup.hidePopup();
while (this._popup.firstChild) { while (this._popup.firstChild) {
this._popup.removeChild(this._popup.firstChild); this._popup.removeChild(this._popup.firstChild);
} }
for (let item of items) { for (let itemGroup of itemGroups) {
let menuitem = this._popup.ownerDocument.createElement('menuitem'); for (let item of itemGroup) {
menuitem.setAttribute('value', item[0]); let menuitem = this._popup.ownerDocument.createElement('menuitem');
menuitem.setAttribute('label', item[1]); menuitem.setAttribute('value', item.name);
menuitem.addEventListener('command', () => { menuitem.setAttribute('label', item.label);
this._postMessage({ if (!item.enabled) {
action: 'contextMenuAction', menuitem.setAttribute('disabled', true);
ctxAction: item[0], }
pos menuitem.addEventListener('command', () => {
this._postMessage({
action: 'contextMenuAction',
ctxAction: item.name,
pos
});
}); });
}); this._popup.appendChild(menuitem);
this._popup.appendChild(menuitem); }
if (itemGroups.indexOf(itemGroup) !== itemGroups.length - 1) {
let separator = this._popup.ownerDocument.createElement('menuseparator');
this._popup.appendChild(separator);
}
} }
this._popup.openPopupAtScreen(x, y, true); this._popup.openPopupAtScreen(x, y, true);
@ -329,7 +389,7 @@ class EditorInstance {
async _save(noteData) { async _save(noteData) {
if (!noteData) return; if (!noteData) return;
let { schemaVersion, state, html } = noteData; let { state, html } = noteData;
if (html === undefined) return; if (html === undefined) return;
try { try {
if (this._disableSaving) { if (this._disableSaving) {
@ -349,12 +409,8 @@ class EditorInstance {
if (this._item) { if (this._item) {
await Zotero.NoteBackups.ensureBackup(this._item); await Zotero.NoteBackups.ensureBackup(this._item);
await Zotero.DB.executeTransaction(async () => { await Zotero.DB.executeTransaction(async () => {
let changed = this._item.setNote(html, schemaVersion); let changed = this._item.setNote(html);
if (changed && this._saveOnEdit) { if (changed && !this._disableSaving) {
// Make sure saving is not disabled
if (this._disableSaving) {
return;
}
await this._item.save({ await this._item.save({
notifierData: { notifierData: {
noteEditorID: this.instanceID, noteEditorID: this.instanceID,
@ -370,11 +426,11 @@ class EditorInstance {
if (this.parentItem) { if (this.parentItem) {
item.libraryID = this.parentItem.libraryID; item.libraryID = this.parentItem.libraryID;
} }
item.setNote(html, schemaVersion); item.setNote(html);
if (this.parentItem) { if (this.parentItem) {
item.parentKey = this.parentItem.key; item.parentKey = this.parentItem.key;
} }
if (this._saveOnEdit) { if (!this._disableSaving) {
var id = await item.saveTx(); var id = await item.saveTx();
if (!this.parentItem && this.collection) { if (!this.parentItem && this.collection) {
@ -463,7 +519,7 @@ class EditorInstance {
for (var i = 0; i < len; i++) { for (var i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]); binary += String.fromCharCode(bytes[i]);
} }
return this._iframeWindow.btoa(binary); return btoa(binary);
} }
_dataURLtoBlob(dataurl) { _dataURLtoBlob(dataurl) {
@ -482,7 +538,15 @@ class EditorInstance {
return null; return null;
} }
_openQuickFormatDialog(nodeId, citationData, filterLibraryIDs) { async _getDataURL(item) {
let path = await item.getFilePathAsync();
let buf = await OS.File.read(path, {});
buf = new Uint8Array(buf).buffer;
return 'data:' + item.attachmentContentType + ';base64,' + this._arrayBufferToBase64(buf);
}
async _openQuickFormatDialog(nodeId, citationData, filterLibraryIDs) {
await Zotero.Styles.init();
let that = this; let that = this;
let win; let win;
/** /**
@ -500,18 +564,16 @@ class EditorInstance {
* Execute a callback with a preview of the given citation * Execute a callback with a preview of the given citation
* @return {Promise} A promise resolved with the previewed citation string * @return {Promise} A promise resolved with the previewed citation string
*/ */
preview: function () { preview: async function () {
Zotero.debug('CI: preview'); // Zotero.debug('CI: preview');
}, },
/** /**
* Sort the citationItems within citation (depends on this.citation.properties.unsorted) * Sort the citationItems within citation (depends on this.citation.properties.unsorted)
* @return {Promise} A promise resolved with the previewed citation string * @return {Promise} A promise resolved with the previewed citation string
*/ */
sort: function () { sort: async function () {
Zotero.debug('CI: sort'); // Zotero.debug('CI: sort');
return async function () {
};
}, },
/** /**
@ -520,7 +582,7 @@ class EditorInstance {
* Receives a number from 0 to 100 indicating current status. * Receives a number from 0 to 100 indicating current status.
*/ */
accept: async function (progressCallback) { accept: async function (progressCallback) {
Zotero.debug('CI: accept'); // Zotero.debug('CI: accept');
if (progressCallback) progressCallback(100); if (progressCallback) progressCallback(100);
if (win) { if (win) {
@ -533,13 +595,13 @@ class EditorInstance {
} }
for (let citationItem of citation.citationItems) { for (let citationItem of citation.citationItems) {
let itm = await Zotero.Items.getAsync(citationItem.id); let itm = await Zotero.Items.getAsync(parseInt(citationItem.id));
delete citationItem.id; delete citationItem.id;
citationItem.uri = Zotero.URI.getItemURI(itm); citationItem.uri = Zotero.URI.getItemURI(itm);
citationItem.backupText = that._getBackupStr(itm); citationItem.backupText = that._getBackupStr(itm);
} }
if (this.citation.citationItems.length) { if (progressCallback || !citationData.citationItems.length) {
that._postMessage({ action: 'setCitation', nodeId, citation }); that._postMessage({ action: 'setCitation', nodeId, citation });
} }
}, },
@ -549,7 +611,7 @@ class EditorInstance {
* @return {Promise} A promise resolved by the items * @return {Promise} A promise resolved by the items
*/ */
getItems: async function () { getItems: async function () {
Zotero.debug('CI: getItems'); // Zotero.debug('CI: getItems');
return []; return [];
} }
} }
@ -578,19 +640,19 @@ class EditorInstance {
* - Zotero.Integration.DELETE * - Zotero.Integration.DELETE
*/ */
loadItemData() { loadItemData() {
Zotero.debug('Citation: loadItemData'); // Zotero.debug('Citation: loadItemData');
} }
async handleMissingItem(idx) { async handleMissingItem(idx) {
Zotero.debug('Citation: handleMissingItem'); // Zotero.debug('Citation: handleMissingItem');
} }
async prepareForEditing() { async prepareForEditing() {
Zotero.debug('Citation: prepareForEditing'); // Zotero.debug('Citation: prepareForEditing');
} }
toJSON() { toJSON() {
Zotero.debug('Citation: toJSON'); // Zotero.debug('Citation: toJSON');
} }
/** /**
@ -598,13 +660,13 @@ class EditorInstance {
* @returns {string} * @returns {string}
*/ */
serialize() { serialize() {
Zotero.debug('Citation: serialize'); // Zotero.debug('Citation: serialize');
} }
}; };
if (that.quickFormatWindow) { if (that._quickFormatWindow) {
that.quickFormatWindow.close(); that._quickFormatWindow.close();
that.quickFormatWindow = null; that._quickFormatWindow = null;
} }
let citation = new Citation(); let citation = new Citation();
@ -624,7 +686,7 @@ class EditorInstance {
var mode = (!Zotero.isMac && Zotero.Prefs.get('integration.keepAddCitationDialogRaised') var mode = (!Zotero.isMac && Zotero.Prefs.get('integration.keepAddCitationDialogRaised')
? 'popup' : 'alwaysRaised') + ',resizable=false,centerscreen'; ? 'popup' : 'alwaysRaised') + ',resizable=false,centerscreen';
win = that.quickFormatWindow = Components.classes['@mozilla.org/embedcomp/window-watcher;1'] win = that._quickFormatWindow = Components.classes['@mozilla.org/embedcomp/window-watcher;1']
.getService(Components.interfaces.nsIWindowWatcher) .getService(Components.interfaces.nsIWindowWatcher)
.openWindow(null, 'chrome://zotero/content/integration/quickFormat.xul', '', mode, { .openWindow(null, 'chrome://zotero/content/integration/quickFormat.xul', '', mode, {
wrappedJSObject: io wrappedJSObject: io

View file

@ -33,7 +33,9 @@ Zotero.NoteBackups = {
}, },
ensureBackup: async function(item) { ensureBackup: async function(item) {
if (item.noteSchemaVersion === 0) { let note = item.note;
// TODO: We should avoid hitting `data-schema-version` in note text
if (note && note.toLowerCase().indexOf('data-schema-version') < 0) {
await Zotero.DB.queryAsync("INSERT OR IGNORE INTO noteBackups VALUES (?, ?)", [item.id, item.note]); await Zotero.DB.queryAsync("INSERT OR IGNORE INTO noteBackups VALUES (?, ?)", [item.id, item.note]);
} }
}, },

@ -1 +1 @@
Subproject commit cb09ac97385b17bad5ef3cb70daa57fb998b309f Subproject commit 7aefe43059843f2b065f1ca9630d1bb48e08d4c3

@ -1 +1 @@
Subproject commit b855ed86d8f50261a4b5437d5894d32fe5389a67 Subproject commit 20b9f8f7a197b809c310db0c94bc7a5d22ed9bd0

@ -1 +1 @@
Subproject commit d60cd22eee9d5cca35dd5ad77d73f3d99c755c92 Subproject commit 3e8ec222463b2eb05c4fecd4c43b8b629311e583