@joewinke/jatui 0.21.0 → 0.23.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.
Files changed (47) hide show
  1. package/CLAUDE.md +47 -1
  2. package/dist/actions/reveal.d.ts +37 -0
  3. package/dist/actions/reveal.js +42 -0
  4. package/dist/components/AudioRecorder.svelte +749 -0
  5. package/dist/components/AudioRecorder.svelte.d.ts +36 -0
  6. package/dist/components/IconSelector.svelte +417 -0
  7. package/dist/components/IconSelector.svelte.d.ts +55 -0
  8. package/dist/components/InfiniteGrid.svelte +305 -0
  9. package/dist/components/InfiniteGrid.svelte.d.ts +71 -0
  10. package/dist/components/InfiniteSearchGrid.svelte +131 -0
  11. package/dist/components/InfiniteSearchGrid.svelte.d.ts +67 -0
  12. package/dist/components/KeyboardShortcutsOverlay.svelte +40 -3
  13. package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +31 -16
  14. package/dist/components/MediaCaptureIcon.svelte +23 -0
  15. package/dist/components/MediaCaptureIcon.svelte.d.ts +1 -1
  16. package/dist/components/ThumbnailGrid.svelte +137 -0
  17. package/dist/components/ThumbnailGrid.svelte.d.ts +42 -0
  18. package/dist/components/batch/BatchJobTable.svelte +549 -0
  19. package/dist/components/batch/BatchJobTable.svelte.d.ts +114 -0
  20. package/dist/components/batch/QuotaMeter.svelte +78 -0
  21. package/dist/components/batch/QuotaMeter.svelte.d.ts +16 -0
  22. package/dist/components/batch/batchQueue.d.ts +56 -0
  23. package/dist/components/batch/batchQueue.js +81 -0
  24. package/dist/components/batch/elapsedEstimate.d.ts +24 -0
  25. package/dist/components/batch/elapsedEstimate.js +25 -0
  26. package/dist/components/batch/pollOperation.d.ts +45 -0
  27. package/dist/components/batch/pollOperation.js +80 -0
  28. package/dist/components/batch/useElapsedEstimate.d.ts +10 -0
  29. package/dist/components/batch/useElapsedEstimate.js +38 -0
  30. package/dist/components/infiniteGridLoader.d.ts +70 -0
  31. package/dist/components/infiniteGridLoader.js +141 -0
  32. package/dist/components/table/columnResize.d.ts +38 -0
  33. package/dist/components/table/columnResize.js +117 -0
  34. package/dist/components/table/index.d.ts +2 -0
  35. package/dist/components/table/index.js +1 -0
  36. package/dist/components/variant/VariantMatrixPicker.svelte +181 -0
  37. package/dist/components/variant/VariantMatrixPicker.svelte.d.ts +37 -0
  38. package/dist/components/variant/variantCombinations.d.ts +75 -0
  39. package/dist/components/variant/variantCombinations.js +100 -0
  40. package/dist/history.d.ts +44 -0
  41. package/dist/history.js +87 -0
  42. package/dist/index.d.ts +30 -0
  43. package/dist/index.js +27 -0
  44. package/dist/styles/motion.css +100 -0
  45. package/dist/utils/keyboardShortcutRegistry.d.ts +152 -0
  46. package/dist/utils/keyboardShortcutRegistry.js +365 -0
  47. package/package.json +5 -1
