Allow to Quick Copy annotations (#2377)

This commit is contained in:
Martynas Bagdonas 2022-03-04 10:52:23 +02:00 committed by GitHub
parent 07aeff4f64
commit 3c42103848
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 343 additions and 273 deletions

View file

@ -178,7 +178,8 @@ class EditorInstance {
async insertAnnotations(annotations) {
await this._ensureNoteCreated();
let { html } = await this._serializeAnnotations(annotations);
await this.importImages(annotations);
let { html } = Zotero.EditorInstanceUtilities.serializeAnnotations(annotations);
if (html) {
this._postMessage({ action: 'insertHTML', pos: -1, html });
}
@ -235,198 +236,13 @@ class EditorInstance {
}
}
/**
* Transform plain text, containing some supported HTML tags, into actual HTML.
* A similar code is also used in pdf-reader mini editor for annotation text and comments.
* It basically creates a text node and then parses and wraps specific parts
* of it into supported HTML tags
*
* @param text Plain text flavored with some HTML tags
* @returns {string} HTML
* @private
*/
_transformTextToHTML(text) {
const supportedFormats = ['i', 'b', 'sub', 'sup'];
function getFormatter(str) {
let results = supportedFormats.map(format => str.toLowerCase().indexOf('<' + format + '>'));
results = results.map((offset, idx) => [supportedFormats[idx], offset]);
results.sort((a, b) => a[1] - b[1]);
for (let result of results) {
let format = result[0];
let offset = result[1];
if (offset < 0) continue;
let lastIndex = str.toLowerCase().indexOf('</' + format + '>', offset);
if (lastIndex >= 0) {
let parts = [];
parts.push(str.slice(0, offset));
parts.push(str.slice(offset + format.length + 2, lastIndex));
parts.push(str.slice(lastIndex + format.length + 3));
return {
format,
parts
};
}
}
return null;
}
function walkFormat(parent) {
let child = parent.firstChild;
while (child) {
if (child.nodeType === 3) {
let text = child.nodeValue;
let formatter = getFormatter(text);
if (formatter) {
let nodes = [];
nodes.push(doc.createTextNode(formatter.parts[0]));
let midNode = doc.createElement(formatter.format);
midNode.appendChild(doc.createTextNode(formatter.parts[1]));
nodes.push(midNode);
nodes.push(doc.createTextNode(formatter.parts[2]));
child.replaceWith(...nodes);
child = midNode;
}
}
walkFormat(child);
child = child.nextSibling;
}
}
let parser = Components.classes['@mozilla.org/xmlextras/domparser;1']
.createInstance(Components.interfaces.nsIDOMParser);
let doc = parser.parseFromString('', 'text/html');
// innerText transforms \n into <br>
doc.body.innerText = text;
walkFormat(doc.body);
return doc.body.innerHTML;
}
/**
* @param {Object[]} annotations JSON annotations
* @param {Boolean} skipEmbeddingItemData Do not add itemData to citation items
* @return {Object} Object with `html` string and `citationItems` array to embed into metadata container
*/
async _serializeAnnotations(annotations, skipEmbeddingItemData) {
let storedCitationItems = [];
let html = '';
async importImages(annotations) {
for (let annotation of annotations) {
let attachmentItem = await Zotero.Items.getAsync(annotation.attachmentItemID);
if (!attachmentItem) {
continue;
}
if (!annotation.text
&& !annotation.comment
&& !annotation.image) {
continue;
}
let citationHTML = '';
let imageHTML = '';
let highlightHTML = '';
let quotedHighlightHTML = '';
let commentHTML = '';
let storedAnnotation = {
attachmentURI: Zotero.URI.getItemURI(attachmentItem),
annotationKey: annotation.id,
color: annotation.color,
pageLabel: annotation.pageLabel,
position: annotation.position
};
// Citation
let parentItem = attachmentItem.parentID && await Zotero.Items.getAsync(attachmentItem.parentID);
if (parentItem) {
let uris = [Zotero.URI.getItemURI(parentItem)];
let citationItem = {
uris,
locator: annotation.pageLabel
};
// Note: integration.js` uses `Zotero.Cite.System.prototype.retrieveItem`,
// which produces a little bit different CSL JSON
let itemData = Zotero.Utilities.Item.itemToCSLJSON(parentItem);
if (!skipEmbeddingItemData) {
citationItem.itemData = itemData;
}
let item = storedCitationItems.find(item => item.uris.some(uri => uris.includes(uri)));
if (!item) {
storedCitationItems.push({ uris, itemData });
}
storedAnnotation.citationItem = citationItem;
let citation = {
citationItems: [citationItem],
properties: {}
};
let citationWithData = JSON.parse(JSON.stringify(citation));
citationWithData.citationItems[0].itemData = itemData;
let formatted = this._formatCitation(citationWithData);
citationHTML = `<span class="citation" data-citation="${encodeURIComponent(JSON.stringify(citation))}">${formatted}</span>`;
}
// Image
if (annotation.image && !this._filesReadOnly) {
// We assume that annotation.image is always PNG
let imageAttachmentKey = await this._importImage(annotation.image);
delete annotation.image;
// Normalize image dimensions to 1.25 of the print size
let rect = annotation.position.rects[0];
let rectWidth = rect[2] - rect[0];
let rectHeight = rect[3] - rect[1];
// Constants from pdf.js
const CSS_UNITS = 96.0 / 72.0;
const PDFJS_DEFAULT_SCALE = 1.25;
let width = Math.round(rectWidth * CSS_UNITS * PDFJS_DEFAULT_SCALE);
let height = Math.round(rectHeight * width / rectWidth);
imageHTML = `<img data-attachment-key="${imageAttachmentKey}" width="${width}" height="${height}" data-annotation="${encodeURIComponent(JSON.stringify(storedAnnotation))}"/>`;
annotation.imageAttachmentKey = await this._importImage(annotation.image);
}
// Text
if (annotation.text) {
let text = this._transformTextToHTML(annotation.text.trim());
highlightHTML = `<span class="highlight" data-annotation="${encodeURIComponent(JSON.stringify(storedAnnotation))}">${text}</span>`;
quotedHighlightHTML = `<span class="highlight" data-annotation="${encodeURIComponent(JSON.stringify(storedAnnotation))}">${Zotero.getString('punctuation.openingQMark')}${text}${Zotero.getString('punctuation.closingQMark')}</span>`;
}
// Note
if (annotation.comment) {
commentHTML = this._transformTextToHTML(annotation.comment.trim());
}
let template;
if (annotation.type === 'highlight') {
template = Zotero.Prefs.get('annotations.noteTemplates.highlight');
}
else if (annotation.type === 'note') {
template = Zotero.Prefs.get('annotations.noteTemplates.note');
}
else if (annotation.type === 'image') {
template = '<p>{{image}}<br/>{{citation}} {{comment}}</p>';
}
let vars = {
color: annotation.color,
highlight: (attrs) => attrs.quotes === 'true' ? quotedHighlightHTML : highlightHTML,
comment: commentHTML,
citation: citationHTML,
image: imageHTML,
tags: (attrs) => annotation.tags && annotation.tags.map(tag => tag.name).join(attrs.join || ' ')
};
let templateHTML = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars);
// Remove some spaces at the end of paragraph
templateHTML = templateHTML.replace(/([\s]*)(<\/p)/g, '$2');
// Remove multiple spaces
templateHTML = templateHTML.replace(/\s\s+/g, ' ');
html += templateHTML;
delete annotation.image;
}
return { html, citationItems: storedCitationItems };
}
async _digestItems(ids) {
@ -450,7 +266,7 @@ class EditorInstance {
}],
properties: {}
};
let formatted = this._formatCitation(citation);
let formatted = Zotero.EditorInstanceUtilities.formatCitation(citation);
html += `<p><span class="citation" data-citation="${encodeURIComponent(JSON.stringify(citation))}">${formatted}</span></p>`;
}
else if (item.isNote()) {
@ -576,7 +392,8 @@ class EditorInstance {
}
else if (type === 'zotero/annotation') {
let annotations = JSON.parse(data);
let { html: serializedHTML } = await this._serializeAnnotations(annotations);
await this.importImages(annotations);
let { html: serializedHTML } = Zotero.EditorInstanceUtilities.serializeAnnotations(annotations);
html = serializedHTML;
}
if (html) {
@ -1077,86 +894,6 @@ class EditorInstance {
}
}
/**
* Build citation item preview string (based on _buildBubbleString in quickFormat.js)
* TODO: Try to avoid duplicating this code here and inside note-editor
*/
_formatCitationItemPreview(citationItem) {
const STARTSWITH_ROMANESQUE_REGEXP = /^[&a-zA-Z\u0e01-\u0e5b\u00c0-\u017f\u0370-\u03ff\u0400-\u052f\u0590-\u05d4\u05d6-\u05ff\u1f00-\u1fff\u0600-\u06ff\u200c\u200d\u200e\u0218\u0219\u021a\u021b\u202a-\u202e]/;
const ENDSWITH_ROMANESQUE_REGEXP = /[.;:&a-zA-Z\u0e01-\u0e5b\u00c0-\u017f\u0370-\u03ff\u0400-\u052f\u0590-\u05d4\u05d6-\u05ff\u1f00-\u1fff\u0600-\u06ff\u200c\u200d\u200e\u0218\u0219\u021a\u021b\u202a-\u202e]$/;
let { itemData } = citationItem;
let str = '';
// Authors
let authors = itemData.author;
if (authors) {
if (authors.length === 1) {
str = authors[0].family || authors[0].literal;
}
else if (authors.length === 2) {
let a = authors[0].family || authors[0].literal;
let b = authors[1].family || authors[1].literal;
str = a + ' ' + Zotero.getString('general.and') + ' ' + b;
}
else if (authors.length >= 3) {
str = (authors[0].family || authors[0].literal) + ' ' + Zotero.getString('general.etAl');
}
}
// Title
if (!str && itemData.title) {
str = `${itemData.title}`;
}
// Date
if (itemData.issued
&& itemData.issued['date-parts']
&& itemData.issued['date-parts'][0]) {
let year = itemData.issued['date-parts'][0][0];
if (year && year != '0000') {
str += ', ' + year;
}
}
// Locator
if (citationItem.locator) {
if (citationItem.label) {
// TODO: Localize and use short forms
var label = citationItem.label;
}
else if (/[\-,]/.test(citationItem.locator)) {
var label = 'pp.';
}
else {
var label = 'p.';
}
str += ', ' + label + ' ' + citationItem.locator;
}
// Prefix
if (citationItem.prefix && ENDSWITH_ROMANESQUE_REGEXP) {
str = citationItem.prefix
+ (ENDSWITH_ROMANESQUE_REGEXP.test(citationItem.prefix) ? ' ' : '')
+ str;
}
// Suffix
if (citationItem.suffix && STARTSWITH_ROMANESQUE_REGEXP) {
str += (STARTSWITH_ROMANESQUE_REGEXP.test(citationItem.suffix) ? ' ' : '')
+ citationItem.suffix;
}
return str;
}
_formatCitation(citation) {
return '(' + citation.citationItems.map((x) => {
return `<span class="citation-item">${this._formatCitationItemPreview(x)}</span>`;
}).join('; ') + ')';
}
_arrayBufferToBase64(buffer) {
var binary = '';
var bytes = new Uint8Array(buffer);
@ -1494,7 +1231,8 @@ class EditorInstance {
// New line is needed for note title parser
html += '\n';
let { html: serializedHTML, citationItems } = await editorInstance._serializeAnnotations(jsonAnnotations, true);
await editorInstance.importImages(jsonAnnotations);
let { html: serializedHTML, citationItems } = Zotero.EditorInstanceUtilities.serializeAnnotations(jsonAnnotations, true);
html += serializedHTML;
citationItems = encodeURIComponent(JSON.stringify(citationItems));
html = `<div data-citation-items="${citationItems}" data-schema-version="${SCHEMA_VERSION}">${html}</div>`;
@ -1504,5 +1242,283 @@ class EditorInstance {
}
}
class EditorInstanceUtilities {
/**
* Serialize annotations into HTML
*
* @param {Object[]} annotations JSON annotations
* @param {Boolean} skipEmbeddingItemData Do not add itemData to citation items
* @return {Object} Object with `html` string and `citationItems` array to embed into metadata container
*/
serializeAnnotations(annotations, skipEmbeddingItemData) {
let storedCitationItems = [];
let html = '';
for (let annotation of annotations) {
let attachmentItem = Zotero.Items.get(annotation.attachmentItemID);
if (!attachmentItem) {
continue;
}
if (!annotation.text
&& !annotation.comment
&& !annotation.imageAttachmentKey) {
continue;
}
let citationHTML = '';
let imageHTML = '';
let highlightHTML = '';
let quotedHighlightHTML = '';
let commentHTML = '';
let storedAnnotation = {
attachmentURI: Zotero.URI.getItemURI(attachmentItem),
annotationKey: annotation.id,
color: annotation.color,
pageLabel: annotation.pageLabel,
position: annotation.position
};
// Citation
let parentItem = attachmentItem.parentID && Zotero.Items.get(attachmentItem.parentID);
if (parentItem) {
let uris = [Zotero.URI.getItemURI(parentItem)];
let citationItem = {
uris,
locator: annotation.pageLabel
};
// Note: integration.js` uses `Zotero.Cite.System.prototype.retrieveItem`,
// which produces a little bit different CSL JSON
let itemData = Zotero.Utilities.Item.itemToCSLJSON(parentItem);
if (!skipEmbeddingItemData) {
citationItem.itemData = itemData;
}
let item = storedCitationItems.find(item => item.uris.some(uri => uris.includes(uri)));
if (!item) {
storedCitationItems.push({ uris, itemData });
}
storedAnnotation.citationItem = citationItem;
let citation = {
citationItems: [citationItem],
properties: {}
};
let citationWithData = JSON.parse(JSON.stringify(citation));
citationWithData.citationItems[0].itemData = itemData;
let formatted = Zotero.EditorInstanceUtilities.formatCitation(citationWithData);
citationHTML = `<span class="citation" data-citation="${encodeURIComponent(JSON.stringify(citation))}">${formatted}</span>`;
}
// Image
if (annotation.imageAttachmentKey) {
// // let imageAttachmentKey = await this._importImage(annotation.image);
// delete annotation.image;
// Normalize image dimensions to 1.25 of the print size
let rect = annotation.position.rects[0];
let rectWidth = rect[2] - rect[0];
let rectHeight = rect[3] - rect[1];
// Constants from pdf.js
const CSS_UNITS = 96.0 / 72.0;
const PDFJS_DEFAULT_SCALE = 1.25;
let width = Math.round(rectWidth * CSS_UNITS * PDFJS_DEFAULT_SCALE);
let height = Math.round(rectHeight * width / rectWidth);
imageHTML = `<img data-attachment-key="${annotation.imageAttachmentKey}" width="${width}" height="${height}" data-annotation="${encodeURIComponent(JSON.stringify(storedAnnotation))}"/>`;
}
// Text
if (annotation.text) {
let text = this._transformTextToHTML(annotation.text.trim());
highlightHTML = `<span class="highlight" data-annotation="${encodeURIComponent(JSON.stringify(storedAnnotation))}">${text}</span>`;
quotedHighlightHTML = `<span class="highlight" data-annotation="${encodeURIComponent(JSON.stringify(storedAnnotation))}">${Zotero.getString('punctuation.openingQMark')}${text}${Zotero.getString('punctuation.closingQMark')}</span>`;
}
// Note
if (annotation.comment) {
commentHTML = this._transformTextToHTML(annotation.comment.trim());
}
let template;
if (annotation.type === 'highlight') {
template = Zotero.Prefs.get('annotations.noteTemplates.highlight');
}
else if (annotation.type === 'note') {
template = Zotero.Prefs.get('annotations.noteTemplates.note');
}
else if (annotation.type === 'image') {
template = '<p>{{image}}<br/>{{citation}} {{comment}}</p>';
}
let vars = {
color: annotation.color,
highlight: (attrs) => attrs.quotes === 'true' ? quotedHighlightHTML : highlightHTML,
comment: commentHTML,
citation: citationHTML,
image: imageHTML,
tags: (attrs) => annotation.tags && annotation.tags.map(tag => tag.name).join(attrs.join || ' ')
};
let templateHTML = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars);
// Remove some spaces at the end of paragraph
templateHTML = templateHTML.replace(/([\s]*)(<\/p)/g, '$2');
// Remove multiple spaces
templateHTML = templateHTML.replace(/\s\s+/g, ' ');
html += templateHTML;
}
return { html, citationItems: storedCitationItems };
}
/**
* Transform plain text, containing some supported HTML tags, into actual HTML.
* A similar code is also used in pdf-reader mini editor for annotation text and comments.
* It basically creates a text node and then parses and wraps specific parts
* of it into supported HTML tags
*
* @param text Plain text flavored with some HTML tags
* @returns {string} HTML
* @private
*/
_transformTextToHTML(text) {
const supportedFormats = ['i', 'b', 'sub', 'sup'];
function getFormatter(str) {
let results = supportedFormats.map(format => str.toLowerCase().indexOf('<' + format + '>'));
results = results.map((offset, idx) => [supportedFormats[idx], offset]);
results.sort((a, b) => a[1] - b[1]);
for (let result of results) {
let format = result[0];
let offset = result[1];
if (offset < 0) continue;
let lastIndex = str.toLowerCase().indexOf('</' + format + '>', offset);
if (lastIndex >= 0) {
let parts = [];
parts.push(str.slice(0, offset));
parts.push(str.slice(offset + format.length + 2, lastIndex));
parts.push(str.slice(lastIndex + format.length + 3));
return {
format,
parts
};
}
}
return null;
}
function walkFormat(parent) {
let child = parent.firstChild;
while (child) {
if (child.nodeType === 3) {
let text = child.nodeValue;
let formatter = getFormatter(text);
if (formatter) {
let nodes = [];
nodes.push(doc.createTextNode(formatter.parts[0]));
let midNode = doc.createElement(formatter.format);
midNode.appendChild(doc.createTextNode(formatter.parts[1]));
nodes.push(midNode);
nodes.push(doc.createTextNode(formatter.parts[2]));
child.replaceWith(...nodes);
child = midNode;
}
}
walkFormat(child);
child = child.nextSibling;
}
}
let parser = Components.classes['@mozilla.org/xmlextras/domparser;1']
.createInstance(Components.interfaces.nsIDOMParser);
let doc = parser.parseFromString('', 'text/html');
// innerText transforms \n into <br>
doc.body.innerText = text;
walkFormat(doc.body);
return doc.body.innerHTML;
}
/**
* Build citation item preview string (based on _buildBubbleString in quickFormat.js)
* TODO: Try to avoid duplicating this code here and inside note-editor
*/
_formatCitationItemPreview(citationItem) {
const STARTSWITH_ROMANESQUE_REGEXP = /^[&a-zA-Z\u0e01-\u0e5b\u00c0-\u017f\u0370-\u03ff\u0400-\u052f\u0590-\u05d4\u05d6-\u05ff\u1f00-\u1fff\u0600-\u06ff\u200c\u200d\u200e\u0218\u0219\u021a\u021b\u202a-\u202e]/;
const ENDSWITH_ROMANESQUE_REGEXP = /[.;:&a-zA-Z\u0e01-\u0e5b\u00c0-\u017f\u0370-\u03ff\u0400-\u052f\u0590-\u05d4\u05d6-\u05ff\u1f00-\u1fff\u0600-\u06ff\u200c\u200d\u200e\u0218\u0219\u021a\u021b\u202a-\u202e]$/;
let { itemData } = citationItem;
let str = '';
// Authors
let authors = itemData.author;
if (authors) {
if (authors.length === 1) {
str = authors[0].family || authors[0].literal;
}
else if (authors.length === 2) {
let a = authors[0].family || authors[0].literal;
let b = authors[1].family || authors[1].literal;
str = a + ' ' + Zotero.getString('general.and') + ' ' + b;
}
else if (authors.length >= 3) {
str = (authors[0].family || authors[0].literal) + ' ' + Zotero.getString('general.etAl');
}
}
// Title
if (!str && itemData.title) {
str = `${itemData.title}`;
}
// Date
if (itemData.issued
&& itemData.issued['date-parts']
&& itemData.issued['date-parts'][0]) {
let year = itemData.issued['date-parts'][0][0];
if (year && year != '0000') {
str += ', ' + year;
}
}
// Locator
if (citationItem.locator) {
if (citationItem.label) {
// TODO: Localize and use short forms
var label = citationItem.label;
}
else if (/[\-,]/.test(citationItem.locator)) {
var label = 'pp.';
}
else {
var label = 'p.';
}
str += ', ' + label + ' ' + citationItem.locator;
}
// Prefix
if (citationItem.prefix && ENDSWITH_ROMANESQUE_REGEXP) {
str = citationItem.prefix
+ (ENDSWITH_ROMANESQUE_REGEXP.test(citationItem.prefix) ? ' ' : '')
+ str;
}
// Suffix
if (citationItem.suffix && STARTSWITH_ROMANESQUE_REGEXP) {
str += (STARTSWITH_ROMANESQUE_REGEXP.test(citationItem.suffix) ? ' ' : '')
+ citationItem.suffix;
}
return str;
}
formatCitation(citation) {
return '(' + citation.citationItems.map((x) => {
return `<span class="citation-item">${this._formatCitationItemPreview(x)}</span>`;
}).join('; ') + ')';
}
}
Zotero.EditorInstance = EditorInstance;
Zotero.EditorInstance.SCHEMA_VERSION = SCHEMA_VERSION;
Zotero.EditorInstanceUtilities = new EditorInstanceUtilities();

View file

@ -838,6 +838,60 @@ class ReaderTab extends ReaderInstance {
Zotero.logError(event.error);
});
this._iframeWindow.wrappedJSObject.zoteroSetDataTransferAnnotations = (dataTransfer, annotations) => {
let res = Zotero.EditorInstanceUtilities.serializeAnnotations(annotations);
let tmpNote = new Zotero.Item('note');
tmpNote.libraryID = Zotero.Libraries.userLibraryID;
tmpNote.setNote(res.html);
let items = [tmpNote];
let format = Zotero.QuickCopy.getNoteFormat();
Zotero.debug('Copying/dragging annotation(s) with ' + format);
format = Zotero.QuickCopy.unserializeSetting(format);
// Basically the same code is used in itemTree.jsx onDragStart
try {
if (format.mode === 'export') {
// If exporting with virtual "Markdown + Rich Text" translator, call Note Markdown
// and Note HTML translators instead
if (format.id === Zotero.Translators.TRANSLATOR_ID_MARKDOWN_AND_RICH_TEXT) {
let markdownFormat = { mode: 'export', id: Zotero.Translators.TRANSLATOR_ID_NOTE_MARKDOWN };
let htmlFormat = { mode: 'export', id: Zotero.Translators.TRANSLATOR_ID_NOTE_HTML };
Zotero.QuickCopy.getContentFromItems(items, markdownFormat, (obj, worked) => {
if (!worked) {
return;
}
Zotero.QuickCopy.getContentFromItems(items, htmlFormat, (obj2, worked) => {
if (!worked) {
return;
}
dataTransfer.setData('text/plain', obj.string.replace(/\r\n/g, '\n'));
dataTransfer.setData('text/html', obj2.string.replace(/\r\n/g, '\n'));
});
});
}
else {
Zotero.QuickCopy.getContentFromItems(items, format, (obj, worked) => {
if (!worked) {
return;
}
var text = obj.string.replace(/\r\n/g, '\n');
// For Note HTML translator use body content only
if (format.id === Zotero.Translators.TRANSLATOR_ID_NOTE_HTML) {
// Use body content only
let parser = Cc['@mozilla.org/xmlextras/domparser;1']
.createInstance(Ci.nsIDOMParser);
let doc = parser.parseFromString(text, 'text/html');
text = doc.body.innerHTML;
}
dataTransfer.setData('text/plain', text);
});
}
}
}
catch (e) {
Zotero.debug(e);
}
};
this._iframeWindow.wrappedJSObject.zoteroConfirmDeletion = function (plural) {
let ps = Services.prompt;
let buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING

@ -1 +1 @@
Subproject commit 826cce2fa0c754b9de456aa2d52f9bfc95dfd3ba
Subproject commit b97d62e02bdba96ef95a74dcefeab23ce2520eae