@medway-ui/native 1.11.0 → 1.13.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.
- package/README.md +1 -0
- package/lib/commonjs/components/TextMarker.js +485 -0
- package/lib/commonjs/components/TextMarker.js.map +1 -0
- package/lib/commonjs/editor/NoteEditor.js +29 -152
- package/lib/commonjs/editor/NoteEditor.js.map +1 -1
- package/lib/commonjs/editor/editorHtml.generated.js +1 -1
- package/lib/commonjs/editor/editorHtml.generated.js.map +1 -1
- package/lib/commonjs/editor/protocol.js +2 -3
- package/lib/commonjs/editor/protocol.js.map +1 -1
- package/lib/commonjs/index.js +19 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/components/TextMarker.js +479 -0
- package/lib/module/components/TextMarker.js.map +1 -0
- package/lib/module/editor/NoteEditor.js +32 -155
- package/lib/module/editor/NoteEditor.js.map +1 -1
- package/lib/module/editor/editorHtml.generated.js +1 -1
- package/lib/module/editor/editorHtml.generated.js.map +1 -1
- package/lib/module/editor/protocol.js +2 -3
- package/lib/module/editor/protocol.js.map +1 -1
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/components/TextMarker.d.ts +94 -0
- package/lib/typescript/components/TextMarker.d.ts.map +1 -0
- package/lib/typescript/editor/NoteEditor.d.ts +1 -1
- package/lib/typescript/editor/NoteEditor.d.ts.map +1 -1
- package/lib/typescript/editor/editorHtml.generated.d.ts.map +1 -1
- package/lib/typescript/editor/protocol.d.ts +2 -10
- package/lib/typescript/editor/protocol.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +2 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/TextMarker.tsx +696 -0
- package/src/editor/NoteEditor.tsx +10 -192
- package/src/editor/editorHtml.generated.ts +1 -1
- package/src/editor/protocol.ts +2 -8
- package/src/index.ts +14 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CloseSm,
|
|
3
|
+
MapPin,
|
|
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={MapPin}
|
|
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 };
|