+ {#if level.enabled}
+
+ {
+ const stop = trackStuck(
+ row,
+ () => level.top,
+ (value) => {
+ stuck = value;
+ level.stuckRows.set(fullId, level.depth, value);
+ }
+ );
+ return () => {
+ stop?.();
+ level.stuckRows.set(fullId, level.depth, false);
+ };
+ }}
+ >
+ {@render row()}
+
+ {:else}
+ {@render row()}
+ {/if}
{@render props.content(open.current)}
diff --git a/website/src/lib/components/collapsible-tree/sticky.ts b/website/src/lib/components/collapsible-tree/sticky.ts
new file mode 100644
index 000000000..cb30f80b0
--- /dev/null
+++ b/website/src/lib/components/collapsible-tree/sticky.ts
@@ -0,0 +1,79 @@
+import type { StuckRows } from './utils.svelte';
+
+/**
+ * Where the row of a node of a sticky tree sticks: under the rows of the nodes it is in, which stay
+ * in view as long as some of their content can be seen.
+ */
+export type StickyLevel = {
+ /** Whether the rows stick. */
+ readonly enabled: boolean;
+ /** How far from the top of the scrolled area the rows of this level stick. */
+ readonly top: number;
+ /** How many nodes the rows of this level are in. */
+ readonly depth: number;
+ /** Which rows of the tree are stuck. */
+ readonly stuckRows: StuckRows;
+};
+
+/** The key of the context that holds the `StickyLevel`. */
+export const stickyKey = 'collapsible-tree-sticky';
+
+/** The rows of the outer levels are above the ones of the inner levels. */
+export function stickyZIndex(depth: number): number {
+ return Math.max(100 - depth, 1);
+}
+
+/** The first ancestor that scrolls (or clips), which a sticky row sticks to. */
+function scrollParent(node: HTMLElement): HTMLElement | null {
+ for (let parent = node.parentElement; parent; parent = parent.parentElement) {
+ if (getComputedStyle(parent).overflowY !== 'visible') {
+ return parent;
+ }
+ }
+ return null;
+}
+
+/**
+ * Tells whether a sticky row is stuck, and in view: it is held at `top()` from the top of the
+ * scrolled area, further down than the beginning of the node it is in. A row that is on its way out
+ * with the end of its node (pushed up under the row of the node above) is not.
+ */
+export function trackStuck(
+ row: HTMLElement,
+ top: () => number,
+ onChange: (stuck: boolean) => void
+) {
+ const node = row.parentElement;
+ const scroller = scrollParent(row);
+ if (!node || !scroller) {
+ return;
+ }
+ let stuck = false;
+ let frame = 0;
+ const check = () => {
+ frame = 0;
+ const rowTop = row.getBoundingClientRect().top;
+ const held = scroller.getBoundingClientRect().top + scroller.clientTop + top();
+ const now =
+ rowTop - node.getBoundingClientRect().top > 0.5 && Math.abs(rowTop - held) < 0.5;
+ if (now !== stuck) {
+ stuck = now;
+ onChange(stuck);
+ }
+ };
+ const schedule = () => {
+ if (!frame) {
+ frame = requestAnimationFrame(check);
+ }
+ };
+ scroller.addEventListener('scroll', schedule, { passive: true });
+ // the node changes when it is opened or closed, or its content does
+ const observer = new ResizeObserver(schedule);
+ observer.observe(node);
+ schedule();
+ return () => {
+ scroller.removeEventListener('scroll', schedule);
+ observer.disconnect();
+ cancelAnimationFrame(frame);
+ };
+}
diff --git a/website/src/lib/components/collapsible-tree/utils.svelte.ts b/website/src/lib/components/collapsible-tree/utils.svelte.ts
index 3beccd18e..0db3193ca 100644
--- a/website/src/lib/components/collapsible-tree/utils.svelte.ts
+++ b/website/src/lib/components/collapsible-tree/utils.svelte.ts
@@ -29,3 +29,24 @@ export class CollapsibleTreeState {
return this._open[id];
}
}
+
+/**
+ * The rows of a sticky tree that are stuck, by node, with how deep they are: only the deepest one
+ * gets the border that shows where the rows end.
+ */
+export class StuckRows {
+ private rows: Record = $state({});
+
+ set(id: string, depth: number, stuck: boolean) {
+ if (stuck) {
+ this.rows[id] = depth;
+ } else if (id in this.rows) {
+ delete this.rows[id];
+ }
+ }
+
+ /** The depth of the deepest row that is stuck, -1 if none is. */
+ get deepest(): number {
+ return Math.max(-1, ...Object.values(this.rows));
+ }
+}
diff --git a/website/src/lib/components/explorer/Explorer.svelte b/website/src/lib/components/explorer/Explorer.svelte
index 327f72762..8ba0322e9 100644
--- a/website/src/lib/components/explorer/Explorer.svelte
+++ b/website/src/lib/components/explorer/Explorer.svelte
@@ -2,7 +2,6 @@
import { CollapsibleTree, CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import ExplorerTree from './ExplorerTree.svelte';
- import { stickyRowStyle } from './sticky';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine';
import { folderName } from '$lib/engine/workspace';
@@ -24,12 +23,8 @@
{#key folder}
-
-
+
+
{#snippet trigger()}
{folderName(folder)}
diff --git a/website/src/lib/components/explorer/ExplorerTree.svelte b/website/src/lib/components/explorer/ExplorerTree.svelte
index 3db5fdbea..4f1576ff3 100644
--- a/website/src/lib/components/explorer/ExplorerTree.svelte
+++ b/website/src/lib/components/explorer/ExplorerTree.svelte
@@ -3,7 +3,6 @@
import { Button } from '$lib/components/ui/button';
import { FileText } from '@lucide/svelte';
import ExplorerTree from './ExplorerTree.svelte';
- import { stickyRowStyle } from './sticky';
import { openPath } from '$lib/actions/file-actions';
import { engine, type FolderEntry } from '$lib/engine';
import { selectedFileIds } from '$lib/engine/selection-helpers';
@@ -11,12 +10,9 @@
let {
path,
- depth = 1,
}: {
/** The folder whose content is listed, the open folder if it is not given. */
path?: string;
- /** How many folders deep the folders listed are (the open folder is 0). */
- depth?: number;
} = $props();
const { workspace, selection, folderVersion } = engine;
@@ -49,11 +45,7 @@
{#each entries as entry (entry.path)}
{#if entry.isDir}
-
+
{#snippet trigger()}
{entry.name}
{/snippet}
@@ -61,7 +53,7 @@
{#if opened}
-
+
{/if}
{/snippet}
diff --git a/website/src/lib/components/explorer/sticky.test.ts b/website/src/lib/components/explorer/sticky.test.ts
deleted file mode 100644
index 48a189cde..000000000
--- a/website/src/lib/components/explorer/sticky.test.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import assert from 'node:assert/strict';
-import { describe, it } from 'node:test';
-import { rowHeight, stickyRowStyle } from './sticky';
-
-describe('stickyRowStyle', () => {
- it('puts each row under the ones of the folders it is in', () => {
- assert.match(stickyRowStyle(0), /top: 0px/);
- assert.match(stickyRowStyle(2), new RegExp(`top: ${2 * rowHeight}px`));
- });
-
- it('puts the rows of the folders above over the rows below', () => {
- const zIndex = (depth: number) => Number(/z-index: (\d+)/.exec(stickyRowStyle(depth))![1]);
- assert.ok(zIndex(0) > zIndex(1));
- assert.ok(zIndex(1) > zIndex(5));
- assert.ok(zIndex(500) >= 1);
- });
-});
diff --git a/website/src/lib/components/explorer/sticky.ts b/website/src/lib/components/explorer/sticky.ts
deleted file mode 100644
index 6e0a6c996..000000000
--- a/website/src/lib/components/explorer/sticky.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/** The height of a row of the explorer. */
-export const rowHeight = 28;
-
-/**
- * The style of the row of a folder that is `depth` folders deep (0 for the open folder), to stick
- * under the rows of the folders it is in while any of its content can be seen: the rows of the
- * folders above are above it too.
- */
-export function stickyRowStyle(depth: number): string {
- return `top: ${depth * rowHeight}px; height: ${rowHeight}px; z-index: ${Math.max(100 - depth, 1)};`;
-}