@x-tech-laboratories/x-libs 0.4.0 → 0.7.0
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.
- package/README.md +89 -13
- package/dist/backups.d.ts +31 -0
- package/dist/backups.js +43 -0
- package/dist/dates.d.ts +9 -0
- package/dist/dates.js +60 -0
- package/dist/durations.d.ts +9 -1
- package/dist/durations.js +19 -5
- package/dist/electron/app.d.ts +13 -0
- package/dist/electron/app.js +27 -0
- package/dist/electron/bridge.d.ts +32 -0
- package/dist/electron/bridge.js +53 -0
- package/dist/electron/events.d.ts +21 -0
- package/dist/electron/events.js +25 -0
- package/dist/electron/fetch.d.ts +25 -0
- package/dist/electron/fetch.js +67 -0
- package/dist/electron/window.d.ts +96 -0
- package/dist/electron/window.js +128 -0
- package/dist/log.d.ts +30 -0
- package/dist/log.js +79 -0
- package/dist/server/backup_files.d.ts +3 -0
- package/dist/server/backup_files.js +25 -0
- package/dist/web/drag.d.ts +3 -0
- package/dist/web/drag.js +13 -0
- package/dist/web/format.d.ts +1 -0
- package/dist/web/format.js +5 -0
- package/dist/web/menus.d.ts +4 -0
- package/dist/web/menus.js +28 -1
- package/dist/web/prefs.d.ts +12 -0
- package/dist/web/prefs.js +62 -0
- package/dist/web/{preact → react}/badges.d.ts +9 -9
- package/dist/web/{preact → react}/badges.js +6 -6
- package/dist/web/react/controls.d.ts +72 -0
- package/dist/web/react/controls.js +88 -0
- package/dist/web/{preact → react}/dialog.d.ts +10 -10
- package/dist/web/{preact → react}/dialog.js +14 -9
- package/dist/web/react/layout.d.ts +100 -0
- package/dist/web/react/layout.js +80 -0
- package/dist/web/react/menus.d.ts +31 -0
- package/dist/web/react/menus.js +159 -0
- package/dist/web/react/prefs.d.ts +2 -0
- package/dist/web/react/prefs.js +11 -0
- package/dist/web/react/reveal.d.ts +9 -0
- package/dist/web/react/reveal.js +30 -0
- package/dist/web/react/shell.d.ts +41 -0
- package/dist/web/react/shell.js +90 -0
- package/dist/web/react/shortcuts.d.ts +23 -0
- package/dist/web/react/shortcuts.js +95 -0
- package/dist/web/{preact → react}/split.d.ts +4 -4
- package/dist/web/{preact → react}/split.js +9 -10
- package/dist/web/{preact → react}/states.d.ts +7 -7
- package/dist/web/{preact → react}/states.js +6 -5
- package/dist/web/react/table.d.ts +69 -0
- package/dist/web/react/table.js +278 -0
- package/dist/web/{preact → react}/time.d.ts +2 -1
- package/dist/web/react/time.js +19 -0
- package/dist/web/react/toasts.d.ts +29 -0
- package/dist/web/react/toasts.js +72 -0
- package/dist/web/react/tree.d.ts +28 -0
- package/dist/web/react/tree.js +183 -0
- package/dist/web/react/watch.d.ts +3 -0
- package/dist/web/react/watch.js +5 -0
- package/dist/web/selection.d.ts +25 -0
- package/dist/web/selection.js +106 -0
- package/dist/web/tables.d.ts +19 -1
- package/dist/web/tables.js +78 -8
- package/dist/web/theme.d.ts +6 -1
- package/dist/web/theme.js +31 -21
- package/dist/web/tree.d.ts +37 -0
- package/dist/web/tree.js +128 -0
- package/dist/web/virtual.d.ts +7 -0
- package/dist/web/virtual.js +10 -0
- package/dist/web/watch.d.ts +6 -0
- package/dist/web/watch.js +17 -0
- package/package.json +15 -4
- package/dist/web/preact/controls.d.ts +0 -37
- package/dist/web/preact/controls.js +0 -43
- package/dist/web/preact/layout.d.ts +0 -80
- package/dist/web/preact/layout.js +0 -71
- package/dist/web/preact/menus.d.ts +0 -19
- package/dist/web/preact/menus.js +0 -70
- package/dist/web/preact/shell.d.ts +0 -27
- package/dist/web/preact/shell.js +0 -63
- package/dist/web/preact/table.d.ts +0 -23
- package/dist/web/preact/table.js +0 -20
- package/dist/web/preact/time.js +0 -9
- package/dist/web/preact/toasts.d.ts +0 -15
- package/dist/web/preact/toasts.js +0 -34
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type SelectionIntent = "replace" | "toggle" | "range" | "range-add";
|
|
2
|
+
export declare function intent_for(modifiers: {
|
|
3
|
+
shift?: boolean;
|
|
4
|
+
ctrl?: boolean;
|
|
5
|
+
}): SelectionIntent;
|
|
6
|
+
export declare class Selection<Id = number> {
|
|
7
|
+
private readonly picked;
|
|
8
|
+
private anchor;
|
|
9
|
+
get size(): number;
|
|
10
|
+
has(id: Id): boolean;
|
|
11
|
+
ids(): Id[];
|
|
12
|
+
inspect(): {
|
|
13
|
+
size: number;
|
|
14
|
+
anchor: number | null;
|
|
15
|
+
};
|
|
16
|
+
clear(): void;
|
|
17
|
+
apply(ordered: readonly Id[], index: number, intent: SelectionIntent): void;
|
|
18
|
+
select_all(ordered: readonly Id[]): void;
|
|
19
|
+
invert(ordered: readonly Id[]): void;
|
|
20
|
+
retain(ordered: readonly Id[]): void;
|
|
21
|
+
}
|
|
22
|
+
export declare function toggled(picked: readonly string[], id: string): string[];
|
|
23
|
+
export declare function with_range(order: readonly string[], picked: readonly string[], anchor_id: string | null, id: string): string[];
|
|
24
|
+
export declare function still_shown(order: readonly string[], picked: readonly string[]): string[];
|
|
25
|
+
export declare function row_after_key(key: string, current: number, last: number, rows_per_page: number): number | null;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Picking rows in a list: plain, Ctrl and Shift clicks, an anchor ranges measure from, and the row an arrow or page key moves to.
|
|
2
|
+
export function intent_for(modifiers) {
|
|
3
|
+
if (modifiers.shift)
|
|
4
|
+
return modifiers.ctrl ? "range-add" : "range";
|
|
5
|
+
return modifiers.ctrl ? "toggle" : "replace";
|
|
6
|
+
}
|
|
7
|
+
// ── Selection ────────────────────────────────────────────────────────────────
|
|
8
|
+
export class Selection {
|
|
9
|
+
picked = new Set();
|
|
10
|
+
anchor = null;
|
|
11
|
+
get size() {
|
|
12
|
+
return this.picked.size;
|
|
13
|
+
}
|
|
14
|
+
has(id) {
|
|
15
|
+
return this.picked.has(id);
|
|
16
|
+
}
|
|
17
|
+
ids() {
|
|
18
|
+
return [...this.picked];
|
|
19
|
+
}
|
|
20
|
+
// The picks and the anchor, for finding why a range came out wrong.
|
|
21
|
+
inspect() {
|
|
22
|
+
return { size: this.picked.size, anchor: this.anchor };
|
|
23
|
+
}
|
|
24
|
+
clear() {
|
|
25
|
+
this.picked.clear();
|
|
26
|
+
this.anchor = null;
|
|
27
|
+
}
|
|
28
|
+
// Applies a click on the row at index in the list as shown.
|
|
29
|
+
apply(ordered, index, intent) {
|
|
30
|
+
if (index < 0 || index >= ordered.length)
|
|
31
|
+
return;
|
|
32
|
+
const id = ordered[index];
|
|
33
|
+
if (intent === "replace") {
|
|
34
|
+
this.picked.clear();
|
|
35
|
+
this.picked.add(id);
|
|
36
|
+
this.anchor = index;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (intent === "toggle") {
|
|
40
|
+
if (this.picked.has(id))
|
|
41
|
+
this.picked.delete(id);
|
|
42
|
+
else
|
|
43
|
+
this.picked.add(id);
|
|
44
|
+
this.anchor = index;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// The anchor stays put, so every extension measures from the same row.
|
|
48
|
+
const from = this.anchor ?? index;
|
|
49
|
+
const [low, high] = from <= index ? [from, index] : [index, from];
|
|
50
|
+
if (intent === "range")
|
|
51
|
+
this.picked.clear();
|
|
52
|
+
for (let row = low; row <= high; row += 1)
|
|
53
|
+
this.picked.add(ordered[row]);
|
|
54
|
+
}
|
|
55
|
+
select_all(ordered) {
|
|
56
|
+
for (const id of ordered)
|
|
57
|
+
this.picked.add(id);
|
|
58
|
+
}
|
|
59
|
+
invert(ordered) {
|
|
60
|
+
const next = ordered.filter((id) => !this.picked.has(id));
|
|
61
|
+
this.picked.clear();
|
|
62
|
+
for (const id of next)
|
|
63
|
+
this.picked.add(id);
|
|
64
|
+
}
|
|
65
|
+
// Drops picks no longer in the list, such as after a filter narrows it.
|
|
66
|
+
retain(ordered) {
|
|
67
|
+
const kept = new Set(ordered);
|
|
68
|
+
for (const id of this.picked)
|
|
69
|
+
if (!kept.has(id))
|
|
70
|
+
this.picked.delete(id);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ── Ids ──────────────────────────────────────────────────────────────────────
|
|
74
|
+
// The same picking as plain lists of ids held in an app's state, the anchor kept by id so it survives the rows moving between clicks.
|
|
75
|
+
// Adds the id when missing and takes it out when there.
|
|
76
|
+
export function toggled(picked, id) {
|
|
77
|
+
return picked.includes(id) ? picked.filter((each) => each !== id) : [...picked, id];
|
|
78
|
+
}
|
|
79
|
+
// Every id between the anchor and the id in the shown order, both included, added to those picked; just the id when the anchor is not shown.
|
|
80
|
+
export function with_range(order, picked, anchor_id, id) {
|
|
81
|
+
const anchor_index = anchor_id === null ? -1 : order.indexOf(anchor_id);
|
|
82
|
+
const index = order.indexOf(id);
|
|
83
|
+
if (anchor_index === -1 || index === -1)
|
|
84
|
+
return [id];
|
|
85
|
+
const run = order.slice(Math.min(anchor_index, index), Math.max(anchor_index, index) + 1);
|
|
86
|
+
return [...picked.filter((each) => !run.includes(each)), ...run];
|
|
87
|
+
}
|
|
88
|
+
// Keeps only the picked ids still shown, in the shown order.
|
|
89
|
+
export function still_shown(order, picked) {
|
|
90
|
+
const picked_ids = new Set(picked);
|
|
91
|
+
return order.filter((id) => picked_ids.has(id));
|
|
92
|
+
}
|
|
93
|
+
// ── Keys ─────────────────────────────────────────────────────────────────────
|
|
94
|
+
// The row an arrow, page, Home or End key moves to, held inside the list, or null for any other key.
|
|
95
|
+
export function row_after_key(key, current, last, rows_per_page) {
|
|
96
|
+
const clamp = (row) => Math.min(last, Math.max(0, row));
|
|
97
|
+
switch (key) {
|
|
98
|
+
case "ArrowDown": return clamp(current + 1);
|
|
99
|
+
case "ArrowUp": return clamp(current - 1);
|
|
100
|
+
case "PageDown": return clamp(current + rows_per_page);
|
|
101
|
+
case "PageUp": return clamp(current - rows_per_page);
|
|
102
|
+
case "Home": return 0;
|
|
103
|
+
case "End": return Math.max(0, last);
|
|
104
|
+
default: return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/web/tables.d.ts
CHANGED
|
@@ -4,7 +4,25 @@ export interface Sorting {
|
|
|
4
4
|
}
|
|
5
5
|
export interface SortableColumn<Row> {
|
|
6
6
|
label: string;
|
|
7
|
+
id?: string;
|
|
7
8
|
sort?: (row: Row) => string | number | null;
|
|
8
9
|
}
|
|
9
|
-
export
|
|
10
|
+
export type SortValue = string | number | null | undefined;
|
|
11
|
+
export declare function column_id(column: {
|
|
12
|
+
id?: string;
|
|
13
|
+
label: string;
|
|
14
|
+
}): string;
|
|
15
|
+
export declare function sorting_list(sorting: Sorting | readonly Sorting[] | undefined): Sorting[];
|
|
16
|
+
export declare function compare_values(first: SortValue, second: SortValue, descending?: boolean): number;
|
|
17
|
+
export declare function sorted_rows<Row>(columns: SortableColumn<Row>[], rows: Row[], sorting: Sorting | readonly Sorting[] | undefined): Row[];
|
|
18
|
+
export interface TreeRow<Row> {
|
|
19
|
+
row: Row;
|
|
20
|
+
children: TreeRow<Row>[];
|
|
21
|
+
}
|
|
22
|
+
export declare function sorted_tree<Row>(columns: SortableColumn<Row>[], rows: Row[], sorting: Sorting | readonly Sorting[] | undefined, get_children: (row: Row) => Row[] | undefined): TreeRow<Row>[];
|
|
23
|
+
export declare function sort_after_click(sorting: readonly Sorting[], key: string, adding: boolean): Sorting[];
|
|
10
24
|
export declare function sorting_from(query: URLSearchParams, fallback: Sorting): Sorting;
|
|
25
|
+
export declare function ordered_columns(saved_order: readonly string[], all_columns: readonly string[]): string[];
|
|
26
|
+
export declare function move_column(order: readonly string[], column: string, target: string, after: boolean): string[];
|
|
27
|
+
export declare function moved<Item>(items: readonly Item[], from: number, to: number): Item[];
|
|
28
|
+
export declare function clamp_width(width: number, min?: number, max?: number): number;
|
package/dist/web/tables.js
CHANGED
|
@@ -1,18 +1,88 @@
|
|
|
1
|
-
// The order a sorted table shows its rows in,
|
|
2
|
-
|
|
1
|
+
// The order a sorted table shows its rows in, the sorting a click or a view's query asks for, and the order of its columns.
|
|
2
|
+
export function column_id(column) {
|
|
3
|
+
return column.id ?? column.label;
|
|
4
|
+
}
|
|
5
|
+
// One sorting or several as a list, the first deciding first.
|
|
6
|
+
export function sorting_list(sorting) {
|
|
7
|
+
if (!sorting)
|
|
8
|
+
return [];
|
|
9
|
+
return Array.isArray(sorting) ? [...sorting] : [sorting];
|
|
10
|
+
}
|
|
11
|
+
const is_empty = (value) => value === null || value === undefined || value === "";
|
|
12
|
+
// Numbers by size and text by its words and numbers, with an empty value last whichever way the column sorts.
|
|
13
|
+
export function compare_values(first, second, descending = false) {
|
|
14
|
+
if (is_empty(first) || is_empty(second))
|
|
15
|
+
return Number(is_empty(first)) - Number(is_empty(second));
|
|
16
|
+
const order = typeof first === "number" && typeof second === "number"
|
|
17
|
+
? first - second
|
|
18
|
+
: String(first).localeCompare(String(second), undefined, { numeric: true, sensitivity: "base" });
|
|
19
|
+
return descending ? -order : order;
|
|
20
|
+
}
|
|
21
|
+
// The rows in the order the sortings ask for, the later ones breaking ties, and rows missing a value last.
|
|
3
22
|
export function sorted_rows(columns, rows, sorting) {
|
|
4
|
-
const
|
|
5
|
-
|
|
23
|
+
const keys = sorting_list(sorting).flatMap((key) => {
|
|
24
|
+
const column = columns.find((candidate) => column_id(candidate) === key.key && candidate.sort);
|
|
25
|
+
return column?.sort ? [{ value: column.sort, descending: key.descending }] : [];
|
|
26
|
+
});
|
|
27
|
+
if (keys.length === 0)
|
|
6
28
|
return rows;
|
|
7
|
-
const value = column.sort;
|
|
8
29
|
return [...rows].sort((left, right) => {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
30
|
+
for (const key of keys) {
|
|
31
|
+
const order = compare_values(key.value(left), key.value(right), key.descending);
|
|
32
|
+
if (order !== 0)
|
|
33
|
+
return order;
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
12
36
|
});
|
|
13
37
|
}
|
|
38
|
+
// Sorts each level of a tree on its own, so children stay under their parents.
|
|
39
|
+
export function sorted_tree(columns, rows, sorting, get_children) {
|
|
40
|
+
return sorted_rows(columns, rows, sorting).map((row) => ({ row, children: sorted_tree(columns, get_children(row) ?? [], sorting, get_children) }));
|
|
41
|
+
}
|
|
42
|
+
// A plain click sorts by that column alone; a Shift+click adds it to the sort or flips it; a third click takes it out.
|
|
43
|
+
export function sort_after_click(sorting, key, adding) {
|
|
44
|
+
const current = sorting.find((each) => each.key === key);
|
|
45
|
+
const next = !current ? { key, descending: false } : current.descending ? null : { key, descending: true };
|
|
46
|
+
if (!adding) {
|
|
47
|
+
if (current && sorting.length === 1)
|
|
48
|
+
return next ? [next] : [];
|
|
49
|
+
return [{ key, descending: false }];
|
|
50
|
+
}
|
|
51
|
+
if (!current)
|
|
52
|
+
return [...sorting, next];
|
|
53
|
+
return next ? sorting.map((each) => (each.key === key ? next : each)) : sorting.filter((each) => each.key !== key);
|
|
54
|
+
}
|
|
14
55
|
// The sorting a view's query asks for, such as ?sort=Name&dir=desc.
|
|
15
56
|
export function sorting_from(query, fallback) {
|
|
16
57
|
const key = query.get("sort");
|
|
17
58
|
return key ? { key, descending: query.get("dir") === "desc" } : fallback;
|
|
18
59
|
}
|
|
60
|
+
// ── Columns ──────────────────────────────────────────────────────────────────
|
|
61
|
+
// Every column in its saved order, leaving out ones gone since and placing a new one just after the column before it.
|
|
62
|
+
export function ordered_columns(saved_order, all_columns) {
|
|
63
|
+
const ordered = saved_order.filter((column, index) => all_columns.includes(column) && saved_order.indexOf(column) === index);
|
|
64
|
+
all_columns.forEach((column, index) => {
|
|
65
|
+
if (ordered.includes(column))
|
|
66
|
+
return;
|
|
67
|
+
ordered.splice(index === 0 ? 0 : ordered.indexOf(all_columns[index - 1]) + 1, 0, column);
|
|
68
|
+
});
|
|
69
|
+
return ordered;
|
|
70
|
+
}
|
|
71
|
+
// Moves a column to just before or after another one.
|
|
72
|
+
export function move_column(order, column, target, after) {
|
|
73
|
+
const rest = order.filter((each) => each !== column);
|
|
74
|
+
const index = rest.indexOf(target) + (after ? 1 : 0);
|
|
75
|
+
return [...rest.slice(0, index), column, ...rest.slice(index)];
|
|
76
|
+
}
|
|
77
|
+
// Moves the item at from to to, such as for a list's up and down buttons.
|
|
78
|
+
export function moved(items, from, to) {
|
|
79
|
+
if (from < 0 || from >= items.length)
|
|
80
|
+
return [...items];
|
|
81
|
+
const rest = items.filter((_, index) => index !== from);
|
|
82
|
+
rest.splice(Math.max(0, Math.min(to, rest.length)), 0, items[from]);
|
|
83
|
+
return rest;
|
|
84
|
+
}
|
|
85
|
+
// A width held between the column's narrowest and widest.
|
|
86
|
+
export function clamp_width(width, min = 48, max = 960) {
|
|
87
|
+
return Math.round(Math.min(max, Math.max(min, width)));
|
|
88
|
+
}
|
package/dist/web/theme.d.ts
CHANGED
|
@@ -5,5 +5,10 @@ export interface ThemeChoice {
|
|
|
5
5
|
on_change(listener: (theme: Theme) => void): () => void;
|
|
6
6
|
apply(theme?: Theme): void;
|
|
7
7
|
}
|
|
8
|
-
export
|
|
8
|
+
export interface ThemeStorage {
|
|
9
|
+
read(): Theme | undefined;
|
|
10
|
+
write(theme: Theme): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function local_theme_storage(storage_key: string): ThemeStorage;
|
|
13
|
+
export declare function theme_choice(where: string | ThemeStorage): ThemeChoice;
|
|
9
14
|
export declare function next_theme(theme: Theme): Theme;
|
package/dist/web/theme.js
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
|
-
// The light, dark or system theme, kept in this browser and set as data-theme on the page.
|
|
2
|
-
// The choice kept under the storage key, such as "x-nix-theme".
|
|
3
|
-
export function
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
// The light, dark or system theme, kept in this browser or the app's own settings, and set as data-theme on the page.
|
|
2
|
+
// The choice kept in this browser under the storage key, such as "x-nix-theme".
|
|
3
|
+
export function local_theme_storage(storage_key) {
|
|
4
|
+
return {
|
|
5
|
+
read: () => {
|
|
6
|
+
try {
|
|
7
|
+
const saved = localStorage.getItem(storage_key);
|
|
8
|
+
return saved === "light" || saved === "dark" ? saved : undefined;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
write: (theme) => {
|
|
15
|
+
try {
|
|
16
|
+
if (theme === "system")
|
|
17
|
+
localStorage.removeItem(storage_key);
|
|
18
|
+
else
|
|
19
|
+
localStorage.setItem(storage_key, theme);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Without storage the choice lasts until the page is closed.
|
|
23
|
+
}
|
|
24
|
+
},
|
|
13
25
|
};
|
|
26
|
+
}
|
|
27
|
+
// The choice kept under a storage key in this browser, or in the storage the app hands over.
|
|
28
|
+
export function theme_choice(where) {
|
|
29
|
+
const storage = typeof where === "string" ? local_theme_storage(where) : where;
|
|
30
|
+
const listeners = new Set();
|
|
31
|
+
const get = () => storage.read() ?? "system";
|
|
14
32
|
const apply = (theme = get()) => {
|
|
15
33
|
if (theme === "system")
|
|
16
34
|
delete document.documentElement.dataset.theme;
|
|
@@ -20,15 +38,7 @@ export function theme_choice(storage_key) {
|
|
|
20
38
|
listener(theme);
|
|
21
39
|
};
|
|
22
40
|
const set = (theme) => {
|
|
23
|
-
|
|
24
|
-
if (theme === "system")
|
|
25
|
-
localStorage.removeItem(storage_key);
|
|
26
|
-
else
|
|
27
|
-
localStorage.setItem(storage_key, theme);
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
// Without storage the choice lasts until the page is closed.
|
|
31
|
-
}
|
|
41
|
+
storage.write(theme);
|
|
32
42
|
apply(theme);
|
|
33
43
|
};
|
|
34
44
|
return { get, set, apply, on_change: (listener) => {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface TreeItem {
|
|
2
|
+
id: string;
|
|
3
|
+
parent_id: string | null;
|
|
4
|
+
position: number;
|
|
5
|
+
}
|
|
6
|
+
export interface TreeNode<Item> {
|
|
7
|
+
item: Item;
|
|
8
|
+
children: TreeNode<Item>[];
|
|
9
|
+
}
|
|
10
|
+
export type SidePlacement = "before" | "after";
|
|
11
|
+
export type DropPlacement = SidePlacement | "inside";
|
|
12
|
+
export interface TreeMove {
|
|
13
|
+
parent_id: string | null;
|
|
14
|
+
position: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function by_position<Item extends {
|
|
17
|
+
position: number;
|
|
18
|
+
}>(first: Item, second: Item): number;
|
|
19
|
+
export declare function children_of<Item extends TreeItem>(parent_id: string | null, items: Item[]): Item[];
|
|
20
|
+
export declare function subtree_ids(item_id: string, items: TreeItem[]): Set<string>;
|
|
21
|
+
export declare function nest<Item extends TreeItem>(shown: Item[], all?: Item[]): TreeNode<Item>[];
|
|
22
|
+
export declare function in_tree_order<Item extends TreeItem>(items: Item[], reversed?: boolean): Item[];
|
|
23
|
+
export declare function path_names<Item extends TreeItem>(item: Item, items: Item[], name_of: (item: Item) => string): string[];
|
|
24
|
+
export declare function placement_at(fraction_down: number, allow_inside: boolean): DropPlacement;
|
|
25
|
+
export declare function can_drop(dragged_id: string, target_id: string, items: TreeItem[]): boolean;
|
|
26
|
+
export declare function position_for_drop<Item extends {
|
|
27
|
+
id: string;
|
|
28
|
+
position: number;
|
|
29
|
+
}>(dragged: Item, target: Item, placement: SidePlacement, items: Item[]): number;
|
|
30
|
+
export declare function move_for_drop<Item extends TreeItem>(dragged: Item, target: Item, placement: DropPlacement, items: Item[]): TreeMove;
|
|
31
|
+
export declare function moves_for_drop<Item extends TreeItem>(dragged: Item[], target: Item, placement: DropPlacement, items: Item[]): TreeMove[];
|
|
32
|
+
export type KeyMove = "up" | "down" | "indent" | "outdent";
|
|
33
|
+
export declare function key_drop<Item extends TreeItem>(item: Item, direction: KeyMove, items: Item[]): {
|
|
34
|
+
target: Item;
|
|
35
|
+
placement: DropPlacement;
|
|
36
|
+
} | null;
|
|
37
|
+
export declare function move_for_key<Item extends TreeItem>(item: Item, direction: KeyMove, items: Item[]): TreeMove | null;
|
package/dist/web/tree.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Trees of rows that name their parent and their place among siblings, and where a dragged or keyed row lands.
|
|
2
|
+
// ── Shape ────────────────────────────────────────────────────────────────────
|
|
3
|
+
export function by_position(first, second) {
|
|
4
|
+
return first.position - second.position;
|
|
5
|
+
}
|
|
6
|
+
export function children_of(parent_id, items) {
|
|
7
|
+
return items.filter((item) => item.parent_id === parent_id);
|
|
8
|
+
}
|
|
9
|
+
// The item itself plus everything below it, at any depth.
|
|
10
|
+
export function subtree_ids(item_id, items) {
|
|
11
|
+
const ids = new Set([item_id]);
|
|
12
|
+
for (const id of ids) {
|
|
13
|
+
for (const child of children_of(id, items))
|
|
14
|
+
ids.add(child.id);
|
|
15
|
+
}
|
|
16
|
+
return ids;
|
|
17
|
+
}
|
|
18
|
+
// Each shown item sits under its nearest ancestor that is also shown, or at the top when none is, in the order given.
|
|
19
|
+
export function nest(shown, all = shown) {
|
|
20
|
+
const item_by_id = new Map(all.map((item) => [item.id, item]));
|
|
21
|
+
const node_by_id = new Map(shown.map((item) => [item.id, { item, children: [] }]));
|
|
22
|
+
const top_nodes = [];
|
|
23
|
+
for (const node of node_by_id.values()) {
|
|
24
|
+
const seen = new Set([node.item.id]);
|
|
25
|
+
let ancestor_id = node.item.parent_id;
|
|
26
|
+
while (ancestor_id && !node_by_id.has(ancestor_id) && !seen.has(ancestor_id)) {
|
|
27
|
+
seen.add(ancestor_id);
|
|
28
|
+
ancestor_id = item_by_id.get(ancestor_id)?.parent_id ?? null;
|
|
29
|
+
}
|
|
30
|
+
if (ancestor_id && node_by_id.has(ancestor_id) && !seen.has(ancestor_id))
|
|
31
|
+
node_by_id.get(ancestor_id).children.push(node);
|
|
32
|
+
else
|
|
33
|
+
top_nodes.push(node);
|
|
34
|
+
}
|
|
35
|
+
return top_nodes;
|
|
36
|
+
}
|
|
37
|
+
// Every item in tree order: each followed by the items below it, siblings by position, each set of siblings reversed when asked.
|
|
38
|
+
export function in_tree_order(items, reversed = false) {
|
|
39
|
+
const sorted = [...items].sort(by_position);
|
|
40
|
+
if (reversed)
|
|
41
|
+
sorted.reverse();
|
|
42
|
+
const walk = (nodes) => nodes.flatMap((node) => [node.item, ...walk(node.children)]);
|
|
43
|
+
const ordered = walk(nest(sorted, items));
|
|
44
|
+
const reached = new Set(ordered);
|
|
45
|
+
return [...ordered, ...sorted.filter((item) => !reached.has(item))];
|
|
46
|
+
}
|
|
47
|
+
// The names from the top of the tree down to the item, such as ["Work", "Clients"]; stops at a missing parent or a loop.
|
|
48
|
+
export function path_names(item, items, name_of) {
|
|
49
|
+
const item_by_id = new Map(items.map((each) => [each.id, each]));
|
|
50
|
+
const path = [item];
|
|
51
|
+
let parent = item.parent_id === null ? undefined : item_by_id.get(item.parent_id);
|
|
52
|
+
while (parent && !path.includes(parent)) {
|
|
53
|
+
path.unshift(parent);
|
|
54
|
+
parent = parent.parent_id === null ? undefined : item_by_id.get(parent.parent_id);
|
|
55
|
+
}
|
|
56
|
+
return path.map(name_of);
|
|
57
|
+
}
|
|
58
|
+
// ── Dropping ─────────────────────────────────────────────────────────────────
|
|
59
|
+
// The top and bottom quarters of a row drop beside it and the middle drops into it, or halves when nothing drops inside.
|
|
60
|
+
export function placement_at(fraction_down, allow_inside) {
|
|
61
|
+
if (!allow_inside)
|
|
62
|
+
return fraction_down < 0.5 ? "before" : "after";
|
|
63
|
+
if (fraction_down < 0.25)
|
|
64
|
+
return "before";
|
|
65
|
+
if (fraction_down > 0.75)
|
|
66
|
+
return "after";
|
|
67
|
+
return "inside";
|
|
68
|
+
}
|
|
69
|
+
// A row cannot drop onto itself or anything below it.
|
|
70
|
+
export function can_drop(dragged_id, target_id, items) {
|
|
71
|
+
return !subtree_ids(dragged_id, items).has(target_id);
|
|
72
|
+
}
|
|
73
|
+
// Picks a position between the neighbours at the drop point, so only the moved item changes.
|
|
74
|
+
function position_beside(siblings, target, placement) {
|
|
75
|
+
const sorted = [...siblings].sort(by_position);
|
|
76
|
+
const index = sorted.findIndex((sibling) => sibling.id === target.id);
|
|
77
|
+
const neighbour = sorted[placement === "before" ? index - 1 : index + 1];
|
|
78
|
+
if (!neighbour)
|
|
79
|
+
return placement === "before" ? target.position - 1 : target.position + 1;
|
|
80
|
+
return (target.position + neighbour.position) / 2;
|
|
81
|
+
}
|
|
82
|
+
// For a flat list, where items only move before or after each other.
|
|
83
|
+
export function position_for_drop(dragged, target, placement, items) {
|
|
84
|
+
return position_beside(items.filter((item) => item.id !== dragged.id), target, placement);
|
|
85
|
+
}
|
|
86
|
+
// Inside drops after the target's last child; before and after drop among the target's siblings.
|
|
87
|
+
export function move_for_drop(dragged, target, placement, items) {
|
|
88
|
+
const others = items.filter((item) => item.id !== dragged.id);
|
|
89
|
+
if (placement === "inside") {
|
|
90
|
+
const last_child_position = Math.max(target.position, ...children_of(target.id, others).map((item) => item.position));
|
|
91
|
+
return { parent_id: target.id, position: last_child_position + 1 };
|
|
92
|
+
}
|
|
93
|
+
return { parent_id: target.parent_id, position: position_beside(children_of(target.parent_id, others), target, placement) };
|
|
94
|
+
}
|
|
95
|
+
// Moves several items together, keeping their order: inside the target after its last child, or in a run between it and its neighbour.
|
|
96
|
+
export function moves_for_drop(dragged, target, placement, items) {
|
|
97
|
+
const dragged_ids = new Set(dragged.map((item) => item.id));
|
|
98
|
+
const others = items.filter((item) => !dragged_ids.has(item.id));
|
|
99
|
+
if (placement === "inside") {
|
|
100
|
+
const last_child_position = Math.max(target.position, ...children_of(target.id, others).map((item) => item.position));
|
|
101
|
+
return dragged.map((_, index) => ({ parent_id: target.id, position: last_child_position + index + 1 }));
|
|
102
|
+
}
|
|
103
|
+
const siblings = children_of(target.parent_id, others).sort(by_position);
|
|
104
|
+
const index = siblings.findIndex((sibling) => sibling.id === target.id);
|
|
105
|
+
const neighbour = siblings[placement === "before" ? index - 1 : index + 1];
|
|
106
|
+
const low = placement === "before" ? (neighbour?.position ?? target.position - 1) : target.position;
|
|
107
|
+
const high = placement === "before" ? target.position : (neighbour?.position ?? target.position + 1);
|
|
108
|
+
return dragged.map((_, index) => ({ parent_id: target.parent_id, position: low + ((high - low) * (index + 1)) / (dragged.length + 1) }));
|
|
109
|
+
}
|
|
110
|
+
// Up and down pass the sibling beside it, indent goes last inside the sibling above, outdent goes after its parent; null when it cannot go that way.
|
|
111
|
+
export function key_drop(item, direction, items) {
|
|
112
|
+
const siblings = children_of(item.parent_id, items).sort(by_position);
|
|
113
|
+
const index = siblings.findIndex((sibling) => sibling.id === item.id);
|
|
114
|
+
const above = siblings[index - 1];
|
|
115
|
+
const below = siblings[index + 1];
|
|
116
|
+
const parent = items.find((each) => each.id === item.parent_id);
|
|
117
|
+
if (direction === "up")
|
|
118
|
+
return above ? { target: above, placement: "before" } : null;
|
|
119
|
+
if (direction === "down")
|
|
120
|
+
return below ? { target: below, placement: "after" } : null;
|
|
121
|
+
if (direction === "indent")
|
|
122
|
+
return above ? { target: above, placement: "inside" } : null;
|
|
123
|
+
return parent ? { target: parent, placement: "after" } : null;
|
|
124
|
+
}
|
|
125
|
+
export function move_for_key(item, direction, items) {
|
|
126
|
+
const drop = key_drop(item, direction, items);
|
|
127
|
+
return drop && move_for_drop(item, drop.target, drop.placement, items);
|
|
128
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface VirtualWindow {
|
|
2
|
+
start: number;
|
|
3
|
+
end: number;
|
|
4
|
+
offset_px: number;
|
|
5
|
+
total_px: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function virtual_window(total_rows: number, row_height: number, scroll_top: number, viewport_height: number, overscan?: number): VirtualWindow;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Which rows of a long list a scrolled viewport needs, so only those are drawn.
|
|
2
|
+
export function virtual_window(total_rows, row_height, scroll_top, viewport_height, overscan = 8) {
|
|
3
|
+
if (row_height <= 0)
|
|
4
|
+
throw new Error("row_height must be positive");
|
|
5
|
+
const first_visible = Math.floor(Math.max(0, scroll_top) / row_height);
|
|
6
|
+
const visible_count = Math.ceil(viewport_height / row_height);
|
|
7
|
+
const start = Math.max(0, first_visible - overscan);
|
|
8
|
+
const end = Math.min(total_rows, first_visible + visible_count + overscan);
|
|
9
|
+
return { start, end, offset_px: start * row_height, total_px: total_rows * row_height };
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// A change counter a store bumps and a page watches, so only what reads the store redraws when it changes.
|
|
2
|
+
export function create_watch() {
|
|
3
|
+
let version = 0;
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
return {
|
|
6
|
+
version: () => version,
|
|
7
|
+
bump: () => {
|
|
8
|
+
version += 1;
|
|
9
|
+
for (const listener of listeners)
|
|
10
|
+
listener();
|
|
11
|
+
},
|
|
12
|
+
subscribe: (listener) => {
|
|
13
|
+
listeners.add(listener);
|
|
14
|
+
return () => void listeners.delete(listener);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x-tech-laboratories/x-libs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Code the X-apps share: agents and their long runs, passwords, durations, and the web page's helpers and widgets",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"@fastify/websocket": "^11.3.5",
|
|
31
31
|
"fastify": "^5.12.7",
|
|
32
|
-
"
|
|
32
|
+
"react": "^19",
|
|
33
|
+
"react-dom": "^19"
|
|
33
34
|
},
|
|
34
35
|
"peerDependenciesMeta": {
|
|
35
36
|
"@fastify/websocket": {
|
|
@@ -38,22 +39,32 @@
|
|
|
38
39
|
"fastify": {
|
|
39
40
|
"optional": true
|
|
40
41
|
},
|
|
41
|
-
"
|
|
42
|
+
"react": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"react-dom": {
|
|
42
46
|
"optional": true
|
|
43
47
|
}
|
|
44
48
|
},
|
|
45
49
|
"devDependencies": {
|
|
46
50
|
"@fastify/websocket": "^11.3.5",
|
|
51
|
+
"@testing-library/dom": "^10.4.2",
|
|
52
|
+
"@testing-library/react": "^16.3.3",
|
|
47
53
|
"@types/node": "^24.19.2",
|
|
54
|
+
"@types/react": "^19.3.0",
|
|
55
|
+
"@types/react-dom": "^19.3.0",
|
|
48
56
|
"@types/sshpk": "^1.17.5",
|
|
49
57
|
"fastify": "^5.12.7",
|
|
58
|
+
"jsdom": "^31.0.0",
|
|
50
59
|
"oxlint": "^1.90.0",
|
|
51
|
-
"
|
|
60
|
+
"react": "^19.3.1",
|
|
61
|
+
"react-dom": "^19.3.1",
|
|
52
62
|
"typescript": "^7.0.2",
|
|
53
63
|
"vitest": "^5.0.5"
|
|
54
64
|
},
|
|
55
65
|
"dependencies": {
|
|
56
66
|
"@inquirer/password": "^5.2.5",
|
|
67
|
+
"@tanstack/react-table": "^8.21.3",
|
|
57
68
|
"parse-duration": "^2.1.11",
|
|
58
69
|
"pretty-bytes": "^7.2.0",
|
|
59
70
|
"sshpk": "^1.18.0"
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import type { ComponentChildren } from "preact";
|
|
2
|
-
interface SegmentedProps<Value extends string> {
|
|
3
|
-
label: string;
|
|
4
|
-
choices: {
|
|
5
|
-
value: Value;
|
|
6
|
-
label: string;
|
|
7
|
-
}[];
|
|
8
|
-
value: Value;
|
|
9
|
-
on_change: (value: Value) => void;
|
|
10
|
-
class_name?: string;
|
|
11
|
-
}
|
|
12
|
-
export declare function Segmented<Value extends string>({ label, choices, value, on_change, class_name }: SegmentedProps<Value>): import("preact").JSX.Element;
|
|
13
|
-
export declare function ButtonGroup({ label, children }: {
|
|
14
|
-
label: string;
|
|
15
|
-
children: ComponentChildren;
|
|
16
|
-
}): import("preact").JSX.Element;
|
|
17
|
-
interface SwitchProps {
|
|
18
|
-
label: string;
|
|
19
|
-
checked: boolean;
|
|
20
|
-
on_change: (on: boolean) => Promise<boolean | void> | boolean | void;
|
|
21
|
-
disabled?: boolean;
|
|
22
|
-
}
|
|
23
|
-
export declare function Switch({ label, checked, on_change, disabled }: SwitchProps): import("preact").JSX.Element;
|
|
24
|
-
interface SearchBoxProps {
|
|
25
|
-
label: string;
|
|
26
|
-
value: string;
|
|
27
|
-
on_input: (value: string) => void;
|
|
28
|
-
}
|
|
29
|
-
export declare function SearchBox({ label, value, on_input }: SearchBoxProps): import("preact").JSX.Element;
|
|
30
|
-
interface SelectProps<Value extends string> {
|
|
31
|
-
label: string;
|
|
32
|
-
options: [value: Value, text: string][];
|
|
33
|
-
value: Value;
|
|
34
|
-
on_change: (value: Value) => void;
|
|
35
|
-
}
|
|
36
|
-
export declare function Select<Value extends string>({ label, options, value, on_change }: SelectProps<Value>): import("preact").JSX.Element;
|
|
37
|
-
export {};
|