@opengeni/react-native 4.2.1-canary.39593846423001 → 4.3.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/android/build.gradle +15 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/dev/opengeni/textselection/OpenGeniTextSelectionModule.kt +274 -0
- package/dist/{chunk-D3MFBDVR.js → chunk-2RNMGRTB.js} +3 -3
- package/dist/{chunk-D3MFBDVR.js.map → chunk-2RNMGRTB.js.map} +1 -1
- package/dist/{chunk-2W74OY6I.js → chunk-FHPMRXIG.js} +16 -2
- package/dist/chunk-FHPMRXIG.js.map +1 -0
- package/dist/{chunk-63PZ52YJ.js → chunk-QFV5DISV.js} +38 -5
- package/dist/chunk-QFV5DISV.js.map +1 -0
- package/dist/{chunk-U5L2CUMJ.js → chunk-ZZNZBZWV.js} +3 -3
- package/dist/{chunk-U5L2CUMJ.js.map → chunk-ZZNZBZWV.js.map} +1 -1
- package/dist/index.js +2 -2
- package/dist/timeline/activity.d.ts +3 -1
- package/dist/timeline/annotations.d.ts +81 -0
- package/dist/timeline/icon-nodes.generated.d.ts +5 -0
- package/dist/timeline/index.d.ts +2 -0
- package/dist/timeline/index.js +1223 -203
- package/dist/timeline/index.js.map +1 -1
- package/dist/timeline/markdown.js +5 -2
- package/dist/timeline/markdown.js.map +1 -1
- package/dist/timeline/message-timeline.d.ts +20 -2
- package/dist/timeline/previews.js +2 -2
- package/dist/timeline/primitives.d.ts +7 -1
- package/dist/timeline/queue-dock.d.ts +4 -1
- package/dist/timeline/text-selection.d.ts +44 -0
- package/expo-module.config.json +5 -2
- package/ios/OpenGeniTextSelectionModule.swift +560 -0
- package/package.json +6 -4
- package/src/timeline/activity.tsx +76 -21
- package/src/timeline/annotations.tsx +877 -0
- package/src/timeline/icon-nodes.generated.ts +14 -0
- package/src/timeline/index.ts +2 -0
- package/src/timeline/markdown.tsx +7 -0
- package/src/timeline/message-timeline.tsx +340 -174
- package/src/timeline/primitives.tsx +50 -1
- package/src/timeline/queue-dock.tsx +36 -0
- package/src/timeline/session-screen.tsx +122 -4
- package/src/timeline/sheet.tsx +3 -2
- package/src/timeline/text-selection.ts +115 -0
- package/dist/chunk-2W74OY6I.js.map +0 -1
- package/dist/chunk-63PZ52YJ.js.map +0 -1
|
@@ -13,6 +13,7 @@ import Animated, {
|
|
|
13
13
|
import { Icon, type NativeIconName } from "./icon";
|
|
14
14
|
import { fontStyle, useNativeTimelineTheme, type NativeTimelineTheme } from "./theme";
|
|
15
15
|
import { useOpenGeniText } from "@opengeni/react/messages";
|
|
16
|
+
import type { ToolOutputPreview } from "@opengeni/react/session";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* A reader opening or closing a disclosure owns the scroll position: the
|
|
@@ -160,6 +161,11 @@ export interface ActivityDisclosureProps {
|
|
|
160
161
|
/** Compact reel rendering for the rolling status (no chevron, no body). */
|
|
161
162
|
compact?: boolean | undefined;
|
|
162
163
|
accessibilityLabel?: string | undefined;
|
|
164
|
+
/**
|
|
165
|
+
* Set while a tool row's output is a bounded preview: opening the row reads
|
|
166
|
+
* the exact output, and the body waits for it (or says it is a preview).
|
|
167
|
+
*/
|
|
168
|
+
outputPreview?: ToolOutputPreview | null | undefined;
|
|
163
169
|
children?: ReactNode | undefined;
|
|
164
170
|
}
|
|
165
171
|
|
|
@@ -179,10 +185,19 @@ export function ActivityDisclosure({
|
|
|
179
185
|
defaultOpen,
|
|
180
186
|
compact,
|
|
181
187
|
accessibilityLabel,
|
|
188
|
+
outputPreview,
|
|
182
189
|
children,
|
|
183
190
|
}: ActivityDisclosureProps) {
|
|
184
191
|
const theme = useNativeTimelineTheme();
|
|
192
|
+
const t = useOpenGeniText();
|
|
185
193
|
const [open, setOpen] = useState(defaultOpen ?? false);
|
|
194
|
+
// A previewed output reads its exact output when the row opens (once: a
|
|
195
|
+
// failed read waits for Retry).
|
|
196
|
+
const loadOutput = outputPreview?.load;
|
|
197
|
+
const outputPending = outputPreview?.state === "preview";
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (open && outputPending && !compact) loadOutput?.();
|
|
200
|
+
}, [open, outputPending, loadOutput, compact]);
|
|
186
201
|
const releaseFollow = useReleaseFollow();
|
|
187
202
|
const iconTone = failed && iconToneProp === "muted" ? "failed" : iconToneProp;
|
|
188
203
|
const chip =
|
|
@@ -327,7 +342,41 @@ export function ActivityDisclosure({
|
|
|
327
342
|
{row}
|
|
328
343
|
</Pressable>
|
|
329
344
|
{open ? (
|
|
330
|
-
<View style={{ marginLeft: 28, marginTop: 6, marginBottom: 8, gap: 8 }}>
|
|
345
|
+
<View style={{ marginLeft: 28, marginTop: 6, marginBottom: 8, gap: 8 }}>
|
|
346
|
+
{outputPreview && loadOutput && outputPreview.state !== "error" ? (
|
|
347
|
+
// Never show a preview's body as if it were the whole output.
|
|
348
|
+
<BodyNote>{t("timeline.shared.loadingFullOutput")}</BodyNote>
|
|
349
|
+
) : outputPreview ? (
|
|
350
|
+
<>
|
|
351
|
+
<View
|
|
352
|
+
style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8 }}
|
|
353
|
+
>
|
|
354
|
+
<BodyNote>
|
|
355
|
+
{loadOutput
|
|
356
|
+
? t("timeline.shared.outputPreviewFailed")
|
|
357
|
+
: t("timeline.shared.outputPreviewOnly")}
|
|
358
|
+
</BodyNote>
|
|
359
|
+
{loadOutput ? (
|
|
360
|
+
<Pressable accessibilityRole="button" onPress={loadOutput} hitSlop={8}>
|
|
361
|
+
<Text
|
|
362
|
+
style={{
|
|
363
|
+
...fontStyle(theme, 500),
|
|
364
|
+
fontSize: theme.size.sm,
|
|
365
|
+
lineHeight: 20,
|
|
366
|
+
color: theme.colors["fg-muted"],
|
|
367
|
+
}}
|
|
368
|
+
>
|
|
369
|
+
{t("timeline.shared.retryFullOutput")}
|
|
370
|
+
</Text>
|
|
371
|
+
</Pressable>
|
|
372
|
+
) : null}
|
|
373
|
+
</View>
|
|
374
|
+
{children}
|
|
375
|
+
</>
|
|
376
|
+
) : (
|
|
377
|
+
children
|
|
378
|
+
)}
|
|
379
|
+
</View>
|
|
331
380
|
) : null}
|
|
332
381
|
</View>
|
|
333
382
|
);
|
|
@@ -17,6 +17,7 @@ import { blendOver, withAlpha } from "./primitives";
|
|
|
17
17
|
import { CompactSignalsContext } from "./session-signals";
|
|
18
18
|
import { fontStyle, useNativeTimelineTheme } from "./theme";
|
|
19
19
|
import { useNativeTimelineMessages } from "./messages";
|
|
20
|
+
import type { NativeAnnotationLike } from "./annotations";
|
|
20
21
|
|
|
21
22
|
/* ----------------------------------------------------------------------------
|
|
22
23
|
The web session-chrome queue dock above the composer: a "N queued · Steer"
|
|
@@ -40,12 +41,15 @@ export function QueueDock({
|
|
|
40
41
|
queue,
|
|
41
42
|
composer,
|
|
42
43
|
onComposerFocus,
|
|
44
|
+
onReviewAnnotations,
|
|
43
45
|
leading,
|
|
44
46
|
singleSend = false,
|
|
45
47
|
}: {
|
|
46
48
|
queue: TurnQueue;
|
|
47
49
|
composer?: QueueDockComposer | undefined;
|
|
48
50
|
onComposerFocus?: (() => void) | undefined;
|
|
51
|
+
/** Show a waiting message's quotes and notes. */
|
|
52
|
+
onReviewAnnotations?: ((annotations: readonly NativeAnnotationLike[]) => void) | undefined;
|
|
49
53
|
/** Chips sharing the queue chip's row (goal, agents, commands). */
|
|
50
54
|
leading?: ReactNode;
|
|
51
55
|
/** One Send and nothing to choose: no Steer on waiting messages. */
|
|
@@ -215,6 +219,38 @@ export function QueueDock({
|
|
|
215
219
|
>
|
|
216
220
|
{label}
|
|
217
221
|
</Text>
|
|
222
|
+
{annotationCount > 0 && onReviewAnnotations ? (
|
|
223
|
+
<Pressable
|
|
224
|
+
accessibilityRole="button"
|
|
225
|
+
accessibilityLabel={t("timelineAnnotations.reviewCountlabel", {
|
|
226
|
+
countLabel: t("counts.annotations", { count: annotationCount }),
|
|
227
|
+
})}
|
|
228
|
+
hitSlop={8}
|
|
229
|
+
onPress={() => onReviewAnnotations(turn.annotations ?? [])}
|
|
230
|
+
style={({ pressed }) => ({
|
|
231
|
+
flexDirection: "row",
|
|
232
|
+
alignItems: "center",
|
|
233
|
+
gap: 3,
|
|
234
|
+
height: 22,
|
|
235
|
+
paddingHorizontal: 7,
|
|
236
|
+
borderRadius: 11,
|
|
237
|
+
backgroundColor: c["accent-soft"],
|
|
238
|
+
opacity: pressed ? 0.6 : 1,
|
|
239
|
+
})}
|
|
240
|
+
>
|
|
241
|
+
<Icon name="quote" size={10} color={c["fg-muted"]} />
|
|
242
|
+
<Text
|
|
243
|
+
style={{
|
|
244
|
+
...fontStyle(theme, 500),
|
|
245
|
+
fontSize: 11,
|
|
246
|
+
color: c["fg-muted"],
|
|
247
|
+
fontVariant: ["tabular-nums"],
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
{annotationCount}
|
|
251
|
+
</Text>
|
|
252
|
+
</Pressable>
|
|
253
|
+
) : null}
|
|
218
254
|
{turns.length > 1 ? (
|
|
219
255
|
<>
|
|
220
256
|
<RowAction
|
|
@@ -13,7 +13,16 @@ import {
|
|
|
13
13
|
type ComponentRef,
|
|
14
14
|
type ReactNode,
|
|
15
15
|
} from "react";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
ActivityIndicator,
|
|
18
|
+
Alert,
|
|
19
|
+
Image,
|
|
20
|
+
Pressable,
|
|
21
|
+
Text,
|
|
22
|
+
View,
|
|
23
|
+
type TextInput,
|
|
24
|
+
} from "react-native";
|
|
25
|
+
import { annotationHasNote } from "@opengeni/react/timeline-model";
|
|
17
26
|
import {
|
|
18
27
|
defaultChatComposerMessages,
|
|
19
28
|
type ChatComposerMessages,
|
|
@@ -33,6 +42,12 @@ import {
|
|
|
33
42
|
} from "./decisions";
|
|
34
43
|
import { NativeMessageAttachments } from "./message-attachments";
|
|
35
44
|
import { QueueDock } from "./queue-dock";
|
|
45
|
+
import {
|
|
46
|
+
AnnotationsChip,
|
|
47
|
+
AnnotationsSheet,
|
|
48
|
+
type AnnotationReveal,
|
|
49
|
+
type NativeAnnotationLike,
|
|
50
|
+
} from "./annotations";
|
|
36
51
|
import { SessionCommandsList, SessionSignals } from "./session-signals";
|
|
37
52
|
import type { ClientModel, OpenGeniClient, SessionBackgroundCommand } from "@opengeni/sdk";
|
|
38
53
|
import { MessageTimeline, type NativeMessageTimelineProps } from "./message-timeline";
|
|
@@ -156,9 +171,29 @@ function SessionScreenBody({
|
|
|
156
171
|
...timelineProps
|
|
157
172
|
}: Omit<NativeSessionScreenProps, "timelineMessages">) {
|
|
158
173
|
const theme = useNativeTimelineTheme();
|
|
174
|
+
const t = useOpenGeniText();
|
|
159
175
|
const m = useNativeTimelineMessages();
|
|
160
176
|
const { composer, queue, humanInput, approvals, control, attachments } = controller;
|
|
161
177
|
const composerInput = useRef<ComponentRef<typeof TextInput>>(null);
|
|
178
|
+
const annotationReveal = useRef<AnnotationReveal | null>(null);
|
|
179
|
+
const draftAnnotations = composer.annotations ?? [];
|
|
180
|
+
const annotationsIncomplete = draftAnnotations.some(
|
|
181
|
+
(annotation) => annotation.note.trim().length === 0,
|
|
182
|
+
);
|
|
183
|
+
const reviewTargetId = composer.annotationReviewTargetId ?? null;
|
|
184
|
+
const [reviewOpen, setReviewOpen] = useState(false);
|
|
185
|
+
// A new quote, a marker tap or a send with an empty note opens its note.
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (reviewTargetId) setReviewOpen(true);
|
|
188
|
+
}, [reviewTargetId]);
|
|
189
|
+
const closeReview = useCallback(() => {
|
|
190
|
+
setReviewOpen(false);
|
|
191
|
+
composer.clearAnnotationReviewTarget?.();
|
|
192
|
+
}, [composer]);
|
|
193
|
+
const [queuedReview, setQueuedReview] = useState<readonly NativeAnnotationLike[] | null>(null);
|
|
194
|
+
// Each send brings the conversation back to its latest row.
|
|
195
|
+
const [followLatestSignal, setFollowLatestSignal] = useState(0);
|
|
196
|
+
const followLatest = useCallback(() => setFollowLatestSignal((value) => value + 1), []);
|
|
162
197
|
// The composer floats over the conversation; its occupied height insets the timeline.
|
|
163
198
|
const [composerHeight, setComposerHeight] = useState(120);
|
|
164
199
|
const items = useMemo(
|
|
@@ -201,6 +236,11 @@ function SessionScreenBody({
|
|
|
201
236
|
<View style={{ flex: 1, backgroundColor: theme.colors.bg }}>
|
|
202
237
|
{topBar}
|
|
203
238
|
<MessageTimeline
|
|
239
|
+
onAnnotate={composer.addAnnotation}
|
|
240
|
+
draftAnnotations={composer.annotations}
|
|
241
|
+
onDraftAnnotationSelect={composer.requestAnnotationReview}
|
|
242
|
+
annotationRevealRef={annotationReveal}
|
|
243
|
+
followLatestSignal={followLatestSignal}
|
|
204
244
|
{...timelineProps}
|
|
205
245
|
hasOlder={controller.hasOlder}
|
|
206
246
|
loadingOlder={controller.loadingOlder}
|
|
@@ -276,14 +316,31 @@ function SessionScreenBody({
|
|
|
276
316
|
onActionFeedback: composerSlots?.onActionFeedback,
|
|
277
317
|
value: composer.value,
|
|
278
318
|
onChangeText: composer.setValue,
|
|
279
|
-
|
|
319
|
+
// A quote without its note cannot be sent: Send opens that note instead
|
|
320
|
+
// of staying disabled with no reason given.
|
|
321
|
+
onSend: () => {
|
|
322
|
+
if (annotationsIncomplete) composer.requestAnnotationReview?.();
|
|
323
|
+
else {
|
|
324
|
+
followLatest();
|
|
325
|
+
void composer.send();
|
|
326
|
+
}
|
|
327
|
+
},
|
|
280
328
|
// The other choice beside Send while the agent works: Queue when
|
|
281
329
|
// Send steers, Steer when Send queues.
|
|
282
330
|
alternateSend: composer.alternateSend ?? null,
|
|
283
331
|
...(composer.sendAlternate
|
|
284
|
-
? {
|
|
332
|
+
? {
|
|
333
|
+
onAlternateSend: () => {
|
|
334
|
+
if (annotationsIncomplete) {
|
|
335
|
+
composer.requestAnnotationReview?.();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
followLatest();
|
|
339
|
+
void composer.sendAlternate?.();
|
|
340
|
+
},
|
|
341
|
+
}
|
|
285
342
|
: {}),
|
|
286
|
-
canSend: composer.canSend && !busy,
|
|
343
|
+
canSend: (composer.canSend || (annotationsIncomplete && !composer.sending)) && !busy,
|
|
287
344
|
sending: composer.sending,
|
|
288
345
|
running: controller.runActive,
|
|
289
346
|
paused: paused,
|
|
@@ -311,6 +368,44 @@ function SessionScreenBody({
|
|
|
311
368
|
<>
|
|
312
369
|
{composerSlots?.header}
|
|
313
370
|
<AttachmentChips attachments={attachments} />
|
|
371
|
+
{draftAnnotations.length > 0 && composer.removeAnnotation ? (
|
|
372
|
+
<View style={{ paddingHorizontal: 12, paddingTop: 10 }}>
|
|
373
|
+
<AnnotationsChip
|
|
374
|
+
annotations={draftAnnotations}
|
|
375
|
+
onPress={() => composer.requestAnnotationReview?.()}
|
|
376
|
+
onClear={() => {
|
|
377
|
+
const removeAll = () => {
|
|
378
|
+
for (const annotation of draftAnnotations) {
|
|
379
|
+
composer.removeAnnotation?.(annotation.id);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
// Bare quotes go at once; written notes are worth one confirm,
|
|
383
|
+
// since the × sits right beside the chip.
|
|
384
|
+
if (!draftAnnotations.some((item) => annotationHasNote(item.note))) {
|
|
385
|
+
removeAll();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
Alert.alert(
|
|
389
|
+
t("native.timeline.annotations.removeAllTitle", {
|
|
390
|
+
count: draftAnnotations.length,
|
|
391
|
+
}),
|
|
392
|
+
t("native.timeline.annotations.removeAllMessage", {
|
|
393
|
+
count: draftAnnotations.filter((item) => annotationHasNote(item.note))
|
|
394
|
+
.length,
|
|
395
|
+
}),
|
|
396
|
+
[
|
|
397
|
+
{ text: t("native.timeline.annotations.cancel"), style: "cancel" },
|
|
398
|
+
{
|
|
399
|
+
text: t("native.timeline.annotations.removeAllConfirm"),
|
|
400
|
+
style: "destructive",
|
|
401
|
+
onPress: removeAll,
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
);
|
|
405
|
+
}}
|
|
406
|
+
/>
|
|
407
|
+
</View>
|
|
408
|
+
) : null}
|
|
314
409
|
</>
|
|
315
410
|
),
|
|
316
411
|
above: (
|
|
@@ -320,6 +415,7 @@ function SessionScreenBody({
|
|
|
320
415
|
composer={composer}
|
|
321
416
|
singleSend={composer.singleSend === true}
|
|
322
417
|
onComposerFocus={() => composerInput.current?.focus()}
|
|
418
|
+
onReviewAnnotations={setQueuedReview}
|
|
323
419
|
leading={
|
|
324
420
|
<SessionSignals
|
|
325
421
|
goal={controller.goal}
|
|
@@ -354,6 +450,28 @@ function SessionScreenBody({
|
|
|
354
450
|
</>
|
|
355
451
|
),
|
|
356
452
|
})}
|
|
453
|
+
<AnnotationsSheet
|
|
454
|
+
open={reviewOpen}
|
|
455
|
+
onClose={closeReview}
|
|
456
|
+
annotations={draftAnnotations}
|
|
457
|
+
editable={Boolean(composer.updateAnnotation)}
|
|
458
|
+
focusId={reviewTargetId}
|
|
459
|
+
onFocusConsumed={composer.clearAnnotationReviewTarget}
|
|
460
|
+
onUpdate={composer.updateAnnotation}
|
|
461
|
+
onRemove={composer.removeAnnotation}
|
|
462
|
+
onReveal={(source, quote) => annotationReveal.current?.(source, quote) ?? false}
|
|
463
|
+
onCommit={() => {
|
|
464
|
+
closeReview();
|
|
465
|
+
setTimeout(() => composerInput.current?.focus(), 250);
|
|
466
|
+
}}
|
|
467
|
+
/>
|
|
468
|
+
<AnnotationsSheet
|
|
469
|
+
open={queuedReview !== null}
|
|
470
|
+
onClose={() => setQueuedReview(null)}
|
|
471
|
+
annotations={queuedReview ?? []}
|
|
472
|
+
editable={false}
|
|
473
|
+
onReveal={(source, quote) => annotationReveal.current?.(source, quote) ?? false}
|
|
474
|
+
/>
|
|
357
475
|
</View>
|
|
358
476
|
);
|
|
359
477
|
}
|
package/src/timeline/sheet.tsx
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
KeyboardAvoidingView,
|
|
7
7
|
Modal,
|
|
8
8
|
PanResponder,
|
|
9
|
-
Platform,
|
|
10
9
|
Pressable,
|
|
11
10
|
View,
|
|
12
11
|
useWindowDimensions,
|
|
@@ -96,7 +95,9 @@ export function BottomSheet(props: {
|
|
|
96
95
|
onRequestClose={props.onClose}
|
|
97
96
|
>
|
|
98
97
|
<KeyboardAvoidingView
|
|
99
|
-
|
|
98
|
+
// Edge-to-edge Android doesn't resize a modal's window for the
|
|
99
|
+
// keyboard either, so the sheet lifts itself on both platforms.
|
|
100
|
+
behavior="padding"
|
|
100
101
|
style={{ flex: 1, justifyContent: "flex-end" }}
|
|
101
102
|
>
|
|
102
103
|
<Animated.View
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { DraftTimelineAnnotation } from "@opengeni/sdk";
|
|
2
|
+
import type { TimelineAnnotationSourceDescriptor } from "@opengeni/react/session";
|
|
3
|
+
import { annotationFromSelection } from "@opengeni/react/timeline-model";
|
|
4
|
+
import { useOpenGeniText } from "@opengeni/react/messages";
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
import { Platform } from "react-native";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Text selection inside timeline messages. A message body sits in a view with
|
|
10
|
+
* this test id prefix plus its annotation source key (empty when it cannot be
|
|
11
|
+
* annotated). On iOS the package's native module then gives its text the
|
|
12
|
+
* system selection (handles, Copy, Look Up, Share) instead of React Native's
|
|
13
|
+
* whole-paragraph Copy; on both platforms it adds Add note to the selection
|
|
14
|
+
* menu when a host takes annotations.
|
|
15
|
+
*/
|
|
16
|
+
export const ANNOTATION_SOURCE_TEST_ID_PREFIX = "og-annotation-source:";
|
|
17
|
+
|
|
18
|
+
export function annotationSourceTestId(sourceKey: string | undefined): string {
|
|
19
|
+
return `${ANNOTATION_SOURCE_TEST_ID_PREFIX}${sourceKey ?? ""}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type AnnotateEvent = { sourceKey: string; quote: string; before: string; after: string };
|
|
23
|
+
|
|
24
|
+
export type QuoteBox = { x: number; y: number; width: number; height: number };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where a quote is drawn in its source view: the end of its last glyph, the
|
|
28
|
+
* top of that line's text, and a text box for each line it spans.
|
|
29
|
+
*/
|
|
30
|
+
export type QuoteAnchor = { x: number; top: number; boxes: QuoteBox[] };
|
|
31
|
+
|
|
32
|
+
export type QuoteAnchorQuery = { quote: string; before: string; after: string };
|
|
33
|
+
|
|
34
|
+
type TextSelectionModule = {
|
|
35
|
+
addListener(
|
|
36
|
+
eventName: "onAnnotate",
|
|
37
|
+
listener: (event: AnnotateEvent) => void,
|
|
38
|
+
): { remove(): void };
|
|
39
|
+
setAnnotateTitle(title: string | null): void;
|
|
40
|
+
quoteAnchors?(
|
|
41
|
+
sourceKey: string,
|
|
42
|
+
queries: QuoteAnchorQuery[],
|
|
43
|
+
): Promise<({ x?: number; top?: number; rects?: number[] } | null)[]>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function textSelectionModule(): TextSelectionModule | null {
|
|
47
|
+
if (Platform.OS !== "ios" && Platform.OS !== "android") return null;
|
|
48
|
+
const registry = (globalThis as { expo?: { modules?: Record<string, unknown> } }).expo?.modules;
|
|
49
|
+
const module = registry?.OpenGeniTextSelection as TextSelectionModule | undefined;
|
|
50
|
+
return module && typeof module.addListener === "function" ? module : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether this build can turn a text selection into an annotation. */
|
|
54
|
+
export function nativeTextAnnotationAvailable(): boolean {
|
|
55
|
+
return textSelectionModule() !== null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Measures where each quote ends inside the source drawn for `sourceKey`;
|
|
60
|
+
* null for a quote that is not drawn there (or without native measuring).
|
|
61
|
+
*/
|
|
62
|
+
export async function measureQuoteAnchors(
|
|
63
|
+
sourceKey: string,
|
|
64
|
+
queries: QuoteAnchorQuery[],
|
|
65
|
+
): Promise<(QuoteAnchor | null)[]> {
|
|
66
|
+
const module = textSelectionModule();
|
|
67
|
+
if (!module?.quoteAnchors || queries.length === 0) return queries.map(() => null);
|
|
68
|
+
try {
|
|
69
|
+
const anchors = await module.quoteAnchors(sourceKey, queries);
|
|
70
|
+
return queries.map((_, index) => {
|
|
71
|
+
const anchor = anchors[index];
|
|
72
|
+
if (typeof anchor?.x !== "number" || typeof anchor.top !== "number") return null;
|
|
73
|
+
const flat = anchor.rects ?? [];
|
|
74
|
+
const boxes: QuoteBox[] = [];
|
|
75
|
+
for (let at = 0; at + 3 < flat.length; at += 4) {
|
|
76
|
+
boxes.push({ x: flat[at]!, y: flat[at + 1]!, width: flat[at + 2]!, height: flat[at + 3]! });
|
|
77
|
+
}
|
|
78
|
+
return { x: anchor.x, top: anchor.top, boxes };
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
return queries.map(() => null);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Turns Add note on a selection into a draft annotation for `onAnnotate`.
|
|
87
|
+
* `sources` maps each message's annotation source key to its source.
|
|
88
|
+
*/
|
|
89
|
+
export function useNativeTextAnnotation(
|
|
90
|
+
sources: ReadonlyMap<string, TimelineAnnotationSourceDescriptor>,
|
|
91
|
+
onAnnotate: ((annotation: DraftTimelineAnnotation) => void) | undefined,
|
|
92
|
+
): void {
|
|
93
|
+
const t = useOpenGeniText();
|
|
94
|
+
const latest = useRef({ sources, onAnnotate });
|
|
95
|
+
latest.current = { sources, onAnnotate };
|
|
96
|
+
const enabled = Boolean(onAnnotate);
|
|
97
|
+
const title = t("timelineAnnotationSelection.addNote");
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const module = textSelectionModule();
|
|
100
|
+
if (!module || !enabled) return;
|
|
101
|
+
// Add note shows while some timeline listens (the module tracks listeners).
|
|
102
|
+
module.setAnnotateTitle(title);
|
|
103
|
+
const subscription = module.addListener("onAnnotate", (event) => {
|
|
104
|
+
const source = latest.current.sources.get(event.sourceKey);
|
|
105
|
+
if (!source) return;
|
|
106
|
+
const annotation = annotationFromSelection(source, {
|
|
107
|
+
text: event.quote,
|
|
108
|
+
textBefore: event.before,
|
|
109
|
+
textAfter: event.after,
|
|
110
|
+
});
|
|
111
|
+
if (annotation) latest.current.onAnnotate?.(annotation);
|
|
112
|
+
});
|
|
113
|
+
return () => subscription.remove();
|
|
114
|
+
}, [enabled, title]);
|
|
115
|
+
}
|