Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 59 additions & 42 deletions v2/pink-sb/src/lib/spreadsheet/Cell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -45,31 +45,29 @@
$: isVerticalEnd = alignment.startsWith('end');
$: isHorizontalStart = alignment.endsWith('start');
$: isHorizontalEnd = alignment.endsWith('end');
$: endsBeforeFixedRight = column === root.lastColumnBeforeAction;
$: isEmptyCell = id?.includes(EMPTY_ROW_ID) || false;
$: options = typeof column !== 'undefined' ? root.columns?.[column] : undefined;
$: resizable = (options?.resizable ?? true) && column !== root.lastResizableColumnId;

$: hasKeyboardNavigation = root.keyboardNavigation ?? false;
$: columnIndex = Array.isArray(root.columns)
? root.columns.findIndex((col) => col.id === column)
: Object.values(root.columns).findIndex((col) => col.id === column);

$: isHeaderBeingHovered = false;

$: isAction = options?.isAction ?? false;
$: isEditing = root.currentlyEditingCellId === id;
$: isSelect = (root.allowSelection && column?.includes('__select_')) || false;
$: isFixed = isSelect || isAction || options?.fixed;
$: endsBeforeFixedRight = column === root.lastColumnBeforeAction;
$: resizable = (options?.resizable ?? true) && column !== root.lastResizableColumnId;
$: isEditing = root.currentlyEditingCellId === id;
$: isDraggedOver = root.dragOverColumn === column;
$: isDragging = root.draggingColumn === column;
$: isEmptyCell = id?.includes(EMPTY_ROW_ID) || false;

$: columnIndex = column && root.columnIndexMap ? (root.columnIndexMap.get(column) ?? -1) : -1;

$: columnWidth =
typeof options?.width === 'number'
? options?.width
: typeof options?.width === 'object'
? options?.width.min
: 100;

let isHeaderBeingHovered = false;

function handleKeydown(e: KeyboardEvent) {
e.stopPropagation();
if (e.key === 'Escape') {
Expand Down Expand Up @@ -216,6 +214,48 @@
}
}

function handleCellClick() {
if (!openEditOnTap || !isEditable || isEmptyCell || isAction) return;
originalValue = value;
root.setEditing(id);
}

function handleCellDoubleClick() {
if (!isEditable || isEmptyCell || isAction) return;
originalValue = value;
root.setEditing(id);
}

function handleDragStart(e: DragEvent) {
root.startDrag(column, e);
}

function handleDragOver(e: DragEvent) {
root.overDrag(column, e);
}

function handleDragLeave() {
root.clearDragOver();
}

function handleMouseEnter() {
if (isHeader && options?.draggable) {
isHeaderBeingHovered = true;
root.setColumnHeaderHovered(column);
}
}

function handleMouseLeave() {
if (isHeader && options?.draggable) {
isHeaderBeingHovered = false;
root.setColumnHeaderHovered(null);
}
}

function handleClickOutside() {
if (isEditing) root.setEditing(null);
}

