@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.
@@ -0,0 +1,111 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Text } from "./text";
4
+ import { IconButton } from "./icon_button";
5
+ import { useLoticsLocale } from "./locale";
6
+
7
+ /** The branch/version pager slot — the previous/next controls over a message's
8
+ * sibling versions. Rendered only when `total > 1`. */
9
+ export interface MessageActionsBranch {
10
+ current: number;
11
+ total: number;
12
+ onPrevious: () => void;
13
+ onNext: () => void;
14
+ disabled?: boolean;
15
+ }
16
+
17
+ export interface MessageActionsProps {
18
+ /** Copy the message. The bar OWNS the copied feedback — the icon flips to a
19
+ * check and the tooltip flips to "Copied" for ~2s (the timer is cleared on
20
+ * unmount). The handler only performs the clipboard write — it reads the
21
+ * message content the bar cannot see. */
22
+ onCopy?: () => void | Promise<void>;
23
+ /** Regenerate the (assistant) message. */
24
+ onRegenerate?: () => void;
25
+ /** Edit the (user) message. */
26
+ onEdit?: () => void;
27
+ /** The sibling-version pager, absorbed as an internal part of the bar. */
28
+ branch?: MessageActionsBranch;
29
+ }
30
+
31
+ /**
32
+ * The single shared per-message action bar — the branch/version pager, copy
33
+ * (with the owned copied-feedback contract), regenerate, and edit. Each action
34
+ * renders ONLY when its handler is provided, so an assistant bar (copy +
35
+ * regenerate) and a user bar (copy + edit) are the same component with a
36
+ * different handler set; the pager renders when `branch.total > 1`. The bar
37
+ * hugs its content, so the parent controls the row's side (left for an
38
+ * assistant turn, right for a user bubble). Strings resolve through the
39
+ * `messageActions` locale slice.
40
+ */
41
+ export function MessageActions(props: MessageActionsProps) {
42
+ const { onCopy, onRegenerate, onEdit, branch } = props;
43
+ const labels = useLoticsLocale().messageActions;
44
+ const [copied, setCopied] = useState(false);
45
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
46
+
47
+ // The copied flag flips back after a beat; the timer is cleared on unmount so
48
+ // a bar that disappears (last message loses its actions) never sets state on
49
+ // an unmounted tree.
50
+ useEffect(() => {
51
+ return () => {
52
+ if (timer.current) clearTimeout(timer.current);
53
+ };
54
+ }, []);
55
+
56
+ const handleCopy = useCallback(async () => {
57
+ await onCopy?.();
58
+ setCopied(true);
59
+ if (timer.current) clearTimeout(timer.current);
60
+ timer.current = setTimeout(() => setCopied(false), 2000);
61
+ }, [onCopy]);
62
+
63
+ return (
64
+ <View style={styles.bar}>
65
+ {branch ? <BranchPager branch={branch} previousLabel={labels.previousVersion} nextLabel={labels.nextVersion} /> : null}
66
+ {onCopy ? (
67
+ <IconButton
68
+ testID="copy-button"
69
+ onPress={handleCopy}
70
+ icon={copied ? "check" : "copy"}
71
+ tooltip={copied ? labels.copied : labels.copy}
72
+ />
73
+ ) : null}
74
+ {onRegenerate ? (
75
+ <IconButton testID="regenerate-button" onPress={onRegenerate} icon="refresh-cw" tooltip={labels.regenerate} />
76
+ ) : null}
77
+ {onEdit ? (
78
+ <IconButton testID="edit-button" onPress={onEdit} icon="pencil" tooltip={labels.edit} />
79
+ ) : null}
80
+ </View>
81
+ );
82
+ }
83
+
84
+ // The sibling-version pager: [‹] {current} / {total} [›]. A single version has
85
+ // nothing to page, so it renders nothing.
86
+ function BranchPager({ branch, previousLabel, nextLabel }: { branch: MessageActionsBranch; previousLabel: string; nextLabel: string }) {
87
+ const { current, total, onPrevious, onNext, disabled } = branch;
88
+ if (total <= 1) return null;
89
+ return (
90
+ <View testID="branch-pagination" style={styles.branch}>
91
+ <IconButton testID="branch-prev" icon="chevron-left" tooltip={previousLabel} onPress={onPrevious} disabled={disabled || current <= 1} />
92
+ <Text size="xs" color="muted">
93
+ {current} / {total}
94
+ </Text>
95
+ <IconButton testID="branch-next" icon="chevron-right" tooltip={nextLabel} onPress={onNext} disabled={disabled || current >= total} />
96
+ </View>
97
+ );
98
+ }
99
+
100
+ const styles = StyleSheet.create({
101
+ bar: {
102
+ flexDirection: "row",
103
+ alignItems: "center",
104
+ gap: 4,
105
+ },
106
+ branch: {
107
+ flexDirection: "row",
108
+ alignItems: "center",
109
+ gap: 2,
110
+ },
111
+ });
@@ -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
+ }