mirror of
https://github.com/wekan/wekan.git
synced 2025-12-27 20:58:48 +01:00
perf(unicode-icons): replace body-wide scans with added-nodes observer; prevent unresponsiveness while greying icons
This commit is contained in:
parent
1b6e8797ec
commit
a68993d099
1 changed files with 96 additions and 58 deletions
|
|
@ -1,6 +1,4 @@
|
|||
Meteor.startup(() => {
|
||||
// Unicode pictographic ranges (emoji, symbols, etc.)
|
||||
// Only greyscale these icons:
|
||||
const greyscaleIcons = [
|
||||
'🔼', '❌', '🏷️', '📅', '📥', '🚀', '👤', '👥', '✍️', '📋', '✏️', '🌐', '📎', '📝', '📋', '📜', '🏠', '🔒', '🔕', '🃏',
|
||||
'⏰', '🛒', '🔢', '✅', '❌', '👁️', '👍', '📋', '🕐', '🎨',
|
||||
|
|
@ -9,75 +7,115 @@ Meteor.startup(() => {
|
|||
'🔔', '⚙️', '🖼️', '🔑', '🚪', '◀️', '⌨️', '👥', '🏷️', '✅', '🚫'
|
||||
];
|
||||
|
||||
function wrapUnicodeIcons(root) {
|
||||
try {
|
||||
// Exclude avatar initials from wrapping
|
||||
const excludeSelector = '.header-user-bar-avatar, .avatar-initials';
|
||||
const EXCLUDE_SELECTOR = '.header-user-bar-avatar, .avatar-initials, script, style';
|
||||
let observer = null;
|
||||
let enabled = false;
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
if (!node || !node.nodeValue) continue;
|
||||
const parent = node.parentNode;
|
||||
if (!parent) continue;
|
||||
if (parent.closest && (parent.closest('.unicode-icon') || parent.closest(excludeSelector))) continue;
|
||||
if (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE') continue;
|
||||
// Only wrap if the text node is a single greyscale icon (no other text)
|
||||
const txt = node.nodeValue.trim();
|
||||
if (greyscaleIcons.includes(txt)) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'unicode-icon';
|
||||
span.textContent = txt;
|
||||
parent.replaceChild(span, node);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Also wrap direct unicode icon children (e.g., <a>🎨</a>), including Member Settings and card details, but not avatar initials
|
||||
const elements = root.querySelectorAll('*:not(script):not(style):not(.header-user-bar-avatar):not(.avatar-initials)');
|
||||
elements.forEach((el) => {
|
||||
el.childNodes.forEach((child) => {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const txt = child.nodeValue.trim();
|
||||
if (greyscaleIcons.includes(txt)) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'unicode-icon';
|
||||
span.textContent = txt;
|
||||
el.replaceChild(span, 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 (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
function unwrap() {
|
||||
document.querySelectorAll('span.unicode-icon').forEach((span) => {
|
||||
const txt = document.createTextNode(span.textContent);
|
||||
span.parentNode.replaceChild(txt, span);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function runWrapAfterDOM() {
|
||||
Meteor.defer(() => {
|
||||
setTimeout(() => wrapUnicodeIcons(document.body), 100);
|
||||
});
|
||||
// Also rerun after Blaze renders popups
|
||||
const observer = new MutationObserver(() => {
|
||||
const user = Meteor.user();
|
||||
if (user && user.profile && user.profile.GreyIcons) {
|
||||
wrapUnicodeIcons(document.body);
|
||||
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();
|
||||
if (user && user.profile && user.profile.GreyIcons) {
|
||||
runWrapAfterDOM();
|
||||
} else {
|
||||
Meteor.defer(() => unwrap());
|
||||
}
|
||||
const on = !!(user && user.profile && user.profile.GreyIcons);
|
||||
if (on) enableGrey(); else disableGrey();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue