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
-->
<field name="editable">false</field>
<field name="saveOnEdit">false</field>
<field name="displayTags">false</field>
<field name="displayRelated">false</field>
<field name="displayButton">false</field>
@ -55,15 +54,25 @@
this._noteEditorID = Zotero.Utilities.randomString();
this._iframe = document.getAnonymousElementByAttribute(this, 'anonid', 'editor-view');
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;
});
window.fillTooltip = (tooltip) => {
let node = window.document.tooltipNode.closest('*[title]');
if (!node) {
if (!node || !node.getAttribute('title')) {
return false;
}
tooltip.setAttribute('label', node.getAttribute('title'));
return true;
}
@ -84,7 +93,8 @@
item: this._item,
iframeWindow: document.getAnonymousElementByAttribute(this, 'anonid', 'editor-view').contentWindow,
popup: document.getAnonymousElementByAttribute(this, 'anonid', 'editor-menu'),
onNavigate: this._navigateHandler
onNavigate: this._navigateHandler,
readOnly: !this.editable
});
}
@ -138,7 +148,6 @@
<![CDATA[
// Duplicate default property settings here
this.editable = false;
this.saveOnEdit = false;
this.displayTags = false;
this.displayRelated = false;
this.displayButton = false;
@ -151,7 +160,6 @@
case 'edit':
this.editable = true;
this.saveOnEdit = true;
this.parentClickHandler = this.selectParent;
this.keyDownHandler = this.handleKeyDown;
this.commandHandler = this.save;
@ -186,8 +194,8 @@
<setter><![CDATA[
return (async () => {
// `item` field can be set before the constructor is called
// (which happens in the merge dialog i.e.), therefore we wait for
// the initialization
// or noteditor is attached to dom (which happens in the
// merge dialog i.e.), therefore we wait for the initialization
let n = 0;
while (!this._initialized && !this._destroyed) {
if (n >= 1000) {
@ -197,8 +205,8 @@
n++;
}
// The binding can also be immediately destrcutred
// (which also happens in the marge dialog)
// The binding can also be immediately destructed
// (which also happens in the merge dialog)
if (this._destroyed) {
return;
}
@ -207,17 +215,10 @@
if (this._item && this._item.id === val.id) return;
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.initEditor();
var parentKey = this._item.parentKey;
if (parentKey) {

View file

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

View file

@ -27,7 +27,6 @@ class EditorInstance {
constructor() {
this.instanceID = Zotero.Utilities.randomString();
Zotero.Notes.registerEditorInstance(this);
Zotero.debug('Creating a new editor instance');
}
async init(options) {
@ -37,13 +36,16 @@ class EditorInstance {
this._iframeWindow = options.iframeWindow;
this._popup = options.popup;
this._state = options.state;
this._saveOnEdit = true;
this._disableSaving = false;
this._subscriptions = [];
this._deletedImages = {};
this._quickFormatWindow = null;
await this._waitForEditor();
this._isAttachment = this._item.isAttachment();
this._prefObserverIDs = [
Zotero.Prefs.registerObserver('note.fontSize', this._handleFontChange),
Zotero.Prefs.registerObserver('note.fontFamily', this._handleFontChange)
];
// Run Cut/Copy/Paste with chrome privileges
this._iframeWindow.wrappedJSObject.zoteroExecCommand = function (doc, command, ui, value) {
// Is that safe enough?
@ -53,19 +55,40 @@ class EditorInstance {
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({
action: 'init',
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() {
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);
this.saveSync();
}
focus() {
@ -84,27 +107,32 @@ class EditorInstance {
saveSync() {
if (!this._readOnly && !this._disableSaving && this._iframeWindow) {
let noteData = this._iframeWindow.wrappedJSObject.getDataSync();
noteData = JSON.parse(JSON.stringify(noteData));
if (noteData) {
noteData = JSON.parse(JSON.stringify(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) {
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) {
return;
}
@ -202,8 +230,7 @@ class EditorInstance {
return;
}
case 'subscribeProvider': {
let { id, type, data } = message;
let subscription = { id, type, data };
let { subscription } = message;
this._subscriptions.push(subscription);
await this._feedSubscription(subscription);
return;
@ -230,32 +257,44 @@ class EditorInstance {
}
case 'importImages': {
let { images } = message;
if (this._isAttachment) {
return;
}
for (let image of images) {
let { nodeId, src } = image;
let attachmentKey = await this._importImage(src);
// TODO: Inform editor about the failed to import images
this._postMessage({ action: 'attachImportedImage', nodeId, attachmentKey });
}
return;
}
case 'syncAttachmentKeys': {
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 abandonedItems = attachmentItems.filter(item => !attachmentKeys.includes(item.key));
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();
}
return;
}
case 'popup': {
let { x, y, pos, items } = message;
this._openPopup(x, y, pos, items);
case 'openContextMenu': {
let { x, y, pos, itemGroups } = message;
this._openPopup(x, y, pos, itemGroups);
return;
}
}
}
async _feedSubscription(subscription) {
let { id, type, data } = subscription;
let { id, type, nodeId, data } = subscription;
if (type === 'citation') {
let formattedCitation = await this._getFormattedCitation(data.citation);
this._postMessage({ action: 'notifyProvider', id, type, data: { formattedCitation } });
@ -263,11 +302,22 @@ class EditorInstance {
else if (type === 'image') {
let { attachmentKey } = data;
let item = Zotero.Items.getByLibraryAndKey(this._item.libraryID, attachmentKey);
if (!item) return;
let path = await item.getFilePathAsync();
let buf = await OS.File.read(path, {});
buf = new Uint8Array(buf).buffer;
let src = 'data:' + item.attachmentContentType + ';base64,' + this._arrayBufferToBase64(buf);
if (!item) {
// TODO: Find a better way to undo image deletion,
// probably just keep it in a trash until the note is closed
// This recreates the attachment as a completely new item
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 } });
}
}
@ -303,25 +353,35 @@ class EditorInstance {
return attachment.key;
}
_openPopup(x, y, pos, items) {
_openPopup(x, y, pos, itemGroups) {
this._popup.hidePopup();
while (this._popup.firstChild) {
this._popup.removeChild(this._popup.firstChild);
}
for (let item of items) {
let menuitem = this._popup.ownerDocument.createElement('menuitem');
menuitem.setAttribute('value', item[0]);
menuitem.setAttribute('label', item[1]);
menuitem.addEventListener('command', () => {
this._postMessage({
action: 'contextMenuAction',
ctxAction: item[0],
pos
for (let itemGroup of itemGroups) {
for (let item of itemGroup) {
let menuitem = this._popup.ownerDocument.createElement('menuitem');
menuitem.setAttribute('value', item.name);
menuitem.setAttribute('label', item.label);
if (!item.enabled) {
menuitem.setAttribute('disabled', true);
}
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);
@ -329,7 +389,7 @@ class EditorInstance {
async _save(noteData) {
if (!noteData) return;
let { schemaVersion, state, html } = noteData;
let { state, html } = noteData;
if (html === undefined) return;
try {
if (this._disableSaving) {
@ -349,12 +409,8 @@ class EditorInstance {
if (this._item) {
await Zotero.NoteBackups.ensureBackup(this._item);
await Zotero.DB.executeTransaction(async () => {
let changed = this._item.setNote(html, schemaVersion);
if (changed && this._saveOnEdit) {
// Make sure saving is not disabled
if (this._disableSaving) {
return;
}
let changed = this._item.setNote(html);
if (changed && !this._disableSaving) {
await this._item.save({
notifierData: {
noteEditorID: this.instanceID,
@ -370,11 +426,11 @@ class EditorInstance {
if (this.parentItem) {
item.libraryID = this.parentItem.libraryID;
}
item.setNote(html, schemaVersion);
item.setNote(html);
if (this.parentItem) {
item.parentKey = this.parentItem.key;
}
if (this._saveOnEdit) {
if (!this._disableSaving) {
var id = await item.saveTx();
if (!this.parentItem && this.collection) {
@ -463,7 +519,7 @@ class EditorInstance {
for (var i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return this._iframeWindow.btoa(binary);
return btoa(binary);
}
_dataURLtoBlob(dataurl) {
@ -482,7 +538,15 @@ class EditorInstance {
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 win;
/**
@ -500,18 +564,16 @@ class EditorInstance {
* Execute a callback with a preview of the given citation
* @return {Promise} A promise resolved with the previewed citation string
*/
preview: function () {
Zotero.debug('CI: preview');
preview: async function () {
// Zotero.debug('CI: preview');
},
/**
* Sort the citationItems within citation (depends on this.citation.properties.unsorted)
* @return {Promise} A promise resolved with the previewed citation string
*/
sort: function () {
Zotero.debug('CI: sort');
return async function () {
};
sort: async function () {
// Zotero.debug('CI: sort');
},
/**
@ -520,7 +582,7 @@ class EditorInstance {
* Receives a number from 0 to 100 indicating current status.
*/
accept: async function (progressCallback) {
Zotero.debug('CI: accept');
// Zotero.debug('CI: accept');
if (progressCallback) progressCallback(100);
if (win) {
@ -533,13 +595,13 @@ class EditorInstance {
}
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;
citationItem.uri = Zotero.URI.getItemURI(itm);
citationItem.backupText = that._getBackupStr(itm);
}
if (this.citation.citationItems.length) {
if (progressCallback || !citationData.citationItems.length) {
that._postMessage({ action: 'setCitation', nodeId, citation });
}
},
@ -549,7 +611,7 @@ class EditorInstance {
* @return {Promise} A promise resolved by the items
*/
getItems: async function () {
Zotero.debug('CI: getItems');
// Zotero.debug('CI: getItems');
return [];
}
}
@ -578,19 +640,19 @@ class EditorInstance {
* - Zotero.Integration.DELETE
*/
loadItemData() {
Zotero.debug('Citation: loadItemData');
// Zotero.debug('Citation: loadItemData');
}
async handleMissingItem(idx) {
Zotero.debug('Citation: handleMissingItem');
// Zotero.debug('Citation: handleMissingItem');
}
async prepareForEditing() {
Zotero.debug('Citation: prepareForEditing');
// Zotero.debug('Citation: prepareForEditing');
}
toJSON() {
Zotero.debug('Citation: toJSON');
// Zotero.debug('Citation: toJSON');
}
/**
@ -598,13 +660,13 @@ class EditorInstance {
* @returns {string}
*/
serialize() {
Zotero.debug('Citation: serialize');
// Zotero.debug('Citation: serialize');
}
};
if (that.quickFormatWindow) {
that.quickFormatWindow.close();
that.quickFormatWindow = null;
if (that._quickFormatWindow) {
that._quickFormatWindow.close();
that._quickFormatWindow = null;
}
let citation = new Citation();
@ -624,7 +686,7 @@ class EditorInstance {
var mode = (!Zotero.isMac && Zotero.Prefs.get('integration.keepAddCitationDialogRaised')
? '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)
.openWindow(null, 'chrome://zotero/content/integration/quickFormat.xul', '', mode, {
wrappedJSObject: io

View file

@ -33,7 +33,9 @@ Zotero.NoteBackups = {
},
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]);
}
},

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

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

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