$: if (isEditing) {
tick().then(() => {
const selects = cellEl?.querySelector('button.input') as HTMLDivElement;
Expand Down Expand Up @@ -283,41 +323,18 @@
style:left={isSelect ? '0' : undefined}
style:right={isAction ? '0' : undefined}
on:contextmenu={isEmptyCell ? undefined : handleContextMenu}
use:clickOutside={() => {
if (isEditing) root.setEditing(null);
}}
on:click={() => {
if (!openEditOnTap || !isEditable || isEmptyCell || isAction) return;
originalValue = value;
root.setEditing(id);
}}
on:dblclick={() => {
if (!isEditable || isEmptyCell || isAction) return;
originalValue = value;
root.setEditing(id);
}}
on:dragstart={(e) => root.startDrag(column, e)}
on:dragover={(e) => root.overDrag(column, e)}
on:dragleave={() => {
// Clear drag over when leaving the element
root.clearDragOver();
}}
use:clickOutside={handleClickOutside}
on:click={handleCellClick}
on:dblclick={handleCellDoubleClick}
on:dragstart={handleDragStart}
on:dragover={handleDragOver}
on:dragleave={handleDragLeave}
on:drop={root.endDrag}
on:keydown={handleCellKeydown}
on:focus={handleCellFocus}
on:blur={handleCellBlur}
on:mouseenter={() => {
if (isHeader && options?.draggable) {
isHeaderBeingHovered = true;
root.setColumnHeaderHovered(column);
}
}}
on:mouseleave={() => {
if (isHeader && options?.draggable) {
isHeaderBeingHovered = false;
root.setColumnHeaderHovered(null);
}
}}
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
>
{#if isLoading && !isHeader}
{@const variant = isSelect || isAction ? 'square' : 'line'}
Expand Down
158 changes: 89 additions & 69 deletions v2/pink-sb/src/lib/spreadsheet/Root.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -75,68 +75,80 @@
initColumns();
}

let scrollRafId: number | null = null;

const handleScroll = () => {
const totalPages = Math.ceil(rowCount / itemsPerPage) || 1;
if (scrollRafId !== null) return;

const scrollTop = $virtualizer.scrollElement?.scrollTop ?? 0;
const topVisibleIndex = Math.floor(scrollTop / ESTIMATED_ROW_HEIGHT);
const calculatedPage = Math.floor(topVisibleIndex / itemsPerPage) + 1;
scrollRafId = requestAnimationFrame(() => {
scrollRafId = null;

// update `currentPage` regardless of listeners availability on scroll!
if (calculatedPage !== currentPage && calculatedPage > 0 && calculatedPage <= totalPages) {
currentPage = calculatedPage;
}
const totalPages = Math.ceil(rowCount / itemsPerPage) || 1;

if (!virtualizer || loadingTriggered || loadingMore) return;
if (!loadPreviousPage && !loadNextPage) return;
const scrollTop = $virtualizer.scrollElement?.scrollTop ?? 0;
const topVisibleIndex = Math.floor(scrollTop / ESTIMATED_ROW_HEIGHT);
const calculatedPage = Math.floor(topVisibleIndex / itemsPerPage) + 1;

const virtualItems = $virtualizer.getVirtualItems();
if (virtualItems.length === 0) return;
// update `currentPage` regardless of listeners availability on scroll!
if (
calculatedPage !== currentPage &&
calculatedPage > 0 &&
calculatedPage <= totalPages
) {
currentPage = calculatedPage;
}

// next page loading
if (loadNextPage) {
const scrollElement = $virtualizer.scrollElement;
if (!scrollElement) return;
if (!virtualizer || loadingTriggered || loadingMore) return;
if (!loadPreviousPage && !loadNextPage) return;

const { scrollTop, scrollHeight, clientHeight } = scrollElement;
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
const triggerDistance = nextPageTriggerOffset * ESTIMATED_ROW_HEIGHT;
const hasBufferSpace = distanceFromBottom <= triggerDistance;
const virtualItems = $virtualizer.getVirtualItems();
if (virtualItems.length === 0) return;

if (hasBufferSpace && !loadingTriggered) {
loadingTriggered = true;
const nextPage = Math.floor(rowCount / itemsPerPage) + 1;
// next page loading
if (loadNextPage) {
const scrollElement = $virtualizer.scrollElement;
if (!scrollElement) return;

loadNextPage(nextPage)
.then(() => {
loadingTriggered = false;
tick().then(() => $virtualizer.measure());
})
.catch(() => (loadingTriggered = false));
}
}
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
const triggerDistance = nextPageTriggerOffset * ESTIMATED_ROW_HEIGHT;
const hasBufferSpace = distanceFromBottom <= triggerDistance;

if (loadPreviousPage) {
const firstVisibleItem = virtualItems[0];
if (firstVisibleItem && firstVisibleItem.index >= 0) {
const pageOfFirstItem = Math.floor(firstVisibleItem.index / itemsPerPage) + 1;

if (!lastCheckedPages.has(pageOfFirstItem)) {
lastCheckedPages.add(pageOfFirstItem);
if (hasBufferSpace && !loadingTriggered) {
loadingTriggered = true;
const nextPage = Math.floor(rowCount / itemsPerPage) + 1;

loadPreviousPage(pageOfFirstItem)
loadNextPage(nextPage)
.then(() => {
loadingTriggered = false;
setTimeout(() => lastCheckedPages.delete(pageOfFirstItem), 2000);
tick().then(() => $virtualizer.measure());
})
.catch(() => {
loadingTriggered = false;
lastCheckedPages.delete(pageOfFirstItem);
});
.catch(() => (loadingTriggered = false));
}
}
}

if (loadPreviousPage) {
const firstVisibleItem = virtualItems[0];
if (firstVisibleItem && firstVisibleItem.index >= 0) {
const pageOfFirstItem = Math.floor(firstVisibleItem.index / itemsPerPage) + 1;

if (!lastCheckedPages.has(pageOfFirstItem)) {
lastCheckedPages.add(pageOfFirstItem);
loadingTriggered = true;

loadPreviousPage(pageOfFirstItem)
.then(() => {
loadingTriggered = false;
setTimeout(() => lastCheckedPages.delete(pageOfFirstItem), 2000);
})
.catch(() => {
loadingTriggered = false;
lastCheckedPages.delete(pageOfFirstItem);
});
}
}
}
});
};

onMount(initColumns);
Expand Down Expand Up @@ -271,22 +283,23 @@
}

function toggleAll() {
const selectedSet = new Set(selectedRows);
if (allRowsSelected) {
selectedRows = selectedRows.filter((row) => !availableIds.has(row));
availableIds.forEach((id) => selectedSet.delete(id));
} else {
selectedRows = [
...selectedRows,
...[...availableIds].filter((row) => !selectedRows.includes(row))
];
availableIds.forEach((id) => selectedSet.add(id));
}
selectedRows = Array.from(selectedSet);
}

function toggle(id: string) {
if (selectedRows.includes(id)) {
selectedRows = selectedRows.filter((row) => row !== id);
const selectedSet = new Set(selectedRows);
if (selectedSet.has(id)) {
selectedSet.delete(id);
} else {
selectedRows = [...selectedRows, id];
selectedSet.add(id);
}
selectedRows = Array.from(selectedSet);
}

function addAvailableId(id: string) {
Expand Down Expand Up @@ -329,7 +342,6 @@
function endDrag() {
const oldPositions = new Map<string, number>();

// should be equal as the length of columns
const columnEls = Array.from(rootEl.querySelectorAll('[data-column-id]')) as HTMLElement[];

for (const column of columnEls) {
Expand All @@ -353,11 +365,8 @@
requestAnimationFrame(() => {
const movedElements: HTMLElement[] = [];

const swappedElements = Array.from(
rootEl.querySelectorAll('[data-column-id]')
) as HTMLElement[];

for (const swappedElement of swappedElements) {
// reuse cached columnEls instead of querying again
for (const swappedElement of columnEls) {
const id = swappedElement.getAttribute('data-column-id');
if (!id || !oldPositions.has(id)) continue;

Expand Down Expand Up @@ -574,12 +583,28 @@
$: allRowsSelected =
availableIds.size > 0 && [...availableIds].every((row) => selectedRows.includes(row));

$: columnsById = groupById(columns);
$: columnIndexMap = createColumnIndexMap(columns);
$: gridTemplateColumns = createGridTemplateColumns(columns);
$: lastResizableColumnId = calculateLastResizableId(columns);
$: lastColumnBeforeAction = getLastVisibleColumnBeforeActions(columns);
$: borderRadiusValue = resolveBorderRadius();

function createColumnIndexMap(cols: typeof columns): Map<string, number> {
const map = new Map<string, number>();
cols.forEach((col, index) => {
map.set(col.id, index);
});
return map;
}

$: root = {
loading,
selectedRows,
allowSelection,
keyboardNavigation,
columns: groupById(columns),
columns: columnsById,
columnIndexMap,
toggleAll,
toggle,
updateCells,
Expand All @@ -596,8 +621,8 @@
overDrag,
endDrag,
clearDragOver,
lastResizableColumnId: calculateLastResizableId(columns),
lastColumnBeforeAction: getLastVisibleColumnBeforeActions(columns),
lastResizableColumnId,
lastColumnBeforeAction,
registerForNavigation,
unregisterForNavigation,
moveFocus,
Expand Down Expand Up @@ -671,18 +696,13 @@
}}
/>

<div
class="root"
bind:this={rootEl}
style:height
style:--sheet-border-radius={resolveBorderRadius()}
>
<div class="root" bind:this={rootEl} style:height style:--sheet-border-radius={borderRadiusValue}>
<div class="spreadsheet-container" bind:this={sheetContainer} on:scroll={handleScroll}>
<div
role="grid"
class:reordering={!!draggingColumn}
style:--fixed-columns-width={`${fixedColumnsWidth}px`}
style:--grid-template-columns={createGridTemplateColumns(columns)}
style:--grid-template-columns={gridTemplateColumns}
>
{#if $$slots.header}
<Row type="header" {root} sticky select={selection}>
Expand Down
1 change: 1 addition & 0 deletions v2/pink-sb/src/lib/spreadsheet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type SpreadsheetRootProps = Readonly<{
selectedNone: boolean;
selectedSome: boolean;
columns: Record<SpreadsheetColumn['id'], SpreadsheetColumn>;
columnIndexMap: Map<string, number>;
toggle: (id: string) => void;
toggleAll: () => void;
addAvailableId: (id: string) => void;
Expand Down
Loading