|
|
@@ -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;
|
|
|
+
|
|
|
+ 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;
|
|
|
+ }
|
|
|
|
|
|
- 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 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
|
|
|
- }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (_) {}
|
|
|
}
|
|
|
- function unwrap() {
|
|
|
- document.querySelectorAll('span.unicode-icon').forEach((span) => {
|
|
|
- const txt = document.createTextNode(span.textContent);
|
|
|
- span.parentNode.replaceChild(txt, span);
|
|
|
- });
|
|
|
+
|
|
|
+ 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 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 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();
|
|
|
});
|
|
|
});
|