@protobase/ui 0.26.8 → 0.26.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,5 +15,15 @@ const anchoredTop = (top, before, after) => {
15
15
  if (before.columns === after.columns) return row * after.rowHeight;
16
16
  return Math.floor(Math.floor(row) * before.columns / after.columns) * after.rowHeight;
17
17
  };
18
+ /**
19
+ * Where the cards are at the offset `top`, as a list remembers it: the first record of the row at the top, plus how far
20
+ * into that row as a fraction of its height, so it comes back to the same records whatever the columns then.
21
+ */
22
+ const cardPlace = (top, layout) => {
23
+ const row = top / layout.rowHeight;
24
+ return Math.floor(row) * layout.columns + row % 1;
25
+ };
26
+ /** The row, with how far into it, that a place `cardPlace` gave falls in with `columns` cards a row. */
27
+ const placeRow = (place, columns) => Math.floor(Math.floor(place) / columns) + place % 1;
18
28
  //#endregion
19
- export { anchoredTop, cardColumns, cardWidth, recordRange };
29
+ export { anchoredTop, cardColumns, cardPlace, cardWidth, placeRow, recordRange };
@@ -2,7 +2,7 @@ import { Skeleton } from "../../primitives/skeleton.js";
2
2
  import { usePagedRows } from "../../data-grid/use-paged-rows.js";
3
3
  import { useVirtualRows } from "../../data-grid/use-virtual-rows.js";
4
4
  import { createFlipGuard } from "../../lib/flip-guard.js";
5
- import { anchoredTop, cardColumns, cardWidth, recordRange } from "./card-layout.js";
5
+ import { anchoredTop, cardColumns, cardPlace, cardWidth, placeRow, recordRange } from "./card-layout.js";
6
6
  import { withMeasuredHeight } from "./measured-heights.js";
7
7
  import { useCallback, useLayoutEffect, useRef, useState } from "react";
8
8
  import { jsx } from "react/jsx-runtime";
@@ -33,12 +33,14 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
33
33
  const changed = anchor.current.top > 0 && (anchor.current.layout.columns !== columns || anchor.current.layout.rowHeight !== rowHeight);
34
34
  const settle = (top) => changed ? anchoredTop(anchor.current.top, anchor.current.layout, layout) : top;
35
35
  const ready = itemWidth > 0 && measured.has(itemWidth);
36
+ const onScroll = (top) => onScrollIndex(cardPlace(top, layout));
36
37
  const scroll = useVirtualRows({
37
38
  total: Math.ceil(total / columns),
38
39
  rowHeight,
39
- initialIndex: Math.floor(startIndex / columns),
40
+ initialIndex: placeRow(startIndex, columns),
40
41
  settle,
41
- ready
42
+ ready,
43
+ onScroll
42
44
  });
43
45
  useLayoutEffect(() => {
44
46
  if (changed) scroll.scrollTo(scroll.top);
@@ -46,7 +48,6 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
46
48
  layout,
47
49
  top: scroll.top
48
50
  };
49
- if (!changed) onScrollIndex(Math.floor(Math.max(0, scroll.top) / rowHeight) * columns);
50
51
  });
51
52
  const range = recordRange(scroll.first, scroll.last, columns, total);
