mirror of
https://github.com/wekan/wekan.git
synced 2025-12-27 12:48:49 +01:00
feat: grey unicode icons without UI freezes
This commit is contained in:
commit
4408eae158
9 changed files with 198 additions and 1 deletions
|
|
@ -62,3 +62,12 @@ Meteor.startup(() => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to per-user small publications
|
||||
Meteor.startup(() => {
|
||||
Tracker.autorun(() => {
|
||||
if (Meteor.userId()) {
|
||||
Meteor.subscribe('userGreyIcons');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -406,6 +406,7 @@ Template.memberPopup.events({
|
|||
FlowRouter.go('home');
|
||||
});
|
||||
}),
|
||||
|
||||
});
|
||||
|
||||
Template.removeMemberPopup.helpers({
|
||||
|
|
|
|||
6
client/components/unicode-icons.css
Normal file
6
client/components/unicode-icons.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.unicode-icon {
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.8;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
}
|
||||
121
client/components/unicode-icons.js
Normal file
121
client/components/unicode-icons.js
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -94,6 +94,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();
|
||||
|
||||
|
|
|
|||
|
|
@ -553,6 +553,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",
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
@ -709,6 +716,7 @@ Users.safeFields = {
|
|||
'profile.initials': 1,
|
||||
'profile.zoomLevel': 1,
|
||||
'profile.mobileMode': 1,
|
||||
'profile.GreyIcons': 1,
|
||||
orgs: 1,
|
||||
teams: 1,
|
||||
authenticationMethod: 1,
|
||||
|
|
@ -1062,6 +1070,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;
|
||||
|
|
@ -1486,6 +1499,13 @@ Users.mutations({
|
|||
},
|
||||
};
|
||||
},
|
||||
toggleGreyIcons(value = false) {
|
||||
return {
|
||||
$set: {
|
||||
'profile.GreyIcons': !value,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNotification(activityId) {
|
||||
return {
|
||||
|
|
@ -1689,6 +1709,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());
|
||||
|
|
|
|||
7
server/publications/userGreyIcons.js
Normal file
7
server/publications/userGreyIcons.js
Normal file
|
|
@ -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 } });
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue