You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
324 lines
8.9 KiB
324 lines
8.9 KiB
import { context, type WithRefOpts } from '../../provider';
|
|
import { type ActiveProps } from '$libs/reactive';
|
|
import type { ScrollToCellOptions, VirtualCell } from './types';
|
|
|
|
import { virtualGridMorfo } from '../../../morfo/components/virtual-grid';
|
|
import { Soma } from '../../core/soma.svelte';
|
|
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
|
|
|
// ── Helpers (1D per-axis) ──────────────────────────────────────────────────
|
|
|
|
function rangeFor(
|
|
scrollStart: number,
|
|
viewportSize: number,
|
|
itemSize: number,
|
|
count: number,
|
|
overscan: number
|
|
): { first: number; last: number } {
|
|
if (count === 0 || itemSize <= 0) return { first: 0, last: -1 };
|
|
if (viewportSize <= 0) {
|
|
// Pre-first-measurement — render overscan worth of items.
|
|
return { first: 0, last: Math.min(count - 1, overscan) };
|
|
}
|
|
const first = Math.max(0, Math.floor(scrollStart / itemSize) - overscan);
|
|
const last = Math.min(
|
|
count - 1,
|
|
Math.ceil((scrollStart + viewportSize) / itemSize) - 1 + overscan
|
|
);
|
|
return { first, last };
|
|
}
|
|
|
|
function scrollTargetFor(
|
|
index: number,
|
|
count: number,
|
|
itemSize: number,
|
|
currentScroll: number,
|
|
viewportSize: number,
|
|
totalSize: number,
|
|
align: 'start' | 'center' | 'end' | 'auto'
|
|
): number | null {
|
|
if (count === 0) return null;
|
|
const clamped = Math.max(0, Math.min(count - 1, index));
|
|
const start = clamped * itemSize;
|
|
const end = start + itemSize;
|
|
|
|
let target: number;
|
|
if (align === 'start') target = start;
|
|
else if (align === 'end') target = end - viewportSize;
|
|
else if (align === 'center') target = start + itemSize / 2 - viewportSize / 2;
|
|
else {
|
|
if (start < currentScroll) target = start;
|
|
else if (end > currentScroll + viewportSize) target = end - viewportSize;
|
|
else return null;
|
|
}
|
|
return Math.max(0, Math.min(totalSize - viewportSize, target));
|
|
}
|
|
|
|
// ── Root provider ──────────────────────────────────────────────────────────
|
|
|
|
interface VirtualGridOpts
|
|
extends
|
|
WithRefOpts,
|
|
ActiveProps<{
|
|
rowCount: number;
|
|
columnCount: number;
|
|
rowSize: number;
|
|
columnSize: number;
|
|
rowOverscan: number;
|
|
columnOverscan: number;
|
|
getRowKey: (index: number) => string | number;
|
|
getColumnKey: (index: number) => string | number;
|
|
}> {}
|
|
|
|
export class VirtualGridProvider {
|
|
readonly opts: VirtualGridOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
readonly soma: Soma;
|
|
readonly runtime: SomaRuntime;
|
|
static readonly ctx = context<VirtualGridProvider>('VirtualGrid');
|
|
static get(): VirtualGridProvider | undefined {
|
|
return this.ctx.getOr(undefined) as VirtualGridProvider | undefined;
|
|
}
|
|
static require(): VirtualGridProvider {
|
|
return this.ctx.get();
|
|
}
|
|
|
|
static create(opts: VirtualGridOpts) {
|
|
return new VirtualGridProvider(opts);
|
|
}
|
|
|
|
viewportRef = $state<HTMLElement | null>(null);
|
|
scrollTop = $state(0);
|
|
scrollLeft = $state(0);
|
|
viewportHeight = $state(0);
|
|
viewportWidth = $state(0);
|
|
|
|
private constructor(opts: VirtualGridOpts) {
|
|
this.opts = opts;
|
|
this.soma = Soma.require();
|
|
this.runtime = this.soma.runtime(virtualGridMorfo, {});
|
|
this.runtimePart = this.runtime.part('provider', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
context: VirtualGridProvider.ctx
|
|
});
|
|
}
|
|
|
|
readonly totalHeight = $derived.by(() => this.opts.rowCount.current * this.opts.rowSize.current);
|
|
readonly totalWidth = $derived.by(
|
|
() => this.opts.columnCount.current * this.opts.columnSize.current
|
|
);
|
|
|
|
readonly isEmpty = $derived.by(
|
|
() => this.opts.rowCount.current === 0 || this.opts.columnCount.current === 0
|
|
);
|
|
|
|
readonly virtualCells: VirtualCell[] = $derived.by(() => {
|
|
const rowCount = this.opts.rowCount.current;
|
|
const columnCount = this.opts.columnCount.current;
|
|
if (rowCount === 0 || columnCount === 0) return [];
|
|
|
|
const rowSize = this.opts.rowSize.current;
|
|
const columnSize = this.opts.columnSize.current;
|
|
const { first: firstRow, last: lastRow } = rangeFor(
|
|
this.scrollTop,
|
|
this.viewportHeight,
|
|
rowSize,
|
|
rowCount,
|
|
this.opts.rowOverscan.current
|
|
);
|
|
const { first: firstCol, last: lastCol } = rangeFor(
|
|
this.scrollLeft,
|
|
this.viewportWidth,
|
|
columnSize,
|
|
columnCount,
|
|
this.opts.columnOverscan.current
|
|
);
|
|
|
|
const rowKey = this.opts.getRowKey.current;
|
|
const colKey = this.opts.getColumnKey.current;
|
|
const cells: VirtualCell[] = [];
|
|
for (let r = firstRow; r <= lastRow; r++) {
|
|
for (let c = firstCol; c <= lastCol; c++) {
|
|
cells.push({
|
|
rowIndex: r,
|
|
columnIndex: c,
|
|
rowStart: r * rowSize,
|
|
columnStart: c * columnSize,
|
|
rowSize,
|
|
columnSize,
|
|
key: `${rowKey(r)}|${colKey(c)}`
|
|
});
|
|
}
|
|
}
|
|
return cells;
|
|
});
|
|
|
|
scrollToCell = (rowIndex: number, columnIndex: number, opts: ScrollToCellOptions = {}) => {
|
|
const vp = this.viewportRef;
|
|
if (!vp) return;
|
|
const top = scrollTargetFor(
|
|
rowIndex,
|
|
this.opts.rowCount.current,
|
|
this.opts.rowSize.current,
|
|
this.scrollTop,
|
|
this.viewportHeight,
|
|
this.totalHeight,
|
|
opts.rowAlign ?? 'auto'
|
|
);
|
|
const left = scrollTargetFor(
|
|
columnIndex,
|
|
this.opts.columnCount.current,
|
|
this.opts.columnSize.current,
|
|
this.scrollLeft,
|
|
this.viewportWidth,
|
|
this.totalWidth,
|
|
opts.columnAlign ?? 'auto'
|
|
);
|
|
if (top === null && left === null) return;
|
|
|
|
this.soma.dom.scrollTo(vp, {
|
|
top: top ?? this.scrollTop,
|
|
left: left ?? this.scrollLeft,
|
|
behavior: opts.behavior ?? 'auto'
|
|
});
|
|
};
|
|
|
|
readonly snippetProps = $derived.by(() => ({
|
|
virtualCells: this.virtualCells,
|
|
totalHeight: this.totalHeight,
|
|
totalWidth: this.totalWidth,
|
|
isEmpty: this.isEmpty,
|
|
scrollToCell: this.scrollToCell
|
|
}));
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Viewport ───────────────────────────────────────────────────────────────
|
|
|
|
interface VirtualGridViewportOpts extends WithRefOpts {}
|
|
|
|
export class VirtualGridViewportProvider {
|
|
readonly opts: VirtualGridViewportOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: VirtualGridViewportOpts) {
|
|
return new VirtualGridViewportProvider(opts);
|
|
}
|
|
|
|
readonly provider: VirtualGridProvider;
|
|
|
|
private constructor(opts: VirtualGridViewportOpts) {
|
|
this.opts = opts;
|
|
this.provider = VirtualGridProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('viewport', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
onRefChange: (el) => {
|
|
this.provider.viewportRef = el;
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const el = this.opts.ref.current;
|
|
if (!el) return;
|
|
|
|
const updateSize = () => {
|
|
this.provider.viewportHeight = el.clientHeight;
|
|
this.provider.viewportWidth = el.clientWidth;
|
|
};
|
|
const updateScroll = () => {
|
|
this.provider.scrollTop = el.scrollTop;
|
|
this.provider.scrollLeft = el.scrollLeft;
|
|
};
|
|
|
|
updateSize();
|
|
updateScroll();
|
|
|
|
const cleanupResize = this.provider.soma.dom.observeResize(el, updateSize);
|
|
|
|
const cleanupScroll = this.provider.soma.dom.listen(el, 'scroll', updateScroll, {
|
|
passive: true
|
|
});
|
|
|
|
return () => {
|
|
cleanupResize();
|
|
cleanupScroll();
|
|
};
|
|
});
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
style: {
|
|
position: 'relative',
|
|
overflow: 'auto'
|
|
}
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Cell ───────────────────────────────────────────────────────────────────
|
|
|
|
interface VirtualGridCellOpts
|
|
extends
|
|
WithRefOpts,
|
|
ActiveProps<{
|
|
rowIndex: number;
|
|
columnIndex: number;
|
|
rowStart: number;
|
|
columnStart: number;
|
|
rowSize: number;
|
|
columnSize: number;
|
|
}> {}
|
|
|
|
export class VirtualGridCellProvider {
|
|
readonly opts: VirtualGridCellOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: VirtualGridCellOpts) {
|
|
return new VirtualGridCellProvider(opts);
|
|
}
|
|
|
|
readonly provider: VirtualGridProvider;
|
|
|
|
private constructor(opts: VirtualGridCellOpts) {
|
|
this.opts = opts;
|
|
this.provider = VirtualGridProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('cell', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
props: {
|
|
rowIndex: () => this.opts.rowIndex.current,
|
|
columnIndex: () => this.opts.columnIndex.current
|
|
},
|
|
syncAttrs: true
|
|
});
|
|
}
|
|
|
|
readonly props = $derived.by(() => {
|
|
const rowStart = this.opts.rowStart.current;
|
|
const columnStart = this.opts.columnStart.current;
|
|
const rowSize = this.opts.rowSize.current;
|
|
const columnSize = this.opts.columnSize.current;
|
|
return this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
style: {
|
|
position: 'absolute',
|
|
top: '0',
|
|
left: '0',
|
|
transform: `translate3d(${columnStart}px, ${rowStart}px, 0)`,
|
|
width: `${columnSize}px`,
|
|
height: `${rowSize}px`
|
|
}
|
|
} as const);
|
|
});
|
|
}
|