display emojis from color-less tag in itemTreeRow (#3330)

- display the first continuous span of emojis in the primary cell of
the itemTree for non-colored tags.
- the emojis appear after the colored tags' circles (if any)
- to keep things consistent with itemTree, sort tags in the tagsBox in
the following order: colored tags first sorted by their position,
emoji tags after sorted alphabetically, followed by remaining tags sorted
alphabetically.
This commit is contained in:
abaevbog 2024-07-14 21:28:01 -07:00 committed by GitHub
parent 833ecca364
commit 26f7c707ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 247 additions and 76 deletions

View file

@ -125,7 +125,7 @@ class TagList extends React.PureComponent {
forceNewLine = true; forceNewLine = true;
} }
// size of the colored dot + space between the dot and the tag name always sums up to fontSize (e.g., 8px + 3px at 11px fontSize) // size of the colored dot + space between the dot and the tag name always sums up to fontSize (e.g., 8px + 3px at 11px fontSize)
const tagColorWidth = (tag.color && !Zotero.Utilities.Internal.isOnlyEmoji(tag.name)) ? this.props.fontSize : 0; const tagColorWidth = (tag.color && !Zotero.Utilities.Internal.containsEmoji(tag.name)) ? this.props.fontSize : 0;
let tagWidth = tagPaddingLeft + Math.min(tag.width, tagMaxWidth) + tagPaddingRight + tagColorWidth; let tagWidth = tagPaddingLeft + Math.min(tag.width, tagMaxWidth) + tagPaddingRight + tagColorWidth;
// If first row or cell fits, add to current row // If first row or cell fits, add to current row
if (!forceNewLine && (i == 0 || ((rowX + tagWidth) < (this.props.width - panePaddingRight - this.scrollbarWidth)))) { if (!forceNewLine && (i == 0 || ((rowX + tagWidth) < (this.props.width - panePaddingRight - this.scrollbarWidth)))) {
@ -175,7 +175,7 @@ class TagList extends React.PureComponent {
if (tag.disabled) { if (tag.disabled) {
className += ' disabled'; className += ' disabled';
} }
if (Zotero.Utilities.Internal.isOnlyEmoji(tag.name)) { if (Zotero.Utilities.Internal.containsEmoji(tag.name)) {
className += ' emoji'; className += ' emoji';
} }

View file

@ -140,22 +140,10 @@
var tags = this.item.getTags(); var tags = this.item.getTags();
// Sort tags alphabetically
var collation = Zotero.getLocaleCollation(); // Sort tags alphabetically with colored tags at the top followed by emoji tags
tags.sort((a, b) => { tags.sort((a, b) => Zotero.Tags.compareTagsOrder(this.item.libraryID, a.tag, b.tag));
let aTag = a.tag;
let bTag = b.tag;
let aHasColor = this._tagColors.has(aTag);
let bHasColor = this._tagColors.has(bTag);
// Sort colored tags to the top
if (aHasColor && !bHasColor) {
return -1;
}
if (!aHasColor && bHasColor) {
return 1;
}
return collation.compareString(1, aTag, bTag);
});
for (let i = 0; i < tags.length; i++) { for (let i = 0; i < tags.length; i++) {
this.addDynamicRow(tags[i], i + 1); this.addDynamicRow(tags[i], i + 1);
@ -523,24 +511,18 @@
row = this.addDynamicRow(tagData, false, true); row = this.addDynamicRow(tagData, false, true);
var elem = row.getElementsByAttribute('fieldname', 'tag')[0]; var elem = row.getElementsByAttribute('fieldname', 'tag')[0];
// Move row to appropriate place, alphabetically // Construct what the array of tags would be if this tag was a part of it
var collation = Zotero.getLocaleCollation(); let newTagsArray = this.item.getTags();
var tagEditables = rowsElement.getElementsByAttribute('fieldname', 'tag'); newTagsArray.push({ tag: tagName, color: color || null });
// Sort it with the colored tags on top, followed by emoji tags, followed by everything else
var inserted = false; newTagsArray.sort((a, b) => Zotero.Tags.compareTagsOrder(this._item.libraryID, a.tag, b.tag));
for (let editable of tagEditables) { // Find where the new tag should be placed and insert it there
// Sort tags without colors below tags with colors let newTagIndex = newTagsArray.findIndex(tag => tag.tag == tagName);
if (!color && this._tagColors.has(editable.value) if (newTagIndex < rowsElement.childNodes.length) {
|| editable.value && collation.compareString(1, tagName, editable.value) > 0) { rowsElement.insertBefore(row, rowsElement.childNodes[newTagIndex]);
continue;
}
rowsElement.insertBefore(row, editable.parentNode);
inserted = true;
break;
} }
if (!inserted) { else {
rowsElement.appendChild(row); rowsElement.append(row);
} }
this.updateCount(this.count + 1); this.updateCount(this.count + 1);

View file

@ -2801,15 +2801,14 @@ var ItemTree = class ItemTree extends LibraryTree {
let tagAriaLabel = ''; let tagAriaLabel = '';
let tagSpans = []; let tagSpans = [];
let coloredTags = item.getColoredTags(); let coloredTags = item.getItemsListTags();
if (coloredTags.length) { if (coloredTags.length) {
let { emoji, colored } = coloredTags.reduce((acc, tag) => { let { emoji, colored } = coloredTags.reduce((acc, tag) => {
acc[Zotero.Utilities.Internal.isOnlyEmoji(tag.tag) ? 'emoji' : 'colored'].push(tag); acc[Zotero.Utilities.Internal.containsEmoji(tag.tag) ? 'emoji' : 'colored'].push(tag);
return acc; return acc;
}, { emoji: [], colored: [] }); }, { emoji: [], colored: [] });
tagSpans.push(...emoji.map(x => this._getTagSwatch(x.tag))); // Add colored tags first
if (colored.length) { if (colored.length) {
let coloredTagSpans = colored.map(x => this._getTagSwatch(x.tag, x.color)); let coloredTagSpans = colored.map(x => this._getTagSwatch(x.tag, x.color));
let coloredTagSpanWrapper = document.createElement('span'); let coloredTagSpanWrapper = document.createElement('span');
@ -2818,6 +2817,9 @@ var ItemTree = class ItemTree extends LibraryTree {
tagSpans.push(coloredTagSpanWrapper); tagSpans.push(coloredTagSpanWrapper);
} }
// Add emoji tags after
tagSpans.push(...emoji.map(x => this._getTagSwatch(x.tag)));
tagAriaLabel = coloredTags.length == 1 ? Zotero.getString('searchConditions.tag') : Zotero.getString('itemFields.tags'); tagAriaLabel = coloredTags.length == 1 ? Zotero.getString('searchConditions.tag') : Zotero.getString('itemFields.tags');
tagAriaLabel += ' ' + coloredTags.map(x => x.tag).join(', ') + '.'; tagAriaLabel += ' ' + coloredTags.map(x => x.tag).join(', ') + '.';
} }
@ -3864,12 +3866,13 @@ var ItemTree = class ItemTree extends LibraryTree {
_getTagSwatch(tag, color) { _getTagSwatch(tag, color) {
let span = document.createElement('span'); let span = document.createElement('span');
span.className = 'tag-swatch'; span.className = 'tag-swatch';
// If only emoji, display directly let extractedEmojis = Zotero.Tags.extractEmojiForItemsList(tag);
// If contains emojis, display directly
// //
// TODO: Check for a maximum number of graphemes, which is hard to do // TODO: Check for a maximum number of graphemes, which is hard to do
// https://stackoverflow.com/a/54369605 // https://stackoverflow.com/a/54369605
if (Zotero.Utilities.Internal.isOnlyEmoji(tag)) { if (extractedEmojis) {
span.textContent = tag; span.textContent = extractedEmojis;
span.className += ' emoji'; span.className += ' emoji';
} }
// Otherwise display color // Otherwise display color

View file

@ -739,7 +739,7 @@ Zotero.DataObjectUtilities = {
numNotes: () => 0, numNotes: () => 0,
isAttachment: () => false, isAttachment: () => false,
numAttachments: () => false, numAttachments: () => false,
getColoredTags: () => false, getItemsListTags: () => [],
isRegularItem: () => false, // Should be false to prevent items dropped into deleted searches isRegularItem: () => false, // Should be false to prevent items dropped into deleted searches
getNotes: () => [], getNotes: () => [],
getAttachments: () => [], getAttachments: () => [],

View file

@ -4535,33 +4535,20 @@ Zotero.Item.prototype.getItemTypeIconName = function (skipLinkMode = false) {
}; };
Zotero.Item.prototype.getTagColors = function () {
Zotero.warn("Zotero.Item::getTagColors() is deprecated -- use Zotero.Item::getColoredTags()");
return this.getColoredTags().map(x => x.color);
};
/** /**
* Return tags and colors * Return tags with assigned colors and tags that contain emojis
* *
* @return {Object[]} - Array of object with 'tag' and 'color' properties * @return {Object[]} - Array of object with 'tag' and 'color' properties
*/ */
Zotero.Item.prototype.getColoredTags = function () { Zotero.Item.prototype.getItemsListTags = function () {
var tags = this.getTags(); var tags = this.getTags();
if (!tags.length) return []; if (!tags.length) return [];
let colorData = [];
let tagColors = Zotero.Tags.getColors(this.libraryID); let tagColors = Zotero.Tags.getColors(this.libraryID);
for (let tag of tags) { let colorOrEmojiTags = tags.filter(tag => tagColors.get(tag.tag) || Zotero.Utilities.Internal.containsEmoji(tag.tag));
let data = tagColors.get(tag.tag); colorOrEmojiTags.sort((a, b) => Zotero.Tags.compareTagsOrder(this.libraryID, a.tag, b.tag));
if (data) { return colorOrEmojiTags.map(x => ({ tag: x.tag, color: tagColors.get(x.tag)?.color || null }));
colorData.push({tag: tag.tag, ...data});
}
}
return colorData.sort((a, b) => a.position - b.position).map(x => ({ tag: x.tag, color: x.color }));
}; };
/** /**
* Compares this item to another * Compares this item to another
* *

View file

@ -710,6 +710,10 @@ Zotero.Tags = new function() {
else { else {
tagColors.splice(position, 0, newObj); tagColors.splice(position, 0, newObj);
} }
_libraryColorsByName[libraryID].set(name, {
color: color,
position: position
});
} }
if (tagColors.length) { if (tagColors.length) {
@ -992,6 +996,34 @@ Zotero.Tags = new function() {
} }
} }
// Return the first sequence of emojis from a string
this.extractEmojiForItemsList = function (str) {
// Split by anything that is not an emoji, Zero Width Joiner, or Variation Selector-16
// And return first continuous span of emojis
let re = /[^\p{Extended_Pictographic}\u200D\uFE0F]+/gu;
return str.split(re).filter(Boolean)[0] || null;
};
// Used as parameter for .sort() method on an array of tags
// Orders colored tags first by their position
// Then order tags with emojis alphabetically.
// Then order all remaining tags alphabetically
this.compareTagsOrder = function (libraryID, tagA, tagB) {
var collation = Zotero.getLocaleCollation();
let tagColors = this.getColors(libraryID);
let colorForA = tagColors.get(tagA);
let colorForB = tagColors.get(tagB);
if (colorForA && !colorForB) return -1;
if (!colorForA && colorForB) return 1;
if (colorForA && colorForB) {
return colorForA.position - colorForB.position;
}
let emojiForA = Zotero.Utilities.Internal.containsEmoji(tagA);
let emojiForB = Zotero.Utilities.Internal.containsEmoji(tagB);
if (emojiForA && !emojiForB) return -1;
if (!emojiForA && emojiForB) return 1;
return collation.compareString(1, tagA, tagB);
};
/** /**
* Compare two API JSON tag objects * Compare two API JSON tag objects

View file

@ -396,10 +396,9 @@ Zotero.Utilities.Internal = {
return s; return s;
}, },
isOnlyEmoji: function (str) { containsEmoji: function (str) {
// Remove emoji, Zero Width Joiner, and Variation Selector-16 and see if anything's left let re = /\p{Extended_Pictographic}/gu;
const re = /\p{Extended_Pictographic}|\u200D|\uFE0F/gu; return !!str.match(re);
return !str.replace(re, '');
}, },
includesEmoji: function (str) { includesEmoji: function (str) {

View file

@ -1850,6 +1850,49 @@ describe("Zotero.Item", function () {
}) })
}) })
describe("#getItemsListTags", function() {
it("should return tags with emojis after colored tags", async function () {
var tags = [
{
tag: "BBB ⭐️⭐️"
},
{
tag: "ZZZ 👲"
},
{
tag: "colored tag two"
},
{
tag: "AAA 😀"
},
{
tag: "colored tag one"
},
{
tag: "not included"
}
];
await Zotero.Tags.setColor(Zotero.Libraries.userLibraryID, "colored tag one", "#990000");
await Zotero.Tags.setColor(Zotero.Libraries.userLibraryID, "colored tag two", "#FF6666");
var item = new Zotero.Item('journalArticle');
item.setTags(tags);
await item.saveTx();
var itemListTags = item.getItemsListTags();
var expected = [
{ tag: "colored tag one", color: "#990000" },
{ tag: "colored tag two", color: "#FF6666" },
{ tag: "AAA 😀", color: null },
{ tag: "BBB ⭐️⭐️", color: null },
{ tag: "ZZZ 👲", color: null },
];
for (let i = 0; i < 5; i++) {
assert.deepEqual(itemListTags[i], expected[i]);
}
});
});
// //
// Relations and related items // Relations and related items
// //

View file

@ -222,4 +222,56 @@ describe("Zotero.Tags", function () {
]); ]);
}); });
}); });
})
describe("#extractEmojiForItemsList()", function () {
it("should return first emoji span", function () {
assert.equal(Zotero.Tags.extractEmojiForItemsList("🐩🐩🐩 🐩🐩🐩🐩"), "🐩🐩🐩");
});
it("should return first emoji span when string doesn't start with emoji", function () {
assert.equal(Zotero.Tags.extractEmojiForItemsList("./'!@#$ 🐩🐩🐩 🐩🐩🐩🐩"), "🐩🐩🐩");
});
it("should return first emoji span for text with an emoji with Variation Selector-16", function () {
assert.equal(Zotero.Tags.extractEmojiForItemsList("Here are ⭐️⭐️⭐️⭐️⭐️"), "⭐️⭐️⭐️⭐️⭐️");
});
it("should return first emoji span for text with an emoji made up of multiple characters with ZWJ", function () {
assert.equal(Zotero.Tags.extractEmojiForItemsList("We are 👨‍🌾👨‍🌾. And I am a 👨‍🏫."), "👨‍🌾👨‍🌾");
});
});
describe("#compareTagsOrder()", function () {
it('should order colored tags by position and other tags - alphabetically', async function () {
var libraryID = Zotero.Libraries.userLibraryID;
await createDataObject('item', {
tags: [
{ tag: 'one' },
{ tag: 'two', type: 1 },
{ tag: 'three' },
{ tag: 'four', type: 1 },
{ tag: 'five' },
{ tag: 'six😀' },
{ tag: 'seven😀' }
]
});
await Zotero.Tags.setColor(libraryID, 'three', '#111111', 0);
await Zotero.Tags.setColor(libraryID, 'four', '#222222', 1);
await Zotero.Tags.setColor(libraryID, 'two', '#222222', 2);
assert.equal(Zotero.Tags.compareTagsOrder(libraryID, 'three', 'one'), -1, "colored vs ordinary tag -> -1");
assert.equal(Zotero.Tags.compareTagsOrder(libraryID, 'one', 'three'), 1, "ordinary vs colored -> 1");
assert.equal(Zotero.Tags.compareTagsOrder(libraryID, 'three', 'six😀'), -1, "colored vs emoji tag -> -1");
assert.equal(Zotero.Tags.compareTagsOrder(libraryID, 'six😀', 'three'), 1, "emoji vs colored tag -> 1");
assert.equal(Zotero.Tags.compareTagsOrder(libraryID, 'two', 'three'), 2, "colored vs colored => compare their positions");
assert.equal(Zotero.Tags.compareTagsOrder(libraryID, 'one', 'six😀'), 1, "ordinary tag vs tag with emoji -> 1");
assert.equal(Zotero.Tags.compareTagsOrder(libraryID, 'six😀', 'one'), -1, "tag with emoji vs ordinary tag -> -1");
assert.equal(Zotero.Tags.compareTagsOrder(libraryID, 'six😀', 'seven😀'), 1, "both emoji tags -> alphabetical");
assert.isAbove(Zotero.Tags.compareTagsOrder(libraryID, 'one', 'five'), 0, "ordinary tag vs ordinary tag -> alphabetical");
});
});
});

View file

@ -143,4 +143,78 @@ describe("Item Tags Box", function () {
assert.equal(rows.length, 0); assert.equal(rows.length, 0);
}) })
}) })
describe("#render", function() {
it("should render colored tags followed by emoji tags followed by ordinary tags", async function() {
let item = await createDataObject('item', {
tags: [
{ tag: 'A_usual_tag' },
{ tag: 'B_usual_tag' },
{ tag: 'C_emoji_tag😀' },
{ tag: 'D_emoji_tag😀' },
{ tag: 'E_colored_tag' },
{ tag: 'F_colored_tag' },
]
});
await Zotero.Tags.setColor(item.libraryID, 'F_colored_tag', '#111111', 0);
await Zotero.Tags.setColor(item.libraryID, 'E_colored_tag', '#222222', 1);
var tagsbox = doc.querySelector('#zotero-editpane-tags');
var tagRows = [...tagsbox.querySelectorAll(".row")];
// Colored tags sorted first by their position
assert.equal(tagRows[0].querySelector("editable-text").value, "F_colored_tag");
assert.equal(tagRows[1].querySelector("editable-text").value, "E_colored_tag");
// Followed by emoji tags sorted alphabetically
assert.equal(tagRows[2].querySelector("editable-text").value, "C_emoji_tag😀");
assert.equal(tagRows[3].querySelector("editable-text").value, "D_emoji_tag😀");
// Followed by remaining tags sorted alphabetically
assert.equal(tagRows[4].querySelector("editable-text").value, "A_usual_tag");
assert.equal(tagRows[5].querySelector("editable-text").value, "B_usual_tag");
});
it("should add a new tag at the correct position", async function () {
// Create a colored tag that the item does not have
await createDataObject('item', {
tags: [
{ tag: 'a_colored_tag' },
]
});
// Create item with a lot of tags - colored, emoji and usual
let item = await createDataObject('item', {
tags: [
{ tag: 'AA_usual_tag' },
{ tag: 'BB_usual_tag' },
{ tag: 'CC_emoji_tag😀' },
{ tag: 'DD_emoji_tag😀' },
{ tag: 'EE_colored_tag' },
{ tag: 'FF_colored_tag' },
]
});
await Zotero.Tags.setColor(item.libraryID, 'FF_colored_tag', '#111111', 0);
await Zotero.Tags.setColor(item.libraryID, 'EE_colored_tag', '#222222', 1);
await Zotero.Tags.setColor(item.libraryID, 'a_colored_tag', '#222222', 2);
var tagsbox = doc.querySelector('#zotero-editpane-tags');
var tagRows;
// should be added above all usual tags but below colored and emoji
tagsbox.add("a_usual_tag");
tagRows = [...tagsbox.querySelectorAll(".row")];
assert.equal(tagRows[4].querySelector("editable-text").value, "a_usual_tag");
// should be added below colored tags above all other emoji tags
tagsbox.add("a_emoji_tag😀");
tagRows = [...tagsbox.querySelectorAll(".row")];
assert.equal(tagRows[2].querySelector("editable-text").value, "a_emoji_tag😀");
// should be added at the position of the colored tag
tagsbox.add("a_colored_tag");
tagRows = [...tagsbox.querySelectorAll(".row")];
assert.equal(tagRows[2].querySelector("editable-text").value, "a_colored_tag");
});
});
}) })

View file

@ -86,25 +86,24 @@ describe("Zotero.Utilities.Internal", function () {
}); });
describe("#isOnlyEmoji()", function () { describe("#containsEmoji()", function () {
it("should return true for emoji", function () { it("should return true for text with an emoji", function () {
assert.isTrue(Zotero.Utilities.Internal.isOnlyEmoji("🐩")); assert.isTrue(Zotero.Utilities.Internal.containsEmoji("🐩 Hello 🐩"));
}); });
it("should return true for emoji with text representation that use Variation Selector-16", function () { it("should return true for text with an emoji with text representation that use Variation Selector-16", function () {
assert.isTrue(Zotero.Utilities.Internal.isOnlyEmoji("⭐️")); assert.isTrue(Zotero.Utilities.Internal.containsEmoji("This is a ⭐️"));
}); });
it("should return true for emoji made up of multiple characters with ZWJ", function () { it("should return true for text with an emoji made up of multiple characters with ZWJ", function () {
assert.isTrue(Zotero.Utilities.Internal.isOnlyEmoji("👨‍🌾")); assert.isTrue(Zotero.Utilities.Internal.containsEmoji("I am a 👨‍🌾"));
}); });
it("should return false for integer", function () { it("should return false for integer", function () {
assert.isFalse(Zotero.Utilities.Internal.isOnlyEmoji("0")); assert.isFalse(Zotero.Utilities.Internal.containsEmoji("0"));
}); });
}); });
describe("#delayGenerator", function () { describe("#delayGenerator", function () {
var spy; var spy;