Collapse Swimlane, List, Opened Card. Opened Card window X and Y position can be moved freely from drag handle. Fix some dragging not possible. Fix iPhone Safari.

Thanks to xet7 !

Fixes #6040,
fixes #6027,
fixes #6021,
fixes #6002
This commit is contained in:
Lauri Ojansivu 2025-12-23 06:47:02 +02:00
parent 95d1625a9f
commit 58f4884ad6
37 changed files with 1415 additions and 112 deletions

View file

@ -70,4 +70,12 @@ Meteor.startup(() => {
Meteor.subscribe('userGreyIcons');
}
});
// Initialize mobile mode on startup for iOS devices
// This ensures mobile mode is applied correctly on page load
Tracker.afterFlush(() => {
if (typeof Utils !== 'undefined' && Utils.initializeUserSettings) {
Utils.initializeUserSettings();
}
});
});

View file

@ -231,6 +231,30 @@
font-size: 1em !important; /* Keep original icon size */
}
/* Mobile iPhone: scale card details text and icons to 2x */
body.mobile-mode.iphone-device .card-details {
font-size: 2em !important;
}
body.mobile-mode.iphone-device .card-details .fa,
body.mobile-mode.iphone-device .card-details .icon,
body.mobile-mode.iphone-device .card-details i,
body.mobile-mode.iphone-device .card-details .emoji-icon,
body.mobile-mode.iphone-device .card-details a,
body.mobile-mode.iphone-device .card-details p,
body.mobile-mode.iphone-device .card-details span,
body.mobile-mode.iphone-device .card-details div,
body.mobile-mode.iphone-device .card-details button,
body.mobile-mode.iphone-device .card-details input,
body.mobile-mode.iphone-device .card-details select,
body.mobile-mode.iphone-device .card-details textarea {
font-size: inherit !important;
}
/* Section titles slightly larger than content but not as big as card title */
body.mobile-mode.iphone-device .card-details .card-details-item-title {
font-size: 1.1em !important;
font-weight: bold;
}
/* Ensure scrollbars are positioned correctly */
#content[style*="overflow-x: auto"]::-webkit-scrollbar:vertical {
width: 12px;
@ -263,6 +287,35 @@
animation: fadeIn 0.2s;
z-index: 16;
}
/* Fix for mobile Safari: ensure overlay stays behind card details */
@media screen and (max-width: 800px) {
.board-wrapper .board-canvas .board-overlay {
z-index: 17 !important;
}
/* In desktop mode on small screens, still keep overlay behind card */
body.desktop-mode .board-wrapper .board-canvas .board-overlay {
z-index: 17 !important;
}
}
/* In mobile mode, lower the overlay z-index to stay behind card details */
body.mobile-mode .board-wrapper .board-canvas .board-overlay {
z-index: 17 !important;
}
/* iPhone in desktop mode: remove overlay to avoid blocking card */
body.desktop-mode.iphone-device .board-wrapper .board-canvas .board-overlay {
display: none !important;
pointer-events: none !important;
}
/* Desktop mode: hide overlay to allow multiple cards and board interaction */
body.desktop-mode .board-wrapper .board-canvas .board-overlay {
display: none !important;
pointer-events: none !important;
}
.board-wrapper .board-canvas.is-dragging-active .open-minicard-composer,
.board-wrapper .board-canvas.is-dragging-active .minicard-wrapper.is-checked {
display: none;

View file

@ -58,6 +58,10 @@ template(name="boardBody")
+swimlane(this)
else
+listsGroup(currentBoard)
//- Render multiple open cards in desktop mode
unless isMiniScreen
each openCards
+cardDetails(this cardIndex=@index)
+sidebar
template(name="calendarView")

View file

@ -516,6 +516,16 @@ BlazeComponent.extendComponent({
return isMiniScreen && currentCardId;
},
openCards() {
// In desktop mode, return array of all open cards
const isMobile = Utils.getMobileMode();
if (!isMobile) {
const openCardIds = Session.get('openCards') || [];
return openCardIds.map(id => ReactiveCache.getCard(id)).filter(card => card);
}
return [];
},
goHome() {
FlowRouter.go('home');
},
@ -1642,6 +1652,15 @@ BlazeComponent.extendComponent({
// Open card the same way as clicking a minicard - set currentCard session
// This shows the full card details overlay, not a popup
// In desktop mode, add to openCards array to support multiple cards
const isMobile = Utils.getMobileMode();
if (!isMobile) {
const openCards = Session.get('openCards') || [];
if (!openCards.includes(cardId)) {
openCards.push(cardId);
Session.set('openCards', openCards);
}
}
Session.set('currentCard', cardId);
});
});

View file

@ -583,9 +583,9 @@
}
.board-list .board-list-item .multi-selection-checkbox.is-checked {
background: #2196F3;
border-color: #2196F3;
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.6);
background: #3cb500;
border-color: #3cb500;
box-shadow: 0 2px 8px rgba(60, 181, 0, 0.6);
width: 24px !important;
height: 24px !important;
top: auto !important;
@ -601,10 +601,22 @@
font-weight: bold;
}
/* Grey checkboxes when grey icons setting is enabled */
body.grey-icons-enabled .board-list .board-list-item .multi-selection-checkbox.is-checked {
background: #7a7a7a;
border-color: #7a7a7a;
box-shadow: 0 2px 8px rgba(122, 122, 122, 0.6);
}
body.grey-icons-enabled .board-list.is-multiselection-active .js-board.is-checked {
outline: 4px solid #7a7a7a;
box-shadow: 0 4px 12px rgba(122, 122, 122, 0.4);
}
.board-list.is-multiselection-active .js-board.is-checked {
outline: 4px solid #2196F3;
outline: 4px solid #3cb500;
outline-offset: -4px;
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.4);
box-shadow: 0 4px 12px rgba(60, 181, 0, 0.4);
}
/* Visual hint when multiselection is active */
@ -645,7 +657,11 @@
}
.board-backgrounds-list .board-background-select .background-box i.fa-check {
font-size: 25px;
color: #fff;
color: #3cb500;
}
/* Grey check icons when grey icons setting is enabled */
body.grey-icons-enabled .board-backgrounds-list .board-background-select .background-box i.fa-check {
color: #7a7a7a;
}
/* Prevent Grey Icons from affecting checkmarks in background color list */

View file