52
53
  const getRow = usePagedRows({
@@ -0,0 +1,48 @@
1
+ import { guarded } from "../lib/guarded.js";
2
+ //#region src/app/list-places.ts
3
+ const storageKey = "protobase:list-places";
4
+ /** The places kept, the most recent ones, so the storage stays small however long the tab is used. */
5
+ const kept = 50;
6
+ const keyOf = (entry, url) => `${entry} ${url}`;
7
+ const isSaved = (value) => {
8
+ if (typeof value !== "object" || value === null) return false;
9
+ const { entry, url, place } = value;
10
+ return typeof entry === "string" && typeof url === "string" && typeof place === "number" && Number.isFinite(place) && place >= 0;
11
+ };
12
+ const read = (storage) => guarded(() => {
13
+ const raw = storage.getItem(storageKey);
14
+ if (raw === null) return [];
15
+ const parsed = JSON.parse(raw);
16
+ return Array.isArray(parsed) ? parsed.filter(isSaved) : [];
17
+ }, []);
18
+ /**
19
+ * The places of the lists of a tab, by history entry and list URL, kept in `storage` (the tab's session storage) when
20
+ * `persist` is called, so they outlast a reload of the page. A list starts where it was in its history entry, as on
21
+ * Back; in an entry it has no place in yet, as after a link to the same list, where it last was in any entry.
22
+ */
23
+ const createListPlaces = (storage) => {
24
+ const places = new Map(read(storage).map((saved) => [keyOf(saved.entry, saved.url), saved]));
25
+ const placeOf = (entry, url) => {
26
+ const own = places.get(keyOf(entry, url));
27
+ if (own) return own.place;
28
+ return [...places.values()].findLast((saved) => saved.url === url)?.place ?? 0;
29
+ };
30
+ const save = (entry, url, place) => {
31
+ const key = keyOf(entry, url);
32
+ places.delete(key);
33
+ places.set(key, {
34
+ entry,
35
+ url,
36
+ place
37
+ });
38
+ for (const oldest of [...places.keys()].slice(0, Math.max(0, places.size - kept))) places.delete(oldest);
39
+ };
40
+ const persist = () => guarded(() => storage.setItem(storageKey, JSON.stringify([...places.values()])), void 0);
41
+ return {
42
+ placeOf,
43
+ save,
44
+ persist
45
+ };
46
+ };
47
+ //#endregion
48
+ export { createListPlaces };
@@ -1,23 +1,42 @@
1
+ import { guarded } from "../lib/guarded.js";
1
2
  import { useRouter } from "./router.js";
2
- import { useCallback } from "react";
3
+ import { createListPlaces } from "./list-places.js";
4
+ import { useCallback, useEffect } from "react";
3
5
  //#region src/app/list-scroll.ts
4
- /** The first record in view of the list each history entry last showed, for this tab; a page load starts empty. */
5
- const remembered = /* @__PURE__ */ new Map();
6
+ const noStorage = {
7
+ getItem: () => null,
8
+ setItem: () => {}
9
+ };
10
+ /** The places of this tab's lists, read from its session storage on first use. */
11
+ let places;
12
+ const tabPlaces = () => places ??= createListPlaces(guarded(() => window.sessionStorage, void 0) ?? noStorage);
6
13
  /**
7
- * Where a list starts and how it remembers where it is, so going back to it shows the same records. Only the list the
8
- * history entry showed last comes back: after a change of sort or filter, which replaces the entry, the list starts at
9
- * the top again.
14
+ * Where a list starts and how it remembers where it is, so going back to it, or following a link to it, shows the same
15
+ * records at the same height. After a change of sort or filter, a list of another URL, it starts at the top. The places
16
+ * are stored when the list goes and when the page is hidden or left, so a reload or a discarded tab keeps them.
10
17
  */
11
18
  const useListScroll = () => {
12
19
  const { entry, path, params } = useRouter();
13
20
  const url = `${path}?${params.toString()}`;
14
- const last = remembered.get(entry);
15
- const remember = useCallback((index) => void remembered.set(entry, {
16
- url,
17
- index
18
- }), [entry, url]);
21
+ const tab = tabPlaces();
22
+ useEffect(() => {
23
+ const persist = () => tab.persist();
24
+ const hidden = () => document.visibilityState === "hidden" && tab.persist();
25
+ window.addEventListener("pagehide", persist);
26
+ document.addEventListener("visibilitychange", hidden);
27
+ return () => {
28
+ window.removeEventListener("pagehide", persist);
29
+ document.removeEventListener("visibilitychange", hidden);
30
+ tab.persist();
31
+ };
32
+ }, [tab]);
33
+ const remember = useCallback((place) => tab.save(entry, url, place), [
34
+ tab,
35
+ entry,
36
+ url
37
+ ]);
19
38
  return {
20
- initialIndex: last?.url === url ? last.index : 0,
39
+ initialIndex: tab.placeOf(entry, url),
21
40
  remember
22
41
  };
23
42
  };
@@ -13,14 +13,30 @@ const useRouter = () => {
13
13
  };
14
14
  const browserUrl = () => `${window.location.pathname}${window.location.search}`;
15
15
  const pathOf = (url) => new URL(url, "http://local").pathname;
16
+ const historyState = () => window.history.state ?? {};
16
17
  /** Our place in the browser history, so a back or forward press can be told apart and, if needed, undone. */
17
- const historyIndex = () => window.history.state?.protobaseIndex ?? 0;
18
+ const historyIndex = () => historyState().protobaseIndex ?? 0;
19
+ const newEntry = () => Math.random().toString(36).slice(2);
20
+ /** The id of the current history entry, given one if it has none yet, as an entry pushed before the app started. */
21
+ const historyEntry = () => {
22
+ const state = historyState();
23
+ if (state.protobaseEntry) return state.protobaseEntry;
24
+ const entry = newEntry();
25
+ window.history.replaceState({
26
+ ...state,
27
+ protobaseEntry: entry
28
+ }, "");
29
+ return entry;
30
+ };
18
31
  const Router = ({ basePath = "", initialUrl, children }) => {
19
32
  const memory = initialUrl !== void 0;
20
- const [url, setUrl] = useState(initialUrl ?? browserUrl());
33
+ const [page, setPage] = useState(() => ({
34
+ url: initialUrl ?? browserUrl(),
35
+ entry: memory ? newEntry() : historyEntry()
36
+ }));
21
37
  const guards = useRef(/* @__PURE__ */ new Set());
22
- const urlRef = useRef(url);
23
- urlRef.current = url;
38
+ const current = useRef(page);
39
+ current.current = page;
24
40
  const position = useRef(memory ? 0 : historyIndex());
25
41
  const undoing = useRef(false);
26
42
  const proceeding = useRef(false);
@@ -28,7 +44,10 @@ const Router = ({ basePath = "", initialUrl, children }) => {
28
44
  const [pending, setPending] = useState();
29
45
  useEffect(() => {
30
46
  if (memory) return;
31
- window.history.replaceState({ protobaseIndex: position.current }, "");
47
+ window.history.replaceState({
48
+ protobaseIndex: position.current,
49
+ protobaseEntry: current.current.entry
50
+ }, "");
32
51
  const onPop = () => {
33
52
  const next = historyIndex();
34
53
  if (undoing.current) {
@@ -39,7 +58,7 @@ const Router = ({ basePath = "", initialUrl, children }) => {
39
58
  return;
40
59
  }
41
60
  const delta = next - position.current;
42
- const leaving = pathOf(browserUrl()) !== pathOf(urlRef.current);
61
+ const leaving = pathOf(browserUrl()) !== pathOf(current.current.url);
43
62
  if (!proceeding.current && guards.current.size > 0 && leaving) {
44
63
  undoing.current = true;
45
64
  window.history.go(-delta);
@@ -51,21 +70,31 @@ const Router = ({ basePath = "", initialUrl, children }) => {
51
70
  }
52
71
  proceeding.current = false;
53
72
  position.current = next;
54
- setUrl(browserUrl());
73
+ setPage({
74
+ url: browserUrl(),
75
+ entry: historyEntry()
76
+ });
55
77
  };
56
78
  window.addEventListener("popstate", onPop);
57
79
  return () => window.removeEventListener("popstate", onPop);
58
80
  }, [memory]);
59
81
  const go = useCallback((full, replace) => {
82
+ const entry = replace ? current.current.entry : newEntry();
60
83
  if (!memory) {
61
84
  position.current += replace ? 0 : 1;
62
- window.history[replace ? "replaceState" : "pushState"]({ protobaseIndex: position.current }, "", full);
85
+ window.history[replace ? "replaceState" : "pushState"]({
86
+ protobaseIndex: position.current,
87
+ protobaseEntry: entry
88
+ }, "", full);
63
89
  }
64
- setUrl(full);
90
+ setPage({
91
+ url: full,
92
+ entry
93
+ });
65
94
  }, [memory]);
66
95
  const navigate = useCallback((to, { replace = false } = {}) => {
67
96
  const full = `${basePath}${to}`;
68
- if (pathOf(full) !== pathOf(urlRef.current) && guards.current.size > 0) return setPending({
97
+ if (pathOf(full) !== pathOf(current.current.url) && guards.current.size > 0) return setPending({
69
98
  kind: "link",
70
99
  full,
71
100
  replace
@@ -86,17 +115,17 @@ const Router = ({ basePath = "", initialUrl, children }) => {
86
115
  else window.history.go(pending.delta);
87
116
  };
88
117
  const value = useMemo(() => {
89
- const parsed = new URL(url, "http://local");
118
+ const parsed = new URL(page.url, "http://local");
90
119
  return {
91
120
  path: parsed.pathname.slice(basePath.length) || "/",
92
121
  params: parsed.searchParams,
93
122
  basePath,
94
- entry: position.current,
123
+ entry: page.entry,
95
124
  navigate,
96
125
  registerGuard
97
126
  };
98
127
  }, [
99
- url,
128
+ page,
100
129
  basePath,
101
130
  navigate,
102
131
  registerGuard
@@ -16,7 +16,7 @@ import { usePagedRows } from "./use-paged-rows.js";
16
16
  import { useVirtualRows } from "./use-virtual-rows.js";
17
17
  import { useCellEditing } from "./use-cell-editing.js";
18
18
  import { ReorderRows, SortableRow } from "./row-reorder.js";
19
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
19
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
20
20
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
21
21
  import { useTable } from "@tanstack/react-table";
22
22
  //#region src/data-grid/data-grid.tsx
@@ -61,15 +61,14 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
61
61
  total
62
62
  ]);
63
63
  const [startIndex] = useState(initialScrollIndex);
64
- const { ref, top, geometry, rows: virtualRows, first: firstIndex, last: lastIndex } = useVirtualRows({
64
+ const { ref, geometry, rows: virtualRows, first: firstIndex, last: lastIndex } = useVirtualRows({
65
65
  total,
66
66
  rowHeight,
67
67
  initialIndex: startIndex,
68
- inset: headerHeight
68
+ inset: headerHeight,
69
+ onScroll: onScrollIndex && ((offset) => onScrollIndex(offset / rowHeight))
69
70
  });
70
71
  const gutter = narrow || !rowNumbers ? 40 : gutterWidthFor(total);
71
- const topIndex = Math.floor(Math.max(0, top) / rowHeight);
72
- useEffect(() => onScrollIndex?.(topIndex), [topIndex, onScrollIndex]);
73
72
  const getRow = usePagedRows({
74
73
  total,
75
74
  rowAt: sourceRowAt ? rowAt : void 0,
@@ -20,7 +20,7 @@ const viewRows = (view, total, rowHeight) => {
20
20
  * wheel or touch: the list follows the scroll position, and draws again only when other rows come into view.
21
21
  * `first` and `last` are the indexes of the drawn rows, overscan included.
22
22
  */
23
- const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle, ready = true }) => {
23
+ const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle, ready = true, onScroll }) => {
24
24
  const ref = useRef(null);
25
25
  const scroller = useRef(null);
26
26
  const [view, setView] = useState({
@@ -32,14 +32,16 @@ const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle, rea
32
32
  rowHeight,
33
33
  initialIndex,
34
34
  inset,
35
- ready
35
+ ready,
36
+ onScroll
36
37
  });
37
38
  options.current = {
38
39
  total,
39
40
  rowHeight,
40
41
  initialIndex,
41
42
  inset,
42
- ready
43
+ ready,
44
+ onScroll
43
45
  };
44
46
  const read = useCallback(() => {
45
47
  const content = ref.current;
@@ -51,24 +53,25 @@ const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle, rea
51
53
  height: Math.max(0, area.height - hidden)
52
54
  };
53
55
  }, []);
56
+ const start = useRef({
57
+ pending: true,
58
+ laidOut: false
59
+ });
54
60
  const shown = useRef(view);
55
61
  shown.current = view;
56
62
  const measure = useCallback(() => {
57
63
  const next = read();
58
64
  if (!next) return;
59
65
  const current = shown.current;
60
- const { total, rowHeight } = options.current;
66
+ const { total, rowHeight, onScroll } = options.current;
61
67
  const before = viewRows(current, total, rowHeight);
62
68
  const after = viewRows(next, total, rowHeight);
69
+ if (!start.current.pending) onScroll?.(Math.max(0, after.top));
63
70
  const sameRows = !after.geometry.scaled && before.first === after.first && before.last === after.last && before.topIndex === after.topIndex;
64
71
  if (current.height === next.height && (current.position === next.position || sameRows)) return;
65
72
  shown.current = next;
66
73
  setView(next);
67
74
  }, [read]);
68
- const start = useRef({
69
- pending: true,
70
- laidOut: false
71
- });
72
75
  const begin = useCallback(() => {
73
76
  if (!start.current.pending || !start.current.laidOut || !options.current.ready) return;
74
77
  const view = read();
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.26.8";
2
+ var version = "0.26.9";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };