@lotics/ui 15.0.0 → 15.1.1
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 +15 -1
- package/MIGRATION.md +24 -2
- package/docs/catalog.md +34 -4
- package/docs/data_entry.md +53 -2
- package/docs/templates.md +21 -3
- package/examples/tpl_item_list.tsx +146 -48
- package/examples/tpl_record.tsx +37 -12
- package/package.json +10 -1
- 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 +2 -2
- package/src/use_paste_files.ts +14 -0
- package/src/use_paste_files.web.ts +101 -0
|
@@ -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
|
@@ -230,7 +230,7 @@ export const en: LoticsLocale = {
|
|
|
230
230
|
chart: { noData: "No data", total: "Total" },
|
|
231
231
|
composer: { send: "Send", stop: "Stop" },
|
|
232
232
|
overlay: { close: "Close" },
|
|
233
|
-
fileDropzone: { label: "Drag files here", hint: "or click
|
|
233
|
+
fileDropzone: { label: "Drag files here", hint: "or click, or paste (⌘V)", drop: "Drop to upload" },
|
|
234
234
|
fileThumbnail: { remove: "Remove" },
|
|
235
235
|
imageGallery: { empty: "No images.", rotateLeft: "Rotate left", rotateRight: "Rotate right", zoom: "Zoom image" },
|
|
236
236
|
infoPopover: { more: "More information" },
|
|
@@ -328,7 +328,7 @@ export const vi: LoticsLocale = {
|
|
|
328
328
|
chart: { noData: "Không có dữ liệu", total: "Tổng" },
|
|
329
329
|
composer: { send: "Gửi", stop: "Dừng" },
|
|
330
330
|
overlay: { close: "Đóng" },
|
|
331
|
-
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" },
|
|
332
332
|
fileThumbnail: { remove: "Xóa" },
|
|
333
333
|
imageGallery: { empty: "Chưa có ảnh.", rotateLeft: "Xoay trái", rotateRight: "Xoay phải", zoom: "Phóng to ảnh" },
|
|
334
334
|
infoPopover: { more: "Thông tin thêm" },
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { UsePasteFilesOptions } from "./file_intake";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Native stand-in for the web clipboard sink.
|
|
5
|
+
*
|
|
6
|
+
* There is no document-level `paste` event on React Native — the OS clipboard is
|
|
7
|
+
* read imperatively — so the hook is a no-op and the surface's own Add-file CTA
|
|
8
|
+
* stays the intake path. The file exists so a shared screen can call
|
|
9
|
+
* `usePasteFiles` unconditionally without the DOM code ever reaching Metro.
|
|
10
|
+
*
|
|
11
|
+
* Web targets resolve `use_paste_files.web.ts` instead (Metro's `.web` extension
|
|
12
|
+
* resolution; the package's `react-native` export condition).
|
|
13
|
+
*/
|
|
14
|
+
export function usePasteFiles(_options: UsePasteFilesOptions): void {}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { filesFromTransfer, selectPasteSink, type UsePasteFilesOptions } from "./file_intake";
|
|
3
|
+
import { resolveRegionNode } from "./dom_region";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Web variant: the document-level clipboard sink. Isolated into its own module
|
|
7
|
+
* so the native bundle (which resolves `use_paste_files.ts`) never references
|
|
8
|
+
* the DOM.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
type PasteSink = (event: ClipboardEvent) => void;
|
|
12
|
+
|
|
13
|
+
interface RegisteredSink {
|
|
14
|
+
sink: PasteSink;
|
|
15
|
+
/** Evaluated at dispatch time: does this sink's region contain focus? A
|
|
16
|
+
* region-less sink is always false — it routes by stack order alone. */
|
|
17
|
+
hasFocusWithin: () => boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The active sinks, most recently subscribed LAST, behind ONE document listener.
|
|
22
|
+
* A paste is routed by `selectPasteSink`: the TOP-MOST sink whose region holds
|
|
23
|
+
* focus wins (so two file targets on one layer each take the paste when the user
|
|
24
|
+
* is working in them), else the top of the stack (right for modal stacking — a
|
|
25
|
+
* dialog over a screen).
|
|
26
|
+
*/
|
|
27
|
+
const sinks: RegisteredSink[] = [];
|
|
28
|
+
let listening = false;
|
|
29
|
+
|
|
30
|
+
function dispatch(event: ClipboardEvent) {
|
|
31
|
+
// An inner handler (a composer with its own attach path) already claimed it.
|
|
32
|
+
if (event.defaultPrevented) return;
|
|
33
|
+
const chosen = selectPasteSink(
|
|
34
|
+
sinks.map((registered) => ({ sink: registered.sink, focused: registered.hasFocusWithin() })),
|
|
35
|
+
);
|
|
36
|
+
chosen?.(event);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pushSink(registered: RegisteredSink): () => void {
|
|
40
|
+
sinks.push(registered);
|
|
41
|
+
if (!listening) {
|
|
42
|
+
document.addEventListener("paste", dispatch);
|
|
43
|
+
listening = true;
|
|
44
|
+
}
|
|
45
|
+
return () => {
|
|
46
|
+
const index = sinks.lastIndexOf(registered);
|
|
47
|
+
if (index >= 0) sinks.splice(index, 1);
|
|
48
|
+
if (sinks.length === 0 && listening) {
|
|
49
|
+
document.removeEventListener("paste", dispatch);
|
|
50
|
+
listening = false;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ctrl/Cmd+V adds files — the paste half of file intake, alongside the drop
|
|
57
|
+
* (`FileDropTarget` / `FileDropzone`) and the pick (`pickFiles`). Reads whatever
|
|
58
|
+
* the clipboard carries (a copied file, or a screenshot, which some engines
|
|
59
|
+
* surface only through `clipboardData.items`), filters it by `accept`, and hands
|
|
60
|
+
* it to `onFiles`.
|
|
61
|
+
*
|
|
62
|
+
* Scope it with `enabled` so only the open surface listens. Pass `region` (the
|
|
63
|
+
* ref of the surface's DOM region) to route by FOCUS: when the paste fires while
|
|
64
|
+
* focus is inside this region, this sink wins over a later-mounted one — so two
|
|
65
|
+
* peer file sections each take the paste when the user is in them. Without a
|
|
66
|
+
* region it routes by stack order (the modal-stacking default). A paste carrying
|
|
67
|
+
* NO acceptable file is left entirely alone — typing Ctrl+V in a text field
|
|
68
|
+
* still pastes text.
|
|
69
|
+
*/
|
|
70
|
+
export function usePasteFiles(options: UsePasteFilesOptions): void {
|
|
71
|
+
const { onFiles, accept, multiple = true, enabled = true, region } = options;
|
|
72
|
+
|
|
73
|
+
// Read through a ref so the sink subscribes ONCE per enabled window. Keying
|
|
74
|
+
// the effect on `onFiles` instead would re-push on every parent render —
|
|
75
|
+
// routine with an inline callback — and a background surface re-rendering
|
|
76
|
+
// would jump the stack and steal the next paste.
|
|
77
|
+
const latest = useRef({ onFiles, accept, multiple, region });
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
latest.current = { onFiles, accept, multiple, region };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!enabled) return;
|
|
84
|
+
if (typeof document === "undefined") return;
|
|
85
|
+
return pushSink({
|
|
86
|
+
hasFocusWithin: () => {
|
|
87
|
+
const node = resolveRegionNode(latest.current.region);
|
|
88
|
+
return node !== null && node.contains(document.activeElement);
|
|
89
|
+
},
|
|
90
|
+
sink: (event) => {
|
|
91
|
+
const settings = latest.current;
|
|
92
|
+
const files = filesFromTransfer(event.clipboardData, settings);
|
|
93
|
+
if (files.length === 0) return;
|
|
94
|
+
// Claim ONLY the paste actually consumed, so a text paste stays the
|
|
95
|
+
// browser's (and the focused input's) to handle.
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
settings.onFiles(files);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}, [enabled]);
|
|
101
|
+
}
|