@medway-ui/native 1.12.0 → 1.13.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,696 @@
1
+ import {
2
+ CloseSm,
3
+ Pin,
4
+ Note,
5
+ Strikethrough,
6
+ Underline,
7
+ } from "@medway-ui/icons/native";
8
+ import { cva } from "class-variance-authority";
9
+ import {
10
+ useContext,
11
+ useEffect,
12
+ useId,
13
+ useRef,
14
+ useState,
15
+ useSyncExternalStore,
16
+ type ComponentType,
17
+ } from "react";
18
+ import {
19
+ BackHandler,
20
+ Pressable,
21
+ StyleSheet,
22
+ View,
23
+ useWindowDimensions,
24
+ type LayoutChangeEvent,
25
+ } from "react-native";
26
+ import { SafeAreaInsetsContext } from "react-native-safe-area-context";
27
+ import Svg, { Path } from "react-native-svg";
28
+
29
+ import { Button } from "@/components/Button";
30
+ import { Separator } from "@/components/Separator";
31
+ import { ThemedIcon, type NativeIconProps } from "@/components/ThemedIcon";
32
+ import { useThemeColor, type ThemeColorToken } from "@/hooks/useThemeColor";
33
+ import { useOverlayHost, type HostOrigin } from "@/lib/overlay-host";
34
+ import { getMinTouchHitSlop } from "@/lib/touch-target";
35
+ import { cn } from "@/lib/utils";
36
+
37
+ export type TextMarkerColor = "cm" | "go" | "cir" | "ped" | "prev";
38
+
39
+ export type TextMarkerMode = "create" | "edit";
40
+
41
+ /**
42
+ * Retângulo do trecho em coordenadas da janela, como o `measureInWindow`
43
+ * devolve (`x` → `left`, `y` → `top`).
44
+ */
45
+ export type TextMarkerAnchor = {
46
+ top: number;
47
+ left: number;
48
+ width: number;
49
+ height: number;
50
+ };
51
+
52
+ export interface TextMarkerLabels {
53
+ toolbar: string;
54
+ color: string;
55
+ close: string;
56
+ strikethrough: string;
57
+ strikethroughHint: string;
58
+ underline: string;
59
+ underlineHint: string;
60
+ annotate: string;
61
+ annotateHint: string;
62
+ pin: string;
63
+ pinHint: string;
64
+ pinArmedHint: string;
65
+ unpinHint: string;
66
+ remove: string;
67
+ removeHint: string;
68
+ colorName: (color: TextMarkerColor) => string;
69
+ }
70
+
71
+ export interface TextMarkerProps {
72
+ /** Controla se a interface está visível. */
73
+ open?: boolean;
74
+ /**
75
+ * Retângulo do trecho na janela. A toolbar abre acima (ou abaixo, se não
76
+ * couber). Ao rolar, meça de novo e passe o retângulo novo, ou feche.
77
+ */
78
+ anchor?: TextMarkerAnchor | null;
79
+ /** `create` é seleção nova; `edit` é toque numa marcação existente. */
80
+ mode?: TextMarkerMode;
81
+ /** Trecho selecionado, passado em `onNote`. */
82
+ selectedText?: string;
83
+ /**
84
+ * Cor já aplicada. A amostra aparece selecionada, e tocar nela de novo
85
+ * chama `onRemove`.
86
+ */
87
+ color?: TextMarkerColor;
88
+ colors?: readonly TextMarkerColor[];
89
+ /**
90
+ * Se o trecho já está riscado. Riscar aparece selecionado, e tocar nele de
91
+ * novo chama `onRemove`.
92
+ */
93
+ strikethrough?: boolean;
94
+ /**
95
+ * Se o trecho já está sublinhado. Sublinhar aparece selecionado, e tocar
96
+ * nele de novo chama `onRemove`.
97
+ */
98
+ underline?: boolean;
99
+ /**
100
+ * Cor fixada. Com ela, o app marca as próximas seleções direto, sem abrir a
101
+ * toolbar. `null` ou ausente: nada fixado.
102
+ */
103
+ pinnedColor?: TextMarkerColor | null;
104
+ disabled?: boolean;
105
+ className?: string;
106
+ labels?: Partial<TextMarkerLabels>;
107
+ onHighlight?: (color: TextMarkerColor) => void;
108
+ /** Toque em Riscar num trecho que ainda não está riscado. */
109
+ onStrikethrough?: () => void;
110
+ /** Toque em Sublinhar num trecho que ainda não está sublinhado. */
111
+ onUnderline?: () => void;
112
+ /**
113
+ * Fixar ou desafixar. Recebe a cor escolhida depois de Fixar, ou `null` ao
114
+ * desafixar.
115
+ */
116
+ onPinnedColorChange?: (color: TextMarkerColor | null) => void;
117
+ /**
118
+ * Toque em Anotar. O app abre o Note Editor com este trecho. O TextMarker
119
+ * não monta o editor.
120
+ */
121
+ onNote?: (selectedText: string) => void;
122
+ onRemove?: () => void;
123
+ /** Toque fora da toolbar ou voltar do Android. */
124
+ onClose?: () => void;
125
+ }
126
+
127
+ const TEXT_MARKER_COLORS: readonly TextMarkerColor[] = Object.freeze([
128
+ "cm",
129
+ "go",
130
+ "cir",
131
+ "ped",
132
+ "prev",
133
+ ]);
134
+
135
+ const COLOR_NAMES: Record<TextMarkerColor, string> = {
136
+ cm: "Azul",
137
+ go: "Rosa",
138
+ cir: "Verde",
139
+ ped: "Amarelo",
140
+ prev: "Roxo",
141
+ };
142
+
143
+ const DEFAULT_LABELS: TextMarkerLabels = {
144
+ toolbar: "Marcação da seleção",
145
+ color: "Cor",
146
+ close: "Fechar marcação",
147
+ strikethrough: "Riscar",
148
+ strikethroughHint:
149
+ "Riscar: passa um traço sobre o trecho para destacar o que não importa",
150
+ underline: "Sublinhar",
151
+ underlineHint:
152
+ "Sublinhar: passa um traço sob o trecho para destacar o que importa",
153
+ annotate: "Anotar",
154
+ annotateHint: "Anotar: abre a nota com o trecho selecionado",
155
+ pin: "Fixar",
156
+ pinHint:
157
+ "Fixar: escolha uma cor e as próximas seleções já saem marcadas com ela",
158
+ pinArmedHint: "Escolha a cor que vai ficar fixada",
159
+ unpinHint: "Desafixar: as próximas seleções voltam a abrir esta ferramenta",
160
+ remove: "Remover marcação",
161
+ removeHint: "Remover a marcação deste trecho",
162
+ colorName: (color) => COLOR_NAMES[color],
163
+ };
164
+
165
+ const SWATCH_CLASS: Record<TextMarkerColor, string> = {
166
+ cm: "bg-cm-background-fill",
167
+ go: "bg-go-background-fill",
168
+ cir: "bg-cir-background-fill",
169
+ ped: "bg-ped-background-fill",
170
+ prev: "bg-prev-background-fill",
171
+ };
172
+
173
+ const SWATCH_SELECTED_CLASS: Record<TextMarkerColor, string> = {
174
+ cm: "border-cm-background-fill",
175
+ go: "border-go-background-fill",
176
+ cir: "border-cir-background-fill",
177
+ ped: "border-ped-background-fill",
178
+ prev: "border-prev-background-fill",
179
+ };
180
+
181
+ const MARK_COLOR_CLASS: Record<TextMarkerColor, string> = {
182
+ cm: "bg-cm-16 dark:bg-cm-48",
183
+ go: "bg-go-16 dark:bg-go-48",
184
+ cir: "bg-cir-16 dark:bg-cir-24",
185
+ ped: "bg-ped-24 dark:bg-ped-48",
186
+ prev: "bg-prev-16 dark:bg-prev-48",
187
+ };
188
+
189
+ const textMarkerMarkVariants = cva("", {
190
+ variants: {
191
+ color: { ...MARK_COLOR_CLASS, none: "bg-transparent" },
192
+ strikethrough: {
193
+ true: "text-gray-foreground-muted line-through",
194
+ false: "",
195
+ },
196
+ underline: {
197
+ true: "underline",
198
+ false: "",
199
+ },
200
+ },
201
+ defaultVariants: {
202
+ color: "none",
203
+ strikethrough: false,
204
+ underline: false,
205
+ },
206
+ });
207
+
208
+ const EDGE_GAP = 16;
209
+ const SIDE_OFFSET = 4;
210
+ const ARROW_WIDTH = 16;
211
+ const ARROW_HEIGHT = 10;
212
+ const ARROW_INSET = 24;
213
+ const SWATCH_SIZE = 32;
214
+ const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
215
+
216
+ type Insets = typeof NO_INSETS;
217
+
218
+ type Size = { width: number; height: number };
219
+
220
+ type Placement = {
221
+ top: number;
222
+ left: number;
223
+ arrowLeft: number;
224
+ side: "top" | "bottom" | "over";
225
+ };
226
+
227
+ function availableWidth(windowWidth: number, insets: Insets) {
228
+ return windowWidth - insets.left - insets.right - 2 * EDGE_GAP;
229
+ }
230
+
231
+ function computePlacement({
232
+ anchor,
233
+ hostOrigin,
234
+ window,
235
+ insets,
236
+ size,
237
+ }: {
238
+ anchor: TextMarkerAnchor;
239
+ hostOrigin: HostOrigin;
240
+ window: Size;
241
+ insets: Insets;
242
+ size: Size;
243
+ }): Placement {
244
+ const gap = SIDE_OFFSET + ARROW_HEIGHT;
245
+ const anchorTop = anchor.top - hostOrigin.y;
246
+ const anchorBottom = anchorTop + anchor.height;
247
+ const anchorCenter = anchor.left + anchor.width / 2 - hostOrigin.x;
248
+
249
+ const minTop = Math.max(insets.top - hostOrigin.y, 0) + EDGE_GAP;
250
+ const maxBottom =
251
+ Math.min(hostOrigin.height, window.height - insets.bottom - hostOrigin.y) -
252
+ EDGE_GAP;
253
+ const above = anchorTop - gap - size.height;
254
+ const below = anchorBottom + gap;
255
+
256
+ const side =
257
+ above >= minTop
258
+ ? "top"
259
+ : below + size.height <= maxBottom
260
+ ? "bottom"
261
+ : "over";
262
+ const top =
263
+ side === "top"
264
+ ? above
265
+ : side === "bottom"
266
+ ? below
267
+ : Math.max(minTop, Math.min(below, maxBottom - size.height));
268
+
269
+ const minLeft = Math.max(insets.left - hostOrigin.x, 0) + EDGE_GAP;
270
+ const maxLeft =
271
+ window.width - hostOrigin.x - insets.right - EDGE_GAP - size.width;
272
+ const left = Math.max(
273
+ minLeft,
274
+ Math.min(anchorCenter - size.width / 2, maxLeft)
275
+ );
276
+ const arrowLeft = Math.max(
277
+ ARROW_INSET - ARROW_WIDTH / 2,
278
+ Math.min(
279
+ anchorCenter - left - ARROW_WIDTH / 2,
280
+ size.width - ARROW_INSET - ARROW_WIDTH / 2
281
+ )
282
+ );
283
+
284
+ return { side, top, left, arrowLeft };
285
+ }
286
+
287
+ type PropsStore = {
288
+ get: () => TextMarkerProps;
289
+ set: (props: TextMarkerProps) => void;
290
+ subscribe: (listener: () => void) => () => void;
291
+ };
292
+
293
+ function createPropsStore(initial: TextMarkerProps): PropsStore {
294
+ let current = initial;
295
+ const listeners = new Set<() => void>();
296
+ return {
297
+ get: () => current,
298
+ set: (props) => {
299
+ if (props === current) return;
300
+ current = props;
301
+ listeners.forEach((listener) => listener());
302
+ },
303
+ subscribe: (listener) => {
304
+ listeners.add(listener);
305
+ return () => {
306
+ listeners.delete(listener);
307
+ };
308
+ },
309
+ };
310
+ }
311
+
312
+ function Arrow({ side, left }: { side: "top" | "bottom"; left: number }) {
313
+ const fill = useThemeColor("surface-elevated");
314
+ const stroke = useThemeColor("gray-stroke-muted");
315
+ const pointsDown = side === "top";
316
+
317
+ return (
318
+ <View
319
+ pointerEvents="none"
320
+ style={[
321
+ styles.arrow,
322
+ { left },
323
+ pointsDown
324
+ ? { bottom: -ARROW_HEIGHT + 1 }
325
+ : { top: -ARROW_HEIGHT + 1, transform: [{ rotate: "180deg" }] },
326
+ ]}
327
+ >
328
+ <Svg width={ARROW_WIDTH} height={ARROW_HEIGHT}>
329
+ <Path
330
+ d={`M0 0 L${ARROW_WIDTH / 2} ${ARROW_HEIGHT} L${ARROW_WIDTH} 0 Z`}
331
+ fill={fill}
332
+ />
333
+ <Path
334
+ d={`M0 0 L${ARROW_WIDTH / 2} ${ARROW_HEIGHT} L${ARROW_WIDTH} 0`}
335
+ fill="none"
336
+ stroke={stroke}
337
+ strokeWidth={1}
338
+ />
339
+ </Svg>
340
+ </View>
341
+ );
342
+ }
343
+
344
+ function ColorSwatch({
345
+ color,
346
+ selected,
347
+ name,
348
+ hint,
349
+ disabled,
350
+ onSelect,
351
+ }: {
352
+ color: TextMarkerColor;
353
+ selected: boolean;
354
+ name: string;
355
+ hint?: string;
356
+ disabled: boolean;
357
+ onSelect: (color: TextMarkerColor) => void;
358
+ }) {
359
+ return (
360
+ <Pressable
361
+ accessibilityRole="button"
362
+ accessibilityLabel={name}
363
+ accessibilityHint={hint}
364
+ accessibilityState={{ selected, disabled }}
365
+ disabled={disabled}
366
+ hitSlop={getMinTouchHitSlop(SWATCH_SIZE)}
367
+ onPress={() => onSelect(color)}
368
+ className={cn(
369
+ "size-8 shrink-0 items-center justify-center rounded-circular border border-transparent p-1",
370
+ selected && SWATCH_SELECTED_CLASS[color],
371
+ disabled && "opacity-50"
372
+ )}
373
+ >
374
+ <View
375
+ pointerEvents="none"
376
+ className={cn("size-6 shrink-0 rounded-circular", SWATCH_CLASS[color])}
377
+ />
378
+ </Pressable>
379
+ );
380
+ }
381
+
382
+ function ActionButton({
383
+ icon,
384
+ label,
385
+ hint,
386
+ selected,
387
+ selectedClassName,
388
+ selectedIconToken,
389
+ iconToken = "primary-foreground",
390
+ variant = "regular-basic",
391
+ disabled,
392
+ onPress,
393
+ }: {
394
+ icon: ComponentType<NativeIconProps>;
395
+ label: string;
396
+ hint: string;
397
+ selected?: boolean;
398
+ selectedClassName?: string;
399
+ selectedIconToken?: ThemeColorToken;
400
+ iconToken?: ThemeColorToken;
401
+ variant?: "regular-basic" | "error-soft";
402
+ disabled: boolean;
403
+ onPress: () => void;
404
+ }) {
405
+ const token = disabled
406
+ ? "gray-foreground-muted"
407
+ : selected && selectedIconToken
408
+ ? selectedIconToken
409
+ : iconToken;
410
+
411
+ return (
412
+ <Button
413
+ variant={variant}
414
+ size="sm"
415
+ accessibilityLabel={label}
416
+ accessibilityHint={hint}
417
+ accessibilityState={
418
+ selected === undefined ? { disabled } : { selected, disabled }
419
+ }
420
+ disabled={disabled}
421
+ hitSlop={getMinTouchHitSlop(SWATCH_SIZE)}
422
+ className={cn(selected && selectedClassName)}
423
+ iconLeft={<ThemedIcon icon={icon} size={16} colorToken={token} />}
424
+ onPress={onPress}
425
+ />
426
+ );
427
+ }
428
+
429
+ function ChromeSeparator() {
430
+ return <Separator orientation="vertical" className="h-7 self-center" />;
431
+ }
432
+
433
+ function TextMarkerLayer({ store }: { store: PropsStore }) {
434
+ const {
435
+ anchor = null,
436
+ mode = "create",
437
+ selectedText,
438
+ color,
439
+ colors = TEXT_MARKER_COLORS,
440
+ strikethrough = false,
441
+ underline = false,
442
+ pinnedColor = null,
443
+ disabled = false,
444
+ className,
445
+ labels: labelsProp,
446
+ onHighlight,
447
+ onStrikethrough,
448
+ onUnderline,
449
+ onPinnedColorChange,
450
+ onNote,
451
+ onRemove,
452
+ onClose,
453
+ } = useSyncExternalStore(store.subscribe, store.get, store.get);
454
+ const overlay = useOverlayHost();
455
+ const window = useWindowDimensions();
456
+ const insets = useContext(SafeAreaInsetsContext) ?? NO_INSETS;
457
+ const layerA = useThemeColor("drop-shadow-layer-a");
458
+ const layerB = useThemeColor("drop-shadow-layer-b");
459
+ const labels = { ...DEFAULT_LABELS, ...labelsProp };
460
+
461
+ const [hostOrigin, setHostOrigin] = useState<HostOrigin | null>(null);
462
+ const [size, setSize] = useState<Size | null>(null);
463
+ const [rowCheck, setRowCheck] = useState<{
464
+ width: number;
465
+ compact: boolean;
466
+ } | null>(null);
467
+ const rowY = useRef<{ width?: number; colors?: number; actions?: number }>(
468
+ {}
469
+ );
470
+ const [pinArmed, setPinArmed] = useState(false);
471
+
472
+ const maxWidth = availableWidth(window.width, insets);
473
+ const compact = rowCheck?.width === maxWidth ? rowCheck.compact : null;
474
+ const pinSelected = Boolean(pinnedColor) || pinArmed;
475
+ const pinHint = pinnedColor
476
+ ? labels.unpinHint
477
+ : pinArmed
478
+ ? labels.pinArmedHint
479
+ : labels.pinHint;
480
+ const placement =
481
+ anchor && hostOrigin && size && compact !== null
482
+ ? computePlacement({ anchor, hostOrigin, window, insets, size })
483
+ : null;
484
+
485
+ const selectColor = (next: TextMarkerColor) => {
486
+ if (next === color && !pinArmed) {
487
+ onRemove?.();
488
+ return;
489
+ }
490
+ if (pinArmed) {
491
+ setPinArmed(false);
492
+ onPinnedColorChange?.(next);
493
+ }
494
+ onHighlight?.(next);
495
+ };
496
+
497
+ const togglePin = () => {
498
+ if (pinnedColor) {
499
+ onPinnedColorChange?.(null);
500
+ return;
501
+ }
502
+ setPinArmed(!pinArmed);
503
+ };
504
+
505
+ const measureRow =
506
+ (key: "colors" | "actions") => (event: LayoutChangeEvent) => {
507
+ if (compact !== null) return;
508
+ if (rowY.current.width !== maxWidth) rowY.current = { width: maxWidth };
509
+ const { y, height } = event.nativeEvent.layout;
510
+ rowY.current[key] = key === "colors" ? y + height : y;
511
+ const { colors: colorsBottom, actions: actionsTop } = rowY.current;
512
+ if (colorsBottom === undefined || actionsTop === undefined) return;
513
+ setRowCheck({ width: maxWidth, compact: actionsTop >= colorsBottom });
514
+ };
515
+
516
+ const colorGroup = (
517
+ <View
518
+ className="flex-row items-center gap-2"
519
+ onLayout={measureRow("colors")}
520
+ >
521
+ {colors.map((item) => (
522
+ <ColorSwatch
523
+ key={item}
524
+ color={item}
525
+ name={`${labels.color} ${labels.colorName(item)}`}
526
+ hint={pinArmed ? labels.pinArmedHint : undefined}
527
+ selected={color === item}
528
+ disabled={disabled}
529
+ onSelect={selectColor}
530
+ />
531
+ ))}
532
+ </View>
533
+ );
534
+
535
+ const actionGroup = (
536
+ <View
537
+ className="flex-row items-center gap-2"
538
+ onLayout={measureRow("actions")}
539
+ >
540
+ <ActionButton
541
+ icon={Strikethrough}
542
+ label={labels.strikethrough}
543
+ hint={labels.strikethroughHint}
544
+ selected={strikethrough}
545
+ selectedClassName="bg-primary-background-soft-emphasis"
546
+ selectedIconToken="primary-foreground-emphasis"
547
+ disabled={disabled}
548
+ onPress={() => (strikethrough ? onRemove?.() : onStrikethrough?.())}
549
+ />
550
+ <ActionButton
551
+ icon={Underline}
552
+ label={labels.underline}
553
+ hint={labels.underlineHint}
554
+ selected={underline}
555
+ selectedClassName="bg-primary-background-soft-emphasis"
556
+ selectedIconToken="primary-foreground-emphasis"
557
+ disabled={disabled}
558
+ onPress={() => (underline ? onRemove?.() : onUnderline?.())}
559
+ />
560
+ <ActionButton
561
+ icon={Note}
562
+ label={labels.annotate}
563
+ hint={labels.annotateHint}
564
+ disabled={disabled}
565
+ onPress={() => onNote?.(selectedText ?? "")}
566
+ />
567
+ <ChromeSeparator />
568
+ <ActionButton
569
+ icon={Pin}
570
+ label={labels.pin}
571
+ hint={pinHint}
572
+ selected={pinSelected}
573
+ selectedClassName="bg-primary-background-fill active:bg-primary-background-fill-emphasis"
574
+ selectedIconToken="primary-foreground-onfill"
575
+ disabled={disabled}
576
+ onPress={togglePin}
577
+ />
578
+ {mode === "edit" ? (
579
+ <ActionButton
580
+ icon={CloseSm}
581
+ label={labels.remove}
582
+ hint={labels.removeHint}
583
+ variant="error-soft"
584
+ iconToken="feedback-error-foreground"
585
+ disabled={disabled}
586
+ onPress={() => onRemove?.()}
587
+ />
588
+ ) : null}
589
+ </View>
590
+ );
591
+
592
+ useEffect(() => {
593
+ let cancelled = false;
594
+ overlay.measureHostOrigin((origin) => {
595
+ if (!cancelled) setHostOrigin(origin);
596
+ });
597
+ return () => {
598
+ cancelled = true;
599
+ };
600
+ }, [overlay, window.width, window.height]);
601
+
602
+ useEffect(() => {
603
+ const subscription = BackHandler.addEventListener(
604
+ "hardwareBackPress",
605
+ () => {
606
+ const close = store.get().onClose;
607
+ close?.();
608
+ return Boolean(close);
609
+ }
610
+ );
611
+ return () => subscription.remove();
612
+ }, [store]);
613
+
614
+ if (!anchor || !hostOrigin) return null;
615
+
616
+ return (
617
+ <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
618
+ <Pressable
619
+ accessibilityLabel={labels.close}
620
+ style={StyleSheet.absoluteFill}
621
+ onPress={() => onClose?.()}
622
+ />
623
+ <View
624
+ key={maxWidth}
625
+ accessibilityRole="toolbar"
626
+ accessibilityLabel={labels.toolbar}
627
+ pointerEvents={placement ? "auto" : "none"}
628
+ className={cn(
629
+ "border border-gray-stroke-muted bg-surface-elevated",
630
+ compact
631
+ ? "items-center gap-2 rounded-lg p-2"
632
+ : "flex-row flex-wrap items-center gap-2 rounded-pill py-2 pl-3 pr-2",
633
+ className
634
+ )}
635
+ style={[
636
+ styles.toolbar,
637
+ {
638
+ maxWidth,
639
+ top: placement?.top ?? 0,
640
+ left: placement?.left ?? 0,
641
+ opacity: placement ? 1 : 0,
642
+ boxShadow: `0 0 2px 0 ${layerA}, 0 2px 4px 0 ${layerB}`,
643
+ },
644
+ ]}
645
+ onLayout={(event) => {
646
+ const { width, height } = event.nativeEvent.layout;
647
+ setSize((current) =>
648
+ current?.width === width && current.height === height
649
+ ? current
650
+ : { width, height }
651
+ );
652
+ }}
653
+ >
654
+ {colorGroup}
655
+ {compact ? null : <ChromeSeparator />}
656
+ {actionGroup}
657
+ {placement && placement.side !== "over" ? (
658
+ <Arrow side={placement.side} left={placement.arrowLeft} />
659
+ ) : null}
660
+ </View>
661
+ </View>
662
+ );
663
+ }
664
+
665
+ function TextMarker(props: TextMarkerProps) {
666
+ const id = useId();
667
+ const overlay = useOverlayHost();
668
+ const [store] = useState(() => createPropsStore(props));
669
+ const visible = Boolean(props.open && props.anchor);
670
+
671
+ useEffect(() => {
672
+ store.set(props);
673
+ });
674
+
675
+ useEffect(() => {
676
+ if (!visible) return;
677
+ overlay.mount(id, <TextMarkerLayer store={store} />);
678
+ return () => overlay.unmount(id);
679
+ }, [id, overlay, store, visible]);
680
+
681
+ return null;
682
+ }
683
+
684
+ const styles = StyleSheet.create({
685
+ toolbar: {
686
+ position: "absolute",
687
+ zIndex: 2,
688
+ },
689
+ arrow: {
690
+ position: "absolute",
691
+ width: ARROW_WIDTH,
692
+ height: ARROW_HEIGHT,
693
+ },
694
+ });
695
+
696
+ export { TEXT_MARKER_COLORS, TextMarker, textMarkerMarkVariants };
package/src/index.ts CHANGED
@@ -459,6 +459,20 @@ export type {
459
459
  ToastVariant,
460
460
  } from "@/components/Toast";
461
461
 
462
+ // TextMarker
463
+ export {
464
+ TEXT_MARKER_COLORS,
465
+ TextMarker,
466
+ textMarkerMarkVariants,
467
+ } from "@/components/TextMarker";
468
+ export type {
469
+ TextMarkerAnchor,
470
+ TextMarkerColor,
471
+ TextMarkerLabels,
472
+ TextMarkerMode,
473
+ TextMarkerProps,
474
+ } from "@/components/TextMarker";
475
+
462
476
  // Tooltip
463
477
  export {
464
478
  Tooltip,