feat: grey unicode icons without UI freezes

This commit is contained in:
Lauri Ojansivu 2025-12-22 23:26:30 +02:00
commit 4408eae158
9 changed files with 198 additions and 1 deletions

View file

@ -406,6 +406,7 @@ Template.memberPopup.events({
FlowRouter.go('home');
});
}),
});
Template.removeMemberPopup.helpers({

View file

@ -0,0 +1,6 @@
.unicode-icon {
filter: grayscale(100%);
opacity: 0.8;
display: inline-block;
line-height: 1;
}

View file

@ -0,0 +1,121 @@
Meteor.startup(() => {
const greyscaleIcons = [
'🔼', '❌', '🏷️', '📅', '📥', '🚀', '👤', '👥', '✍️', '📋', '✏️', '🌐', '📎', '📝', '📋', '📜', '🏠', '🔒', '🔕', '🃏',
'⏰', '🛒', '🔢', '✅', '❌', '👁️', '👍', '📋', '🕐', '🎨',
'📤', '⬆️', '⬇️', '➡️', '📦',
'⬅️', '↕️', '🔽', '🔍', '▼', '🏊',
'🔔', '⚙️', '🖼️', '🔑', '🚪', '◀️', '⌨️', '👥', '🏷️', '✅', '🚫'
];
const EXCLUDE_SELECTOR = '.header-user-bar-avatar, .avatar-initials, script, style';
let observer = null;
let enabled = false;
function isExcluded(el) {
if (!el) return true;
if (el.nodeType === Node.ELEMENT_NODE && (el.matches('script') || el.matches('style'))) return true;
if (el.closest && el.closest(EXCLUDE_SELECTOR)) return true;
return false;
}
function wrapTextNodeOnce(parent, textNode) {
if (!parent || !textNode) return;
if (isExcluded(parent)) return;
if (parent.closest && parent.closest('.unicode-icon')) return;
const raw = textNode.nodeValue;
if (!raw) return;
const txt = raw.trim();
// small guard against long text processing
if (txt.length > 3) return;
if (!greyscaleIcons.includes(txt)) return;
const span = document.createElement('span');
span.className = 'unicode-icon';
span.textContent = txt;
parent.replaceChild(span, textNode);
}
function processNode(root) {
try {
if (!root) return;
if (root.nodeType === Node.TEXT_NODE) {
wrapTextNodeOnce(root.parentNode, root);
return;
}
if (root.nodeType !== Node.ELEMENT_NODE) return;
if (isExcluded(root)) return;
// Fast path: only check direct text children first
const children = Array.from(root.childNodes);
for (const child of children) {
if (child.nodeType === Node.TEXT_NODE) {
wrapTextNodeOnce(root, child);
}
}
// If element is small, also scan one level deeper to catch common structures
if (children.length <= 20) {
for (const child of children) {
if (child.nodeType === Node.ELEMENT_NODE && !isExcluded(child)) {
for (const gchild of Array.from(child.childNodes)) {
if (gchild.nodeType === Node.TEXT_NODE) wrapTextNodeOnce(child, gchild);
}
}
}
}
} catch (_) {}
}
function processInitial() {
// Process only frequently used UI containers to avoid full-page walks
const roots = [
document.body,
document.querySelector('#header-user-bar'),
...Array.from(document.querySelectorAll('.pop-over, .pop-over-list, .board-header, .card-details, .sidebar-content')),
].filter(Boolean);
roots.forEach(processNode);
}
function startObserver() {
if (observer) return;
observer = new MutationObserver((mutations) => {
// Batch process only added nodes, ignore attribute/character changes
for (const m of mutations) {
if (m.type !== 'childList') continue;
m.addedNodes && m.addedNodes.forEach((n) => {
// Avoid scanning huge subtrees repeatedly by limiting depth
processNode(n);
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
function stopObserver() {
if (observer) {
try { observer.disconnect(); } catch (_) {}
}
observer = null;
}
function enableGrey() {
if (enabled) return;
enabled = true;
Meteor.defer(processInitial);
startObserver();
}
function disableGrey() {
if (!enabled) return;
enabled = false;
stopObserver();
// unwrap existing
document.querySelectorAll('span.unicode-icon').forEach((span) => {
const txt = document.createTextNode(span.textContent || '');
if (span.parentNode) span.parentNode.replaceChild(txt, span);
});
}
Tracker.autorun(() => {
const user = Meteor.user();
const on = !!(user && user.profile && user.profile.GreyIcons);
if (on) enableGrey(); else disableGrey();
});
});

View file

@ -13,6 +13,13 @@ template(name="headerUserBar")
template(name="memberMenuPopup")
ul.pop-over-list
with currentUser
li
a.js-toggle-grey-icons(href="#")
| 🎨
| {{_ 'grey-icons'}}
if currentUser.profile
if currentUser.profile.GreyIcons
span(key="grey-icons-checkmark") ✅
li
a.js-my-cards(href="{{pathFor 'my-cards'}}")
| 📋
@ -27,7 +34,6 @@ template(name="memberMenuPopup")
| {{_ 'globalSearch-title'}}
li
a(href="{{pathFor 'home'}}")
| 🏠
| 🏠
| {{_ 'all-boards'}}
li

View file

@ -94,6 +94,15 @@ Template.memberMenuPopup.events({
'click .js-notifications-drawer-toggle'() {
Session.set('showNotificationsDrawer', !Session.get('showNotificationsDrawer'));
},
'click .js-toggle-grey-icons'(event) {
event.preventDefault();
const currentUser = ReactiveCache.getCurrentUser();
if (!currentUser || !Meteor.userId()) return;
const current = (currentUser.profile && currentUser.profile.GreyIcons) || false;
Meteor.call('toggleGreyIcons', (err) => {
if (err && process.env.DEBUG === 'true') console.error('toggleGreyIcons error', err);
});
},
'click .js-logout'(event) {
event.preventDefault();