cursedbelt 3.0.2 → 4.0.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/dist/react/components/ListToolbar.d.ts +94 -0
- package/dist/react/components/ListToolbar.d.ts.map +1 -0
- package/dist/react/components/ListToolbar.js +66 -0
- package/dist/react/components/ListToolbar.js.map +1 -0
- package/dist/react/file-tree/FileTree.d.ts +282 -3
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +799 -131
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/fileTree.css +151 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
- package/dist/react/file-tree/fileTreeSelection.js +264 -0
- package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
- package/dist/react/file-tree/index.d.ts +1 -0
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +8 -0
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/form.d.ts +3 -0
- package/dist/react/lib/form.d.ts.map +1 -1
- package/dist/react/lib/form.js +43 -5
- package/dist/react/lib/form.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +19 -3
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +55 -15
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +14 -14
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +23 -0
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +67 -1
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/MediaResize.d.ts +51 -19
- package/dist/react/media/MediaResize.d.ts.map +1 -1
- package/dist/react/media/MediaResize.js +20 -13
- package/dist/react/media/MediaResize.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +67 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
- package/dist/react/media/VideoChapterEditor.js +148 -0
- package/dist/react/media/VideoChapterEditor.js.map +1 -0
- package/dist/react/media/VideoPlayer.d.ts +46 -4
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +124 -11
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts +15 -2
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js +29 -2
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media/denseGallery.css +104 -46
- package/dist/react/media/hlsSource.d.ts +57 -0
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +177 -1
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +2 -0
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +6 -0
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/media/mediaResize.css +127 -54
- package/dist/react/media/pictureEditor.css +52 -6
- package/dist/react/media/videoChapterEditor.css +203 -0
- package/dist/react/media/videoChapters.d.ts +117 -0
- package/dist/react/media/videoChapters.d.ts.map +1 -0
- package/dist/react/media/videoChapters.js +95 -0
- package/dist/react/media/videoChapters.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +40 -9
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +484 -104
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
- package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
- package/dist/react/media-gallery/index.d.ts +2 -0
- package/dist/react/media-gallery/index.d.ts.map +1 -1
- package/dist/react/media-gallery/index.js +5 -0
- package/dist/react/media-gallery/index.js.map +1 -1
- package/dist/react/media-gallery/mediaGallery.css +180 -4
- package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
- package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
- package/dist/react/media-gallery/mediaMeta.js +56 -0
- package/dist/react/media-gallery/mediaMeta.js.map +1 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.js +42 -0
- package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +34 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +29 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
- package/dist/styles-areas/activity-bar.css +30 -0
- package/dist/styles-areas/analytics.css +672 -0
- package/dist/styles-areas/auth.css +154 -0
- package/dist/styles-areas/calendar.css +416 -0
- package/dist/styles-areas/chart-table.css +41 -0
- package/dist/styles-areas/charts.css +89 -0
- package/dist/styles-areas/chat.css +364 -0
- package/dist/styles-areas/clipboard.css +26 -0
- package/dist/styles-areas/code-editor.css +130 -0
- package/dist/styles-areas/companion-link.css +70 -0
- package/dist/styles-areas/core.css +2503 -0
- package/dist/styles-areas/dashboard-grid.css +84 -0
- package/dist/styles-areas/data-table.css +657 -0
- package/dist/styles-areas/deep-link.css +26 -0
- package/dist/styles-areas/diff-viewer.css +30 -0
- package/dist/styles-areas/disk-usage.css +262 -0
- package/dist/styles-areas/emoji.css +154 -0
- package/dist/styles-areas/fields.css +582 -0
- package/dist/styles-areas/file-tree.css +33 -0
- package/dist/styles-areas/filter-rail.css +235 -0
- package/dist/styles-areas/folder-tree.css +219 -0
- package/dist/styles-areas/keep-awake.css +26 -0
- package/dist/styles-areas/layout-engine.css +239 -0
- package/dist/styles-areas/markdown.css +87 -0
- package/dist/styles-areas/master-detail.css +239 -0
- package/dist/styles-areas/master-lock.css +26 -0
- package/dist/styles-areas/media-gallery.css +351 -0
- package/dist/styles-areas/media.css +658 -0
- package/dist/styles-areas/nav.css +488 -0
- package/dist/styles-areas/notifications.css +245 -0
- package/dist/styles-areas/overlays.css +133 -0
- package/dist/styles-areas/palette.css +140 -0
- package/dist/styles-areas/pdf-viewer.css +47 -0
- package/dist/styles-areas/problem-detail.css +97 -0
- package/dist/styles-areas/rich-text.css +167 -0
- package/dist/styles-areas/sharing.css +153 -0
- package/dist/styles-areas/spreadsheet.css +37 -0
- package/dist/styles-areas/stats.css +281 -0
- package/dist/styles-areas/test-report.css +133 -0
- package/dist/styles-areas/virtual.css +30 -0
- package/dist/styles-areas/wizard.css +381 -0
- package/dist/styles-areas/workbench.css +543 -0
- package/dist/styles-areas/workbook-viewer.css +44 -0
- package/dist/styles-static.css +34 -23
- package/dist/styles.css +34 -23
- package/package.json +111 -31
- package/scripts/checkDistExports.ts +67 -0
- package/scripts/cssRules.ts +125 -0
- package/scripts/fixtureAppCss.ts +344 -0
- package/scripts/generateAreaStyles.ts +253 -0
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +67 -3
- package/scripts/styleAreas.ts +585 -0
- package/scripts/verify.ts +5 -0
- package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
- package/src/docsMatchTheSplit.spec.ts +98 -0
- package/src/fixtureAppCss.spec.ts +177 -0
- package/src/namedSubpathsResolve.spec.ts +35 -0
- package/src/publishShape.spec.ts +67 -0
- package/src/react/components/ListToolbar.spec.tsx +172 -0
- package/src/react/components/ListToolbar.tsx +151 -0
- package/src/react/file-tree/FileTree.spec.tsx +1009 -0
- package/src/react/file-tree/FileTree.tsx +1459 -309
- package/src/react/file-tree/fileTree.css +151 -0
- package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
- package/src/react/file-tree/fileTreeSelection.ts +321 -0
- package/src/react/file-tree/index.ts +21 -0
- package/src/react/index.ts +1 -0
- package/src/react/lib/form.ts +51 -8
- package/src/react/media/DenseGalleryShell.tsx +108 -36
- package/src/react/media/FocusViewer.tsx +15 -8
- package/src/react/media/FrameGrabber.spec.tsx +175 -0
- package/src/react/media/FrameGrabber.tsx +67 -0
- package/src/react/media/MediaResize.spec.tsx +84 -14
- package/src/react/media/MediaResize.tsx +51 -19
- package/src/react/media/VideoChapterEditor.tsx +428 -0
- package/src/react/media/VideoPlayer.spec.tsx +111 -1
- package/src/react/media/VideoPlayer.tsx +158 -11
- package/src/react/media/VideoTurner.spec.tsx +67 -0
- package/src/react/media/VideoTurner.tsx +70 -15
- package/src/react/media/denseGallery.css +104 -46
- package/src/react/media/hlsSource.recovery.spec.ts +213 -0
- package/src/react/media/hlsSource.spec.ts +8 -0
- package/src/react/media/hlsSource.ts +189 -1
- package/src/react/media/index.ts +14 -0
- package/src/react/media/mediaResize.css +127 -54
- package/src/react/media/pictureEditor.css +52 -6
- package/src/react/media/videoChapterEditor.css +203 -0
- package/src/react/media/videoChapters.spec.ts +176 -0
- package/src/react/media/videoChapters.ts +181 -0
- package/src/react/media-gallery/GalleryTable.tsx +93 -3
- package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
- package/src/react/media-gallery/MediaGallery.tsx +813 -95
- package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
- package/src/react/media-gallery/galleryTable.spec.ts +41 -1
- package/src/react/media-gallery/index.ts +7 -0
- package/src/react/media-gallery/mediaGallery.css +180 -4
- package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
- package/src/react/media-gallery/mediaMeta.ts +101 -0
- package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
- package/src/react/media-gallery/playbackPreferences.ts +100 -0
- package/src/react/media-gallery/types.ts +34 -0
- package/src/shippedFilesAreTracked.spec.ts +69 -0
- package/src/styles-areas/activity-bar.css +30 -0
- package/src/styles-areas/analytics.css +672 -0
- package/src/styles-areas/auth.css +154 -0
- package/src/styles-areas/calendar.css +416 -0
- package/src/styles-areas/chart-table.css +41 -0
- package/src/styles-areas/charts.css +89 -0
- package/src/styles-areas/chat.css +364 -0
- package/src/styles-areas/clipboard.css +26 -0
- package/src/styles-areas/code-editor.css +130 -0
- package/src/styles-areas/companion-link.css +70 -0
- package/src/styles-areas/core.css +2503 -0
- package/src/styles-areas/dashboard-grid.css +84 -0
- package/src/styles-areas/data-table.css +657 -0
- package/src/styles-areas/deep-link.css +26 -0
- package/src/styles-areas/diff-viewer.css +30 -0
- package/src/styles-areas/disk-usage.css +262 -0
- package/src/styles-areas/emoji.css +154 -0
- package/src/styles-areas/fields.css +582 -0
- package/src/styles-areas/file-tree.css +33 -0
- package/src/styles-areas/filter-rail.css +235 -0
- package/src/styles-areas/folder-tree.css +219 -0
- package/src/styles-areas/keep-awake.css +26 -0
- package/src/styles-areas/layout-engine.css +239 -0
- package/src/styles-areas/markdown.css +87 -0
- package/src/styles-areas/master-detail.css +239 -0
- package/src/styles-areas/master-lock.css +26 -0
- package/src/styles-areas/media-gallery.css +351 -0
- package/src/styles-areas/media.css +658 -0
- package/src/styles-areas/nav.css +488 -0
- package/src/styles-areas/notifications.css +245 -0
- package/src/styles-areas/overlays.css +133 -0
- package/src/styles-areas/palette.css +140 -0
- package/src/styles-areas/pdf-viewer.css +47 -0
- package/src/styles-areas/problem-detail.css +97 -0
- package/src/styles-areas/rich-text.css +167 -0
- package/src/styles-areas/sharing.css +153 -0
- package/src/styles-areas/spreadsheet.css +37 -0
- package/src/styles-areas/stats.css +281 -0
- package/src/styles-areas/test-report.css +133 -0
- package/src/styles-areas/virtual.css +30 -0
- package/src/styles-areas/wizard.css +381 -0
- package/src/styles-areas/workbench.css +543 -0
- package/src/styles-areas/workbook-viewer.css +44 -0
- package/src/styles-static.css +34 -23
- package/src/styles.css +34 -23
- package/src/stylesAreas.spec.ts +247 -0
- package/src/stylesUtilitiesMatches.spec.ts +6 -77
- package/src/testFilesRunInParallel.spec.ts +79 -0
- package/src/typecheckCachesAreSeparate.spec.ts +128 -0
- package/src/verifyGraph.spec.ts +6 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic of a MULTI-selection: which rows a range covers, what a ⌘-click leaves
|
|
3
|
+
* behind, which of N selected rows are the ones actually being carried, and whether a drop
|
|
4
|
+
* is legal for all of them at once — and, since 2026-09-16, where a folder made AROUND a
|
|
5
|
+
* selection goes and what it is called ({@link planWrap}).
|
|
6
|
+
*
|
|
7
|
+
* No DOM, so all of it is unit-tested in `fileTreeSelection.spec.ts` — the same split
|
|
8
|
+
* `resolveDrop`/`resolveKeyboardMove` already make against `FileTree.tsx`, and for the same
|
|
9
|
+
* reason: happy-dom computes no layout, so the pointer half of the gesture cannot be
|
|
10
|
+
* asserted anywhere and the RULES have to be reachable without it.
|
|
11
|
+
*
|
|
12
|
+
* ── Where this came from ────────────────────────────────────────────────────────────────
|
|
13
|
+
* Owner, 2026-09-14, `ideas/file-tree.md` item 2: *"If I click on a file in the sidebar file
|
|
14
|
+
* tree with the folders then hold shift and click another item in the folder, I want it to
|
|
15
|
+
* select all the files from the first file to the one I clicked next while holding shift.
|
|
16
|
+
* Then with all of those selected I want to be able to click and hold on any of them to drag
|
|
17
|
+
* and drop them to another folder or to the root outside of any folder. Another key shortcut
|
|
18
|
+
* functionality I need is selecting multiple files by holding the command key on my mac and
|
|
19
|
+
* clicking each file I want to select then dragging them to another folder. For both of these
|
|
20
|
+
* new selection functionalities I also want to be able to hold command and click delete to
|
|
21
|
+
* delete all the selected files. I also want to be able to cut and paste to move them with
|
|
22
|
+
* command + x and command + v, and copy them with command + c and command + v."*
|
|
23
|
+
*
|
|
24
|
+
* ── 🔴 Why this is here and not in `cursedbelt-core/file-tree` ───────────────────────────
|
|
25
|
+
* `fileTreeModel.ts` — `resolveDrop`, `flattenFileTree`, the node types — lives in the
|
|
26
|
+
* `cursedbelt-core` PACKAGE, which this one consumes by published version. Nothing in this
|
|
27
|
+
* repo can change that file: a `file:` or `link:` dependency is refused outright by
|
|
28
|
+
* `src/noPathDeps.spec.ts`, so the model's other half would have to be published there first
|
|
29
|
+
* and picked up here by a version bump — two packages, two publishes, for one feature.
|
|
30
|
+
*
|
|
31
|
+
* So the SPLIT the model half exists for is honoured exactly (no DOM, unit-tested, the drop
|
|
32
|
+
* rules reachable without a renderer) and the package boundary is honoured too. Everything
|
|
33
|
+
* here is built out of `fileTreeModel`'s own primitives rather than re-deriving them, so
|
|
34
|
+
* there is one definition of "legal drop" and this composes it N times.
|
|
35
|
+
*/
|
|
36
|
+
import {
|
|
37
|
+
type DropOffer,
|
|
38
|
+
descendantIdsOf,
|
|
39
|
+
type FileTreeNode,
|
|
40
|
+
type FileTreeRow,
|
|
41
|
+
parentIdOf,
|
|
42
|
+
type ResolveDropOptions,
|
|
43
|
+
resolveDrop,
|
|
44
|
+
walkFileTree,
|
|
45
|
+
} from 'cursedbelt-core/file-tree/file-tree-model';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every row from the anchor to the one just clicked, inclusive — shift-click's answer.
|
|
49
|
+
*
|
|
50
|
+
* 🔴 Over the rows ON SCREEN, never over the whole tree. A shut folder between the two ends
|
|
51
|
+
* contributes its own row and none of its children, which is what Finder and VS Code both
|
|
52
|
+
* do: a range is what you can see yourself covering. Selecting rows nobody drew and then
|
|
53
|
+
* deleting them on ⌘⌫ is the version of this that loses files.
|
|
54
|
+
*
|
|
55
|
+
* `anchorId` unknown (a row the tree has since re-derived away, or nothing clicked yet)
|
|
56
|
+
* degenerates to just the clicked row rather than to the whole list — the honest reading of
|
|
57
|
+
* "from where?" when there is no where.
|
|
58
|
+
*/
|
|
59
|
+
export function selectionRange(
|
|
60
|
+
rows: readonly FileTreeRow[],
|
|
61
|
+
anchorId: string | null,
|
|
62
|
+
focusId: string,
|
|
63
|
+
): string[] {
|
|
64
|
+
const focus = rows.findIndex((row) => row.node.id === focusId);
|
|
65
|
+
if (focus < 0) return [];
|
|
66
|
+
const anchor = anchorId === null ? -1 : rows.findIndex((row) => row.node.id === anchorId);
|
|
67
|
+
if (anchor < 0) return [focusId];
|
|
68
|
+
const from = Math.min(anchor, focus);
|
|
69
|
+
const to = Math.max(anchor, focus);
|
|
70
|
+
return rows.slice(from, to + 1).map((row) => row.node.id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* ⌘-click: in if it was out, out if it was in — and the ORDER of the rest is kept.
|
|
75
|
+
*
|
|
76
|
+
* The order is what the drag chip counts and what a consumer's own "first selected" reads,
|
|
77
|
+
* so re-sorting on every toggle would shuffle a selection under the person building it.
|
|
78
|
+
*/
|
|
79
|
+
export function toggleSelected(ids: readonly string[], id: string): string[] {
|
|
80
|
+
return ids.includes(id) ? ids.filter((other) => other !== id) : [...ids, id];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The selected rows with any row that is INSIDE another selected row dropped.
|
|
85
|
+
*
|
|
86
|
+
* 🔴 The trap this exists for. Shift-click a folder and the file inside it, drag both into
|
|
87
|
+
* `Archive`, and the naive answer moves the folder into `Archive` **and** the file into
|
|
88
|
+
* `Archive` — so a file that was two levels deep comes out one level deep, next to its own
|
|
89
|
+
* former parent. The person asked for one move and got their tree quietly flattened.
|
|
90
|
+
*
|
|
91
|
+
* Moving a folder moves what is in it by construction, so a descendant in the same gesture
|
|
92
|
+
* is not a second thing to move: it is the same thing, named twice. Finder resolves it the
|
|
93
|
+
* same way and says nothing about it, which is the right amount to say.
|
|
94
|
+
*
|
|
95
|
+
* Order is preserved, so this is safe to run over a selection on its way to a callback.
|
|
96
|
+
*/
|
|
97
|
+
export function topmostSelected(
|
|
98
|
+
nodes: readonly FileTreeNode[],
|
|
99
|
+
ids: readonly string[],
|
|
100
|
+
): string[] {
|
|
101
|
+
const chosen = new Set(ids);
|
|
102
|
+
return ids.filter((id) => {
|
|
103
|
+
// `parentIdOf` answers null both at the top level and for a row this tree no longer
|
|
104
|
+
// holds, so the walk always terminates — see its own header for why the two are one
|
|
105
|
+
// answer.
|
|
106
|
+
let at = parentIdOf(nodes, id);
|
|
107
|
+
while (at !== null) {
|
|
108
|
+
if (chosen.has(at)) return false;
|
|
109
|
+
at = parentIdOf(nodes, at);
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ResolveMultiDropOptions extends Omit<ResolveDropOptions, 'dragId'> {
|
|
116
|
+
/**
|
|
117
|
+
* Everything being carried. Empty means an EXTERNAL payload — the same thing
|
|
118
|
+
* `resolveDrop` means by `dragId: null`.
|
|
119
|
+
*/
|
|
120
|
+
dragIds: readonly string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Where a drag carrying N rows would land — {@link resolveDrop} asked once per row, and the
|
|
125
|
+
* strictest answer wins.
|
|
126
|
+
*
|
|
127
|
+
* 🔴 **One refusal refuses the whole drop.** A gesture that landed four of five rows and
|
|
128
|
+
* silently kept the fifth where it was is the worst possible outcome of a multi-drag: the
|
|
129
|
+
* tree looks like it obeyed, and the row that did not move is the one nobody checks. The
|
|
130
|
+
* rule the single-row drag is built around — *a drop that cannot happen is never offered* —
|
|
131
|
+
* only survives the plural if "can happen" means "for all of them".
|
|
132
|
+
*
|
|
133
|
+
* The first refusal's WORDS are what comes back, so the chip says which rule was broken
|
|
134
|
+
* rather than "some of these cannot go there".
|
|
135
|
+
*
|
|
136
|
+
* Every per-row rule is therefore honoured N times over, out of one definition: the
|
|
137
|
+
* descendant rule (a folder cannot go inside itself, and now also cannot go inside a folder
|
|
138
|
+
* being dragged beside it), `acceptsDrop`, and the consumer's `canDrop` — which is called
|
|
139
|
+
* once per dragged id, so a veto that depends on WHICH row is moving still gets asked about
|
|
140
|
+
* each one.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveMultiDrop({ dragIds, ...rest }: ResolveMultiDropOptions): DropOffer {
|
|
143
|
+
if (dragIds.length === 0) return resolveDrop({ ...rest, dragId: null });
|
|
144
|
+
let agreed: DropOffer | null = null;
|
|
145
|
+
for (const dragId of dragIds) {
|
|
146
|
+
const offer = resolveDrop({ ...rest, dragId });
|
|
147
|
+
if (offer.refused) return offer;
|
|
148
|
+
// The target does not vary with the dragged id — only the refusals do, because the row
|
|
149
|
+
// under the pointer and the ratio are the same question for all of them. So the first
|
|
150
|
+
// non-refusing answer IS the answer; the rest of the loop is only asking permission.
|
|
151
|
+
agreed ??= offer;
|
|
152
|
+
}
|
|
153
|
+
return agreed ?? { target: null, refused: false, reason: null };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether a set of rows may be copied, in the consumer's words — `null` when it may.
|
|
158
|
+
*
|
|
159
|
+
* 🔴 Copy is the one clipboard verb that is NOT universal, and `apps/music` is why. A folder
|
|
160
|
+
* there is derived from the `folder` column of the tracks in it, so "copy this folder" would
|
|
161
|
+
* mean duplicating audio the owner has exactly one copy of — a gesture with no meaning, and
|
|
162
|
+
* an expensive one to offer by accident. So the tree never assumes it can paste; the app
|
|
163
|
+
* says, per row, the same shape `canDrop` already uses.
|
|
164
|
+
*
|
|
165
|
+
* The first row that refuses is the one that speaks, for the same reason as above.
|
|
166
|
+
*/
|
|
167
|
+
export function refuseCopy(
|
|
168
|
+
nodes: readonly FileTreeNode[],
|
|
169
|
+
ids: readonly string[],
|
|
170
|
+
canCopy: ((node: FileTreeNode) => string | null) | undefined,
|
|
171
|
+
): string | null {
|
|
172
|
+
return firstRefusal(nodes, ids, canCopy);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Whether a set of rows may be WRAPPED in a new folder — `refuseCopy`'s twin, for the same
|
|
177
|
+
* reason and in the same shape.
|
|
178
|
+
*
|
|
179
|
+
* 🔴 A tree whose folders are DERIVED cannot wrap anything. `apps/music` builds its folders
|
|
180
|
+
* out of the `folder` column of the tracks in them, so "put these three into a new folder"
|
|
181
|
+
* is not a thing that host can do at all — there is no folder to make. The rule this tree is
|
|
182
|
+
* built around is *a gesture that cannot happen is never offered*, so the veto is asked
|
|
183
|
+
* BEFORE the draft row opens, never after a name has been typed into it: a refusal at commit
|
|
184
|
+
* is a refusal aimed at somebody who has already done the work.
|
|
185
|
+
*
|
|
186
|
+
* The first row that refuses is the one that speaks, as everywhere else here.
|
|
187
|
+
*/
|
|
188
|
+
export function refuseWrap(
|
|
189
|
+
nodes: readonly FileTreeNode[],
|
|
190
|
+
ids: readonly string[],
|
|
191
|
+
canWrap: ((node: FileTreeNode) => string | null) | undefined,
|
|
192
|
+
): string | null {
|
|
193
|
+
return firstRefusal(nodes, ids, canWrap);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The shared body of the two vetoes above — one definition of "the first refusal wins". */
|
|
197
|
+
function firstRefusal(
|
|
198
|
+
nodes: readonly FileTreeNode[],
|
|
199
|
+
ids: readonly string[],
|
|
200
|
+
veto: ((node: FileTreeNode) => string | null) | undefined,
|
|
201
|
+
): string | null {
|
|
202
|
+
if (!veto) return null;
|
|
203
|
+
for (const id of ids) {
|
|
204
|
+
const node = nodeById(nodes, id);
|
|
205
|
+
const reason = node ? veto(node) : null;
|
|
206
|
+
if (reason) return reason;
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A file's name with its extension taken off — the folder name a wrap opens with.
|
|
213
|
+
*
|
|
214
|
+
* `photo.jpg` → `photo`, `Notes` → `Notes`, and 🔴 `.gitignore` → `.gitignore` rather than
|
|
215
|
+
* the empty string: a name that is ALL extension has no basename to offer, and pre-filling a
|
|
216
|
+
* field with nothing is worse than pre-filling it with the name it came from. Same
|
|
217
|
+
* `lastIndexOf('.')` rule `MediaGallery`'s `extLabel` uses, so one tree and one gallery
|
|
218
|
+
* looking at the same file agree about where its name ends.
|
|
219
|
+
*/
|
|
220
|
+
export function wrapFolderName(name: string): string {
|
|
221
|
+
const dot = name.lastIndexOf('.');
|
|
222
|
+
return dot > 0 ? name.slice(0, dot) : name;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** What a wrap would do, worked out before anything is drawn or created. */
|
|
226
|
+
export interface WrapPlan {
|
|
227
|
+
/**
|
|
228
|
+
* What goes into the new folder: the chosen rows, in TREE order, with any row inside
|
|
229
|
+
* another chosen row dropped (see {@link topmostSelected}) and any row this tree no longer
|
|
230
|
+
* holds forgotten.
|
|
231
|
+
*/
|
|
232
|
+
ids: string[];
|
|
233
|
+
/** Where the folder is made — the parent of the first of them. `null` is the top level. */
|
|
234
|
+
parentId: string | null;
|
|
235
|
+
/** The name the field opens with: the first row's, minus its extension. */
|
|
236
|
+
name: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* "New Folder with Selection", worked out — the whole of the gesture that is not DOM.
|
|
241
|
+
*
|
|
242
|
+
* Owner, 2026-09-13: *"allow make dir from file where dir wraps file location and gets name
|
|
243
|
+
* of file"*. Three requirements, and this function is where each of them is decided:
|
|
244
|
+
*
|
|
245
|
+
* 1. **Where the file already is.** The folder is made in the first row's OWN parent, not at
|
|
246
|
+
* the root and not inside the selection.
|
|
247
|
+
* 2. **Named after the file**, extension dropped — {@link wrapFolderName}.
|
|
248
|
+
* 3. **The rows move into it**, which is what *wraps* means. That is `ids`, and it is handed
|
|
249
|
+
* to the consumer in the SAME call that creates the folder — see
|
|
250
|
+
* `FileTreeProps.onDraftCommit`.
|
|
251
|
+
*
|
|
252
|
+
* 🔴 Tree order, not click order. A selection is built by clicking, so `['b', 'a']` is an
|
|
253
|
+
* ordinary thing for a consumer to hold — and Finder names the new folder after the FIRST
|
|
254
|
+
* item, meaning the first one in the list, not the first one you happened to click.
|
|
255
|
+
*
|
|
256
|
+
* A set spread across several parents is named and placed by its first row and the rest come
|
|
257
|
+
* to it, which is the only answer to "where?" that is not arbitrary. `null` when nothing in
|
|
258
|
+
* `ids` is in the tree any more: a wrap with nothing left to wrap is a plain new folder.
|
|
259
|
+
*/
|
|
260
|
+
export function planWrap(
|
|
261
|
+
nodes: readonly FileTreeNode[],
|
|
262
|
+
ids: readonly string[],
|
|
263
|
+
): WrapPlan | null {
|
|
264
|
+
const chosen = new Set(topmostSelected(nodes, ids));
|
|
265
|
+
const ordered = walkFileTree(nodes)
|
|
266
|
+
.filter((node) => chosen.has(node.id))
|
|
267
|
+
.map((node) => node.id);
|
|
268
|
+
const first = ordered[0];
|
|
269
|
+
if (first === undefined) return null;
|
|
270
|
+
return {
|
|
271
|
+
ids: ordered,
|
|
272
|
+
parentId: parentIdOf(nodes, first),
|
|
273
|
+
name: wrapFolderName(nodeById(nodes, first)?.name ?? ''),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The nodes behind a list of ids, in the order the ids were given, skipping any the tree no
|
|
279
|
+
* longer holds.
|
|
280
|
+
*
|
|
281
|
+
* A selection outlives a refresh — the server re-derives the tree between a ⌘-click and a
|
|
282
|
+
* ⌘⌫ and one of the rows is gone — and a callback that handed back `undefined` for it would
|
|
283
|
+
* push that case into every consumer.
|
|
284
|
+
*/
|
|
285
|
+
export function nodesByIds(
|
|
286
|
+
nodes: readonly FileTreeNode[],
|
|
287
|
+
ids: readonly string[],
|
|
288
|
+
): FileTreeNode[] {
|
|
289
|
+
const out: FileTreeNode[] = [];
|
|
290
|
+
for (const id of ids) {
|
|
291
|
+
const node = nodeById(nodes, id);
|
|
292
|
+
if (node) out.push(node);
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** `findFileTreeNode` without the full depth-first materialisation it does per call. */
|
|
298
|
+
function nodeById(nodes: readonly FileTreeNode[], id: string): FileTreeNode | null {
|
|
299
|
+
for (const node of nodes) {
|
|
300
|
+
if (node.id === id) return node;
|
|
301
|
+
const found = node.children ? nodeById(node.children, id) : null;
|
|
302
|
+
if (found) return found;
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Everything a multi-drag has lifted off the page — the carried rows and what is under them.
|
|
309
|
+
*
|
|
310
|
+
* The single-row drag draws `data-dragged` on `descendantIdsOf(drag.id)` so a folder in the
|
|
311
|
+
* air takes its contents with it visually. With N rows that is the union, computed once per
|
|
312
|
+
* pointer move rather than per row per move.
|
|
313
|
+
*/
|
|
314
|
+
export function draggedIdsOf(
|
|
315
|
+
nodes: readonly FileTreeNode[],
|
|
316
|
+
ids: readonly string[],
|
|
317
|
+
): Set<string> {
|
|
318
|
+
const out = new Set<string>();
|
|
319
|
+
for (const id of ids) for (const under of descendantIdsOf(nodes, id)) out.add(under);
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
@@ -31,3 +31,24 @@ export {
|
|
|
31
31
|
walkFileTree,
|
|
32
32
|
} from 'cursedbelt-core/file-tree/file-tree-model';
|
|
33
33
|
export { FileTree, type FileTreeProps } from './FileTree';
|
|
34
|
+
/*
|
|
35
|
+
* The MULTI-selection half of the model — which rows a range covers, and whether a drop is
|
|
36
|
+
* legal for N dragged ids. Same split, same reason: no DOM, so it is unit-tested where the
|
|
37
|
+
* pointer gesture cannot be. It is here rather than in `cursedbelt-core` because this package
|
|
38
|
+
* consumes that one by published version and nothing in this repo may reach across — see the
|
|
39
|
+
* header of `fileTreeSelection.ts`.
|
|
40
|
+
*/
|
|
41
|
+
export {
|
|
42
|
+
draggedIdsOf,
|
|
43
|
+
nodesByIds,
|
|
44
|
+
planWrap,
|
|
45
|
+
refuseCopy,
|
|
46
|
+
refuseWrap,
|
|
47
|
+
type ResolveMultiDropOptions,
|
|
48
|
+
resolveMultiDrop,
|
|
49
|
+
selectionRange,
|
|
50
|
+
toggleSelected,
|
|
51
|
+
topmostSelected,
|
|
52
|
+
type WrapPlan,
|
|
53
|
+
wrapFolderName,
|
|
54
|
+
} from './fileTreeSelection';
|
package/src/react/index.ts
CHANGED
|
@@ -384,6 +384,7 @@ export {
|
|
|
384
384
|
type JsonEditorProps,
|
|
385
385
|
} from './components/JsonEditor';
|
|
386
386
|
export { Lightbox, type LightboxProps } from './components/Lightbox';
|
|
387
|
+
export { ListToolbar, type ListToolbarProps } from './components/ListToolbar';
|
|
387
388
|
export { Cluster, Divider, type Gap, Sidebar, Split, Stack } from './components/layout';
|
|
388
389
|
export { Modal, type ModalProps, type ModalSize } from './components/Modal';
|
|
389
390
|
export {
|
package/src/react/lib/form.ts
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { DefaultValues, FieldValues, UseFormProps } from 'react-hook-form';
|
|
1
|
+
import type { DefaultValues, FieldValues, Resolver, UseFormProps } from 'react-hook-form';
|
|
3
2
|
import type { z } from 'zod';
|
|
4
3
|
|
|
4
|
+
/**
|
|
5
|
+
* 🔴 `@hookform/resolvers` is an OPTIONAL peer, and this dynamic import is what makes it
|
|
6
|
+
* one (task 149). `cursedbelt/react` is the barrel all eight apps import; a static
|
|
7
|
+
* `import { zodResolver } from '@hookform/resolvers/zod'` at the top of this file put the
|
|
8
|
+
* package — and its own dependency tree — into every one of those installs, to serve the
|
|
9
|
+
* one function below. `src/barrelsReachNoOptionalPeer.spec.ts` is the check that keeps it
|
|
10
|
+
* out, and a `await import(...)` inside the function that needs it is the shape that spec
|
|
11
|
+
* blesses.
|
|
12
|
+
*
|
|
13
|
+
* Resolved once per process and memoized: RHF calls the resolver on every validation
|
|
14
|
+
* pass, and `zodResolver(schema)` is cheap but the module load is not.
|
|
15
|
+
*/
|
|
16
|
+
let zodResolverModule: Promise<typeof import('@hookform/resolvers/zod')> | undefined;
|
|
17
|
+
|
|
18
|
+
const loadZodResolver = async (): Promise<typeof import('@hookform/resolvers/zod')> => {
|
|
19
|
+
zodResolverModule ??= import('@hookform/resolvers/zod');
|
|
20
|
+
try {
|
|
21
|
+
return await zodResolverModule;
|
|
22
|
+
} catch (cause) {
|
|
23
|
+
// 🔴 Loud and actionable, not an unhandled rejection three frames into RHF. A missing
|
|
24
|
+
// optional peer surfaces here as "the form never validates", which is the worst
|
|
25
|
+
// possible thing to debug without the package name in the message.
|
|
26
|
+
zodResolverModule = undefined;
|
|
27
|
+
throw new Error(
|
|
28
|
+
"cursedbelt: makeFormConfig() needs the optional peer '@hookform/resolvers' — " +
|
|
29
|
+
'run `bun add @hookform/resolvers` in the app that renders this form.',
|
|
30
|
+
{ cause },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
5
35
|
/**
|
|
6
36
|
* The canonical react-hook-form config for every cursedbelt form (Wizard,
|
|
7
37
|
* RecordPanel, modal forms). One zod schema drives validation; the validation mode
|
|
@@ -22,18 +52,31 @@ import type { z } from 'zod';
|
|
|
22
52
|
* type Values = z.infer<typeof schema>;
|
|
23
53
|
* const form = useForm<Values>(makeFormConfig(schema, { name: '', tags: [] }));
|
|
24
54
|
* ```
|
|
55
|
+
*
|
|
56
|
+
* 🔴 Needs the optional peer `@hookform/resolvers` installed in the app — RHF resolvers
|
|
57
|
+
* are already async, so the load happens on the first validation rather than at import.
|
|
25
58
|
*/
|
|
26
59
|
export function makeFormConfig<TFieldValues extends FieldValues>(
|
|
27
60
|
schema: z.ZodType,
|
|
28
61
|
defaults: TFieldValues,
|
|
29
62
|
): UseFormProps<TFieldValues> {
|
|
63
|
+
// Built once per form, then reused across every validation pass.
|
|
64
|
+
let resolved: Resolver<TFieldValues> | undefined;
|
|
65
|
+
const resolver: Resolver<TFieldValues> = async (values, context, options) => {
|
|
66
|
+
if (!resolved) {
|
|
67
|
+
const { zodResolver } = await loadZodResolver();
|
|
68
|
+
// zodResolver's generics don't line up with the caller's value type (the schema
|
|
69
|
+
// input/output may differ from TFieldValues); the runtime resolver is correct, so
|
|
70
|
+
// cast through the overload's param/return types rather than over-genericize.
|
|
71
|
+
resolved = zodResolver(
|
|
72
|
+
schema as Parameters<typeof zodResolver>[0],
|
|
73
|
+
) as unknown as Resolver<TFieldValues>;
|
|
74
|
+
}
|
|
75
|
+
return resolved(values, context, options);
|
|
76
|
+
};
|
|
77
|
+
|
|
30
78
|
return {
|
|
31
|
-
|
|
32
|
-
// input/output may differ from TFieldValues); the runtime resolver is correct, so
|
|
33
|
-
// cast through the overload's param/return types rather than over-genericize.
|
|
34
|
-
resolver: zodResolver(
|
|
35
|
-
schema as Parameters<typeof zodResolver>[0],
|
|
36
|
-
) as UseFormProps<TFieldValues>['resolver'],
|
|
79
|
+
resolver,
|
|
37
80
|
defaultValues: defaults as DefaultValues<TFieldValues>,
|
|
38
81
|
mode: 'onBlur',
|
|
39
82
|
reValidateMode: 'onChange',
|