Skip to content

Commit 3883a6a

Browse files
committed
Optimize drag performance and render pipeline
1 parent 0c11b57 commit 3883a6a

4 files changed

Lines changed: 103 additions & 238 deletions

File tree

src/components/KanbanCard.svelte

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
6565
let cardEl: HTMLElement | null = $state(null);
6666
let isDraggable: boolean = $state(false);
67+
let rafId: number | null = $state(null);
6768
6869
const filePath = $derived(entry.file.path);
6970
const fullTitle = $derived(getCardTitle(entry, cardTitleSource));
@@ -126,18 +127,35 @@
126127
onDragStart(evt, filePath, cardIndex);
127128
}
128129
130+
function handleDragEnd(): void {
131+
// Cancel any pending RAF callback
132+
if (rafId !== null) {
133+
cancelAnimationFrame(rafId);
134+
rafId = null;
135+
}
136+
onDragEnd();
137+
}
138+
129139
function handleDragOver(evt: DragEvent): void {
130140
if (groupByProperty === null) return;
131141
evt.preventDefault();
132142
evt.stopPropagation();
133143
134-
// Calculate drop placement based on mouse position
135-
if (cardEl !== null) {
136-
const rect = cardEl.getBoundingClientRect();
137-
const midY = rect.top + rect.height / 2;
138-
const placement = evt.clientY < midY ? "before" : "after";
139-
onSetDropTarget(filePath, columnKey, placement);
144+
// Throttle drop target calculation via requestAnimationFrame
145+
// to reduce churn during drag operations
146+
if (rafId !== null) {
147+
cancelAnimationFrame(rafId);
140148
}
149+
rafId = requestAnimationFrame(() => {
150+
rafId = null;
151+
// Calculate drop placement based on mouse position
152+
if (cardEl !== null) {
153+
const rect = cardEl.getBoundingClientRect();
154+
const midY = rect.top + rect.height / 2;
155+
const placement = evt.clientY < midY ? "before" : "after";
156+
onSetDropTarget(filePath, columnKey, placement);
157+
}
158+
});
141159
}
142160
143161
function handleDragLeave(evt: DragEvent): void {
@@ -157,6 +175,11 @@
157175
if (groupByProperty === null) return;
158176
evt.preventDefault();
159177
evt.stopPropagation();
178+
// Cancel any pending RAF callback to prevent stale state updates
179+
if (rafId !== null) {
180+
cancelAnimationFrame(rafId);
181+
rafId = null;
182+
}
160183
const placement = cardDragState.getDropPlacement(filePath) ?? "after";
161184
onDrop(evt, filePath, groupKey, placement);
162185
}
@@ -207,7 +230,7 @@
207230
onmouseup={handleMouseUp}
208231
onkeydown={handleKeyDown}
209232
ondragstart={handleDragStart}
210-
ondragend={onDragEnd}
233+
ondragend={handleDragEnd}
211234
ondragover={handleDragOver}
212235
ondragleave={handleDragLeave}
213236
ondrop={handleDrop}

src/components/KanbanColumn.svelte

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
let columnEl: HTMLElement | null = $state(null);
8787
let cardsEl: HTMLElement | null = $state(null);
8888
let scrollTimeout: ReturnType<typeof setTimeout> | null = $state(null);
89+
let columnRafId: number | null = $state(null);
8990
9091
const columnName = $derived(getColumnName(groupKey, emptyColumnLabel));
9192
@@ -116,12 +117,19 @@
116117
ondragover={(evt) => {
117118
if (!$columnIsDragging) return;
118119
evt.preventDefault();
119-
if (columnEl !== null) {
120-
const rect = columnEl.getBoundingClientRect();
121-
const midX = rect.left + rect.width / 2;
122-
const placement = evt.clientX < midX ? "before" : "after";
123-
onSetColumnDropTarget(columnKey, placement);
120+
// Throttle via requestAnimationFrame to reduce churn
121+
if (columnRafId !== null) {
122+
cancelAnimationFrame(columnRafId);
124123
}
124+
columnRafId = requestAnimationFrame(() => {
125+
columnRafId = null;
126+
if (columnEl !== null) {
127+
const rect = columnEl.getBoundingClientRect();
128+
const midX = rect.left + rect.width / 2;
129+
const placement = evt.clientX < midX ? "before" : "after";
130+
onSetColumnDropTarget(columnKey, placement);
131+
}
132+
});
125133
}}
126134
ondragleave={(evt) => {
127135
const relatedTarget = evt.relatedTarget as Node | null;
@@ -138,6 +146,11 @@
138146
ondrop={(evt) => {
139147
if (!$columnIsDragging) return;
140148
evt.preventDefault();
149+
// Cancel pending RAF
150+
if (columnRafId !== null) {
151+
cancelAnimationFrame(columnRafId);
152+
columnRafId = null;
153+
}
141154
const placement = columnDragState.getDropPlacement(columnKey) ?? "before";
142155
onSetColumnDropTarget(null, null);
143156
onColumnDrop(columnKey, placement);
@@ -152,7 +165,14 @@
152165
class="bases-kanban-column-handle"
153166
draggable="true"
154167
ondragstart={(evt) => onStartColumnDrag(evt, columnKey)}
155-
ondragend={onEndColumnDrag}
168+
ondragend={() => {
169+
// Cancel pending RAF
170+
if (columnRafId !== null) {
171+
cancelAnimationFrame(columnRafId);
172+
columnRafId = null;
173+
}
174+
onEndColumnDrag();
175+
}}
156176
role="button"
157177
tabindex="0"
158178
aria-label="Drag to reorder column"

src/kanban-view.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,40 @@ export class KanbanView extends BasesView {
105105
selectedProperties: BasesPropertyId[];
106106
columnScrollByKey: Record<string, number>;
107107
}> | null = null;
108+
// Cache of current rendered groups for data-driven operations
109+
// Avoids DOM queries for card order operations
110+
private currentRenderedGroups: RenderedGroup[] = [];
108111

109112
// Drag state (replaces drag-controller)
110113
private draggingCardSourcePath: string | null = null;
111114
private draggingColumnSourceKey: string | null = null;
112115

116+
// PERFORMANCE NOTES:
117+
// Large Board Mode (Future Enhancement):
118+
// If boards with >1000 cards become sluggish, consider implementing:
119+
//
120+
// 1. Column-level virtualization: Only render visible columns + 1 buffer
121+
// on each side. Track visible range via IntersectionObserver on column
122+
// container elements. Use transform/absolute positioning for scroll
123+
// virtualization (similar to react-window).
124+
//
125+
// 2. Card-level virtualization within columns: For columns with >50 cards,
126+
// virtualize the card list using similar technique. Each column would
127+
// need its own virtual scroll container with estimated row heights.
128+
//
129+
// 3. Incremental rendering: For initial load, render first N cards per column
130+
// then progressively render rest via requestIdleCallback or setTimeout
131+
// chunks to keep UI responsive during large data loads.
132+
//
133+
// 4. Drag optimization: During drag, temporarily disable virtualization
134+
// or expand buffer to prevent drag target elements from being unmounted.
135+
//
136+
// Current optimizations already in place:
137+
// - Svelte keyed each blocks for efficient DOM reuse
138+
// - RAF-throttled dragover calculations (reduces churn from 100s to 60fps max)
139+
// - Data-driven card order (no DOM queries during drop operations)
140+
// - Cached rendered groups for O(1) column lookups
141+
113142
constructor(
114143
controller: QueryController,
115144
containerEl: HTMLElement,
@@ -189,6 +218,9 @@ export class KanbanView extends BasesView {
189218
const orderedGroups = sortGroupsByColumnOrder(groups, columnOrder);
190219
const renderedGroups = buildRenderedGroups(orderedGroups, localCardOrderByColumn);
191220

221+
// Cache rendered groups for data-driven operations (avoids DOM queries)
222+
this.currentRenderedGroups = renderedGroups;
223+
192224
// Refresh entry indexes from rendered board order (needed for drag/drop and selection)
193225
// Must happen after column order and local card order are applied
194226
this.refreshEntryIndexes(renderedGroups);
@@ -606,21 +638,14 @@ export class KanbanView extends BasesView {
606638
}
607639

608640
private getColumnCardPaths(columnKey: string): string[] {
609-
const columnEl = this.rootEl.querySelector<HTMLElement>(`[data-column-key="${columnKey}"]`);
610-
if (columnEl === null) {
611-
return [];
612-
}
613-
614-
const cards = columnEl.querySelectorAll<HTMLElement>(".bases-kanban-card");
615-
const paths: string[] = [];
616-
cards.forEach((cardEl) => {
617-
const path = cardEl.dataset.cardPath;
618-
if (typeof path === "string" && path.length > 0) {
619-
paths.push(path);
641+
// Use cached rendered groups instead of querying DOM for better performance
642+
// This is O(groups) to find the right column, then O(entries) to extract paths
643+
for (const { group, entries } of this.currentRenderedGroups) {
644+
if (getColumnKey(group.key) === columnKey) {
645+
return entries.map((entry) => entry.file.path);
620646
}
621-
});
622-
623-
return paths;
647+
}
648+
return [];
624649
}
625650

626651
private refreshEntryIndexes(groups: EntryGroupLike[]): void {

0 commit comments

Comments
 (0)