@ -55,10 +55,9 @@ template(name="cardCustomField-number")
template(name="cardCustomField-checkbox")
.js-checklist-item.checklist-item(class="{{#if data.value }}is-checked{{/if}}")
if canModifyCard
.check-box-container
.check-box.materialCheckBox(class="{{#if data.value }}is-checked{{/if}}")
span.check-box-unicode {{#if data.value }}✅{{else}}⬜{{/if}}
else
.materialCheckBox(class="{{#if data.value }}is-checked{{/if}}")
span.check-box-unicode {{#if data.value }}✅{{else}}⬜{{/if}}
template(name="cardCustomField-currency")
if canModifyCard

View file

@ -112,6 +112,7 @@ CardCustomField.register('cardCustomField');
events() {
return [
{
'click .js-checklist-item .check-box-unicode': this.toggleItem,
'click .js-checklist-item .check-box-container': this.toggleItem,
},
];

View file

@ -118,6 +118,65 @@
transition: flex-basis 0.1s;
box-sizing: border-box;
}
/* Desktop mode: position card below board header */
body.desktop-mode .card-details:not(.card-details-popup) {
position: fixed;
width: auto;
max-width: 800px;
flex-basis: auto;
border-radius: 8px;
z-index: 100;
}
/* Default position for first card or when dragged */
body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"]):not([style*="top"]) {
top: 50px;
left: 20px;
right: 20px;
bottom: 20px;
}
/* Stagger positions for multiple cards using nth-of-type */
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(1) {
top: 50px;
left: 20px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(2) {
top: 80px;
left: 50px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(3) {
top: 110px;
left: 80px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(4) {
top: 140px;
left: 110px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(5) {
top: 170px;
left: 140px;
}
/* For expanded cards, set dimensions */
body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-collapsed) {
right: 20px;
bottom: 20px;
}
/* Collapsed card state - hide content and set height to title row only */
.card-details.card-details-collapsed .card-details-canvas > *:not(.card-details-header) {
display: none;
}
.card-details.card-details-collapsed {
height: auto !important;
bottom: auto !important;
overflow: visible;
}
body.desktop-mode .card-details.card-details-collapsed {
bottom: auto !important;
}
.card-details .mCustomScrollBox {
padding-left: 0;
}
@ -139,6 +198,49 @@
display: inline-block;
margin-right: 5px;
}
/* Collapse toggle triangle */
.card-details .card-details-header .card-collapse-toggle {
float: left;
font-size: 20px;
padding: 7px 10px;
margin-left: -10px;
margin-right: 5px;
cursor: pointer;
user-select: none;
color: #000;
}
/* Bring to front / Send to back buttons */
.card-details .card-details-header .card-bring-to-front,
.card-details .card-details-header .card-send-to-back {
float: right;
font-size: 18px;
padding: 7px 8px;
margin-right: 5px;
cursor: pointer;
user-select: none;
color: #333;
}
.card-details .card-details-header .card-bring-to-front:hover,
.card-details .card-details-header .card-send-to-back:hover {
color: #000;
background: rgba(0,0,0,0.05);
border-radius: 3px;
}
/* Drag handle */
.card-details .card-details-header .card-drag-handle {
font-size: 20px;
padding: 8px 10px;
margin-right: 10px;
cursor: move;
user-select: none;
display: inline-block;
float: right;
}
.card-details .card-details-header .close-card-details,
.card-details .card-details-header .maximize-card-details,
.card-details .card-details-header .minimize-card-details,
@ -156,11 +258,16 @@
font-size: 24px;
padding: 5px 10px 5px 10px;
margin-right: -8px;
cursor: pointer;
user-select: none;
}
.card-details .card-details-header .close-card-details-mobile-web {
.card-details .card-details-header .close-card-details-mobile-web,
.card-details .card-details-header .card-mobile-desktop-toggle {
font-size: 24px;
padding: 5px;
margin-right: 40px;
margin-right: 5px;
cursor: pointer;
user-select: none;
}
.card-details .card-details-header .card-copy-button {
font-size: 17px;
@ -181,6 +288,36 @@
padding: 10px;
margin-right: 30px;
}
.card-details .card-details-header .card-mobile-desktop-toggle,
.card-details .card-details-header .card-zoom-in,
.card-details .card-details-header .card-zoom-out {
font-size: 24px;
padding: 5px 10px 5px 10px;
margin-right: 5px;
cursor: pointer;
user-select: none;
float: right;
}
/* Unify all card text to match title size */
.card-details {
font-size: 1em;
}
.card-details p,
.card-details span,
.card-details div,
.card-details a,
.card-details label,
.card-details input,
.card-details textarea,
.card-details select,
.card-details button,
.card-details .card-details-item-title,
.card-details .card-label,
.card-details .viewer {
font-size: inherit;
line-height: 1.4;
}
.card-details .card-details-header .card-details-watch {
font-size: 17px;
padding-left: 7px;
@ -284,6 +421,19 @@
position: fixed;
resize: both;
}
/* Override for mobile mode even on larger screens */
body.mobile-mode .card-details {
width: 100vw !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
height: 100vh !important;
max-height: 100vh !important;
resize: none !important;
}
.card-details-maximized {
padding: 0;
flex-shrink: 0;
@ -335,19 +485,53 @@ input[type="submit"].attachment-add-link-submit {
}
@media screen and (max-width: 800px) {
.card-details {
width: calc(100% - 1px);
padding: 0px 20px 0px 20px;
margin: 0px;
width: 100% !important;
padding: 0px 0px 0px 0px !important;
margin: 0px !important;
transition: none;
overflow-y: revert;
overflow-x: revert;
overflow-y: auto;
overflow-x: hidden;
/* iOS Safari specific fixes */
-webkit-overflow-scrolling: touch;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
z-index: 100 !important;
height: 100vh !important;
max-height: 100vh !important;
border-radius: 0 !important;
box-shadow: none !important;
}
/* Ensure card details are above everything on mobile */
body.mobile-mode .card-details {
z-index: 100 !important;
width: 100vw !important;
left: 0 !important;
right: 0 !important;
}
.card-details .card-details-canvas {
width: 100%;
padding-left: 0px;
padding: 0 15px;
}
.card-details .card-details-header .close-card-details {
margin-right: 0px;
display: block !important;
}
.card-details .card-details-header .close-card-details-mobile-web {
display: block !important;
margin-right: 5px !important;
}
.card-details .card-details-header .card-mobile-desktop-toggle {
display: block !important;
margin-right: 5px !important;
}
.card-details .card-details-header .card-mobile-desktop-toggle {
display: block !important;
margin-right: 5px !important;
}
.card-details .card-details-header .card-details-menu {
margin-right: 40px;
@ -373,6 +557,62 @@ input[type="submit"].attachment-add-link-submit {
.pop-over > .content-wrapper > .popup-container-depth-0 .card-details-header {
margin: 0;
}
/* iPhone mobile: enlarge header buttons and increase spacing */
body.mobile-mode.iphone-device .card-details .card-details-header {
padding-right: 16px;
}
body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details,
body.mobile-mode.iphone-device .card-details .card-details-header .maximize-card-details,
body.mobile-mode.iphone-device .card-details .card-details-header .minimize-card-details,
body.mobile-mode.iphone-device .card-details .card-details-header .card-details-menu-mobile-web,
body.mobile-mode.iphone-device .card-details .card-details-header .card-copy-mobile-button,
body.mobile-mode.iphone-device .card-details .card-details-header .card-mobile-desktop-toggle,
body.mobile-mode.iphone-device .card-details .card-details-header .card-zoom-in,
body.mobile-mode.iphone-device .card-details .card-details-header .card-zoom-out {
font-size: 2em !important; /* 2x bigger */
padding: 0.3em !important;
margin-right: 0.75em !important; /* 2x space compared to default */
margin-left: 0 !important;
}
/* Avoid clipping of the close button on the right edge */
body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details {
margin-right: 0.75em !important;
}
/* Enlarge the header title too */
body.mobile-mode.iphone-device .card-details .card-details-header .card-details-title {
font-size: 1.2em !important;
font-weight: bold;
}
}
/* Mobile mode styles - apply when body has mobile-mode class regardless of screen size */
body.mobile-mode .card-details {
width: 100vw !important;
padding: 0px !important;
margin: 0px !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
z-index: 100 !important;
height: 100vh !important;
max-height: 100vh !important;
border-radius: 0 !important;
box-shadow: none !important;
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch;
}
body.mobile-mode .card-details .card-details-canvas {
width: 100% !important;
padding: 0 15px !important;
}
body.mobile-mode .card-details .card-details-header .close-card-details,
body.mobile-mode .card-details .card-details-header .close-card-details-mobile-web {
display: block !important;
}
.card-details-white {
background: #fff !important;

View file

@ -5,16 +5,25 @@ template(name="cardDetails")
+attachmentViewer
section.card-details.js-card-details.nodragscroll(class='{{#if cardMaximized}}card-details-maximized{{/if}}' class='{{#if isPopup}}card-details-popup{{/if}}' class='{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}'): .card-details-canvas
section.card-details.js-card-details.nodragscroll(class='{{#if cardMaximized}}card-details-maximized{{/if}}' class='{{#if isPopup}}card-details-popup{{/if}}' class='{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}' class='{{#if cardCollapsed}}card-details-collapsed{{/if}}'): .card-details-canvas
.card-details-header(class='{{#if colorClass}}card-details-{{colorClass}}{{/if}}')
+inlinedForm(classNames="js-card-details-title")
+editCardTitleForm
else
unless isMiniScreen
unless isPopup
span.card-collapse-toggle.js-card-collapse-toggle(title="{{_ 'collapse-card'}}")
if cardCollapsed
| ▶
else
| 🔽
a.close-card-details.js-close-card-details(title="{{_ 'close-card'}}")
| ❌
if canModifyCard
a.card-bring-to-front.js-card-bring-to-front(title="Bring to front")
| ⏫
a.card-send-to-back.js-card-send-to-back(title="Send to back")
| ⏬
if cardMaximized
a.minimize-card-details.js-minimize-card-details(title="{{_ 'minimize-card'}}")
| 🔽
@ -30,12 +39,28 @@ template(name="cardDetails")
href="{{ originRelativeUrl }}"
)
span.emoji-icon 🔗
span.card-drag-handle.js-card-drag-handle(title="Drag card")
| ↕️
span.copied-tooltip {{_ 'copied'}}
else
unless isPopup
a.close-card-details.js-close-card-details(title="{{_ 'close-card'}}")
| ❌
a.close-card-details.js-close-card-details(title="{{_ 'close-card'}}")
| ❌
a.card-zoom-out.js-card-zoom-out(title="{{_ 'zoom-out'}}")
| 🔍➖
a.card-zoom-in.js-card-zoom-in(title="{{_ 'zoom-in'}}")
| 🔍➕
a.card-mobile-desktop-toggle.js-card-mobile-desktop-toggle(title="{{_ 'mobile-desktop-toggle'}}")
if mobileMode
| 🖥️
else
| 📱
if canModifyCard
if cardMaximized
a.minimize-card-details.js-minimize-card-details(title="{{_ 'minimize-card'}}")
| 🔽
else
a.maximize-card-details.js-maximize-card-details(title="{{_ 'maximize-card'}}")
| 🔼
a.card-details-menu-mobile-web.js-open-card-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
| ☰
a.card-copy-mobile-button.js-copy-link(

View file

@ -63,7 +63,11 @@ BlazeComponent.extendComponent({
const boardBody = this.parentComponent().parentComponent();
//in Miniview parent is Board, not BoardBody.
if (boardBody !== null) {
boardBody.showOverlay.set(true);
// Only show overlay in mobile mode, not in desktop mode
const isMobile = Utils.getMobileMode();
if (isMobile) {
boardBody.showOverlay.set(true);
}
boardBody.mouseHasEnterCardDetails = false;
}
}
@ -93,6 +97,18 @@ BlazeComponent.extendComponent({
return !Utils.getPopupCardId() && ReactiveCache.getCurrentUser().hasCardMaximized();
},
cardCollapsed() {
const user = ReactiveCache.getCurrentUser();
if (user && user.profile) {
return !!user.profile.cardCollapsed;
}
if (Users.getPublicCardCollapsed) {
const stored = Users.getPublicCardCollapsed();
if (typeof stored === 'boolean') return stored;
}
return false;
},
presentParentTask() {
let result = this.currentBoard.presentParentTask;
if (result === null || result === undefined) {
@ -296,13 +312,88 @@ BlazeComponent.extendComponent({
return [
{
...events,
'click .js-card-collapse-toggle'() {
const user = ReactiveCache.getCurrentUser();
const currentState = user && user.profile ? !!user.profile.cardCollapsed : !!Users.getPublicCardCollapsed();
if (user) {
Meteor.call('setCardCollapsed', !currentState);
} else if (Users.setPublicCardCollapsed) {
Users.setPublicCardCollapsed(!currentState);
}
},
'click .js-card-bring-to-front'(event) {
event.preventDefault();
const $card = $(event.target).closest('.card-details');
// Find the highest z-index among all cards
let maxZ = 100;
$('.card-details').each(function() {
const z = parseInt($(this).css('z-index')) || 100;
if (z > maxZ) maxZ = z;
});
// Set this card's z-index to be higher
$card.css('z-index', maxZ + 1);
},
'click .js-card-send-to-back'(event) {
event.preventDefault();
const $card = $(event.target).closest('.card-details');
// Find the lowest z-index among all cards
let minZ = 100;
$('.card-details').each(function() {
const z = parseInt($(this).css('z-index')) || 100;
if (z < minZ) minZ = z;
});
// Set this card's z-index to be lower
$card.css('z-index', minZ - 1);
},
'mousedown .js-card-drag-handle'(event) {
event.preventDefault();
const $card = $(event.target).closest('.card-details');
const startX = event.clientX;
const startY = event.clientY;
const startLeft = $card.offset().left;
const startTop = $card.offset().top;
const onMouseMove = (e) => {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
$card.css({
left: startLeft + deltaX + 'px',
top: startTop + deltaY + 'px'
});
};
const onMouseUp = () => {
$(document).off('mousemove', onMouseMove);
$(document).off('mouseup', onMouseUp);
};
$(document).on('mousemove', onMouseMove);
$(document).on('mouseup', onMouseUp);
},
'click .js-close-card-details'() {
// Get board ID from either the card data or current board in session
const card = this.currentData() || this.data();
const boardId = (card && card.boardId) || Utils.getCurrentBoard()._id;
const cardId = card && card._id;
if (boardId) {
// Clear the current card session to close the card
// In desktop mode, remove from openCards array
const isMobile = Utils.getMobileMode();
if (!isMobile && cardId) {
const openCards = Session.get('openCards') || [];
const filtered = openCards.filter(id => id !== cardId);
Session.set('openCards', filtered);
// If this was the current card, clear it
if (Session.get('currentCard') === cardId) {
Session.set('currentCard', null);
}
// Don't navigate away in desktop mode - just close the card
return;
}
// Mobile mode: Clear the current card session to close the card
Session.set('currentCard', null);
// Navigate back to board without card
@ -327,6 +418,34 @@ BlazeComponent.extendComponent({
Meteor.call('changeDateFormat', dateFormat);
},
'click .js-open-card-details-menu': Popup.open('cardDetailsActions'),
// Mobile: switch to desktop popup view (maximize)
'click .js-mobile-switch-to-desktop'(event) {
event.preventDefault();
// Switch global mode to desktop so the card appears as desktop popup
Utils.setMobileMode(false);
},
'click .js-card-zoom-in'(event) {
event.preventDefault();
const current = Utils.getCardZoom();
const newZoom = Math.min(3.0, current + 0.1);
Utils.setCardZoom(newZoom);
},
'click .js-card-zoom-out'(event) {
event.preventDefault();
const current = Utils.getCardZoom();
const newZoom = Math.max(0.5, current - 0.1);
Utils.setCardZoom(newZoom);
},
'click .js-card-mobile-desktop-toggle'(event) {
event.preventDefault();
const currentMode = Utils.getMobileMode();
Utils.setMobileMode(!currentMode);
},
'click .js-card-mobile-desktop-toggle'(event) {
event.preventDefault();
const currentMode = Utils.getMobileMode();
Utils.setMobileMode(!currentMode);
},
'submit .js-card-description'(event) {
event.preventDefault();
const description = this.currentComponent().getValue();

View file

@ -37,14 +37,23 @@ textarea.js-edit-checklist-item {
.checklist-progress-bar-container .checklist-progress-bar {
width: 80%;
height: 10px;
background-color: #d6ebff !important;
border-radius: 16px;
}
.checklist-progress-bar-container .checklist-progress-bar .checklist-progress {
color: #fff !important;
background-color: #2196f3 !important;
background-color: #3cb500 !important;
padding: 0.01em 16px;
border-radius: 16px;
height: 100%;
}
/* Grey progress bar when grey icons setting is enabled */
body.grey-icons-enabled .checklist-progress-bar-container .checklist-progress-bar {
background-color: #d9d9d9;
}
body.grey-icons-enabled .checklist-progress-bar-container .checklist-progress-bar .checklist-progress {
background-color: #7a7a7a !important;
}
.checklist-title {
padding: 10px;
}
@ -105,6 +114,25 @@ textarea.js-edit-checklist-item {
height: auto;
overflow: hidden;
}
/* iPhone mobile: larger checklist titles and more spacing between items */
body.mobile-mode.iphone-device .checklist-title .title {
font-size: 1.3em !important;
font-weight: bold;
}
body.mobile-mode.iphone-device .checklist-item {
margin-top: 12px !important;
margin-bottom: 8px !important;
padding: 8px 4px !important;
min-height: 44px; /* iOS recommended touch target size */
}
body.mobile-mode.iphone-device .checklist-item span.checklistitem-handle {
font-size: 1.5em !important;
padding-right: 15px !important;
width: 1.5em !important;
}
.checklist-item.is-checked.invisible {
opacity: 0;
height: 0;
@ -134,6 +162,27 @@ textarea.js-edit-checklist-item {
border-bottom: 2px solid #3cb500;
border-right: 2px solid #3cb500;
}
/* Unicode checkbox icons styling */
.checklist-item .check-box-unicode,
.cardCustomField-checkbox .check-box-unicode {
font-size: 1.3em;
margin-right: 8px;
cursor: pointer;
display: inline-block;
vertical-align: middle;
line-height: 1;
}
/* Grey checkmarks when grey icons setting is enabled */
body.grey-icons-enabled .checklist-item .check-box.is-checked {
border-bottom: 2px solid #7a7a7a;
border-right: 2px solid #7a7a7a;
}
body.grey-icons-enabled .checklist-item .check-box-unicode,
body.grey-icons-enabled .cardCustomField-checkbox .check-box-unicode {
filter: grayscale(100%);
-webkit-filter: grayscale(100%);
opacity: 0.85;
}
.checklist-item .item-title {
flex: 1;
}
@ -155,6 +204,7 @@ textarea.js-edit-checklist-item {
width: 1.2em;
text-align: center;
color: #999;
cursor: pointer;
}
.js-delete-checklist-item,
.js-convert-checklist-item-to-card {

View file

@ -125,14 +125,13 @@ template(name='checklistItemDetail')
.js-checklist-item.checklist-item(class="{{#if item.isFinished }}is-checked{{#if checklist.hideCheckedChecklistItems}} invisible{{/if}}{{/if}}{{#if checklist.hideAllChecklistItems}} is-checked invisible{{/if}}"
role="checkbox" aria-checked="{{#if item.isFinished }}true{{else}}false{{/if}}" tabindex="0")
if canModifyCard
.check-box-container
.check-box.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}")
span.check-box-unicode {{#if item.isFinished }}✅{{else}}⬜{{/if}}
span.checklistitem-handle(title="{{_ 'dragChecklistItem'}}") ↕️
.item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}")
+viewer
= item.title
else
.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}")
span.check-box-unicode {{#if item.isFinished }}✅{{else}}⬜{{/if}}
.item-title(class="{{#if item.isFinished }}is-checked{{/if}}")
+viewer
= item.title

View file

@ -65,7 +65,7 @@ BlazeComponent.extendComponent({
$(self.itemsDom).sortable('option', 'disabled', !userIsMember());
if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
$(self.itemsDom).sortable({
handle: 'span.fa.checklistitem-handle',
handle: 'span.checklistitem-handle',
});
}
}
@ -360,6 +360,7 @@ BlazeComponent.extendComponent({
events() {
return [
{
'click .js-checklist-item .check-box-unicode': this.toggleItem,
'click .js-checklist-item .check-box-container': this.toggleItem,
},
];

View file

@ -87,6 +87,15 @@ textarea.js-edit-subtask-item {
top: 0;
bottom: -600px;
right: 0;
z-index: 15;
}
/* Fix for mobile Safari: ensure this doesn't block card interaction */
@media screen and (max-width: 800px) {
#card-details-overlay {
z-index: 15;
pointer-events: none;
}
}
.subtasks {
background: #f7f7f7;
@ -127,6 +136,25 @@ textarea.js-edit-subtask-item {
border-bottom: 2px solid #3cb500;
border-right: 2px solid #3cb500;
}
/* Unicode checkbox icons styling */
.subtasks-item .check-box-unicode {
font-size: 1.3em;
margin-right: 8px;
cursor: pointer;
display: inline-block;
vertical-align: middle;
line-height: 1;
}
/* Grey checkmarks when grey icons setting is enabled */
body.grey-icons-enabled .subtasks-item .check-box.is-checked {
border-bottom: 2px solid #7a7a7a;
border-right: 2px solid #7a7a7a;
}
body.grey-icons-enabled .subtasks-item .check-box-unicode {
filter: grayscale(100%);
-webkit-filter: grayscale(100%);
opacity: 0.85;
}
.subtasks-item .item-title {
flex: 1;
padding-left: 10px;

View file

@ -74,12 +74,12 @@ template(name="subtasksItems")
template(name='subtaskItemDetail')
.js-subtasks-item.subtasks-item
if canModifyCard
.check-box.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}")
span.check-box-unicode {{#if item.isFinished }}✅{{else}}⬜{{/if}}
.item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}")
+viewer
= item.title
else
.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}")
span.check-box-unicode {{#if item.isFinished }}✅{{else}}⬜{{/if}}
.item-title(class="{{#if item.isFinished }}is-checked{{/if}}")
+viewer
= item.title

View file

@ -104,7 +104,19 @@ BlazeComponent.extendComponent({
}).register('subtasks');
BlazeComponent.extendComponent({
// ...
toggleItem() {
const item = this.currentData().item;
if (item && item._id) {
item.toggleItem();
}
},
events() {
return [
{
'click .js-subtasks-item .check-box-unicode': this.toggleItem,
},
];
},
}).register('subtaskItemDetail');
BlazeComponent.extendComponent({

View file

@ -315,11 +315,18 @@ textarea::-moz-placeholder {
margin-right: 6px;
border-top: 2px solid transparent;
border-left: 2px solid transparent;
border-bottom: 2px solid #3cb500;
border-right: 2px solid #3cb500;
transform: rotate(40deg);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform-origin: 100% 100%;
}
/* Grey checkmarks when grey icons setting is enabled */
body.grey-icons-enabled .materialCheckBox.is-checked {
border-bottom: 2px solid #7a7a7a;
border-right: 2px solid #7a7a7a;
}
.button-link {
background: #fff;
background: linear-gradient(#fff, #f5f5f5);

View file

@ -282,7 +282,7 @@ body.list-resizing-active * {
margin: 0 auto;
}
.list.list-collapsed .list-header .js-collapse {
margin: 0 auto 20px auto;
margin: 0 auto 0 auto;
z-index: 10;
padding: 8px 12px;
font-size: 12px;
@ -290,6 +290,12 @@ body.list-resizing-active * {
display: block;
width: fit-content;
}
.list.list-collapsed .list-header .list-header-handle {
position: absolute !important;
top: 30px !important;
right: 1.5vw !important;
z-index: 15 !important;
}
.list.list-collapsed .list-header .list-rotated {
width: auto !important;
height: auto !important;
@ -297,7 +303,6 @@ body.list-resizing-active * {
position: relative !important;
overflow: visible !important;
}
.list.list-collapsed .list-header .list-rotated h2.list-header-name {
text-align: left;
overflow: visible;
@ -308,15 +313,15 @@ body.list-resizing-active * {
color: #333;
background-color: rgba(255, 255, 255, 0.95);
border: 1px solid #ddd;
padding: 8px 4px;
padding: 0;
border-radius: 4px;
margin: 0 auto;
width: 25vh;
height: 60vh;
margin: 0;
width: 100vh;
height: 30px;
position: absolute;
left: 50%;
left: 40px;
top: 50%;
transform: translate(calc(-50% + 50px), -50%) rotate(0deg);
transform: translateY(calc(-50% + 20px)) rotate(0deg);
z-index: 10;
visibility: visible !important;
opacity: 1 !important;
@ -415,22 +420,42 @@ body.list-resizing-active * {
color: #a6a6a6;
margin-right: 15px;
}
/* List header collapse button styling */
.list-header .list-header-collapse-container {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
flex: 1;
min-width: 0;
}
.list-header .js-collapse {
color: #a6a6a6;
margin-right: 15px;
display: inline-block;
vertical-align: middle;
padding: 5px 8px;
border: 1px solid #ccc;
border-radius: 4px;
background-color: #f5f5f5;
border: none;
border-radius: 0;
background-color: transparent;
cursor: pointer;
font-size: 14px;
font-size: 18px;
line-height: 1;
min-width: 30px;
text-align: center;
flex-shrink: 0;
text-decoration: none;
margin: 0;
}
.list-header .js-collapse:hover {
background-color: #e0e0e0;
background-color: transparent;
color: #333;
}
.list-header .list-header-collapse-container > div {
flex: 1;
min-width: 0;
}
.list.list-collapsed .list-header .js-collapse {
display: inline-block !important;
visibility: visible !important;
@ -459,17 +484,18 @@ body.list-resizing-active * {
position: relative !important;
}
.list.list-collapsed .list-header .list-rotated h2.list-header-name {
width: 15vh;
width: 100vh;
font-size: 12px;
height: 30px;
line-height: 1.2;
padding: 8px 4px;
margin: 0 auto;
padding: 0;
margin: 0;
overflow: visible;
position: absolute;
left: 50%;
left: 40px;
top: 50%;
transform: translate(calc(-50% + 50px), -50%) rotate(0deg);
text-align: left;
transform: translateY(calc(-50% + 120px)) rotate(0deg);
text-align: center;
visibility: visible !important;
opacity: 1 !important;
display: block !important;
@ -499,17 +525,18 @@ body.list-resizing-active * {
position: relative !important;
}
.list.list-collapsed .list-header .list-rotated h2.list-header-name {
width: 15vh;
width: 100vh;
font-size: 12px;
height: 30px;
line-height: 1.2;
padding: 8px 4px;
margin: 0 auto;
padding: 0;
margin: 0;
overflow: visible;
position: absolute;
left: 50%;
left: 40px;
top: 50%;
transform: translate(calc(-50% + 50px), -50%) rotate(0deg);
text-align: left;
transform: translateY(calc(-50% + 120px)) rotate(0deg);
text-align: center;
visibility: visible !important;
opacity: 1 !important;
display: block !important;
@ -539,16 +566,17 @@ body.list-resizing-active * {
position: relative !important;
}
.list.list-collapsed .list-header .list-rotated h2.list-header-name {
width: 15vh;
width: 100vh;
font-size: 12px;
height: 30px;
line-height: 1.2;
padding: 8px 4px;
margin: 0 auto;
padding: 0;
margin: 0;
overflow: visible;
position: absolute;
left: 50%;
left: 40px;
top: 50%;
transform: translate(calc(-50% + 50px), -50%) rotate(0deg);
transform: translateY(calc(-50% + 40px)) rotate(0deg);
text-align: left;
visibility: visible !important;
opacity: 1 !important;
@ -1053,6 +1081,23 @@ body.list-resizing-active * {
grid-row: 1/3 !important;
grid-column: 1 !important;
}
/* Allow long list titles to expand on desktop (non-mobile, non-collapsed) */
.list:not(.mobile-view):not(.list-collapsed) .list-header {
overflow: visible !important;
}
.list:not(.mobile-view):not(.list-collapsed) .list-header .list-header-name {
/* Permit wrapping and full visibility */
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
display: inline-block !important;
/* Reserve space for right-side controls (menu, handle, count) */
max-width: calc(100% - 120px) !important;
/* Break long words to avoid overflow */
word-break: break-word !important;
}
.link-board-wrapper {
display: flex;
align-items: baseline;

View file

@ -3,8 +3,9 @@ template(name='list')
style="{{#unless collapsed}}min-width:{{listWidth}}px;max-width:{{listConstraint}}px;{{/unless}}"
class="{{#if collapsed}}list-collapsed{{/if}} {{#if autoWidth}}list-auto-width{{/if}} {{#if isMiniScreen}}mobile-view{{/if}}")
+listHeader
+listBody
.list-resize-handle.js-list-resize-handle.nodragscroll
unless collapsed
+listBody
.list-resize-handle.js-list-resize-handle.nodragscroll
template(name='miniList')
a.mini-list.js-select-list.js-list(id="js-list-{{_id}}" class="{{#if isMiniScreen}}mobile-view{{/if}}")

View file

@ -279,7 +279,8 @@ BlazeComponent.extendComponent({
// Only enable resize for non-collapsed, non-auto-width lists
const isAutoWidth = this.autoWidth();
if (list.collapsed || isAutoWidth) {
const isCollapsed = Utils.getListCollapseState(list);
if (isCollapsed || isAutoWidth) {
$resizeHandle.hide();
return;
}
@ -433,9 +434,10 @@ BlazeComponent.extendComponent({
});
// Reactively update resize handle visibility when auto-width changes
// Reactively update resize handle visibility when auto-width or collapse changes
component.autorun(() => {
if (component.autoWidth()) {
const collapsed = Utils.getListCollapseState(list);
if (component.autoWidth() || collapsed) {
$resizeHandle.hide();
} else {
$resizeHandle.show();
@ -452,6 +454,12 @@ BlazeComponent.extendComponent({
},
}).register('list');
Template.list.helpers({
collapsed() {
return Utils.getListCollapseState(this);
},
});
Template.miniList.events({
'click .js-select-list'() {
const listId = this._id;

View file

@ -30,20 +30,22 @@ template(name="listHeader")
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
else
if collapsed
a.js-collapse(title="{{_ 'uncollapse'}}")
| ⬅️
| ➡️
div(class="{{#if collapsed}}list-rotated{{/if}}")
h2.list-header-name(
title="{{ moment modifiedAt 'LLL' }}"
class="{{#if currentUser.isBoardMember}}{{#unless currentUser.isCommentOnly}}{{#unless currentUser.isWorker}}js-open-inlined-form is-editable{{/unless}}{{/unless}}{{/if}}")
+viewer
= title
if wipLimit.enabled
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
|/#{wipLimit.value})
div.list-header-collapse-container
a.list-collapse-indicator.js-collapse(title="{{_ 'collapse'}}")
if collapsed
| ▶
else
| 🔽
div(class="{{#if collapsed}}list-rotated{{/if}}")
h2.list-header-name(
title="{{ moment modifiedAt 'LLL' }}"
class="{{#if currentUser.isBoardMember}}{{#unless currentUser.isCommentOnly}}{{#unless currentUser.isWorker}}js-open-inlined-form is-editable{{/unless}}{{/unless}}{{/if}}")
+viewer
= title
if wipLimit.enabled
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
|/#{wipLimit.value})
unless collapsed
if showCardsCountForList cards.length
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cards.length}}
@ -64,6 +66,10 @@ template(name="listHeader")
unless currentUser.isWorker
a.list-header-handle.handle.js-list-handle ↕️
else if currentUser.isBoardMember
if currentUser.isBoardMember
unless currentUser.isCommentOnly
unless currentUser.isWorker
a.list-header-handle.handle.js-list-handle ↕️
if isWatching
i.list-header-watch-icon | 👁️
unless collapsed
@ -73,14 +79,8 @@ template(name="listHeader")
// a.fa.js-list-star.list-header-plus-top(class="fa-star{{#unless starred}}-o{{/unless}}")
if canSeeAddCard
a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}")
a.js-collapse(title="{{_ 'collapse'}}")
| ⬅️
| ➡️
a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}") ☰
if currentUser.isBoardMember
unless currentUser.isCommentOnly
unless currentUser.isWorker
a.list-header-handle.handle.js-list-handle ↕️
template(name="editListTitleForm")
.list-composer

View file

@ -34,13 +34,14 @@ BlazeComponent.extendComponent({
},
collapsed(check = undefined) {
const list = Template.currentData();
const status = list.isCollapsed();
const status = Utils.getListCollapseState(list);
if (check === undefined) {
// just check
return status;
} else {
list.collapse(!status);
return !status;
const next = typeof check === 'boolean' ? check : !status;
Utils.setListCollapseState(list, next);
return next;
}
},
editTitle(event) {

View file

@ -339,15 +339,20 @@
width: 100%;
min-width: 3vw;
font-size: clamp(12px, 2vw, 14px);
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
flex: 0 0 auto;
}
/* Make zoom input wider on all mobile screens */
@media screen and (max-width: 800px),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
#header-quick-access .zoom-controls .zoom-input {
min-width: 50px !important; /* Wider on mobile */
width: 50px !important; /* Fixed width to show all numbers */
font-size: 14px !important; /* Slightly larger text */
min-width: 80px !important; /* Wider on mobile to show 3 digits */
width: 80px !important; /* Fixed width to show 100 fully */
font-size: 16px !important; /* Slightly larger text */
flex: 0 0 80px !important; /* Prevent shrinking in flex */
}
}
@ -850,8 +855,9 @@
#header-quick-access .zoom-controls .zoom-input {
font-size: 16px !important; /* Larger input text */
padding: 0.5vh 0.8vw !important;
min-width: 6vw !important; /* Much wider for mobile */
width: 60px !important; /* Fixed width to show all numbers */
min-width: 80px !important; /* Wider to fit 100 */
width: 80px !important; /* Fixed width to show 100 fully */
flex: 0 0 80px !important; /* Prevent shrinking in flex */
}
/* Make mobile mode toggle larger */

View file

@ -81,6 +81,27 @@ body {
display: flex;
flex-direction: column;
height: 100vh;
/* iOS Safari fixes */
-webkit-overflow-scrolling: touch;
}
/* Mobile mode specific fixes for iOS Safari */
body.mobile-mode {
overflow-x: hidden;
position: fixed;
width: 100%;
height: 100vh;
/* Prevent iOS Safari bounce scroll */
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}
/* Ensure content area is scrollable in mobile mode */
body.mobile-mode #content {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
height: calc(100vh - 48px);
}
#content {
position: relative;
@ -899,6 +920,40 @@ a:not(.disabled).is-active i.fa {
height: 100%;
}
}
/* iOS Safari Mobile Mode Fixes */
@media screen and (max-width: 800px) {
/* Prevent scrolling issues on iOS Safari when card popup is open */
body.mobile-mode {
overflow: hidden;
position: fixed;
width: 100%;
height: 100vh;
}
/* Fix z-index stacking for mobile Safari */
body.mobile-mode .board-wrapper {
z-index: 1;
}
body.mobile-mode .board-wrapper .board-canvas .board-overlay {
z-index: 17 !important;
}
body.mobile-mode .card-details {
z-index: 100 !important;
}
body.mobile-mode .pop-over {
z-index: 999;
}
/* Ensure smooth scrolling on iOS */
body.mobile-mode .card-details,
body.mobile-mode .pop-over .content-wrapper {
-webkit-overflow-scrolling: touch;
}
}
@-moz-keyframes lds-roller {
0% {
transform: rotate(0deg);

View file

@ -2,8 +2,10 @@ template(name="main")
html(lang="{{TAPi18n.getLanguage}}")
head
title
meta(name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes")
meta(name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes, viewport-fit=cover")
meta(http-equiv="X-UA-Compatible" content="IE=edge")
meta(name="apple-mobile-web-app-capable" content="yes")
meta(name="apple-mobile-web-app-status-bar-style" content="black-translucent")
//- XXX We should use pathFor in the following `href` to support the case
where the application is deployed with a path prefix, but it seems to be
difficult to do that cleanly with Blaze -- at least without adding extra

View file

@ -538,6 +538,7 @@
position: absolute;
top: 6px;
right: 12px;
color: #3cb500;
}
.pop-over-list .pop-over-list.checkable li.active a {
padding-right: 28px;
@ -545,6 +546,10 @@
.pop-over-list .pop-over-list.checkable li.active a .fa-check {
display: block;
}
/* Grey check icons when grey icons setting is enabled */
body.grey-icons-enabled .pop-over-list .pop-over-list.checkable .fa-check {
color: #7a7a7a;
}
.pop-over.miniprofile .header {
border-bottom-color: transparent;
height: 30px;
@ -590,6 +595,10 @@
overflow: hidden;
margin-top: 0px;
border: 0px solid #dbdbdb;
/* Ensure popups appear above card details on mobile */
z-index: 999999 !important;
/* iOS Safari scrolling fix */
-webkit-overflow-scrolling: touch;
}
.pop-over .header {
color: #fff;
@ -674,3 +683,23 @@
transform: none !important;
}
}
/* Force full-screen popups in mobile mode regardless of screen width */
body.mobile-mode .pop-over {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
max-width: 100vw !important;
max-height: 100vh !important;
}
body.mobile-mode .pop-over .content-wrapper {
width: 100% !important;
height: calc(100vh - 48px) !important;
max-height: calc(100vh - 48px) !important;
overflow-y: auto !important;
overflow-x: hidden !important;
}

View file

@ -137,8 +137,13 @@
padding: 0.5rem 0.5rem;
}
.setting-content .content-body .main-body ul li a .is-checked {
border-bottom: 2px solid #2980b9;
border-right: 2px solid #2980b9;
border-bottom: 2px solid #3cb500;
border-right: 2px solid #3cb500;
}
/* Grey checkmarks when grey icons setting is enabled */
body.grey-icons-enabled .setting-content .content-body .main-body ul li a .is-checked {
border-bottom: 2px solid #7a7a7a;
border-right: 2px solid #7a7a7a;
}
.setting-content .content-body .main-body ul li a span {
padding: 0 0.5rem;

View file

@ -68,6 +68,14 @@
transform-origin: 100% 100% !important;
}
/* Grey checkmarks when grey icons setting is enabled */
body.grey-icons-enabled .sidebar .materialCheckBox.is-checked,
body.grey-icons-enabled .boardCardSettingsPopup .materialCheckBox.is-checked,
body.grey-icons-enabled .boardSubtaskSettingsPopup .materialCheckBox.is-checked {
border-bottom: 2px solid #7a7a7a !important;
border-right: 2px solid #7a7a7a !important;
}
/* Card Settings 3-column grid layout */
.card-settings-grid {
display: grid;
@ -130,6 +138,11 @@
}
.sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-check {
margin: 0 4px;
color: #3cb500;
}
/* Grey check icons when grey icons setting is enabled */
body.grey-icons-enabled .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-check {
color: #7a7a7a;
}
.sidebar .sidebar-content ul.sidebar-list li .minicard {
padding: 6px 8px 4px;

View file

@ -26,6 +26,11 @@ template(name="swimlaneFixedHeader")
if currentUser
unless currentUser.isCommentOnly
unless currentUser.isWorker
a.swimlane-collapse-indicator.js-collapse-swimlane.swimlane-header-collapse(title="{{_ 'collapse'}}")
if collapseSwimlane
| ▶
else
| 🔽
a.js-open-add-swimlane-menu.swimlane-header-plus-icon(title="{{_ 'add-swimlane'}}")
|
unless isTouchScreen

View file

@ -20,13 +20,14 @@ BlazeComponent.extendComponent({
},
collapsed(check = undefined) {
const swimlane = Template.currentData();
const status = swimlane.isCollapsed();
const status = Utils.getSwimlaneCollapseState(swimlane);
if (check === undefined) {
// just check
return status;
} else {
swimlane.collapse(!status);
return !status;
const next = typeof check === 'boolean' ? check : !status;
Utils.setSwimlaneCollapseState(swimlane, next);
return next;
}
},
@ -49,6 +50,10 @@ Template.swimlaneFixedHeader.helpers({
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
},
collapseSwimlane() {
const swimlane = Template.currentData();
return Utils.getSwimlaneCollapseState(swimlane);
},
isTitleDefault(title) {
// https://github.com/wekan/wekan/issues/4763
// https://github.com/wekan/wekan/issues/4742

View file

@ -130,6 +130,29 @@
pointer-events: auto;
}
/* Swimlane collapse button styling - matches list collapse button */
.swimlane .swimlane-header-wrap .swimlane-header-menu .swimlane-collapse-indicator {
color: #a6a6a6;
display: inline-block;
vertical-align: middle;
padding: 5px 8px;
border: none;
border-radius: 0;
background-color: transparent;
cursor: pointer;
font-size: 18px;
line-height: 1;
min-width: 30px;
text-align: center;
text-decoration: none;
margin: 0;
flex-shrink: 0;
}
.swimlane .swimlane-header-wrap .swimlane-header-menu .swimlane-collapse-indicator:hover {
background-color: transparent;
color: #333;
}
#js-swimlane-height-edit .swimlane-height-error {
display: none;
}

View file

@ -283,6 +283,9 @@ BlazeComponent.extendComponent({
// Wait for DOM to be ready
setTimeout(() => {
const handleSelector = Utils.isTouchScreenOrShowDesktopDragHandles()
? '.js-list-handle'
: '.js-list-header';
const $lists = this.$('.js-list');
const $parent = $lists.parent();
@ -306,7 +309,7 @@ BlazeComponent.extendComponent({
items: '.js-list:not(.js-list-composer)',
placeholder: 'list placeholder',
distance: 7,
handle: '.js-list-handle',
handle: handleSelector,
disabled: !Utils.canModifyBoard(),
start(evt, ui) {
ui.helper.css('z-index', 1000);
@ -319,6 +322,15 @@ BlazeComponent.extendComponent({
boardComponent.setIsDragging(false);
}
});
// Reactively update handle when user toggles desktop drag handles
this.autorun(() => {
const newHandle = Utils.isTouchScreenOrShowDesktopDragHandles()
? '.js-list-handle'
: '.js-list-header';
if ($parent.data('uiSortable') || $parent.data('sortable')) {
try { $parent.sortable('option', 'handle', newHandle); } catch (e) {}
}
});
} else {
}
}, 100);
@ -684,6 +696,10 @@ Template.swimlane.helpers({
lists() {
// Return per-swimlane lists for this swimlane
return this.myLists();
},
collapseSwimlane() {
return Utils.getSwimlaneCollapseState(this);
}
});
@ -691,6 +707,9 @@ Template.swimlane.helpers({
setTimeout(() => {
const $swimlaneElements = $('.swimlane');
const $listsGroupElements = $('.list-group');
const computeHandle = () => (
Utils.isTouchScreenOrShowDesktopDragHandles() ? '.js-list-handle' : '.js-list-header'
);
// Initialize sortable on ALL swimlane elements (even empty ones)
$swimlaneElements.each(function(index) {
@ -707,7 +726,7 @@ setTimeout(() => {
items: '.js-list:not(.js-list-composer)',
placeholder: 'list placeholder',
distance: 7,
handle: '.js-list-handle',
handle: computeHandle(),
disabled: !Utils.canModifyBoard(),
start(evt, ui) {
ui.helper.css('z-index', 1000);
@ -831,6 +850,13 @@ setTimeout(() => {
});
}
});
// Reactively adjust handle when setting changes
Tracker.autorun(() => {
const newHandle = computeHandle();
if ($swimlane.data('uiSortable') || $swimlane.data('sortable')) {
try { $swimlane.sortable('option', 'handle', newHandle); } catch (e) {}
}
});
}
});
@ -849,7 +875,7 @@ setTimeout(() => {
items: '.js-list:not(.js-list-composer)',
placeholder: 'list placeholder',
distance: 7,
handle: '.js-list-handle',
handle: computeHandle(),
disabled: !Utils.canModifyBoard(),
start(evt, ui) {
ui.helper.css('z-index', 1000);
@ -973,6 +999,13 @@ setTimeout(() => {
});
}
});
// Reactively adjust handle when setting changes
Tracker.autorun(() => {
const newHandle = computeHandle();
if ($listsGroup.data('uiSortable') || $listsGroup.data('sortable')) {
try { $listsGroup.sortable('option', 'handle', newHandle); } catch (e) {}
}
});
}
});
}, 1000);
@ -1018,6 +1051,9 @@ BlazeComponent.extendComponent({
// Wait for DOM to be ready
setTimeout(() => {
const handleSelector = Utils.isTouchScreenOrShowDesktopDragHandles()
? '.js-list-handle'
: '.js-list-header';
const $lists = this.$('.js-list');
const $parent = $lists.parent();
@ -1041,7 +1077,7 @@ BlazeComponent.extendComponent({
items: '.js-list:not(.js-list-composer)',
placeholder: 'list placeholder',
distance: 7,
handle: '.js-list-handle',
handle: handleSelector,
disabled: !Utils.canModifyBoard(),
start(evt, ui) {
ui.helper.css('z-index', 1000);
@ -1054,6 +1090,15 @@ BlazeComponent.extendComponent({
boardComponent.setIsDragging(false);
}
});
// Reactively update handle when user toggles desktop drag handles
this.autorun(() => {
const newHandle = Utils.isTouchScreenOrShowDesktopDragHandles()
? '.js-list-handle'
: '.js-list-header';
if ($parent.data('uiSortable') || $parent.data('sortable')) {
try { $parent.sortable('option', 'handle', newHandle); } catch (e) {}
}
});
} else {
}
}, 100);

View file

@ -79,13 +79,21 @@ Utils = {
},
getMobileMode() {
// Check localStorage first - user's explicit preference takes priority
const stored = localStorage.getItem('wekan-mobile-mode');
if (stored !== null) {
return stored === 'true';
}
// Then check user profile
const user = ReactiveCache.getCurrentUser();
if (user && user.profile && user.profile.mobileMode !== undefined) {
return user.profile.mobileMode;
}
// For non-logged-in users, check localStorage
const stored = localStorage.getItem('wekan-mobile-mode');
return stored ? stored === 'true' : false;
// Default to mobile mode for iPhone/iPod
const isIPhone = /iPhone|iPod/i.test(navigator.userAgent);
return isIPhone;
},
setMobileMode(enabled) {
@ -93,13 +101,41 @@ Utils = {
if (user) {
// Update user profile
user.setMobileMode(enabled);
} else {
// Store in localStorage for non-logged-in users
localStorage.setItem('wekan-mobile-mode', enabled.toString());
}
// Always store in localStorage for persistence across sessions
localStorage.setItem('wekan-mobile-mode', enabled.toString());
Utils.applyMobileMode(enabled);
// Trigger reactive updates for UI components
Session.set('wekan-mobile-mode', enabled);
// Re-apply zoom level to ensure proper rendering
const zoomLevel = Utils.getZoomLevel();
Utils.applyZoomLevel(zoomLevel);
},
getCardZoom() {
const user = ReactiveCache.getCurrentUser();
if (user && user.profile && user.profile.cardZoom !== undefined) {
return user.profile.cardZoom;
}
const stored = localStorage.getItem('wekan-card-zoom');
return stored ? parseFloat(stored) : 1.0;
},
setCardZoom(level) {
const user = ReactiveCache.getCurrentUser();
if (user) {
user.setCardZoom(level);
}
localStorage.setItem('wekan-card-zoom', level.toString());
Utils.applyCardZoom(level);
Session.set('wekan-card-zoom', level);
},
applyCardZoom(level) {
const cardDetails = document.querySelector('.card-details');
if (cardDetails) {
cardDetails.style.fontSize = `${level}em`;
}
},
applyZoomLevel(level) {
@ -301,6 +337,85 @@ Utils = {
}
},
getListCollapseState(list) {
if (!list) return false;
const key = `collapsedList-${list._id}`;
const sessionVal = Session.get(key);
if (typeof sessionVal === 'boolean') {
return sessionVal;
}
const user = ReactiveCache.getCurrentUser();
let stored = null;
if (user && user.getCollapsedListFromStorage) {
stored = user.getCollapsedListFromStorage(list.boardId, list._id);
} else if (Users.getPublicCollapsedList) {
stored = Users.getPublicCollapsedList(list.boardId, list._id);
}
if (typeof stored === 'boolean') {
Session.setDefault(key, stored);
return stored;
}
const fallback = typeof list.collapsed === 'boolean' ? list.collapsed : false;
Session.setDefault(key, fallback);
return fallback;
},
setListCollapseState(list, collapsed) {
if (!list) return;
const key = `collapsedList-${list._id}`;
Session.set(key, !!collapsed);
const user = ReactiveCache.getCurrentUser();
if (user) {
Meteor.call('setListCollapsedState', list.boardId, list._id, !!collapsed);
} else if (Users.setPublicCollapsedList) {
Users.setPublicCollapsedList(list.boardId, list._id, !!collapsed);
}
},
getSwimlaneCollapseState(swimlane) {
if (!swimlane) return false;
const key = `collapsedSwimlane-${swimlane._id}`;
const sessionVal = Session.get(key);
if (typeof sessionVal === 'boolean') {
return sessionVal;
}
const user = ReactiveCache.getCurrentUser();
let stored = null;
if (user && user.getCollapsedSwimlaneFromStorage) {
stored = user.getCollapsedSwimlaneFromStorage(
swimlane.boardId,
swimlane._id,
);
} else if (Users.getPublicCollapsedSwimlane) {
stored = Users.getPublicCollapsedSwimlane(swimlane.boardId, swimlane._id);
}
if (typeof stored === 'boolean') {
Session.setDefault(key, stored);
return stored;
}
const fallback = typeof swimlane.collapsed === 'boolean' ? swimlane.collapsed : false;
Session.setDefault(key, fallback);
return fallback;
},
setSwimlaneCollapseState(swimlane, collapsed) {
if (!swimlane) return;
const key = `collapsedSwimlane-${swimlane._id}`;
Session.set(key, !!collapsed);
const user = ReactiveCache.getCurrentUser();
if (user) {
Meteor.call('setSwimlaneCollapsedState', swimlane.boardId, swimlane._id, !!collapsed);
} else if (Users.setPublicCollapsedSwimlane) {
Users.setPublicCollapsedSwimlane(swimlane.boardId, swimlane._id, !!collapsed);
}
},
myCardsSort() {
let sort = window.localStorage.getItem('myCardsSort');

View file

@ -165,6 +165,16 @@ FlowRouter.route('/b/:boardId/:slug/:cardId', {
Session.set('currentCard', params.cardId);
Session.set('popupCardId', null);
Session.set('popupCardBoardId', null);
// In desktop mode, add to openCards array to support multiple cards
const isMobile = Utils.getMobileMode();
if (!isMobile) {
const openCards = Session.get('openCards') || [];
if (!openCards.includes(params.cardId)) {
openCards.push(params.cardId);
Session.set('openCards', openCards);
}
}
Utils.manageCustomUI();
Utils.manageMatomo();

View file

@ -297,6 +297,23 @@ Lists.helpers({
},
isCollapsed() {
if (Meteor.isClient) {
const user = ReactiveCache.getCurrentUser();
// Logged-in users: prefer profile/cookie-backed state
if (user && user.getCollapsedListFromStorage) {
const stored = user.getCollapsedListFromStorage(this.boardId, this._id);
if (typeof stored === 'boolean') {
return stored;
}
}
// Public users: fallback to cookie if available
if (!user && Users.getPublicCollapsedList) {
const stored = Users.getPublicCollapsedList(this.boardId, this._id);
if (typeof stored === 'boolean') {
return stored;
}
}
}
return this.collapsed === true;
},

View file

@ -246,6 +246,21 @@ Swimlanes.helpers({
},
isCollapsed() {
if (Meteor.isClient) {
const user = ReactiveCache.getCurrentUser();
if (user && user.getCollapsedSwimlaneFromStorage) {
const stored = user.getCollapsedSwimlaneFromStorage(this.boardId, this._id);
if (typeof stored === 'boolean') {
return stored;
}
}
if (!user && Users.getPublicCollapsedSwimlane) {
const stored = Users.getPublicCollapsedSwimlane(this.boardId, this._id);
if (typeof stored === 'boolean') {
return stored;
}
}
}
return this.collapsed === true;
},

View file

@ -11,6 +11,83 @@ const isSandstorm =
Meteor.settings && Meteor.settings.public && Meteor.settings.public.sandstorm;
Users = Meteor.users;
// Public-board collapse persistence helpers (cookie-based for non-logged-in users)
if (Meteor.isClient) {
const readCookieMap = name => {
try {
const stored = typeof document !== 'undefined' ? document.cookie : '';
const cookies = stored.split(';').map(c => c.trim());
let json = '{}';
for (const c of cookies) {
if (c.startsWith(name + '=')) {
json = decodeURIComponent(c.substring(name.length + 1));
break;
}
}
return JSON.parse(json || '{}');
} catch (e) {
console.warn('Error parsing collapse cookie', name, e);
return {};
}
};
const writeCookieMap = (name, data) => {
try {
const serialized = encodeURIComponent(JSON.stringify(data || {}));
const maxAge = 60 * 60 * 24 * 365; // 1 year
document.cookie = `${name}=${serialized}; path=/; max-age=${maxAge}`;
} catch (e) {
console.warn('Error writing collapse cookie', name, e);
}
};
Users.getPublicCollapsedList = (boardId, listId) => {
if (!boardId || !listId) return null;
const data = readCookieMap('wekan-collapsed-lists');
if (data[boardId] && typeof data[boardId][listId] === 'boolean') {
return data[boardId][listId];
}
return null;
};
Users.setPublicCollapsedList = (boardId, listId, collapsed) => {
if (!boardId || !listId) return false;
const data = readCookieMap('wekan-collapsed-lists');
if (!data[boardId]) data[boardId] = {};
data[boardId][listId] = !!collapsed;
writeCookieMap('wekan-collapsed-lists', data);
return true;
};
Users.getPublicCollapsedSwimlane = (boardId, swimlaneId) => {
if (!boardId || !swimlaneId) return null;
const data = readCookieMap('wekan-collapsed-swimlanes');
if (data[boardId] && typeof data[boardId][swimlaneId] === 'boolean') {
return data[boardId][swimlaneId];
}
return null;
};
Users.setPublicCollapsedSwimlane = (boardId, swimlaneId, collapsed) => {
if (!boardId || !swimlaneId) return false;
const data = readCookieMap('wekan-collapsed-swimlanes');
if (!data[boardId]) data[boardId] = {};
data[boardId][swimlaneId] = !!collapsed;
writeCookieMap('wekan-collapsed-swimlanes', data);
return true;
};
Users.getPublicCardCollapsed = () => {
const data = readCookieMap('wekan-card-collapsed');
return typeof data.state === 'boolean' ? data.state : null;
};
Users.setPublicCardCollapsed = collapsed => {
writeCookieMap('wekan-card-collapsed', { state: !!collapsed });
return true;
};
}
const allowedSortValues = [
'-modifiedAt',
'modifiedAt',
@ -187,6 +264,13 @@ Users.attachSchema(
type: Boolean,
optional: true,
},
'profile.cardCollapsed': {
/**
* has user collapsed the card details?
*/
type: Boolean,
optional: true,
},
'profile.customFieldsGrid': {
/**
* has user at card Custom Fields have Grid (false) or one per row (true) layout?
@ -476,6 +560,24 @@ Users.attachSchema(
defaultValue: {},
blackbox: true,
},
'profile.collapsedLists': {
/**
* Per-user collapsed state for lists.
* profile[boardId][listId] = true|false
*/
type: Object,
defaultValue: {},
blackbox: true,
},
'profile.collapsedSwimlanes': {
/**
* Per-user collapsed state for swimlanes.
* profile[boardId][swimlaneId] = true|false
*/
type: Object,
defaultValue: {},
blackbox: true,
},
'profile.keyboardShortcuts': {
/**
* User-specified state of keyboard shortcut activation.
@ -522,6 +624,15 @@ Users.attachSchema(
type: Boolean,
defaultValue: false,
},
'profile.cardZoom': {
/**
* User-specified zoom level for card details (1.0 = 100%, 1.5 = 150%, etc.)
*/
type: Number,
defaultValue: 1.0,
min: 0.5,
max: 3.0,
},
services: {
/**
* services field of the user
@ -602,7 +713,7 @@ Users.attachSchema(
);
// Security helpers for user updates
export const USER_UPDATE_ALLOWED_EXACT = ['username'];
export const USER_UPDATE_ALLOWED_EXACT = ['username', 'profile'];
export const USER_UPDATE_ALLOWED_PREFIXES = ['profile.'];
export const USER_UPDATE_FORBIDDEN_PREFIXES = [
'services',
@ -1311,6 +1422,135 @@ Users.helpers({
return false;
}
},
// Per-user collapsed state helpers for lists/swimlanes
getCollapsedList(boardId, listId) {
const { collapsedLists = {} } = this.profile || {};
if (collapsedLists[boardId] && typeof collapsedLists[boardId][listId] === 'boolean') {
return collapsedLists[boardId][listId];
}
return null;
},
getCollapsedSwimlane(boardId, swimlaneId) {
const { collapsedSwimlanes = {} } = this.profile || {};
if (collapsedSwimlanes[boardId] && typeof collapsedSwimlanes[boardId][swimlaneId] === 'boolean') {
return collapsedSwimlanes[boardId][swimlaneId];
}
return null;
},
setCollapsedListToStorage(boardId, listId, collapsed) {
// Logged-in users: save to profile
if (this._id) {
return this.setCollapsedList(boardId, listId, collapsed);
}
// Public users: save to cookie
try {
const name = 'wekan-collapsed-lists';
const stored = (typeof document !== 'undefined') ? document.cookie : '';
const cookies = stored.split(';').map(c => c.trim());
let json = '{}';
for (const c of cookies) {
if (c.startsWith(name + '=')) {
json = decodeURIComponent(c.substring(name.length + 1));
break;
}
}
let data = {};
try { data = JSON.parse(json || '{}'); } catch (e) { data = {}; }
if (!data[boardId]) data[boardId] = {};
data[boardId][listId] = !!collapsed;
const serialized = encodeURIComponent(JSON.stringify(data));
const maxAge = 60 * 60 * 24 * 365; // 1 year
document.cookie = `${name}=${serialized}; path=/; max-age=${maxAge}`;
return true;
} catch (e) {
console.warn('Error saving collapsed list to cookie:', e);
return false;
}
},
getCollapsedListFromStorage(boardId, listId) {
// Logged-in users: read from profile
if (this._id) {
const v = this.getCollapsedList(boardId, listId);
return v;
}
// Public users: read from cookie
try {
const name = 'wekan-collapsed-lists';
const stored = (typeof document !== 'undefined') ? document.cookie : '';
const cookies = stored.split(';').map(c => c.trim());
let json = '{}';
for (const c of cookies) {
if (c.startsWith(name + '=')) {
json = decodeURIComponent(c.substring(name.length + 1));
break;
}
}
const data = JSON.parse(json || '{}');
if (data[boardId] && typeof data[boardId][listId] === 'boolean') {
return data[boardId][listId];
}
} catch (e) {
console.warn('Error reading collapsed list from cookie:', e);
}
return null;
},
setCollapsedSwimlaneToStorage(boardId, swimlaneId, collapsed) {
// Logged-in users: save to profile
if (this._id) {
return this.setCollapsedSwimlane(boardId, swimlaneId, collapsed);
}
// Public users: save to cookie
try {
const name = 'wekan-collapsed-swimlanes';
const stored = (typeof document !== 'undefined') ? document.cookie : '';
const cookies = stored.split(';').map(c => c.trim());
let json = '{}';
for (const c of cookies) {
if (c.startsWith(name + '=')) {
json = decodeURIComponent(c.substring(name.length + 1));
break;
}
}
let data = {};
try { data = JSON.parse(json || '{}'); } catch (e) { data = {}; }
if (!data[boardId]) data[boardId] = {};
data[boardId][swimlaneId] = !!collapsed;
const serialized = encodeURIComponent(JSON.stringify(data));
const maxAge = 60 * 60 * 24 * 365; // 1 year
document.cookie = `${name}=${serialized}; path=/; max-age=${maxAge}`;
return true;
} catch (e) {
console.warn('Error saving collapsed swimlane to cookie:', e);
return false;
}
},
getCollapsedSwimlaneFromStorage(boardId, swimlaneId) {
// Logged-in users: read from profile
if (this._id) {
const v = this.getCollapsedSwimlane(boardId, swimlaneId);
return v;
}
// Public users: read from cookie
try {
const name = 'wekan-collapsed-swimlanes';
const stored = (typeof document !== 'undefined') ? document.cookie : '';
const cookies = stored.split(';').map(c => c.trim());
let json = '{}';
for (const c of cookies) {
if (c.startsWith(name + '=')) {
json = decodeURIComponent(c.substring(name.length + 1));
break;
}
}
const data = JSON.parse(json || '{}');
if (data[boardId] && typeof data[boardId][swimlaneId] === 'boolean') {
return data[boardId][swimlaneId];
}
} catch (e) {
console.warn('Error reading collapsed swimlane from cookie:', e);
}
return null;
},
});
Users.mutations({
@ -1485,6 +1725,14 @@ Users.mutations({
};
},
toggleCardCollapsed(value = false) {
return {
$set: {
'profile.cardCollapsed': !value,
},
};
},
toggleLabelText(value = false) {
return {
$set: {
@ -1621,6 +1869,26 @@ Users.mutations({
},
};
},
setCollapsedList(boardId, listId, collapsed) {
const current = (this.profile && this.profile.collapsedLists) || {};
if (!current[boardId]) current[boardId] = {};
current[boardId][listId] = !!collapsed;
return {
$set: {
'profile.collapsedLists': current,
},
};
},
setCollapsedSwimlane(boardId, swimlaneId, collapsed) {
const current = (this.profile && this.profile.collapsedSwimlanes) || {};
if (!current[boardId]) current[boardId] = {};
current[boardId][swimlaneId] = !!collapsed;
return {
$set: {
'profile.collapsedSwimlanes': current,
},
};
},
setZoomLevel(level) {
return {
@ -1637,6 +1905,14 @@ Users.mutations({
},
};
},
setCardZoom(level) {
return {
$set: {
'profile.cardZoom': level,
},
};
},
});
Meteor.methods({
@ -1809,6 +2085,11 @@ Meteor.methods({
const user = ReactiveCache.getCurrentUser();
user.toggleCardMaximized(user.hasCardMaximized());
},
setCardCollapsed(value) {
check(value, Boolean);
if (!this.userId) throw new Meteor.Error('not-logged-in');
Users.update(this.userId, { $set: { 'profile.cardCollapsed': value } });
},
toggleMinicardLabelText() {
const user = ReactiveCache.getCurrentUser();
user.toggleLabelText(user.hasHiddenMinicardLabelText());
@ -1838,6 +2119,26 @@ Meteor.methods({
user.setListWidth(boardId, listId, width);
user.setListConstraint(boardId, listId, constraint);
},
setListCollapsedState(boardId, listId, collapsed) {
check(boardId, String);
check(listId, String);
check(collapsed, Boolean);
if (!this.userId) {
throw new Meteor.Error('not-logged-in', 'User must be logged in');
}
const user = Users.findOne(this.userId);
if (!user) {
throw new Meteor.Error('user-not-found', 'User not found');
}
const current = (user.profile && user.profile.collapsedLists) || {};
if (!current[boardId]) current[boardId] = {};
current[boardId][listId] = !!collapsed;
Users.update(this.userId, {
$set: {
'profile.collapsedLists': current,
},
});
},
applySwimlaneHeight(boardId, swimlaneId, height) {
check(boardId, String);
check(swimlaneId, String);
@ -1846,6 +2147,27 @@ Meteor.methods({
user.setSwimlaneHeight(boardId, swimlaneId, height);
},
setSwimlaneCollapsedState(boardId, swimlaneId, collapsed) {
check(boardId, String);
check(swimlaneId, String);
check(collapsed, Boolean);
if (!this.userId) {
throw new Meteor.Error('not-logged-in', 'User must be logged in');
}
const user = Users.findOne(this.userId);
if (!user) {
throw new Meteor.Error('user-not-found', 'User not found');
}
const current = (user.profile && user.profile.collapsedSwimlanes) || {};
if (!current[boardId]) current[boardId] = {};
current[boardId][swimlaneId] = !!collapsed;
Users.update(this.userId, {
$set: {
'profile.collapsedSwimlanes': current,
},
});
},
applySwimlaneHeightToStorage(boardId, swimlaneId, height) {
check(boardId, String);
check(swimlaneId, String);