@@ -0,0 +1,38 @@
1
+ export interface ColumnResizeOptions {
2
+ /** When true, the resize handle is not added and nothing happens. Default: false */
3
+ disabled?: boolean;
4
+ /** Minimum column width in pixels. Default: 40 */
5
+ minWidth?: number;
6
+ /** Called on mousedown — use this to show the guide line (e.g. set resizeGuideX state) */
7
+ onResizeStart?: (initialX: number) => void;
8
+ /** Called on every mousemove with the new width */
9
+ onResize: (width: number) => void;
10
+ /** Called on mouseup with the final width — use this to save settings and hide the guide */
11
+ onResizeEnd?: (width: number) => void;
12
+ /**
13
+ * Return the container element that holds the `.resize-guide` element.
14
+ * If provided, the guide line position is updated on each mousemove via direct DOM manipulation,
15
+ * avoiding reactive overhead.
16
+ */
17
+ getGuideContainer?: () => HTMLElement | null;
18
+ }
19
+ /**
20
+ * Svelte action that attaches a column-resize handle to a `<th>` (or any element).
21
+ *
22
+ * Usage:
23
+ * ```svelte
24
+ * <th use:columnResize={{
25
+ * minWidth: col.minWidth,
26
+ * onResize: (w) => { columnWidths[col.id] = w; },
27
+ * onResizeEnd: (w) => { saveColumnSettings(); },
28
+ * getGuideContainer: () => tableContainerEl,
29
+ * }}>
30
+ * ```
31
+ *
32
+ * The action appends a 4px-wide resize handle div at the right edge of the host element.
33
+ * Dragging it fires `onResize` continuously and `onResizeEnd` on mouse-up.
34
+ */
35
+ export declare function columnResize(node: HTMLElement, options: ColumnResizeOptions): {
36
+ update(opts: ColumnResizeOptions): void;
37
+ destroy(): void;
38
+ };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Svelte action that attaches a column-resize handle to a `<th>` (or any element).
3
+ *
4
+ * Usage:
5
+ * ```svelte
6
+ * <th use:columnResize={{
7
+ * minWidth: col.minWidth,
8
+ * onResize: (w) => { columnWidths[col.id] = w; },
9
+ * onResizeEnd: (w) => { saveColumnSettings(); },
10
+ * getGuideContainer: () => tableContainerEl,
11
+ * }}>
12
+ * ```
13
+ *
14
+ * The action appends a 4px-wide resize handle div at the right edge of the host element.
15
+ * Dragging it fires `onResize` continuously and `onResizeEnd` on mouse-up.
16
+ */
17
+ export function columnResize(node, options) {
18
+ let opts = options;
19
+ // If disabled, return a no-op handle
20
+ if (opts.disabled) {
21
+ return {
22
+ update(newOptions) { opts = newOptions; },
23
+ destroy() { },
24
+ };
25
+ }
26
+ // Build the resize handle element
27
+ const handle = document.createElement('div');
28
+ handle.className = 'col-resize-handle';
29
+ handle.style.cssText = [
30
+ 'position: absolute',
31
+ 'right: 0',
32
+ 'top: 0',
33
+ 'bottom: 0',
34
+ 'width: 5px',
35
+ 'cursor: col-resize',
36
+ 'z-index: 2',
37
+ ].join(';');
38
+ // Make the host element position-relative if it isn't already
39
+ const hostPosition = getComputedStyle(node).position;
40
+ if (hostPosition === 'static') {
41
+ node.style.position = 'relative';
42
+ }
43
+ node.appendChild(handle);
44
+ // Resize tracking state
45
+ let startX = 0;
46
+ let startWidth = 0;
47
+ let currentWidth = 0;
48
+ let resizing = false;
49
+ function updateGuide(clientX) {
50
+ const container = opts.getGuideContainer?.();
51
+ if (!container)
52
+ return;
53
+ const guide = container.querySelector('.resize-guide');
54
+ if (!guide)
55
+ return;
56
+ const rect = container.getBoundingClientRect();
57
+ guide.style.left = `${clientX - rect.left + container.scrollLeft}px`;
58
+ }
59
+ function onMouseMove(e) {
60
+ const minWidth = opts.minWidth ?? 40;
61
+ const diff = e.clientX - startX;
62
+ const newWidth = Math.max(minWidth, startWidth + diff);
63
+ currentWidth = newWidth;
64
+ opts.onResize(newWidth);
65
+ updateGuide(e.clientX);
66
+ }
67
+ function onMouseUp() {
68
+ document.body.style.cursor = '';
69
+ document.body.style.userSelect = '';
70
+ // Call onResizeEnd — the parent is responsible for clearing the guide line
71
+ // (typically by setting resizeGuideX = null in its onResizeEnd handler)
72
+ opts.onResizeEnd?.(currentWidth);
73
+ document.removeEventListener('mousemove', onMouseMove);
74
+ document.removeEventListener('mouseup', onMouseUp);
75
+ // Suppress the click event that fires after mouseup on the host <th>,
76
+ // which would otherwise trigger a column sort.
77
+ resizing = true;
78
+ requestAnimationFrame(() => { resizing = false; });
79
+ }
80
+ function onMouseDown(e) {
81
+ e.preventDefault();
82
+ e.stopPropagation();
83
+ startX = e.clientX;
84
+ startWidth = node.offsetWidth;
85
+ currentWidth = startWidth;
86
+ document.body.style.cursor = 'col-resize';
87
+ document.body.style.userSelect = 'none';
88
+ opts.onResizeStart?.(e.clientX);
89
+ updateGuide(e.clientX);
90
+ document.addEventListener('mousemove', onMouseMove);
91
+ document.addEventListener('mouseup', onMouseUp);
92
+ }
93
+ // Suppress click events on the host element that fire right after a resize ends.
94
+ // Without this, mouseup after dragging the resize handle triggers the host's onclick (e.g. sort).
95
+ function onHostClick(e) {
96
+ if (resizing) {
97
+ e.stopImmediatePropagation();
98
+ e.preventDefault();
99
+ }
100
+ }
101
+ handle.addEventListener('mousedown', onMouseDown);
102
+ node.addEventListener('click', onHostClick, true);
103
+ return {
104
+ update(newOptions) {
105
+ opts = newOptions;
106
+ },
107
+ destroy() {
108
+ handle.removeEventListener('mousedown', onMouseDown);
109
+ node.removeEventListener('click', onHostClick, true);
110
+ document.removeEventListener('mousemove', onMouseMove);
111
+ document.removeEventListener('mouseup', onMouseUp);
112
+ if (handle.parentNode === node) {
113
+ node.removeChild(handle);
114
+ }
115
+ },
116
+ };
117
+ }
@@ -1,3 +1,5 @@
1
1
  export { default as DataTable } from './DataTable.svelte';
