浏览代码

perf(unicode-icons): replace body-wide scans with added-nodes observer; prevent unresponsiveness while greying icons

Lauri Ojansivu 1 天之前
父节点
当前提交
a68993d099
共有 1 个文件被更改,包括 95 次插入57 次删除
  1. 95 57
      client/components/unicode-icons.js

+ 95 - 57
client/components/unicode-icons.js

@@ -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();
   });
 });