@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.
@@ -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 to browse", drop: "Drop to upload" },
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 để chọn", drop: "Thả để tải lên" },
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
+ }