unicode-icons.js 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. Meteor.startup(() => {
  2. const greyscaleIcons = [
  3. '🔼', '❌', '🏷️', '📅', '📥', '🚀', '👤', '👥', '✍️', '📋', '✏️', '🌐', '📎', '📝', '📋', '📜', '🏠', '🔒', '🔕', '🃏',
  4. '⏰', '🛒', '🔢', '✅', '❌', '👁️', '👍', '📋', '🕐', '🎨',
  5. '📤', '⬆️', '⬇️', '➡️', '📦',
  6. '⬅️', '↕️', '🔽', '🔍', '▼', '🏊',
  7. '🔔', '⚙️', '🖼️', '🔑', '🚪', '◀️', '⌨️', '👥', '🏷️', '✅', '🚫'
  8. ];
  9. const EXCLUDE_SELECTOR = '.header-user-bar-avatar, .avatar-initials, script, style';
  10. let observer = null;
  11. let enabled = false;
  12. function isExcluded(el) {
  13. if (!el) return true;
  14. if (el.nodeType === Node.ELEMENT_NODE && (el.matches('script') || el.matches('style'))) return true;
  15. if (el.closest && el.closest(EXCLUDE_SELECTOR)) return true;
  16. return false;
  17. }
  18. function wrapTextNodeOnce(parent, textNode) {
  19. if (!parent || !textNode) return;
  20. if (isExcluded(parent)) return;
  21. if (parent.closest && parent.closest('.unicode-icon')) return;
  22. const raw = textNode.nodeValue;
  23. if (!raw) return;
  24. const txt = raw.trim();
  25. // small guard against long text processing
  26. if (txt.length > 3) return;
  27. if (!greyscaleIcons.includes(txt)) return;
  28. const span = document.createElement('span');
  29. span.className = 'unicode-icon';
  30. span.textContent = txt;
  31. parent.replaceChild(span, textNode);
  32. }
  33. function processNode(root) {
  34. try {
  35. if (!root) return;
  36. if (root.nodeType === Node.TEXT_NODE) {
  37. wrapTextNodeOnce(root.parentNode, root);
  38. return;
  39. }
  40. if (root.nodeType !== Node.ELEMENT_NODE) return;
  41. if (isExcluded(root)) return;
  42. // Fast path: only check direct text children first
  43. const children = Array.from(root.childNodes);
  44. for (const child of children) {
  45. if (child.nodeType === Node.TEXT_NODE) {
  46. wrapTextNodeOnce(root, child);
  47. }
  48. }
  49. // If element is small, also scan one level deeper to catch common structures
  50. if (children.length <= 20) {
  51. for (const child of children) {
  52. if (child.nodeType === Node.ELEMENT_NODE && !isExcluded(child)) {
  53. for (const gchild of Array.from(child.childNodes)) {
  54. if (gchild.nodeType === Node.TEXT_NODE) wrapTextNodeOnce(child, gchild);
  55. }
  56. }
  57. }
  58. }
  59. } catch (_) {}
  60. }
  61. function processInitial() {
  62. // Process only frequently used UI containers to avoid full-page walks
  63. const roots = [
  64. document.body,
  65. document.querySelector('#header-user-bar'),
  66. ...Array.from(document.querySelectorAll('.pop-over, .pop-over-list, .board-header, .card-details, .sidebar-content')),
  67. ].filter(Boolean);
  68. roots.forEach(processNode);
  69. }
  70. function startObserver() {
  71. if (observer) return;
  72. observer = new MutationObserver((mutations) => {
  73. // Batch process only added nodes, ignore attribute/character changes
  74. for (const m of mutations) {
  75. if (m.type !== 'childList') continue;
  76. m.addedNodes && m.addedNodes.forEach((n) => {
  77. // Avoid scanning huge subtrees repeatedly by limiting depth
  78. processNode(n);
  79. });
  80. }
  81. });
  82. observer.observe(document.body, { childList: true, subtree: true });
  83. }
  84. function stopObserver() {
  85. if (observer) {
  86. try { observer.disconnect(); } catch (_) {}
  87. }
  88. observer = null;
  89. }
  90. function enableGrey() {
  91. if (enabled) return;
  92. enabled = true;
  93. Meteor.defer(processInitial);
  94. startObserver();
  95. }
  96. function disableGrey() {
  97. if (!enabled) return;
  98. enabled = false;
  99. stopObserver();
  100. // unwrap existing
  101. document.querySelectorAll('span.unicode-icon').forEach((span) => {
  102. const txt = document.createTextNode(span.textContent || '');
  103. if (span.parentNode) span.parentNode.replaceChild(txt, span);
  104. });
  105. }
  106. Tracker.autorun(() => {
  107. const user = Meteor.user();
  108. const on = !!(user && user.profile && user.profile.GreyIcons);
  109. if (on) enableGrey(); else disableGrey();
  110. });
  111. });