Skip to content

Commit 5470049

Browse files
committed
fix: preserve scroll position in Kanban view after task changes (#1266)
Override onDataUpdated() and debouncedRefresh() in KanbanView to save and restore column scroll positions during re-renders. Implements getEphemeralState/setEphemeralState to capture scroll positions for both virtual scrolling columns and regular columns, including swimlane cells.
1 parent 1adbe93 commit 5470049

2 files changed

Lines changed: 154 additions & 0 deletions

File tree

docs/releases/unreleased.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,8 @@ Example:
8989
- Markdown-style project links now resolve properly and display as linked (not orange/unresolved)
9090
- Thanks to @minchinweb for reporting
9191

92+
- (#1266) Fixed Kanban view scroll position resetting to top of column after any change
93+
- Clicking status dots, priority, or other task properties no longer jumps the column back to top
94+
- Scroll position is now preserved for both regular columns and swimlane cells
95+
- Thanks to @essouflenfer for reporting
96+

src/bases/KanbanView.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,25 @@ export class KanbanView extends BasesViewBase {
6262
super.onload();
6363
}
6464

65+
/**
66+
* BasesView lifecycle: Called when Bases data changes.
67+
* Override to preserve scroll position during re-renders.
68+
*/
69+
onDataUpdated(): void {
70+
// Save scroll state before re-render
71+
const savedState = this.getEphemeralState();
72+
73+
try {
74+
this.render();
75+
} catch (error) {
76+
console.error(`[TaskNotes][${this.type}] Render error:`, error);
77+
this.renderError(error as Error);
78+
}
79+
80+
// Restore scroll state after render
81+
this.setEphemeralState(savedState);
82+
}
83+
6584
/**
6685
* Read view configuration options from BasesViewConfig.
6786
*/
@@ -97,6 +116,116 @@ export class KanbanView extends BasesViewBase {
97116
}
98117
}
99118

119+
/**
120+
* Save ephemeral state including scroll positions for all columns.
121+
* This preserves scroll position when the view is re-rendered (e.g., after task updates).
122+
*/
123+
getEphemeralState(): any {
124+
const columnScroll: Record<string, number> = {};
125+
126+
// Save scroll position for virtual scrolling columns (from VirtualScroller)
127+
for (const [columnKey, scroller] of this.columnScrollers) {
128+
const scrollContainer = (scroller as any).scrollContainer as HTMLElement | undefined;
129+
if (scrollContainer) {
130+
columnScroll[columnKey] = scrollContainer.scrollTop;
131+
}
132+
}
133+
134+
// Save scroll position for non-virtual columns (direct DOM elements)
135+
if (this.boardEl) {
136+
const columns = this.boardEl.querySelectorAll('.kanban-view__column');
137+
columns.forEach((column) => {
138+
const groupKey = column.getAttribute('data-group');
139+
const cardsContainer = column.querySelector('.kanban-view__cards') as HTMLElement;
140+
if (groupKey && cardsContainer && !(groupKey in columnScroll)) {
141+
columnScroll[groupKey] = cardsContainer.scrollTop;
142+
}
143+
});
144+
145+
// Also save swimlane cell scroll positions (class is kanban-view__swimlane-column)
146+
const swimlaneCells = this.boardEl.querySelectorAll('.kanban-view__swimlane-column');
147+
swimlaneCells.forEach((cell) => {
148+
const columnKey = cell.getAttribute('data-column');
149+
const swimlaneKey = cell.getAttribute('data-swimlane');
150+
if (columnKey && swimlaneKey) {
151+
const cellKey = `${swimlaneKey}:${columnKey}`;
152+
const tasksContainer = cell.querySelector('.kanban-view__tasks-container') as HTMLElement;
153+
if (tasksContainer && !(cellKey in columnScroll)) {
154+
columnScroll[cellKey] = tasksContainer.scrollTop;
155+
}
156+
}
157+
});
158+
}
159+
160+
return {
161+
scrollTop: this.rootElement?.scrollTop || 0,
162+
columnScroll,
163+
};
164+
}
165+
166+
/**
167+
* Restore ephemeral state including scroll positions for all columns.
168+
*/
169+
setEphemeralState(state: any): void {
170+
if (!state) return;
171+
172+
// Restore board-level horizontal scroll
173+
if (state.scrollTop !== undefined && this.rootElement) {
174+
requestAnimationFrame(() => {
175+
if (this.rootElement && this.rootElement.isConnected) {
176+
this.rootElement.scrollTop = state.scrollTop;
177+
}
178+
});
179+
}
180+
181+
// Restore column scroll positions after render completes
182+
if (state.columnScroll && typeof state.columnScroll === 'object') {
183+
// Use requestAnimationFrame to ensure DOM and VirtualScrollers are ready
184+
requestAnimationFrame(() => {
185+
// Restore virtual scroller positions
186+
for (const [columnKey, scroller] of this.columnScrollers) {
187+
const scrollTop = state.columnScroll[columnKey];
188+
if (scrollTop !== undefined) {
189+
const scrollContainer = (scroller as any).scrollContainer as HTMLElement | undefined;
190+
if (scrollContainer) {
191+
scrollContainer.scrollTop = scrollTop;
192+
}
193+
}
194+
}
195+
196+
// Restore non-virtual column positions
197+
if (this.boardEl) {
198+
const columns = this.boardEl.querySelectorAll('.kanban-view__column');
199+
columns.forEach((column) => {
200+
const groupKey = column.getAttribute('data-group');
201+
if (groupKey && state.columnScroll[groupKey] !== undefined) {
202+
const cardsContainer = column.querySelector('.kanban-view__cards') as HTMLElement;
203+
if (cardsContainer && !this.columnScrollers.has(groupKey)) {
204+
cardsContainer.scrollTop = state.columnScroll[groupKey];
205+
}
206+
}
207+
});
208+
209+
// Restore swimlane cell positions (class is kanban-view__swimlane-column)
210+
const swimlaneCells = this.boardEl.querySelectorAll('.kanban-view__swimlane-column');
211+
swimlaneCells.forEach((cell) => {
212+
const columnKey = cell.getAttribute('data-column');
213+
const swimlaneKey = cell.getAttribute('data-swimlane');
214+
if (columnKey && swimlaneKey) {
215+
const cellKey = `${swimlaneKey}:${columnKey}`;
216+
if (state.columnScroll[cellKey] !== undefined) {
217+
const tasksContainer = cell.querySelector('.kanban-view__tasks-container') as HTMLElement;
218+
if (tasksContainer && !this.columnScrollers.has(cellKey)) {
219+
tasksContainer.scrollTop = state.columnScroll[cellKey];
220+
}
221+
}
222+
}
223+
});
224+
}
225+
});
226+
}
227+
}
228+
100229
async render(): Promise<void> {
101230
if (!this.boardEl || !this.rootElement) return;
102231
if (!this.data?.data) return;
@@ -1211,6 +1340,26 @@ export class KanbanView extends BasesViewBase {
12111340
this.debouncedRefresh();
12121341
}
12131342

1343+
/**
1344+
* Override debouncedRefresh to preserve scroll positions during re-renders.
1345+
* Saves ephemeral state before render and restores it after.
1346+
*/
1347+
protected debouncedRefresh(): void {
1348+
if ((this as any).updateDebounceTimer) {
1349+
clearTimeout((this as any).updateDebounceTimer);
1350+
}
1351+
1352+
// Save current scroll state before the timer fires
1353+
const savedState = this.getEphemeralState();
1354+
1355+
(this as any).updateDebounceTimer = window.setTimeout(async () => {
1356+
await this.render();
1357+
(this as any).updateDebounceTimer = null;
1358+
// Restore scroll state after render completes
1359+
this.setEphemeralState(savedState);
1360+
}, 150);
1361+
}
1362+
12141363
private renderEmptyState(): void {
12151364
if (!this.boardEl) return;
12161365
const empty = document.createElement("div");

0 commit comments

Comments
 (0)