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.
svelte-kit-vice/src/uix/soma/components/virtual-grid/virtual-grid-provider.svelt...

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);
});
}

Powered by TurnKey Linux.