Jelajahi Sumber

feat: grey unicode icons without UI freezes

Lauri Ojansivu 1 hari lalu
induk
melakukan
4408eae158

+ 9 - 0
client/00-startup.js

@@ -62,3 +62,12 @@ Meteor.startup(() => {
     }
     }
   });
   });
 });
 });
+
+// Subscribe to per-user small publications
+Meteor.startup(() => {
+  Tracker.autorun(() => {
+    if (Meteor.userId()) {
+      Meteor.subscribe('userGreyIcons');
+    }
+  });
+});

+ 1 - 0
client/components/sidebar/sidebar.js

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

+ 6 - 0
client/components/unicode-icons.css

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

+ 121 - 0
client/components/unicode-icons.js

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

+ 7 - 1
client/components/users/userHeader.jade

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

+ 9 - 0
client/components/users/userHeader.js

@@ -94,6 +94,15 @@ Template.memberMenuPopup.events({
   'click .js-notifications-drawer-toggle'() {
   'click .js-notifications-drawer-toggle'() {
     Session.set('showNotificationsDrawer', !Session.get('showNotificationsDrawer'));
     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) {
   'click .js-logout'(event) {
     event.preventDefault();
     event.preventDefault();
 
 

+ 1 - 0
imports/i18n/data/en.i18n.json

@@ -553,6 +553,7 @@
   "log-in": "Log In",
   "log-in": "Log In",
   "loginPopup-title": "Log In",
   "loginPopup-title": "Log In",
   "memberMenuPopup-title": "Member Settings",
   "memberMenuPopup-title": "Member Settings",
+  "grey-icons": "Grey Icons",
   "members": "Members",
   "members": "Members",
   "menu": "Menu",
   "menu": "Menu",
   "move-selection": "Move selection",
   "move-selection": "Move selection",

+ 37 - 0
models/users.js

@@ -173,6 +173,13 @@ Users.attachSchema(
       type: Boolean,
       type: Boolean,
       optional: true,
       optional: true,
     },
     },
+    'profile.GreyIcons': {
+      /**
+       * per-user preference to render unicode icons in grey
+       */
+      type: Boolean,
+      optional: true,
+    },
     'profile.cardMaximized': {
     'profile.cardMaximized': {
       /**
       /**
        * has user clicked maximize card?
        * has user clicked maximize card?
@@ -709,6 +716,7 @@ Users.safeFields = {
   'profile.initials': 1,
   'profile.initials': 1,
   'profile.zoomLevel': 1,
   'profile.zoomLevel': 1,
   'profile.mobileMode': 1,
   'profile.mobileMode': 1,
+  'profile.GreyIcons': 1,
   orgs: 1,
   orgs: 1,
   teams: 1,
   teams: 1,
   authenticationMethod: 1,
   authenticationMethod: 1,
@@ -1062,6 +1070,11 @@ Users.helpers({
     return profile.showDesktopDragHandles || false;
     return profile.showDesktopDragHandles || false;
   },
   },
 
 
+  hasGreyIcons() {
+    const profile = this.profile || {};
+    return profile.GreyIcons || false;
+  },
+
   hasCustomFieldsGrid() {
   hasCustomFieldsGrid() {
     const profile = this.profile || {};
     const profile = this.profile || {};
     return profile.customFieldsGrid || false;
     return profile.customFieldsGrid || false;
@@ -1486,6 +1499,13 @@ Users.mutations({
       },
       },
     };
     };
   },
   },
+  toggleGreyIcons(value = false) {
+    return {
+      $set: {
+        'profile.GreyIcons': !value,
+      },
+    };
+  },
 
 
   addNotification(activityId) {
   addNotification(activityId) {
     return {
     return {
@@ -1689,6 +1709,23 @@ Meteor.methods({
     
     
     Users.update(this.userId, updateObject);
     Users.update(this.userId, updateObject);
   },
   },
+  toggleGreyIcons(value) {
+    if (!this.userId) {
+      throw new Meteor.Error('not-logged-in', 'User must be logged in');
+    }
+    if (value !== undefined) check(value, Boolean);
+
+    const user = Users.findOne(this.userId);
+    if (!user) {
+      throw new Meteor.Error('user-not-found', 'User not found');
+    }
+
+    const current = (user.profile && user.profile.GreyIcons) || false;
+    const newValue = value !== undefined ? value : !current;
+
+    Users.update(this.userId, { $set: { 'profile.GreyIcons': newValue } });
+    return newValue;
+  },
   toggleDesktopDragHandles() {
   toggleDesktopDragHandles() {
     const user = ReactiveCache.getCurrentUser();
     const user = ReactiveCache.getCurrentUser();
     user.toggleDesktopHandles(user.hasShowDesktopDragHandles());
     user.toggleDesktopHandles(user.hasShowDesktopDragHandles());

+ 7 - 0
server/publications/userGreyIcons.js

@@ -0,0 +1,7 @@
+// Publish only the current logged-in user's GreyIcons profile flag
+import { Meteor } from 'meteor/meteor';
+
+Meteor.publish('userGreyIcons', function publishUserGreyIcons() {
+  if (!this.userId) return this.ready();
+  return Meteor.users.find({ _id: this.userId }, { fields: { 'profile.GreyIcons': 1 } });
+});