|
<script lang="ts"> |
|
import { onMount, tick } from "svelte"; |
|
import { _ } from "svelte-i18n"; |
|
|
|
export let items: any[][] = []; |
|
|
|
export let max_height: number; |
|
export let actual_height: number; |
|
export let table_scrollbar_width: number; |
|
export let start = 0; |
|
export let end = 20; |
|
export let selected: number | false; |
|
let height = "100%"; |
|
|
|
let average_height = 30; |
|
let bottom = 0; |
|
let contents: HTMLTableSectionElement; |
|
let head_height = 0; |
|
let foot_height = 0; |
|
let height_map: number[] = []; |
|
let mounted: boolean; |
|
let rows: HTMLCollectionOf<HTMLTableRowElement>; |
|
let top = 0; |
|
let viewport: HTMLTableElement; |
|
let viewport_height = 200; |
|
let visible: { index: number; data: any[] }[] = []; |
|
let viewport_box: DOMRectReadOnly; |
|
|
|
$: viewport_height = viewport_box?.height || 200; |
|
|
|
const is_browser = typeof window !== "undefined"; |
|
const raf = is_browser |
|
? window.requestAnimationFrame |
|
: (cb: (...args: any[]) => void) => cb(); |
|
|
|
$: mounted && raf(() => refresh_height_map(sortedItems)); |
|
|
|
let content_height = 0; |
|
async function refresh_height_map(_items: typeof items): Promise<void> { |
|
if (viewport_height === 0) { |
|
return; |
|
} |
|
|
|
const { scrollTop } = viewport; |
|
table_scrollbar_width = viewport.offsetWidth - viewport.clientWidth; |
|
|
|
content_height = top - (scrollTop - head_height); |
|
let i = start; |
|
|
|
while (content_height < max_height && i < _items.length) { |
|
let row = rows[i - start]; |
|
if (!row) { |
|
end = i + 1; |
|
await tick(); // render the newly visible row |
|
row = rows[i - start]; |
|
} |
|
let _h = row?.getBoundingClientRect().height; |
|
if (!_h) { |
|
_h = average_height; |
|
} |
|
const row_height = (height_map[i] = _h); |
|
content_height += row_height; |
|
i += 1; |
|
} |
|
|
|
end = i; |
|
const remaining = _items.length - end; |
|
|
|
const scrollbar_height = viewport.offsetHeight - viewport.clientHeight; |
|
if (scrollbar_height > 0) { |
|
content_height += scrollbar_height; |
|
} |
|
|
|
let filtered_height_map = height_map.filter((v) => typeof v === "number"); |
|
average_height = |
|
filtered_height_map.reduce((a, b) => a + b, 0) / |
|
filtered_height_map.length; |
|
|
|
bottom = remaining * average_height; |
|
height_map.length = _items.length; |
|
await tick(); |
|
if (!max_height) { |
|
actual_height = content_height + 1; |
|
} else if (content_height < max_height) { |
|
actual_height = content_height + 2; |
|
} else { |
|
actual_height = max_height; |
|
} |
|
|
|
await tick(); |
|
} |
|
|
|
$: scroll_and_render(selected); |
|
|
|
async function scroll_and_render(n: number | false): Promise<void> { |
|
raf(async () => { |
|
if (typeof n !== "number") return; |
|
const direction = typeof n !== "number" ? false : is_in_view(n); |
|
if (direction === true) { |
|
return; |
|
} |
|
if (direction === "back") { |
|
await scroll_to_index(n, { behavior: "instant" }); |
|
} |
|
|
|
if (direction === "forwards") { |
|
await scroll_to_index(n, { behavior: "instant" }, true); |
|
} |
|
}); |
|
} |
|
|
|
function is_in_view(n: number): "back" | "forwards" | true { |
|
const current = rows && rows[n - start]; |
|
if (!current && n < start) { |
|
return "back"; |
|
} |
|
if (!current && n >= end - 1) { |
|
return "forwards"; |
|
} |
|
|
|
const { top: viewport_top } = viewport.getBoundingClientRect(); |
|
const { top, bottom } = current.getBoundingClientRect(); |
|
|
|
if (top - viewport_top < 37) { |
|
return "back"; |
|
} |
|
|
|
if (bottom - viewport_top > viewport_height) { |
|
return "forwards"; |
|
} |
|
|
|
return true; |
|
} |
|
|
|
function get_computed_px_amount(elem: HTMLElement, property: string): number { |
|
if (!elem) { |
|
return 0; |
|
} |
|
const compStyle = getComputedStyle(elem); |
|
|
|
let x = parseInt(compStyle.getPropertyValue(property)); |
|
return x; |
|
} |
|
|
|
async function handle_scroll(e: Event): Promise<void> { |
|
const scroll_top = viewport.scrollTop; |
|
|
|
rows = contents.children as HTMLCollectionOf<HTMLTableRowElement>; |
|
const is_start_overflow = sortedItems.length < start; |
|
|
|
const row_top_border = get_computed_px_amount(rows[1], "border-top-width"); |
|
|
|
const actual_border_collapsed_width = 0; |
|
|
|
if (is_start_overflow) { |
|
await scroll_to_index(sortedItems.length - 1, { behavior: "auto" }); |
|
} |
|
|
|
let new_start = 0; |
|
|
|
for (let v = 0; v < rows.length; v += 1) { |
|
height_map[start + v] = rows[v].getBoundingClientRect().height; |
|
} |
|
let i = 0; |
|
|
|
let y = head_height + row_top_border / 2; |
|
let row_heights = []; |
|
|
|
while (i < sortedItems.length) { |
|
const row_height = height_map[i] || average_height; |
|
row_heights[i] = row_height; |
|
// we only want to jump if the full (incl. border) row is away |
|
if (y + row_height + actual_border_collapsed_width > scroll_top) { |
|
// this is the last index still inside the viewport |
|
new_start = i; |
|
top = y - (head_height + row_top_border / 2); |
|
break; |
|
} |
|
y += row_height; |
|
i += 1; |
|
} |
|
|
|
new_start = Math.max(0, new_start); |
|
while (i < sortedItems.length) { |
|
const row_height = height_map[i] || average_height; |
|
y += row_height; |
|
i += 1; |
|
if (y > scroll_top + viewport_height) { |
|
break; |
|
} |
|
} |
|
start = new_start; |
|
end = i; |
|
const remaining = sortedItems.length - end; |
|
if (end === 0) { |
|
end = 10; |
|
} |
|
average_height = (y - head_height) / end; |
|
let remaining_height = remaining * average_height; |
|
|
|
while (i < sortedItems.length) { |
|
i += 1; |
|
height_map[i] = average_height; |
|
} |
|
bottom = remaining_height; |
|
if (!isFinite(bottom)) { |
|
bottom = 200000; |
|
} |
|
} |
|
|
|
export async function scroll_to_index( |
|
index: number, |
|
opts: ScrollToOptions, |
|
align_end = false |
|
): Promise<void> { |
|
await tick(); |
|
|
|
const _itemHeight = average_height; |
|
|
|
let distance = index * _itemHeight; |
|
if (align_end) { |
|
distance = distance - viewport_height + _itemHeight + head_height; |
|
} |
|
|
|
const scrollbar_height = viewport.offsetHeight - viewport.clientHeight; |
|
if (scrollbar_height > 0) { |
|
distance += scrollbar_height; |
|
} |
|
|
|
const _opts = { |
|
top: distance, |
|
behavior: "smooth" as ScrollBehavior, |
|
...opts |
|
}; |
|
|
|
viewport.scrollTo(_opts); |
|
} |
|
|
|
$: sortedItems = items; |
|
|
|
$: visible = is_browser |
|
? sortedItems.slice(start, end).map((data, i) => { |
|
return { index: i + start, data }; |
|
}) |
|
: sortedItems |
|
.slice(0, (max_height / sortedItems.length) * average_height + 1) |
|
.map((data, i) => { |
|
return { index: i + start, data }; |
|
}); |
|
|
|
$: actual_height = visible.length * average_height + 10; |
|
onMount(() => { |
|
rows = contents.children as HTMLCollectionOf<HTMLTableRowElement>; |
|
mounted = true; |
|
refresh_height_map(items); |
|
}); |
|
</script> |
|
|
|
<svelte-virtual-table-viewport> |
|
<table |
|
class="table" |
|
bind:this={viewport} |
|
bind:contentRect={viewport_box} |
|
on:scroll={handle_scroll} |
|
style="height: {height}; --bw-svt-p-top: {top}px; --bw-svt-p-bottom: {bottom}px; --bw-svt-head-height: {head_height}px; --bw-svt-foot-height: {foot_height}px; --bw-svt-avg-row-height: {average_height}px" |
|
> |
|
<thead class="thead" bind:offsetHeight={head_height}> |
|
<slot name="thead" /> |
|
</thead> |
|
<tbody bind:this={contents} class="tbody"> |
|
{#if visible.length && visible[0].data.length} |
|
{#each visible as item (item.data[0].id)} |
|
<slot name="tbody" item={item.data} index={item.index}> |
|
Missing Table Row |
|
</slot> |
|
{/each} |
|
{/if} |
|
</tbody> |
|
<tfoot class="tfoot" bind:offsetHeight={foot_height}> |
|
<slot name="tfoot" /> |
|
</tfoot> |
|
</table> |
|
</svelte-virtual-table-viewport> |
|
|
|
<style type="text/css"> |
|
table { |
|
position: relative; |
|
overflow-y: scroll; |
|
overflow-x: scroll; |
|
-webkit-overflow-scrolling: touch; |
|
max-height: 100vh; |
|
box-sizing: border-box; |
|
display: block; |
|
padding: 0; |
|
margin: 0; |
|
color: var(--body-text-color); |
|
font-size: var(--input-text-size); |
|
line-height: var(--line-md); |
|
font-family: var(--font-mono); |
|
border-spacing: 0; |
|
width: 100%; |
|
scroll-snap-type: x proximity; |
|
border-collapse: separate; |
|
} |
|
table :is(thead, tfoot, tbody) { |
|
display: table; |
|
table-layout: fixed; |
|
width: 100%; |
|
box-sizing: border-box; |
|
} |
|
|
|
tbody { |
|
overflow-x: scroll; |
|
overflow-y: hidden; |
|
} |
|
|
|
table tbody { |
|
padding-top: var(--bw-svt-p-top); |
|
padding-bottom: var(--bw-svt-p-bottom); |
|
} |
|
tbody { |
|
position: relative; |
|
box-sizing: border-box; |
|
border: 0px solid currentColor; |
|
} |
|
|
|
tbody > :global(tr:last-child) { |
|
border: none; |
|
} |
|
|
|
table :global(td) { |
|
scroll-snap-align: start; |
|
} |
|
|
|
tbody > :global(tr:nth-child(even)) { |
|
background: var(--table-even-background-fill); |
|
} |
|
|
|
thead { |
|
position: sticky; |
|
top: 0; |
|
left: 0; |
|
z-index: var(--layer-1); |
|
overflow: hidden; |
|
} |
|
</style> |
|
|