From 1b6e8797ec92a36858579118397d8469e03d5816 Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Tue, 25 Nov 2025 04:33:42 +0200 Subject: [PATCH 1/2] Feature: Grey Icons. This makes WeKan very slow. Not recommended. Thanks to xet7 ! --- client/00-startup.js | 9 +++ client/components/sidebar/sidebar.js | 1 + client/components/unicode-icons.css | 6 ++ client/components/unicode-icons.js | 83 +++++++++++++++++++++++++ client/components/users/userHeader.jade | 8 ++- client/components/users/userHeader.js | 9 +++ imports/i18n/data/en.i18n.json | 1 + models/users.js | 37 +++++++++++ server/publications/userGreyIcons.js | 7 +++ 9 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 client/components/unicode-icons.css create mode 100644 client/components/unicode-icons.js create mode 100644 server/publications/userGreyIcons.js diff --git a/client/00-startup.js b/client/00-startup.js index 52a1c536c..3230b3a6b 100644 --- a/client/00-startup.js +++ b/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'); + } + }); +}); diff --git a/client/components/sidebar/sidebar.js b/client/components/sidebar/sidebar.js index 5831e601a..3b16d51b8 100644 --- a/client/components/sidebar/sidebar.js +++ b/client/components/sidebar/sidebar.js @@ -394,6 +394,7 @@ Template.memberPopup.events({ FlowRouter.go('home'); }); }), + }); Template.removeMemberPopup.helpers({ diff --git a/client/components/unicode-icons.css b/client/components/unicode-icons.css new file mode 100644 index 000000000..429c8c711 --- /dev/null +++ b/client/components/unicode-icons.css @@ -0,0 +1,6 @@ +.unicode-icon { + filter: grayscale(100%); + opacity: 0.8; + display: inline-block; + line-height: 1; +} diff --git a/client/components/unicode-icons.js b/client/components/unicode-icons.js new file mode 100644 index 000000000..b7a321067 --- /dev/null +++ b/client/components/unicode-icons.js @@ -0,0 +1,83 @@ +Meteor.startup(() => { + // Unicode pictographic ranges (emoji, symbols, etc.) + // Only greyscale these icons: + const greyscaleIcons = [ + '🔼', '❌', '🏷️', '📅', '📥', '🚀', '👤', '👥', '✍️', '📋', '✏️', '🌐', '📎', '📝', '📋', '📜', '🏠', '🔒', '🔕', '🃏', + '⏰', '🛒', '🔢', '✅', '❌', '👁️', '👍', '📋', '🕐', '🎨', + '📤', '⬆️', '⬇️', '➡️', '📦', + '⬅️', '↕️', '🔽', '🔍', '▼', '🏊', + '🔔', '⚙️', '🖼️', '🔑', '🚪', '◀️', '⌨️', '👥', '🏷️', '✅', '🚫' + ]; + + function wrapUnicodeIcons(root) { + try { + // Exclude avatar initials from wrapping + const excludeSelector = '.header-user-bar-avatar, .avatar-initials'; + + 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); + } + } + + // Also wrap direct unicode icon children (e.g., 🎨), 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); + } + } + }); + }); + } catch (e) { + // ignore + } + } + function unwrap() { + document.querySelectorAll('span.unicode-icon').forEach((span) => { + const txt = document.createTextNode(span.textContent); + span.parentNode.replaceChild(txt, span); + }); + } + + 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); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + + Tracker.autorun(() => { + const user = Meteor.user(); + if (user && user.profile && user.profile.GreyIcons) { + runWrapAfterDOM(); + } else { + Meteor.defer(() => unwrap()); + } + }); +}); diff --git a/client/components/users/userHeader.jade b/client/components/users/userHeader.jade index 7ee64d138..2f687de13 100644 --- a/client/components/users/userHeader.jade +++ b/client/components/users/userHeader.jade @@ -13,6 +13,13 @@ template(name="headerUserBar") template(name="memberMenuPopup") ul.pop-over-list with currentUser + li + a.js-toggle-grey-icons(href="#") + | 🎨 + | {{_ 'grey-icons'}} + if currentUser.profile + if currentUser.profile.GreyIcons + span(key="grey-icons-checkmark") ✅ li a.js-my-cards(href="{{pathFor 'my-cards'}}") | 📋 @@ -27,7 +34,6 @@ template(name="memberMenuPopup") | {{_ 'globalSearch-title'}} li a(href="{{pathFor 'home'}}") - | 🏠 | 🏠 | {{_ 'all-boards'}} li diff --git a/client/components/users/userHeader.js b/client/components/users/userHeader.js index 5514a1127..55a6d40ca 100644 --- a/client/components/users/userHeader.js +++ b/client/components/users/userHeader.js @@ -90,6 +90,15 @@ Template.memberMenuPopup.events({ 'click .js-notifications-drawer-toggle'() { 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) { event.preventDefault(); diff --git a/imports/i18n/data/en.i18n.json b/imports/i18n/data/en.i18n.json index acf3c6934..550c6a7ad 100644 --- a/imports/i18n/data/en.i18n.json +++ b/imports/i18n/data/en.i18n.json @@ -547,6 +547,7 @@ "log-in": "Log In", "loginPopup-title": "Log In", "memberMenuPopup-title": "Member Settings", + "grey-icons": "Grey Icons", "members": "Members", "menu": "Menu", "move-selection": "Move selection", diff --git a/models/users.js b/models/users.js index 3885638d1..4fdf427ce 100644 --- a/models/users.js +++ b/models/users.js @@ -173,6 +173,13 @@ Users.attachSchema( type: Boolean, optional: true, }, + 'profile.GreyIcons': { + /** + * per-user preference to render unicode icons in grey + */ + type: Boolean, + optional: true, + }, 'profile.cardMaximized': { /** * has user clicked maximize card? @@ -708,6 +715,7 @@ Users.safeFields = { 'profile.initials': 1, 'profile.zoomLevel': 1, 'profile.mobileMode': 1, + 'profile.GreyIcons': 1, orgs: 1, teams: 1, authenticationMethod: 1, @@ -1061,6 +1069,11 @@ Users.helpers({ return profile.showDesktopDragHandles || false; }, + hasGreyIcons() { + const profile = this.profile || {}; + return profile.GreyIcons || false; + }, + hasCustomFieldsGrid() { const profile = this.profile || {}; return profile.customFieldsGrid || false; @@ -1485,6 +1498,13 @@ Users.mutations({ }, }; }, + toggleGreyIcons(value = false) { + return { + $set: { + 'profile.GreyIcons': !value, + }, + }; + }, addNotification(activityId) { return { @@ -1688,6 +1708,23 @@ Meteor.methods({ 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() { const user = ReactiveCache.getCurrentUser(); user.toggleDesktopHandles(user.hasShowDesktopDragHandles()); diff --git a/server/publications/userGreyIcons.js b/server/publications/userGreyIcons.js new file mode 100644 index 000000000..c4d7e359b --- /dev/null +++ b/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 } }); +}); From a68993d099886ebf52c62fba5dd1e24d417863ef Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Mon, 22 Dec 2025 23:26:30 +0200 Subject: [PATCH 2/2] perf(unicode-icons): replace body-wide scans with added-nodes observer; prevent unresponsiveness while greying icons --- client/components/unicode-icons.js | 154 ++++++++++++++++++----------- 1 file changed, 96 insertions(+), 58 deletions(-) diff --git a/client/components/unicode-icons.js b/client/components/unicode-icons.js index b7a321067..2b9569b04 100644 --- a/client/components/unicode-icons.js +++ b/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; - 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., 🎨), 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(); }); });