@lotics/ui 14.3.1 → 15.1.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/AGENTS.md +12 -28
- package/MIGRATION.md +66 -0
- package/docs/ai_patterns.md +38 -16
- package/docs/catalog.md +71 -14
- package/docs/data_entry.md +53 -2
- package/docs/templates.md +15 -2
- package/examples/tpl_item_list.tsx +146 -48
- package/examples/tpl_record.tsx +37 -12
- package/package.json +13 -1
- package/src/agent_progress.tsx +11 -7
- package/src/agent_run.tsx +114 -43
- package/src/agent_transform.test.ts +96 -0
- package/src/agent_transform.ts +17 -5
- package/src/approval_prompt.tsx +70 -0
- package/src/dom_region.ts +14 -0
- package/src/dom_region.web.ts +13 -0
- package/src/file_drop_target.tsx +16 -0
- package/src/file_drop_target.web.tsx +118 -0
- package/src/file_dropzone.tsx +51 -96
- package/src/file_intake.test.ts +124 -0
- package/src/file_intake.ts +148 -0
- package/src/locale.tsx +21 -8
- package/src/message_actions.tsx +111 -0
- package/src/use_paste_files.ts +14 -0
- package/src/use_paste_files.web.ts +101 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { filesFromTransfer, type FileDropTargetProps } from "./file_intake";
|
|
5
|
+
import { resolveRegionNode } from "./dom_region";
|
|
6
|
+
import { usePasteFiles } from "./use_paste_files";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Makes ANY region accept a file drag-drop — the region-wrapper half of file
|
|
10
|
+
* intake (`FileDropzone` stays the dedicated empty-state well; `usePasteFiles`
|
|
11
|
+
* is the clipboard half). Wrap a record's Files section, a card, a whole panel:
|
|
12
|
+
* a file dragged anywhere over it lands, so nobody has to hunt for a dropzone.
|
|
13
|
+
* Pass `paste` to ALSO take Ctrl/Cmd+V scoped to this region's focus.
|
|
14
|
+
*
|
|
15
|
+
* While a drag hovers, the region wears the kit's accent affordance — a blue
|
|
16
|
+
* ring + wash, the same tokens `FileDropzone` lights up with, painted as a
|
|
17
|
+
* box-shadow so nothing reflows. Pass a FUNCTION child to draw the drag state
|
|
18
|
+
* yourself instead (the target then paints nothing).
|
|
19
|
+
*/
|
|
20
|
+
export function FileDropTarget(props: FileDropTargetProps) {
|
|
21
|
+
const { onFiles, accept, multiple = true, paste = false, disabled = false, children, style } = props;
|
|
22
|
+
|
|
23
|
+
const regionRef = useRef<View>(null);
|
|
24
|
+
// dragenter/dragleave fire per descendant element, so a plain boolean flickers
|
|
25
|
+
// as the pointer crosses children — count depth and clear only at zero.
|
|
26
|
+
const dragDepth = useRef(0);
|
|
27
|
+
const [dragging, setDragging] = useState(false);
|
|
28
|
+
|
|
29
|
+
// The clipboard half, scoped to THIS region — the paste routes to whichever
|
|
30
|
+
// enabled target holds focus (see usePasteFiles). Region-scoped so two peer
|
|
31
|
+
// sections each win when focus is in them; a no-op unless `paste` is set.
|
|
32
|
+
usePasteFiles({ onFiles, accept, multiple, enabled: paste && !disabled, region: regionRef });
|
|
33
|
+
|
|
34
|
+
// The drop settings are read through a ref so the listeners subscribe ONCE.
|
|
35
|
+
// Keying the effect on `onFiles` instead would re-bind on every parent render
|
|
36
|
+
// — routine with an inline callback — and tear an in-progress drag's state
|
|
37
|
+
// down mid-hover.
|
|
38
|
+
const latest = useRef({ onFiles, accept, multiple });
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
latest.current = { onFiles, accept, multiple };
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// RN-web exposes the View's underlying HTMLElement through the ref — attach the
|
|
44
|
+
// DOM drag events there (resolved the same way the paste focus check is).
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (disabled) {
|
|
47
|
+
// Disabled mid-drag: drop the affordance with the listeners.
|
|
48
|
+
dragDepth.current = 0;
|
|
49
|
+
setDragging(false);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const node = resolveRegionNode(regionRef);
|
|
53
|
+
if (!node) return;
|
|
54
|
+
|
|
55
|
+
// Scope every drag/drop event to the INNERMOST target — stopPropagation so a
|
|
56
|
+
// FileDropTarget nested in another (e.g. a FileDropzone empty-state inside a
|
|
57
|
+
// Files-section drop target — a FileDropzone IS a FileDropTarget) fires only
|
|
58
|
+
// once: without it one drop bubbles to both handlers and adds the file twice,
|
|
59
|
+
// and both regions light their drag ring.
|
|
60
|
+
const onDragEnter = (e: DragEvent) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
dragDepth.current += 1;
|
|
64
|
+
setDragging(true);
|
|
65
|
+
};
|
|
66
|
+
// preventDefault is what makes the element a legal drop target.
|
|
67
|
+
const onDragOver = (e: DragEvent) => {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
e.stopPropagation();
|
|
70
|
+
};
|
|
71
|
+
const onDragLeave = (e: DragEvent) => {
|
|
72
|
+
e.stopPropagation();
|
|
73
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
74
|
+
if (dragDepth.current === 0) setDragging(false);
|
|
75
|
+
};
|
|
76
|
+
const onDrop = (e: DragEvent) => {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
e.stopPropagation();
|
|
79
|
+
dragDepth.current = 0;
|
|
80
|
+
setDragging(false);
|
|
81
|
+
const settings = latest.current;
|
|
82
|
+
const dropped = filesFromTransfer(e.dataTransfer, settings);
|
|
83
|
+
if (dropped.length === 0) return;
|
|
84
|
+
settings.onFiles(dropped);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
node.addEventListener("dragenter", onDragEnter);
|
|
88
|
+
node.addEventListener("dragover", onDragOver);
|
|
89
|
+
node.addEventListener("dragleave", onDragLeave);
|
|
90
|
+
node.addEventListener("drop", onDrop);
|
|
91
|
+
return () => {
|
|
92
|
+
node.removeEventListener("dragenter", onDragEnter);
|
|
93
|
+
node.removeEventListener("dragover", onDragOver);
|
|
94
|
+
node.removeEventListener("dragleave", onDragLeave);
|
|
95
|
+
node.removeEventListener("drop", onDrop);
|
|
96
|
+
};
|
|
97
|
+
}, [disabled]);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<View
|
|
101
|
+
ref={regionRef}
|
|
102
|
+
style={[style, dragging && typeof children !== "function" ? styles.dragging : null]}
|
|
103
|
+
>
|
|
104
|
+
{typeof children === "function" ? children(dragging) : children}
|
|
105
|
+
</View>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const styles = StyleSheet.create({
|
|
110
|
+
// The drop invitation, restrained: the accent ring reads as the edge of the
|
|
111
|
+
// landing area and the wash tints the region behind its content. Both are
|
|
112
|
+
// box-shadow/background — zero layout impact, so nothing shifts mid-drag.
|
|
113
|
+
dragging: {
|
|
114
|
+
borderRadius: 12,
|
|
115
|
+
backgroundColor: colors.blue[50],
|
|
116
|
+
boxShadow: `0 0 0 1.5px ${colors.blue[500]}, 0 0 0 6px ${colors.blue[50]}`,
|
|
117
|
+
},
|
|
118
|
+
});
|
package/src/file_dropzone.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
2
|
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { Icon } from "./icon";
|
|
5
5
|
import { Text } from "./text";
|
|
6
6
|
import { pickFiles } from "./file_picker";
|
|
7
|
+
import { FileDropTarget } from "./file_drop_target";
|
|
7
8
|
import { FOCUS_RING } from "./control_surface";
|
|
8
9
|
import { useFocusRing } from "./use_focus_ring";
|
|
9
10
|
import { useLoticsLocale } from "./locale";
|
|
@@ -34,25 +35,18 @@ export interface FileDropzoneProps {
|
|
|
34
35
|
style?: StyleProp<ViewStyle>;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
function matchesAccept(file: File, accept: string | undefined): boolean {
|
|
38
|
-
if (!accept) return true;
|
|
39
|
-
const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
|
|
40
|
-
if (patterns.length === 0) return true;
|
|
41
|
-
const mime = file.type.toLowerCase();
|
|
42
|
-
const name = file.name.toLowerCase();
|
|
43
|
-
return patterns.some((p) => {
|
|
44
|
-
if (p.startsWith(".")) return name.endsWith(p);
|
|
45
|
-
if (p.endsWith("/*")) return mime.startsWith(p.slice(0, -1));
|
|
46
|
-
return mime === p;
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
38
|
/**
|
|
51
|
-
* Drag-and-drop upload zone — the capture
|
|
52
|
-
* FileThumbnailGrid/FilePreview, picking = pickFiles, which also
|
|
53
|
-
* zone's click). A dashed well with an icon + invitation; while a
|
|
54
|
-
* hovers it, the zone lights up in the accent and the label flips to
|
|
55
|
-
* `dropLabel`.
|
|
39
|
+
* Drag-and-drop upload zone — the dedicated capture well of the file story
|
|
40
|
+
* (display = FileThumbnailGrid/FilePreview, picking = pickFiles, which also
|
|
41
|
+
* backs this zone's click). A dashed well with an icon + invitation; while a
|
|
42
|
+
* drag hovers it, the zone lights up in the accent and the label flips to
|
|
43
|
+
* `dropLabel`. It is ALSO a paste sink while mounted — Ctrl/Cmd+V drops a
|
|
44
|
+
* copied file or a screenshot straight in, scoped to the zone's own region so
|
|
45
|
+
* it wins while it holds focus (or as the top surface in a dialog). On native
|
|
46
|
+
* (no drag events, no clipboard event) it degrades to press-to-pick.
|
|
47
|
+
*
|
|
48
|
+
* For a region that is not a well — a record's Files section, a card, a panel —
|
|
49
|
+
* use `FileDropTarget`, which this composes.
|
|
56
50
|
*/
|
|
57
51
|
export function FileDropzone(props: FileDropzoneProps) {
|
|
58
52
|
const locale = useLoticsLocale();
|
|
@@ -69,93 +63,54 @@ export function FileDropzone(props: FileDropzoneProps) {
|
|
|
69
63
|
style,
|
|
70
64
|
} = props;
|
|
71
65
|
|
|
72
|
-
const zoneRef = useRef<View>(null);
|
|
73
|
-
const dragDepth = useRef(0);
|
|
74
|
-
const [dragging, setDragging] = useState(false);
|
|
75
66
|
const [hovered, setHovered] = useState(false);
|
|
76
67
|
const { focusVisible, focusProps } = useFocusRing();
|
|
77
68
|
|
|
78
|
-
// RN-web exposes the View's underlying HTMLElement through the ref — attach
|
|
79
|
-
// the DOM drag events there. Native platforms skip this entirely (press-to-
|
|
80
|
-
// pick still works).
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (disabled) return;
|
|
83
|
-
if (typeof HTMLElement === "undefined") return;
|
|
84
|
-
const node = zoneRef.current;
|
|
85
|
-
if (!(node instanceof HTMLElement)) return;
|
|
86
|
-
|
|
87
|
-
const onDragEnter = (e: DragEvent) => {
|
|
88
|
-
e.preventDefault();
|
|
89
|
-
dragDepth.current += 1;
|
|
90
|
-
setDragging(true);
|
|
91
|
-
};
|
|
92
|
-
const onDragOver = (e: DragEvent) => {
|
|
93
|
-
// preventDefault is what makes the element a legal drop target.
|
|
94
|
-
e.preventDefault();
|
|
95
|
-
};
|
|
96
|
-
const onDragLeave = () => {
|
|
97
|
-
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
98
|
-
if (dragDepth.current === 0) setDragging(false);
|
|
99
|
-
};
|
|
100
|
-
const onDrop = (e: DragEvent) => {
|
|
101
|
-
e.preventDefault();
|
|
102
|
-
dragDepth.current = 0;
|
|
103
|
-
setDragging(false);
|
|
104
|
-
const dropped = Array.from(e.dataTransfer?.files ?? []).filter((f) => matchesAccept(f, accept));
|
|
105
|
-
if (dropped.length === 0) return;
|
|
106
|
-
onFiles(multiple ? dropped : dropped.slice(0, 1));
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
node.addEventListener("dragenter", onDragEnter);
|
|
110
|
-
node.addEventListener("dragover", onDragOver);
|
|
111
|
-
node.addEventListener("dragleave", onDragLeave);
|
|
112
|
-
node.addEventListener("drop", onDrop);
|
|
113
|
-
return () => {
|
|
114
|
-
node.removeEventListener("dragenter", onDragEnter);
|
|
115
|
-
node.removeEventListener("dragover", onDragOver);
|
|
116
|
-
node.removeEventListener("dragleave", onDragLeave);
|
|
117
|
-
node.removeEventListener("drop", onDrop);
|
|
118
|
-
};
|
|
119
|
-
}, [accept, disabled, multiple, onFiles]);
|
|
120
|
-
|
|
121
69
|
const handlePick = async () => {
|
|
122
70
|
if (disabled) return;
|
|
123
71
|
const picked = await pickFiles({ accept, multiple });
|
|
124
72
|
if (picked.length > 0) onFiles(picked);
|
|
125
73
|
};
|
|
126
74
|
|
|
75
|
+
// The well draws its OWN drag state (the dashed border turns accent), so the
|
|
76
|
+
// target paints nothing — hence the function child. `paste` scopes the
|
|
77
|
+
// clipboard sink to THIS well's region, so a well inside a dialog takes the
|
|
78
|
+
// paste while it holds focus and peer wells don't fight over it.
|
|
127
79
|
return (
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
80
|
+
<FileDropTarget onFiles={onFiles} accept={accept} multiple={multiple} disabled={disabled} paste>
|
|
81
|
+
{(dragging) => (
|
|
82
|
+
<Pressable
|
|
83
|
+
accessibilityRole="button"
|
|
84
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
onPress={handlePick}
|
|
87
|
+
{...focusProps}
|
|
88
|
+
onHoverIn={() => setHovered(true)}
|
|
89
|
+
onHoverOut={() => setHovered(false)}
|
|
90
|
+
style={[
|
|
91
|
+
styles.zone,
|
|
92
|
+
{ minHeight: height },
|
|
93
|
+
hovered && !dragging ? styles.zoneHovered : null,
|
|
94
|
+
dragging ? styles.zoneDragging : null,
|
|
95
|
+
disabled ? styles.zoneDisabled : null,
|
|
96
|
+
style,
|
|
97
|
+
focusVisible && { boxShadow: FOCUS_RING },
|
|
98
|
+
]}
|
|
99
|
+
>
|
|
100
|
+
<View style={[styles.iconWell, dragging ? styles.iconWellDragging : null]}>
|
|
101
|
+
<Icon name="upload" size={20} color={dragging ? colors.blue[600] : colors.zinc[500]} />
|
|
102
|
+
</View>
|
|
103
|
+
<Text size="sm" weight="medium" style={dragging ? { color: colors.blue[700] } : undefined}>
|
|
104
|
+
{dragging ? dropLabel : label}
|
|
105
|
+
</Text>
|
|
106
|
+
{!dragging && hint ? (
|
|
107
|
+
<Text size="xs" color="muted">
|
|
108
|
+
{hint}
|
|
109
|
+
</Text>
|
|
110
|
+
) : null}
|
|
111
|
+
</Pressable>
|
|
112
|
+
)}
|
|
113
|
+
</FileDropTarget>
|
|
159
114
|
);
|
|
160
115
|
}
|
|
161
116
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { matchesAccept, filesFromTransfer, selectPasteSink, type FileTransferLike, type PasteSinkEntry } from "./file_intake";
|
|
3
|
+
|
|
4
|
+
const file = (name: string, type: string) => new File([new Uint8Array([1])], name, { type });
|
|
5
|
+
|
|
6
|
+
/** A clipboard/drag payload that surfaces its files only through `items` —
|
|
7
|
+
* what a pasted screenshot looks like on the engines that do that. */
|
|
8
|
+
const itemsOnly = (files: File[]): FileTransferLike => ({
|
|
9
|
+
files: [],
|
|
10
|
+
items: files.map((f) => ({ kind: "file" as const, getAsFile: () => f })),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("matchesAccept", () => {
|
|
14
|
+
it("accepts everything when the list is absent or empty", () => {
|
|
15
|
+
expect(matchesAccept(file("a.pdf", "application/pdf"), undefined)).toBe(true);
|
|
16
|
+
expect(matchesAccept(file("a.pdf", "application/pdf"), "")).toBe(true);
|
|
17
|
+
expect(matchesAccept(file("a.pdf", "application/pdf"), " , ")).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("matches an exact MIME, case-insensitively", () => {
|
|
21
|
+
expect(matchesAccept(file("a.pdf", "APPLICATION/PDF"), "application/pdf")).toBe(true);
|
|
22
|
+
expect(matchesAccept(file("a.png", "image/png"), "application/pdf")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("matches a wildcard type", () => {
|
|
26
|
+
expect(matchesAccept(file("a.png", "image/png"), "image/*")).toBe(true);
|
|
27
|
+
expect(matchesAccept(file("a.pdf", "application/pdf"), "image/*")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("matches an extension pattern by filename when the MIME is unknown", () => {
|
|
31
|
+
expect(matchesAccept(file("Report.CSV", ""), ".csv")).toBe(true);
|
|
32
|
+
expect(matchesAccept(file("report.csvx", ""), ".csv")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("accepts a file matching ANY pattern in the list", () => {
|
|
36
|
+
expect(matchesAccept(file("a.png", "image/png"), "application/pdf, image/*")).toBe(true);
|
|
37
|
+
expect(matchesAccept(file("a.zip", "application/zip"), "application/pdf, image/*")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("filesFromTransfer", () => {
|
|
42
|
+
it("returns [] for a missing transfer", () => {
|
|
43
|
+
expect(filesFromTransfer(null)).toEqual([]);
|
|
44
|
+
expect(filesFromTransfer(undefined)).toEqual([]);
|
|
45
|
+
expect(filesFromTransfer({})).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("reads the direct file list", () => {
|
|
49
|
+
const a = file("a.pdf", "application/pdf");
|
|
50
|
+
expect(filesFromTransfer({ files: [a] })).toEqual([a]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("falls back to clipboard items when the file list is empty", () => {
|
|
54
|
+
const shot = file("image.png", "image/png");
|
|
55
|
+
expect(filesFromTransfer(itemsOnly([shot]))).toEqual([shot]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("never doubles a file present in BOTH files and items", () => {
|
|
59
|
+
const a = file("a.png", "image/png");
|
|
60
|
+
expect(filesFromTransfer({ files: [a], items: [{ kind: "file", getAsFile: () => a }] })).toEqual([a]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("skips non-file clipboard items and null getAsFile results", () => {
|
|
64
|
+
const a = file("a.png", "image/png");
|
|
65
|
+
const transfer: FileTransferLike = {
|
|
66
|
+
items: [
|
|
67
|
+
{ kind: "string", getAsFile: () => null },
|
|
68
|
+
{ kind: "file", getAsFile: () => null },
|
|
69
|
+
{ kind: "file", getAsFile: () => a },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
expect(filesFromTransfer(transfer)).toEqual([a]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("filters by accept", () => {
|
|
76
|
+
const pdf = file("a.pdf", "application/pdf");
|
|
77
|
+
const zip = file("a.zip", "application/zip");
|
|
78
|
+
expect(filesFromTransfer({ files: [pdf, zip] }, { accept: "application/pdf" })).toEqual([pdf]);
|
|
79
|
+
expect(filesFromTransfer(itemsOnly([pdf, zip]), { accept: "application/pdf" })).toEqual([pdf]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps every file by default and caps at one when multiple is false", () => {
|
|
83
|
+
const a = file("a.pdf", "application/pdf");
|
|
84
|
+
const b = file("b.pdf", "application/pdf");
|
|
85
|
+
expect(filesFromTransfer({ files: [a, b] })).toEqual([a, b]);
|
|
86
|
+
expect(filesFromTransfer({ files: [a, b] }, { multiple: false })).toEqual([a]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("caps AFTER filtering, so a rejected first file does not eat the slot", () => {
|
|
90
|
+
const zip = file("a.zip", "application/zip");
|
|
91
|
+
const pdf = file("b.pdf", "application/pdf");
|
|
92
|
+
expect(filesFromTransfer({ files: [zip, pdf] }, { accept: "application/pdf", multiple: false })).toEqual([pdf]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("selectPasteSink", () => {
|
|
97
|
+
const entry = (sink: string, focused: boolean): PasteSinkEntry<string> => ({ sink, focused });
|
|
98
|
+
|
|
99
|
+
it("returns undefined for an empty stack", () => {
|
|
100
|
+
expect(selectPasteSink<string>([])).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("falls back to the TOP of the stack when nothing is focused", () => {
|
|
104
|
+
expect(selectPasteSink([entry("a", false), entry("b", false), entry("c", false)])).toBe("c");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("routes to a focused region over the (later-mounted) top of the stack", () => {
|
|
108
|
+
// b holds focus; c is on top but unfocused — b wins.
|
|
109
|
+
expect(selectPasteSink([entry("a", false), entry("b", true), entry("c", false)])).toBe("b");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("picks the TOP-MOST focused region when several are focused", () => {
|
|
113
|
+
// Nested regions can both contain activeElement; the innermost/last wins.
|
|
114
|
+
expect(selectPasteSink([entry("a", true), entry("b", true), entry("c", false)])).toBe("b");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("a single region-less sink still gets the paste (byte-for-byte old behavior)", () => {
|
|
118
|
+
expect(selectPasteSink([entry("only", false)])).toBe("only");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("a focused region beats an unfocused one mounted after it", () => {
|
|
122
|
+
expect(selectPasteSink([entry("focused", true), entry("later", false)])).toBe("focused");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// File INTAKE — the shared core behind every "a file arrived" path in the kit:
|
|
2
|
+
// what counts as an acceptable file, and how to read files off a drag-drop or a
|
|
3
|
+
// clipboard paste. Both payloads are a `DataTransfer`, so ONE extractor serves
|
|
4
|
+
// the `drop` and the `paste` event; `FileDropzone`, `FileDropTarget` and
|
|
5
|
+
// `usePasteFiles` all route through it and can never drift apart.
|
|
6
|
+
//
|
|
7
|
+
// Also the shared CONTRACT module for the two platform-split primitives
|
|
8
|
+
// (`use_paste_files` / `file_drop_target`): their option and prop shapes live
|
|
9
|
+
// here so the `.web` implementation and its native sibling declare them once.
|
|
10
|
+
//
|
|
11
|
+
// No react-native import — the components render this module's result, so the
|
|
12
|
+
// logic stays unit-testable (Vitest cannot parse `react-native`).
|
|
13
|
+
|
|
14
|
+
import type { ReactNode } from "react";
|
|
15
|
+
import type { StyleProp, ViewStyle } from "react-native";
|
|
16
|
+
|
|
17
|
+
/** The filter every intake path applies to what arrived. */
|
|
18
|
+
export interface FileIntakeFilter {
|
|
19
|
+
/** Native `accept` filter (e.g. `"application/pdf,image/*"`) — matched against
|
|
20
|
+
* the file's MIME type, or its extension for a `.ext` pattern. */
|
|
21
|
+
accept?: string;
|
|
22
|
+
/** Keep more than one file. Default true. */
|
|
23
|
+
multiple?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The structural shape a `DataTransfer` satisfies — `DragEvent.dataTransfer`
|
|
28
|
+
* (a drop) and `ClipboardEvent.clipboardData` (a paste) are the same object.
|
|
29
|
+
* Declared structurally so the extractor is testable without a DOM.
|
|
30
|
+
*/
|
|
31
|
+
export interface FileTransferLike {
|
|
32
|
+
readonly files?: ArrayLike<File> | null;
|
|
33
|
+
readonly items?: ArrayLike<{ readonly kind: string; getAsFile: () => File | null }> | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The minimal ref shape `usePasteFiles`/`FileDropTarget` need to locate a
|
|
37
|
+
* region — an RN `View` ref satisfies it (`useRef<View>(null)`); on web its
|
|
38
|
+
* `.current` IS the region's DOM node. Declared structurally (readonly, so an
|
|
39
|
+
* `RefObject<View | null>` assigns cleanly) to keep this module RN-free. */
|
|
40
|
+
export interface RegionRef {
|
|
41
|
+
readonly current: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Options for `usePasteFiles` (`@lotics/ui/use_paste_files`). */
|
|
45
|
+
export interface UsePasteFilesOptions extends FileIntakeFilter {
|
|
46
|
+
/** Receives the pasted files. Bytes/upload belong to the host. */
|
|
47
|
+
onFiles: (files: File[]) => void;
|
|
48
|
+
/** Subscribe only while true (default true) — scope the sink to "while this
|
|
49
|
+
* surface is open" so a background screen never swallows a paste. */
|
|
50
|
+
enabled?: boolean;
|
|
51
|
+
/** The region this sink belongs to. When the paste happens while focus is
|
|
52
|
+
* INSIDE this region, this sink wins over a later-mounted one (see
|
|
53
|
+
* `selectPasteSink`). Omit for a region-less surface (a modal dialog) — the
|
|
54
|
+
* sink then participates by stack order alone. */
|
|
55
|
+
region?: RegionRef;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Props for `FileDropTarget` (`@lotics/ui/file_drop_target`). */
|
|
59
|
+
export interface FileDropTargetProps extends FileIntakeFilter {
|
|
60
|
+
/** Receives the dropped files. */
|
|
61
|
+
onFiles: (files: File[]) => void;
|
|
62
|
+
/** Also accept a clipboard PASTE, scoped to this region's focus: while focus
|
|
63
|
+
* is inside the wrapped region, Ctrl/Cmd+V lands here. Wires `usePasteFiles`
|
|
64
|
+
* with this region — so two peer file sections each win when focus is in
|
|
65
|
+
* them, instead of the last-mounted one silently grabbing every paste. */
|
|
66
|
+
paste?: boolean;
|
|
67
|
+
/** Stop accepting drops/pastes (and drop the affordance). */
|
|
68
|
+
disabled?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* The region. A plain node lets the target paint the drag affordance itself;
|
|
71
|
+
* a FUNCTION receives the drag state and OWNS the visual (the target paints
|
|
72
|
+
* nothing) — how `FileDropzone` keeps its own well styling.
|
|
73
|
+
*/
|
|
74
|
+
children: ReactNode | ((dragging: boolean) => ReactNode);
|
|
75
|
+
style?: StyleProp<ViewStyle>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Does a file pass a native `accept` list? Each comma-separated pattern is an
|
|
80
|
+
* extension (`.pdf`), a wildcard type (`image/*`) or an exact MIME
|
|
81
|
+
* (`application/pdf`); an empty/absent list accepts everything.
|
|
82
|
+
*/
|
|
83
|
+
export function matchesAccept(file: File, accept: string | undefined): boolean {
|
|
84
|
+
if (!accept) return true;
|
|
85
|
+
const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
|
|
86
|
+
if (patterns.length === 0) return true;
|
|
87
|
+
const mime = file.type.toLowerCase();
|
|
88
|
+
const name = file.name.toLowerCase();
|
|
89
|
+
return patterns.some((p) => {
|
|
90
|
+
if (p.startsWith(".")) return name.endsWith(p);
|
|
91
|
+
if (p.endsWith("/*")) return mime.startsWith(p.slice(0, -1));
|
|
92
|
+
return mime === p;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The files a drop or a paste carries, filtered by `accept` and capped by
|
|
98
|
+
* `multiple`. Returns a new array; never throws on a missing/empty transfer.
|
|
99
|
+
*/
|
|
100
|
+
export function filesFromTransfer(
|
|
101
|
+
transfer: FileTransferLike | null | undefined,
|
|
102
|
+
filter: FileIntakeFilter = {},
|
|
103
|
+
): File[] {
|
|
104
|
+
if (!transfer) return [];
|
|
105
|
+
// `files` carries a drop and most pastes. A pasted SCREENSHOT reaches some
|
|
106
|
+
// engines only through `items` (a `kind: "file"` entry), so fall back to it —
|
|
107
|
+
// never union the two, or an engine that populates both doubles every file.
|
|
108
|
+
const direct = transfer.files ? Array.from(transfer.files) : [];
|
|
109
|
+
const candidates =
|
|
110
|
+
direct.length > 0
|
|
111
|
+
? direct
|
|
112
|
+
: Array.from(transfer.items ?? [])
|
|
113
|
+
.filter((item) => item.kind === "file")
|
|
114
|
+
.map((item) => item.getAsFile())
|
|
115
|
+
.filter((file): file is File => file !== null);
|
|
116
|
+
|
|
117
|
+
const accepted = candidates.filter((file) => matchesAccept(file, filter.accept));
|
|
118
|
+
return filter.multiple === false ? accepted.slice(0, 1) : accepted;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One registered paste sink for the routing decision: the payload to hand the
|
|
122
|
+
* paste to, and whether its region currently contains focus. */
|
|
123
|
+
export interface PasteSinkEntry<T> {
|
|
124
|
+
sink: T;
|
|
125
|
+
/** True when `document.activeElement` is inside this sink's region. A
|
|
126
|
+
* region-less sink (a modal dialog) is always false — it routes by stack
|
|
127
|
+
* order alone. */
|
|
128
|
+
focused: boolean;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Which sink gets the paste, given the active sinks in MOUNT order (oldest
|
|
133
|
+
* first, top of the stack last). The rule: the TOP-MOST focused region wins —
|
|
134
|
+
* so when two file targets are enabled on the same layer, the one the user is
|
|
135
|
+
* actually working in takes the file. With NO region focused it falls back to
|
|
136
|
+
* the top of the stack (the last-mounted surface), which is the right default
|
|
137
|
+
* for modal stacking (a dialog over a screen). Returns `undefined` for an empty
|
|
138
|
+
* stack.
|
|
139
|
+
*
|
|
140
|
+
* Pure so the DOM-free routing rule is unit-tested here; the `focused` flags are
|
|
141
|
+
* computed against live focus in the `.web` sink at dispatch time.
|
|
142
|
+
*/
|
|
143
|
+
export function selectPasteSink<T>(entries: readonly PasteSinkEntry<T>[]): T | undefined {
|
|
144
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
145
|
+
if (entries[i].focused) return entries[i].sink;
|
|
146
|
+
}
|
|
147
|
+
return entries.length > 0 ? entries[entries.length - 1].sink : undefined;
|
|
148
|
+
}
|
package/src/locale.tsx
CHANGED
|
@@ -129,10 +129,19 @@ export interface LoticsLocale {
|
|
|
129
129
|
scrollToBottom: { tooltip: string };
|
|
130
130
|
/** `TextInputField`: the clear-button tooltip. */
|
|
131
131
|
textInputField: { clear: string };
|
|
132
|
-
/** `AgentRun`: the reasoning disclosure's label (settled / streaming)
|
|
133
|
-
* auto-built tool peek's Input / Error / Output panel titles
|
|
134
|
-
*
|
|
135
|
-
|
|
132
|
+
/** `AgentRun`: the reasoning disclosure's label (settled / streaming), the
|
|
133
|
+
* auto-built tool peek's Input / Error / Output panel titles, the `awaiting`
|
|
134
|
+
* annotation on a call parked on a human decision, and the terminal error's
|
|
135
|
+
* `retry` action. (Tool-step labels and the "{n} steps" suffix stay
|
|
136
|
+
* prop-localized — `labelForCall` / `stepsLabel`.) */
|
|
137
|
+
agentRun: { thinking: string; thinkingStreaming: string; input: string; error: string; output: string; awaiting: string; retry: string };
|
|
138
|
+
/** `ApprovalPrompt`: the default prompt line (overridable per instance) and
|
|
139
|
+
* the Approve / Deny button labels — the surface that ANSWERS `AgentRun`'s
|
|
140
|
+
* read-only `awaiting` row (approve/deny = the ai-sdk approval vocabulary). */
|
|
141
|
+
approvalPrompt: { message: string; approve: string; deny: string };
|
|
142
|
+
/** `MessageActions`: the copy tooltip + its flipped "copied" confirmation, the
|
|
143
|
+
* regenerate and edit tooltips, and the branch pager's prev/next names. */
|
|
144
|
+
messageActions: { copy: string; copied: string; regenerate: string; edit: string; previousVersion: string; nextVersion: string };
|
|
136
145
|
}
|
|
137
146
|
|
|
138
147
|
/** The platform default — English. Every component's hardcoded default lives
|
|
@@ -221,14 +230,16 @@ export const en: LoticsLocale = {
|
|
|
221
230
|
chart: { noData: "No data", total: "Total" },
|
|
222
231
|
composer: { send: "Send", stop: "Stop" },
|
|
223
232
|
overlay: { close: "Close" },
|
|
224
|
-
fileDropzone: { label: "Drag files here", hint: "or click
|
|
233
|
+
fileDropzone: { label: "Drag files here", hint: "or click, or paste (⌘V)", drop: "Drop to upload" },
|
|
225
234
|
fileThumbnail: { remove: "Remove" },
|
|
226
235
|
imageGallery: { empty: "No images.", rotateLeft: "Rotate left", rotateRight: "Rotate right", zoom: "Zoom image" },
|
|
227
236
|
infoPopover: { more: "More information" },
|
|
228
237
|
matrix: { total: "Total", less: "Less", more: "More" },
|
|
229
238
|
scrollToBottom: { tooltip: "Scroll to bottom" },
|
|
230
239
|
textInputField: { clear: "Clear" },
|
|
231
|
-
agentRun: { thinking: "Thinking", thinkingStreaming: "Thinking…", input: "Input", error: "Error", output: "Output" },
|
|
240
|
+
agentRun: { thinking: "Thinking", thinkingStreaming: "Thinking…", input: "Input", error: "Error", output: "Output", awaiting: "Awaiting", retry: "Retry" },
|
|
241
|
+
approvalPrompt: { message: "The assistant wants to perform an action that needs your approval.", approve: "Approve", deny: "Deny" },
|
|
242
|
+
messageActions: { copy: "Copy", copied: "Copied", regenerate: "Regenerate", edit: "Edit", previousVersion: "Previous version", nextVersion: "Next version" },
|
|
232
243
|
};
|
|
233
244
|
|
|
234
245
|
/** Vietnamese. Maintained once here so every app (and the frontend) shares one
|
|
@@ -317,14 +328,16 @@ export const vi: LoticsLocale = {
|
|
|
317
328
|
chart: { noData: "Không có dữ liệu", total: "Tổng" },
|
|
318
329
|
composer: { send: "Gửi", stop: "Dừng" },
|
|
319
330
|
overlay: { close: "Đóng" },
|
|
320
|
-
fileDropzone: { label: "Kéo tệp vào đây", hint: "hoặc bấm
|
|
331
|
+
fileDropzone: { label: "Kéo tệp vào đây", hint: "hoặc bấm chọn, hoặc dán (Ctrl+V)", drop: "Thả để tải lên" },
|
|
321
332
|
fileThumbnail: { remove: "Xóa" },
|
|
322
333
|
imageGallery: { empty: "Chưa có ảnh.", rotateLeft: "Xoay trái", rotateRight: "Xoay phải", zoom: "Phóng to ảnh" },
|
|
323
334
|
infoPopover: { more: "Thông tin thêm" },
|
|
324
335
|
matrix: { total: "Tổng", less: "Ít", more: "Nhiều" },
|
|
325
336
|
scrollToBottom: { tooltip: "Cuộn xuống cuối" },
|
|
326
337
|
textInputField: { clear: "Xóa" },
|
|
327
|
-
agentRun: { thinking: "Suy nghĩ", thinkingStreaming: "Đang suy nghĩ…", input: "Đầu vào", error: "Lỗi", output: "Kết quả" },
|
|
338
|
+
agentRun: { thinking: "Suy nghĩ", thinkingStreaming: "Đang suy nghĩ…", input: "Đầu vào", error: "Lỗi", output: "Kết quả", awaiting: "Chờ duyệt", retry: "Thử lại" },
|
|
339
|
+
approvalPrompt: { message: "Trợ lý muốn thực hiện thao tác cần bạn duyệt.", approve: "Cho phép", deny: "Từ chối" },
|
|
340
|
+
messageActions: { copy: "Sao chép", copied: "Đã sao chép", regenerate: "Tạo lại", edit: "Chỉnh sửa", previousVersion: "Phiên bản trước", nextVersion: "Phiên bản sau" },
|
|
328
341
|
};
|
|
329
342
|
|
|
330
343
|
const LoticsLocaleContext = createContext<LoticsLocale>(en);
|