2
2
  export { flexRender } from './flexRender';
3
+ export { columnResize } from './columnResize';
4
+ export type { ColumnResizeOptions } from './columnResize';
3
5
  export type { DataTableProps, SelectionEvent, ColumnDef, Row } from './types';
@@ -1,3 +1,4 @@
1
1
  // Subpath entry: @joewinke/jatui/table — requires peer @tanstack/table-core.
2
2
  export { default as DataTable } from './DataTable.svelte';
3
3
  export { flexRender } from './flexRender';
4
+ export { columnResize } from './columnResize';
@@ -0,0 +1,181 @@
1
+ <script lang="ts">
2
+ /**
3
+ * VariantMatrixPicker Component
4
+ *
5
+ * Category/option chip grid for building a combinatorial variant matrix
6
+ * (jatui-m4uz3.8, ported from vid-gen's FrameVariantGenerator selection UI,
7
+ * lines 72–197 / 500–620, with the hardcoded presets stripped).
8
+ *
9
+ * Every category renders as a tab with its options as toggleable chips.
10
+ * Each category carries an `additive`/`multiplicative` badge:
11
+ * - multiplicative: each selection multiplies the total combination count
12
+ * (cartesian product across multiplicative categories);
13
+ * - additive: selections stack flat onto every combination.
14
+ * A live combination count renders in the footer via `countCombinations`.
15
+ *
16
+ * The algebra lives in `./variantCombinations.ts` (pure, unit-tested);
17
+ * the host passes `categories` and reads/binds `selected`.
18
+ */
19
+ import {
20
+ countCombinations,
21
+ variantCategoryMode,
22
+ type VariantCategory,
23
+ type VariantSelection,
24
+ type VariantSelectionMode
25
+ } from './variantCombinations';
26
+
27
+ interface Props {
28
+ /** Category/option matrix — caller-supplied, no presets inside the component. */
29
+ categories: VariantCategory[];
30
+ /** categoryId → selected option ids. Bind to drive the matrix externally. */
31
+ selected: VariantSelection;
32
+ /** Force every category to one mode (`null` respects each category's own). */
33
+ forceMode?: VariantSelectionMode | null;
34
+ /** Show the live combination count footer (default true). */
35
+ showCount?: boolean;
36
+ /** Optional label for the count footer (default "combinations"). */
37
+ countLabel?: string;
38
+ /** Fired after every selection change with the new selection. */
39
+ onChange?: (selected: VariantSelection) => void;
40
+ /** Additional CSS class for the container. */
41
+ class?: string;
42
+ }
43
+
44
+ let {
45
+ categories,
46
+ selected = $bindable({}),
47
+ forceMode = null,
48
+ showCount = true,
49
+ countLabel = 'combinations',
50
+ onChange,
51
+ class: className = ''
52
+ }: Props = $props();
53
+
54
+ let activeTab = $state('');
55
+
56
+ // Follow the category list if it changes identity.
57
+ $effect(() => {
58
+ if (!categories.some((c) => c.id === activeTab)) {
59
+ activeTab = categories[0]?.id ?? '';
60
+ }
61
+ });
62
+
63
+ const combinationCount = $derived(countCombinations(categories, selected, forceMode));
64
+ const selectedCount = (categoryId: string) => selected[categoryId]?.length ?? 0;
65
+ const activeCategory = $derived(categories.find((c) => c.id === activeTab));
66
+
67
+ function toggleOption(categoryId: string, optionId: string) {
68
+ const current = selected[categoryId] ?? [];
69
+ const next = current.includes(optionId)
70
+ ? current.filter((id) => id !== optionId)
71
+ : [...current, optionId];
72
+ selected = { ...selected, [categoryId]: next };
73
+ onChange?.(selected);
74
+ }
75
+
76
+ function selectAll(categoryId: string) {
77
+ const category = categories.find((c) => c.id === categoryId);
78
+ if (!category) return;
79
+ selected = { ...selected, [categoryId]: category.options.map((o) => o.id) };
80
+ onChange?.(selected);
81
+ }
82
+
83
+ function clearCategory(categoryId: string) {
84
+ selected = { ...selected, [categoryId]: [] };
85
+ onChange?.(selected);
86
+ }
87
+
88
+ function clearAll() {
89
+ const next: VariantSelection = {};
90
+ for (const c of categories) next[c.id] = [];
91
+ selected = next;
92
+ onChange?.(next);
93
+ }
94
+ </script>
95
+
96
+ <div class="{className}">
97
+ <!-- Category tabs: icon, label, live selection count -->
98
+ <div role="tablist" aria-label="Variant categories" class="flex flex-wrap gap-1 mb-3">
99
+ {#each categories as category (category.id)}
100
+ {@const count = selectedCount(category.id)}
101
+ <button
102
+ role="tab"
103
+ aria-selected={activeTab === category.id}
104
+ class="btn btn-sm gap-2 {activeTab === category.id ? 'btn-primary' : 'btn-ghost'}"
105
+ onclick={() => (activeTab = category.id)}
106
+ >
107
+ {#if category.icon}<span class="text-base">{category.icon}</span>{/if}
108
+ <span>{category.label}</span>
109
+ {#if count > 0}<span class="badge badge-sm">{count}</span>{/if}
110
+ </button>
111
+ {/each}
112
+ </div>
113
+
114
+ {#if activeCategory}
115
+ {@const count = selectedCount(activeCategory.id)}
116
+ {@const isAdditive = variantCategoryMode(activeCategory, forceMode) === 'additive'}
117
+ <div class="card bg-base-200 shadow-sm">
118
+ <div class="card-body p-4">
119
+ <!-- Category header: mode badge + bulk actions -->
120
+ <div class="flex items-center justify-between gap-2 mb-3">
121
+ <div class="flex items-center gap-2">
122
+ {#if activeCategory.icon}<span class="text-xl">{activeCategory.icon}</span>{/if}
123
+ <h3 class="font-bold">{activeCategory.label}</h3>
124
+ <span
125
+ class="badge badge-sm {isAdditive ? 'badge-success' : 'badge-info'}"
126
+ title={isAdditive
127
+ ? 'Additive — selections stack onto every combination'
128
+ : 'Multiplicative — each selection multiplies the combination count'}
129
+ >
130
+ {isAdditive ? '➕ Additive' : '✖️ Multiplicative'}
131
+ </span>
132
+ {#if count > 0}<span class="badge badge-sm badge-primary">{count}</span>{/if}
133
+ </div>
134
+ <div class="flex items-center gap-1">
135
+ <button
136
+ class="btn btn-xs"
137
+ onclick={() => selectAll(activeCategory.id)}
138
+ disabled={activeCategory.options.length === 0}
139
+ >
140
+ Select all
141
+ </button>
142
+ {#if count > 0}
143
+ <button class="btn btn-xs btn-ghost" onclick={() => clearCategory(activeCategory.id)}>
144
+ Clear
145
+ </button>
146
+ {/if}
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Option chip grid -->
151
+ <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
152
+ {#each activeCategory.options as option (option.id)}
153
+ {@const isSelected = selected[activeCategory.id]?.includes(option.id)}
154
+ <button
155
+ class="btn btn-sm normal-case justify-start gap-1 {isSelected
156
+ ? 'btn-info'
157
+ : 'btn-outline'}"
158
+ aria-pressed={isSelected}
159
+ onclick={() => toggleOption(activeCategory.id, option.id)}
160
+ >
161
+ {isSelected ? '✓' : '➕'}
162
+ <span class="truncate">{option.label}</span>
163
+ </button>
164
+ {/each}
165
+ </div>
166
+ </div>
167
+ </div>
168
+ {/if}
169
+
170
+ <!-- Footer: live combination count -->
171
+ {#if showCount}
172
+ <div class="flex items-center justify-between mt-3 text-sm">
173
+ <span class="text-base-content/60">
174
+ <strong class="text-base-content">{combinationCount}</strong>
175
+ {countLabel}
176
+ {#if combinationCount === 0}<span class="text-base-content/40"> — select an option to start</span>{/if}
177
+ </span>
178
+ <button class="btn btn-xs btn-ghost" onclick={clearAll}>Clear all</button>
179
+ </div>
180
+ {/if}
181
+ </div>
@@ -0,0 +1,37 @@
1
+ /**
2
+ * VariantMatrixPicker Component
3
+ *
4
+ * Category/option chip grid for building a combinatorial variant matrix
5
+ * (jatui-m4uz3.8, ported from vid-gen's FrameVariantGenerator selection UI,
6
+ * lines 72–197 / 500–620, with the hardcoded presets stripped).
7
+ *
8
+ * Every category renders as a tab with its options as toggleable chips.
9
+ * Each category carries an `additive`/`multiplicative` badge:
10
+ * - multiplicative: each selection multiplies the total combination count
11
+ * (cartesian product across multiplicative categories);
12
+ * - additive: selections stack flat onto every combination.
13
+ * A live combination count renders in the footer via `countCombinations`.
14
+ *
15
+ * The algebra lives in `./variantCombinations.ts` (pure, unit-tested);
16
+ * the host passes `categories` and reads/binds `selected`.
17
+ */
18
+ import { type VariantCategory, type VariantSelection, type VariantSelectionMode } from './variantCombinations';
19
+ interface Props {
20
+ /** Category/option matrix — caller-supplied, no presets inside the component. */
21
+ categories: VariantCategory[];
22
+ /** categoryId → selected option ids. Bind to drive the matrix externally. */
23
+ selected: VariantSelection;
24
+ /** Force every category to one mode (`null` respects each category's own). */
25
+ forceMode?: VariantSelectionMode | null;
26
+ /** Show the live combination count footer (default true). */
27
+ showCount?: boolean;
28
+ /** Optional label for the count footer (default "combinations"). */
29
+ countLabel?: string;
30
+ /** Fired after every selection change with the new selection. */
31
+ onChange?: (selected: VariantSelection) => void;
32
+ /** Additional CSS class for the container. */
33
+ class?: string;
34
+ }
35
+ declare const VariantMatrixPicker: import("svelte").Component<Props, {}, "selected">;
36
+ type VariantMatrixPicker = ReturnType<typeof VariantMatrixPicker>;
37
+ export default VariantMatrixPicker;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Pure combination algebra behind `VariantMatrixPicker` (jatui-m4uz3.8).
3
+ *
4
+ * Ported from vid-gen's `variantPresets.js` (generateCombinations + friends),
5
+ * with the hardcoded apparel/background/lighting preset map stripped out:
6
+ * every category and option is caller-supplied, so the algebra works for any
7
+ * combinatorial domain (image variants, A/B matrices, test permutations…).
8
+ *
9
+ * Two category modes drive the math:
10
+ * - `multiplicative`: each selected option pairs with every selection in the
11
+ * other multiplicative categories → cartesian product, one new variant
12
+ * per pick.
13
+ * - `additive`: all selected options stack flat into every combination —
14
+ * they ride along instead of multiplying.
15
+ *
16
+ * No DOM, no runes — kept separate so the math is unit-testable under plain
17
+ * node:test (mirrors the `batch/` module split from jatui-m4uz3.7).
18
+ */
19
+ export type VariantSelectionMode = 'multiplicative' | 'additive';
20
+ export interface VariantOption {
21
+ id: string;
22
+ label: string;
23
+ /** Optional host payload — vid-gen used image-edit prompts; joined with ', ' into `VariantCombination.prompt`. */
24
+ prompt?: string;
25
+ }
26
+ export interface VariantCategory {
27
+ id: string;
28
+ label: string;
29
+ /** Emoji or short glyph shown on the category tab. */
30
+ icon?: string;
31
+ /** Defaults to `multiplicative`. */
32
+ mode?: VariantSelectionMode;
33
+ options: VariantOption[];
34
+ }
35
+ /** categoryId → selected option ids (mirror of vid-gen's selectedModifiers). */
36
+ export type VariantSelection = Record<string, string[]>;
37
+ export interface VariantCombination {
38
+ /** Flat list of every option id in the combination (multiplicative picks + stacked additive picks). */
39
+ modifierIds: string[];
40
+ /** All `prompt`s joined with ', ' — empty string when no option carries a prompt. */
41
+ prompt: string;
42
+ /** Resolved option objects, in category order. */
43
+ options: VariantOption[];
44
+ }
45
+ export interface GenerateCombinationsOptions {
46
+ /** Override every category's mode. `null` (default) respects each category's own mode. */
47
+ forceMode?: VariantSelectionMode | null;
48
+ }
49
+ /** Resolve a category's effective mode: forceMode wins, then the category's own, then multiplicative. */
50
+ export declare function variantCategoryMode(category: VariantCategory, forceMode?: VariantSelectionMode | null): VariantSelectionMode;
51
+ /** Look up an option by category + id across a caller-supplied category list. */
52
+ export declare function findVariantOption(categories: VariantCategory[], categoryId: string, optionId: string): VariantOption | undefined;
53
+ /**
54
+ * Number of combinations `generateCombinations` would produce — the live count
55
+ * the picker footer renders. Multiplicative categories multiply; a selection
56
+ * with only additive categories counts as exactly 1 stacked combination.
57
+ */
58
+ export declare function countCombinations(categories: VariantCategory[], selected: VariantSelection, forceMode?: VariantSelectionMode | null): number;
59
+ /**
60
+ * Generate all combinations from the current selection.
61
+ *
62
+ * Mirrors vid-gen's `generateCombinations(selectedModifiers, options)`:
63
+ * - categories with nothing selected are skipped entirely (all-empty → []);
64
+ * - multiplicative categories contribute one option per combination via
65
+ * cartesian product; additive selections stack flat onto every combination;
66
+ * - with no multiplicative selections at all, a single additive-only
67
+ * combination is produced.
68
+ */
69
+ export declare function generateCombinations(categories: VariantCategory[], selected: VariantSelection, options?: GenerateCombinationsOptions): VariantCombination[];
70
+ /**
71
+ * Build a variant filename from an original filename and modifier ids
72
+ * (vid-gen's `generateVariantFilename`, generic — no preset knowledge):
73
+ * `photo.png` + ['a', 'b'] → `photo_a_b.png`, ids sorted for consistency.
74
+ */
75
+ export declare function generateVariantFilename(originalFilename: string, modifierIds: string[]): string;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Pure combination algebra behind `VariantMatrixPicker` (jatui-m4uz3.8).
3
+ *
4
+ * Ported from vid-gen's `variantPresets.js` (generateCombinations + friends),
5
+ * with the hardcoded apparel/background/lighting preset map stripped out:
6
+ * every category and option is caller-supplied, so the algebra works for any
7
+ * combinatorial domain (image variants, A/B matrices, test permutations…).
8
+ *
9
+ * Two category modes drive the math:
10
+ * - `multiplicative`: each selected option pairs with every selection in the
11
+ * other multiplicative categories → cartesian product, one new variant
12
+ * per pick.
13
+ * - `additive`: all selected options stack flat into every combination —
14
+ * they ride along instead of multiplying.
15
+ *
16
+ * No DOM, no runes — kept separate so the math is unit-testable under plain
17
+ * node:test (mirrors the `batch/` module split from jatui-m4uz3.7).
18
+ */
19
+ /** Resolve a category's effective mode: forceMode wins, then the category's own, then multiplicative. */
20
+ export function variantCategoryMode(category, forceMode = null) {
21
+ return forceMode ?? category.mode ?? 'multiplicative';
22
+ }
23
+ /** Look up an option by category + id across a caller-supplied category list. */
24
+ export function findVariantOption(categories, categoryId, optionId) {
25
+ return categories.find((c) => c.id === categoryId)?.options.find((o) => o.id === optionId);
26
+ }
27
+ /**
28
+ * Number of combinations `generateCombinations` would produce — the live count
29
+ * the picker footer renders. Multiplicative categories multiply; a selection
30
+ * with only additive categories counts as exactly 1 stacked combination.
31
+ */
32
+ export function countCombinations(categories, selected, forceMode = null) {
33
+ const active = categories.filter((c) => (selected[c.id]?.length ?? 0) > 0);
34
+ if (active.length === 0)
35
+ return 0;
36
+ let count = 1;
37
+ let multiplicativeSeen = false;
38
+ for (const category of active) {
39
+ const n = selected[category.id].length;
40
+ if (variantCategoryMode(category, forceMode) === 'multiplicative') {
41
+ multiplicativeSeen = true;
42
+ count *= n;
43
+ }
44
+ }
45
+ // Only-additive selections produce a single stacked combination, not zero.
46
+ return multiplicativeSeen ? count : 1;
47
+ }
48
+ /** Cartesian product of arrays (vid-gen's helper, verbatim semantics). */
49
+ function cartesian(...arrays) {
50
+ return arrays.reduce((acc, array) => acc.flatMap((x) => array.map((y) => [...x, y])), [[]]);
51
+ }
52
+ /**
53
+ * Generate all combinations from the current selection.
54
+ *
55
+ * Mirrors vid-gen's `generateCombinations(selectedModifiers, options)`:
56
+ * - categories with nothing selected are skipped entirely (all-empty → []);
57
+ * - multiplicative categories contribute one option per combination via
58
+ * cartesian product; additive selections stack flat onto every combination;
59
+ * - with no multiplicative selections at all, a single additive-only
60
+ * combination is produced.
61
+ */
62
+ export function generateCombinations(categories, selected, options = {}) {
63
+ const { forceMode = null } = options;
64
+ const active = categories.filter((c) => (selected[c.id]?.length ?? 0) > 0);
65
+ if (active.length === 0)
66
+ return [];
67
+ const multiplicativeArrays = active
68
+ .filter((c) => variantCategoryMode(c, forceMode) === 'multiplicative')
69
+ .map((c) => selected[c.id].map((optionId) => ({ categoryId: c.id, optionId })));
70
+ const additivePicks = active
71
+ .filter((c) => variantCategoryMode(c, forceMode) === 'additive')
72
+ .flatMap((c) => selected[c.id].map((optionId) => ({ categoryId: c.id, optionId })));
73
+ const multiplicativeCombos = cartesian(...multiplicativeArrays);
74
+ const combos = multiplicativeArrays.length
75
+ ? multiplicativeCombos.map((combo) => [...combo, ...additivePicks])
76
+ : [additivePicks];
77
+ return combos.map((combo) => {
78
+ const resolved = combo
79
+ .map(({ categoryId, optionId }) => findVariantOption(categories, categoryId, optionId))
80
+ .filter((o) => o !== undefined);
81
+ return {
82
+ modifierIds: combo.map(({ optionId }) => optionId),
83
+ prompt: resolved
84
+ .map((o) => o.prompt)
85
+ .filter((p) => p !== undefined)
86
+ .join(', '),
87
+ options: resolved
88
+ };
89
+ });
90
+ }
91
+ /**
92
+ * Build a variant filename from an original filename and modifier ids
93
+ * (vid-gen's `generateVariantFilename`, generic — no preset knowledge):
94
+ * `photo.png` + ['a', 'b'] → `photo_a_b.png`, ids sorted for consistency.
95
+ */
96
+ export function generateVariantFilename(originalFilename, modifierIds) {
97
+ const stem = originalFilename.replace(/\.[^/.]+$/, '');
98
+ const suffix = [...modifierIds].sort().join('_');
99
+ return suffix ? `${stem}_${suffix}.png` : `${stem}.png`;
100
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Snapshot undo/redo history. Forked from @wayflow/core's history.ts (see
3
+ * README.md) and generalized to any graph type `G` — this module only
4
+ * deep-clones snapshots, it never inspects node/edge shape.
5
+ *
6
+ * Adds two things upstream keeps outside `core` (in its DOM editor package):
7
+ * push-on-first-move (`beginChange`/`endChange`) so a drag/resize gesture
8
+ * pushes exactly one pre-change snapshot no matter how many move events it
9
+ * fires, and `untracked()` to run a compound multi-step mutation (e.g.
10
+ * paste = add nodes + add edges) without each step pushing its own entry.
11
+ *
12
+ * CANONICAL HOME: this is the shared copy (jatui-00jfh, rebuilding the lost
13
+ * jat-gjw72 upstream half). The JAT IDE (ide/src/lib/utils/history.ts) and
14
+ * JST (src/lib/workflows/core/history.ts) carry vendored copies that
15
+ * repoint their imports at 'jatui' and are deleted at the swap.
16
+ */
17
+ export declare const MAX_UNDO_DEPTH = 50;
18
+ export interface History<G> {
19
+ undoStack: G[];
20
+ redoStack: G[];
21
+ /** True while a push-on-first-move gesture is in progress (see beginChange). */
22
+ changing: boolean;
23
+ /** Suppression depth for untracked(). Pushes no-op while > 0. */
24
+ suppressed: number;
25
+ }
26
+ export declare function createHistory<G>(): History<G>;
27
+ /** Deep-clones `graph` onto the undo stack (capped at maxDepth) and clears redo. No-ops while suppressed. */
28
+ export declare function pushState<G>(history: History<G>, graph: G, maxDepth?: number): void;
29
+ /**
30
+ * Push-on-first-move: call on every move event of a drag/resize gesture.
31
+ * Pushes the pre-gesture snapshot only the first time it's called since the
32
+ * last endChange() — a click that never drags won't add a history entry.
33
+ */
34
+ export declare function beginChange<G>(history: History<G>, graph: G, maxDepth?: number): void;
35
+ /** Ends the current push-on-first-move gesture so the next beginChange() can push again. */
36
+ export declare function endChange<G>(history: History<G>): void;
37
+ /** Runs `fn` with history pushes suppressed — for compound ops that should land as zero or one entries (pair with a manual pushState before calling). */
38
+ export declare function untracked<G>(history: History<G>, fn: () => void): void;
39
+ /** Pops the most recent undo entry, pushing `currentGraph` onto redo. Returns null if nothing to undo. */
40
+ export declare function undo<G>(history: History<G>, currentGraph: G): G | null;
41
+ /** Pops the most recent redo entry, pushing `currentGraph` onto undo. Returns null if nothing to redo. */
42
+ export declare function redo<G>(history: History<G>, currentGraph: G): G | null;
43
+ /** Clears both stacks and any in-progress gesture/suppression state. */
44
+ export declare function clearHistory<G>(history: History<G>): void;