@medway-ui/native 1.2.0 → 1.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/lib/commonjs/editor/NoteEditor.js +710 -0
- package/lib/commonjs/editor/NoteEditor.js.map +1 -0
- package/lib/commonjs/editor/editorHtml.generated.js +21 -0
- package/lib/commonjs/editor/editorHtml.generated.js.map +1 -0
- package/lib/commonjs/editor/index.js +33 -0
- package/lib/commonjs/editor/index.js.map +1 -0
- package/lib/commonjs/editor/protocol.js +88 -0
- package/lib/commonjs/editor/protocol.js.map +1 -0
- package/lib/module/editor/NoteEditor.js +705 -0
- package/lib/module/editor/NoteEditor.js.map +1 -0
- package/lib/module/editor/editorHtml.generated.js +17 -0
- package/lib/module/editor/editorHtml.generated.js.map +1 -0
- package/lib/module/editor/index.js +5 -0
- package/lib/module/editor/index.js.map +1 -0
- package/lib/module/editor/protocol.js +83 -0
- package/lib/module/editor/protocol.js.map +1 -0
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/editor/NoteEditor.d.ts +82 -0
- package/lib/typescript/editor/NoteEditor.d.ts.map +1 -0
- package/lib/typescript/editor/editorHtml.generated.d.ts +13 -0
- package/lib/typescript/editor/editorHtml.generated.d.ts.map +1 -0
- package/lib/typescript/editor/index.d.ts +6 -0
- package/lib/typescript/editor/index.d.ts.map +1 -0
- package/lib/typescript/editor/protocol.d.ts +153 -0
- package/lib/typescript/editor/protocol.d.ts.map +1 -0
- package/package.json +25 -2
- package/setup/metro.js +27 -5
- package/src/editor/NoteEditor.tsx +802 -0
- package/src/editor/editorHtml.generated.ts +14 -0
- package/src/editor/index.ts +13 -0
- package/src/editor/protocol.ts +184 -0
- package/src/index.ts +6 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
memo,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useImperativeHandle,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ComponentType,
|
|
11
|
+
type Ref,
|
|
12
|
+
} from "react";
|
|
13
|
+
import {
|
|
14
|
+
Platform,
|
|
15
|
+
Pressable,
|
|
16
|
+
StyleSheet,
|
|
17
|
+
TextInput,
|
|
18
|
+
View,
|
|
19
|
+
type NativeSyntheticEvent,
|
|
20
|
+
type TextInputKeyPressEventData,
|
|
21
|
+
} from "react-native";
|
|
22
|
+
import UntypedWebView, {
|
|
23
|
+
type WebViewMessageEvent,
|
|
24
|
+
type WebViewProps,
|
|
25
|
+
} from "react-native-webview";
|
|
26
|
+
import { useColorScheme } from "nativewind";
|
|
27
|
+
|
|
28
|
+
import { EDITOR_HTML } from "./editorHtml.generated";
|
|
29
|
+
|
|
30
|
+
import type {
|
|
31
|
+
EditorCommand,
|
|
32
|
+
EditorDocument,
|
|
33
|
+
EditorSelectionState,
|
|
34
|
+
EditorTheme,
|
|
35
|
+
SlashCommandId,
|
|
36
|
+
SlashMenuState,
|
|
37
|
+
} from "@medway-ui/notes-core";
|
|
38
|
+
import {
|
|
39
|
+
EMPTY_DOCUMENT,
|
|
40
|
+
EMPTY_SLASH_STATE,
|
|
41
|
+
INITIAL_EDITOR_STATE,
|
|
42
|
+
getSlashCommand,
|
|
43
|
+
normalizeDocument,
|
|
44
|
+
} from "@medway-ui/notes-core";
|
|
45
|
+
import {
|
|
46
|
+
ArrowUndoUpLeft,
|
|
47
|
+
Bold,
|
|
48
|
+
Code,
|
|
49
|
+
DoubleQuotesL,
|
|
50
|
+
HeadingH2,
|
|
51
|
+
HeadingH3,
|
|
52
|
+
Image as ImageIcon,
|
|
53
|
+
Italic,
|
|
54
|
+
Link as LinkIcon,
|
|
55
|
+
ListOrdered,
|
|
56
|
+
ListUnordered,
|
|
57
|
+
Redo,
|
|
58
|
+
Strikethrough,
|
|
59
|
+
Underline,
|
|
60
|
+
WindowCodeBlock,
|
|
61
|
+
} from "@medway-ui/icons/native";
|
|
62
|
+
|
|
63
|
+
import { Text } from "@/components/Text";
|
|
64
|
+
import { Button } from "@/components/Button";
|
|
65
|
+
import { Input } from "@/components/Input";
|
|
66
|
+
import {
|
|
67
|
+
Dialog,
|
|
68
|
+
DialogContainer,
|
|
69
|
+
DialogContent,
|
|
70
|
+
DialogFooter,
|
|
71
|
+
DialogHeader,
|
|
72
|
+
DialogTitle,
|
|
73
|
+
} from "@/components/Dialog";
|
|
74
|
+
import { useThemeColor } from "@/hooks/useThemeColor";
|
|
75
|
+
import { cn } from "@/lib/utils";
|
|
76
|
+
|
|
77
|
+
import {
|
|
78
|
+
EDITOR_PROTOCOL_VERSION,
|
|
79
|
+
EDITOR_RECEIVE_HANDLER,
|
|
80
|
+
isEditorMessage,
|
|
81
|
+
type EditorToNativeMessage,
|
|
82
|
+
type NativeToEditorMessage,
|
|
83
|
+
} from "./protocol";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The ref surface of `react-native-webview`, kept local so consumers need not
|
|
87
|
+
* depend on internal WebView types. Internal only — `NoteEditorHandle` below is
|
|
88
|
+
* the public ref shape.
|
|
89
|
+
*/
|
|
90
|
+
type WebViewHandle = {
|
|
91
|
+
injectJavaScript: (script: string) => void;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Retyped locally: the library's own `WebView` resolves to `Component<never>`
|
|
96
|
+
* under `strict` and rejects every JSX prop.
|
|
97
|
+
*/
|
|
98
|
+
const WebView = UntypedWebView as unknown as ComponentType<
|
|
99
|
+
WebViewProps & { ref?: Ref<WebViewHandle> }
|
|
100
|
+
>;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What `getSaveData` returns. No `html` field, unlike web: generating HTML needs
|
|
104
|
+
* a DOM, which never exists outside the WebView here. Persist `json` and render
|
|
105
|
+
* HTML backend-side if needed.
|
|
106
|
+
*/
|
|
107
|
+
export type NoteEditorValue = {
|
|
108
|
+
title: string;
|
|
109
|
+
json: EditorDocument;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Same shape as the web `NoteEditor`'s handle, minus `editor` (no in-process
|
|
114
|
+
* Tiptap instance to hand out) and with the document getters async, since
|
|
115
|
+
* reading the document is a bridge round trip here.
|
|
116
|
+
*/
|
|
117
|
+
export type NoteEditorHandle = {
|
|
118
|
+
focus: (position?: "start" | "end") => void;
|
|
119
|
+
blur: () => void;
|
|
120
|
+
getDocument: () => Promise<EditorDocument>;
|
|
121
|
+
getSaveData: () => Promise<NoteEditorValue>;
|
|
122
|
+
setDocument: (
|
|
123
|
+
doc: EditorDocument | string,
|
|
124
|
+
options?: { focus?: boolean }
|
|
125
|
+
) => void;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
type NoteEditorProps = {
|
|
129
|
+
/**
|
|
130
|
+
* Initial document: the note's JSON, or an HTML string (parsed bundle-side,
|
|
131
|
+
* the only side with a DOM). Changing it replaces the content but keeps the
|
|
132
|
+
* instance — pass a new React `key` for a fresh undo stack, as when
|
|
133
|
+
* switching notes.
|
|
134
|
+
*/
|
|
135
|
+
document?: EditorDocument | string;
|
|
136
|
+
/** Initial title, shown above the content in its own always-visible area. */
|
|
137
|
+
initialTitle?: string;
|
|
138
|
+
titlePlaceholder?: string;
|
|
139
|
+
onChange?: (doc: EditorDocument) => void;
|
|
140
|
+
editable?: boolean;
|
|
141
|
+
/** Focuses the title if `initialTitle` is empty, otherwise the content. */
|
|
142
|
+
autofocus?: boolean | "start" | "end";
|
|
143
|
+
/** Shows the formatting bar above the editor. */
|
|
144
|
+
toolbar?: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Resolves with the URL to use as the image's `src`, or `null` if the host's
|
|
147
|
+
* picker was cancelled. **Different shape from the web prop**: there is no
|
|
148
|
+
* file input here and this package carries no image-picker dependency, so
|
|
149
|
+
* picking the file is the host's job. Providing it is what enables the
|
|
150
|
+
* toolbar "Imagem" button and the `/Imagem` slash command.
|
|
151
|
+
*/
|
|
152
|
+
onImageUpload?: () => Promise<{ src: string; alt?: string | null } | null>;
|
|
153
|
+
/**
|
|
154
|
+
* Fires when an image inserted this session leaves the document and stays out
|
|
155
|
+
* past the grace period. Tracked bundle-side (`editor-bundle/src/main.ts`).
|
|
156
|
+
*/
|
|
157
|
+
onImageRemoved?: (src: string) => void;
|
|
158
|
+
/** Default 4000ms. */
|
|
159
|
+
imageDeleteGraceMs?: number;
|
|
160
|
+
/**
|
|
161
|
+
* Native-only. The WebView never navigates, so the host decides what to open
|
|
162
|
+
* (e.g. `Linking.openURL` after validating the URL).
|
|
163
|
+
*/
|
|
164
|
+
onLinkPress?: (href: string) => void;
|
|
165
|
+
/**
|
|
166
|
+
* Native-only. Starting height in dp for embedded mode: the editor sits in a
|
|
167
|
+
* native scroll container, does not scroll itself, and is resized to the
|
|
168
|
+
* height it reports over the bridge. Omit to let the WebView fill and scroll
|
|
169
|
+
* its own parent.
|
|
170
|
+
*/
|
|
171
|
+
height?: number;
|
|
172
|
+
/**
|
|
173
|
+
* Native-only. Applied to the container and the WebView; match the host theme
|
|
174
|
+
* to avoid a flash before the WebView paints its own document background
|
|
175
|
+
* (Android is particularly sensitive).
|
|
176
|
+
*/
|
|
177
|
+
backgroundColor?: string;
|
|
178
|
+
testID?: string;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** The title is single-line; newlines can still arrive via paste. */
|
|
182
|
+
const sanitizeTitle = (value: string): string => value.replace(/\r?\n/g, " ");
|
|
183
|
+
|
|
184
|
+
const DEFAULT_IMAGE_DELETE_GRACE_MS = 4000;
|
|
185
|
+
|
|
186
|
+
let requestCounter = 0;
|
|
187
|
+
/** Correlates a `getDocument` request with the `document` response that answers it. */
|
|
188
|
+
const nextRequestId = (): string => {
|
|
189
|
+
requestCounter += 1;
|
|
190
|
+
return `req_${requestCounter}_${Date.now()}`;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resolves semantic design-system colors into a raw `EditorTheme`. Sent to the
|
|
195
|
+
* WebView on mount and on every color-scheme change, so a host never has to
|
|
196
|
+
* manage editor theming itself.
|
|
197
|
+
*/
|
|
198
|
+
const useNoteEditorTheme = (): EditorTheme => {
|
|
199
|
+
const { colorScheme } = useColorScheme();
|
|
200
|
+
const background = useThemeColor("surface-card");
|
|
201
|
+
const text = useThemeColor("content-paragraph");
|
|
202
|
+
const heading = useThemeColor("content-title");
|
|
203
|
+
const muted = useThemeColor("gray-foreground-muted");
|
|
204
|
+
const border = useThemeColor("gray-stroke-muted");
|
|
205
|
+
const accent = useThemeColor("primary-foreground");
|
|
206
|
+
const codeBackground = useThemeColor("gray-background-soft");
|
|
207
|
+
|
|
208
|
+
return useMemo<EditorTheme>(
|
|
209
|
+
() => ({
|
|
210
|
+
colorScheme: colorScheme === "dark" ? "dark" : "light",
|
|
211
|
+
background,
|
|
212
|
+
text,
|
|
213
|
+
heading,
|
|
214
|
+
muted,
|
|
215
|
+
placeholder: muted,
|
|
216
|
+
border,
|
|
217
|
+
accent,
|
|
218
|
+
caret: accent,
|
|
219
|
+
selection: border,
|
|
220
|
+
codeBackground,
|
|
221
|
+
codeText: text,
|
|
222
|
+
// Approximates the web surface's `text-md`/`leading-md`; not pixel-exact.
|
|
223
|
+
fontSize: 15,
|
|
224
|
+
lineHeight: 22,
|
|
225
|
+
paddingHorizontal: 16,
|
|
226
|
+
paddingTop: 12,
|
|
227
|
+
paddingBottom: 32,
|
|
228
|
+
}),
|
|
229
|
+
[colorScheme, background, text, heading, muted, border, accent, codeBackground]
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** Every icon exported from `@medway-ui/icons/native` shares this component shape. */
|
|
234
|
+
type IconComponent = typeof Bold;
|
|
235
|
+
|
|
236
|
+
type ToolbarItem = {
|
|
237
|
+
id: string;
|
|
238
|
+
label: string;
|
|
239
|
+
Icon: IconComponent;
|
|
240
|
+
command?: EditorCommand;
|
|
241
|
+
activeKey?: keyof EditorSelectionState["active"];
|
|
242
|
+
disabled?: (state: EditorSelectionState) => boolean;
|
|
243
|
+
onPress?: () => void;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Same commands, grouping and order as the web `NoteEditor`'s `TOOLBAR_GROUPS`.
|
|
248
|
+
* `link` and `undo`/`redo` are filled in per-instance: they need the live
|
|
249
|
+
* `EditorSelectionState` and a dialog opener that only exist in the component.
|
|
250
|
+
*/
|
|
251
|
+
const buildToolbarGroups = (
|
|
252
|
+
state: EditorSelectionState,
|
|
253
|
+
openLinkDialog: () => void
|
|
254
|
+
): ToolbarItem[][] => [
|
|
255
|
+
[
|
|
256
|
+
{ id: "bold", label: "Negrito", Icon: Bold, command: { name: "toggleBold" }, activeKey: "bold" },
|
|
257
|
+
{ id: "italic", label: "Itálico", Icon: Italic, command: { name: "toggleItalic" }, activeKey: "italic" },
|
|
258
|
+
{ id: "underline", label: "Sublinhado", Icon: Underline, command: { name: "toggleUnderline" }, activeKey: "underline" },
|
|
259
|
+
{ id: "strike", label: "Tachado", Icon: Strikethrough, command: { name: "toggleStrike" }, activeKey: "strike" },
|
|
260
|
+
{ id: "code", label: "Código", Icon: Code, command: { name: "toggleCode" }, activeKey: "code" },
|
|
261
|
+
],
|
|
262
|
+
[
|
|
263
|
+
{ id: "heading2", label: "Título 2", Icon: HeadingH2, command: { name: "toggleHeading", level: 2 }, activeKey: "heading2" },
|
|
264
|
+
{ id: "heading3", label: "Título 3", Icon: HeadingH3, command: { name: "toggleHeading", level: 3 }, activeKey: "heading3" },
|
|
265
|
+
],
|
|
266
|
+
[
|
|
267
|
+
{ id: "bulletList", label: "Lista com marcadores", Icon: ListUnordered, command: { name: "toggleBulletList" }, activeKey: "bulletList" },
|
|
268
|
+
{ id: "orderedList", label: "Lista numerada", Icon: ListOrdered, command: { name: "toggleOrderedList" }, activeKey: "orderedList" },
|
|
269
|
+
{ id: "blockquote", label: "Citação", Icon: DoubleQuotesL, command: { name: "toggleBlockquote" }, activeKey: "blockquote" },
|
|
270
|
+
{ id: "codeBlock", label: "Bloco de código", Icon: WindowCodeBlock, command: { name: "toggleCodeBlock" }, activeKey: "codeBlock" },
|
|
271
|
+
],
|
|
272
|
+
[{ id: "link", label: "Inserir link", Icon: LinkIcon, activeKey: "link", onPress: openLinkDialog }],
|
|
273
|
+
[
|
|
274
|
+
{ id: "undo", label: "Desfazer", Icon: ArrowUndoUpLeft, command: { name: "undo" }, disabled: (s) => !s.canUndo },
|
|
275
|
+
{ id: "redo", label: "Refazer", Icon: Redo, command: { name: "redo" }, disabled: (s) => !s.canRedo },
|
|
276
|
+
],
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Unlike every other command — a pure ProseMirror op — "Imagem" needs a file
|
|
281
|
+
* first, so it is only offered when the host gave us a way to resolve one.
|
|
282
|
+
* Kept local because `notes-core` cannot know what a given host can do.
|
|
283
|
+
*/
|
|
284
|
+
const visibleSlashCommandIds = (
|
|
285
|
+
commandIds: SlashCommandId[],
|
|
286
|
+
canUploadImage: boolean
|
|
287
|
+
): SlashCommandId[] =>
|
|
288
|
+
canUploadImage ? commandIds : commandIds.filter((id) => id !== "image");
|
|
289
|
+
|
|
290
|
+
const NoteEditor = forwardRef<NoteEditorHandle, NoteEditorProps>(
|
|
291
|
+
(
|
|
292
|
+
{
|
|
293
|
+
document: initialDocument,
|
|
294
|
+
initialTitle,
|
|
295
|
+
titlePlaceholder = "Title",
|
|
296
|
+
onChange,
|
|
297
|
+
editable = true,
|
|
298
|
+
autofocus = false,
|
|
299
|
+
toolbar = false,
|
|
300
|
+
onImageUpload,
|
|
301
|
+
onImageRemoved,
|
|
302
|
+
imageDeleteGraceMs = DEFAULT_IMAGE_DELETE_GRACE_MS,
|
|
303
|
+
onLinkPress,
|
|
304
|
+
height,
|
|
305
|
+
backgroundColor,
|
|
306
|
+
testID,
|
|
307
|
+
},
|
|
308
|
+
ref
|
|
309
|
+
) => {
|
|
310
|
+
const webViewRef = useRef<WebViewHandle>(null);
|
|
311
|
+
const isEmbedded = height !== undefined;
|
|
312
|
+
const titlePlaceholderColor = useThemeColor("gray-foreground-muted");
|
|
313
|
+
|
|
314
|
+
const [isReady, setIsReady] = useState(false);
|
|
315
|
+
const [selectionState, setSelectionState] = useState<EditorSelectionState>(
|
|
316
|
+
INITIAL_EDITOR_STATE
|
|
317
|
+
);
|
|
318
|
+
const [slashState, setSlashState] = useState<SlashMenuState>(EMPTY_SLASH_STATE);
|
|
319
|
+
const [contentHeight, setContentHeight] = useState(height);
|
|
320
|
+
|
|
321
|
+
const [title, setTitle] = useState(() => sanitizeTitle(initialTitle ?? ""));
|
|
322
|
+
const titleInputRef = useRef<TextInput>(null);
|
|
323
|
+
const [titleSelection, setTitleSelection] = useState<
|
|
324
|
+
{ start: number; end: number } | undefined
|
|
325
|
+
>(undefined);
|
|
326
|
+
|
|
327
|
+
const autofocusTitleRef = useRef(Boolean(autofocus) && title.length === 0);
|
|
328
|
+
|
|
329
|
+
useEffect(() => {
|
|
330
|
+
if (autofocusTitleRef.current) titleInputRef.current?.focus();
|
|
331
|
+
// Mount-only: the one-time "new note" autofocus decision.
|
|
332
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
333
|
+
}, []);
|
|
334
|
+
|
|
335
|
+
const focusTitleAtEnd = useCallback(() => {
|
|
336
|
+
titleInputRef.current?.focus();
|
|
337
|
+
setTitleSelection({ start: title.length, end: title.length });
|
|
338
|
+
}, [title.length]);
|
|
339
|
+
|
|
340
|
+
// Internal from here on: a host never sends or reads a bridge message
|
|
341
|
+
// directly. See `./protocol.ts`.
|
|
342
|
+
const send = useCallback((message: NativeToEditorMessage) => {
|
|
343
|
+
webViewRef.current?.injectJavaScript(
|
|
344
|
+
`window.${EDITOR_RECEIVE_HANDLER}(${JSON.stringify(JSON.stringify(message))}); true;`
|
|
345
|
+
);
|
|
346
|
+
}, []);
|
|
347
|
+
|
|
348
|
+
const pendingDocumentRequests = useRef(
|
|
349
|
+
new Map<string, { resolve: (doc: EditorDocument) => void; reject: (error: Error) => void }>()
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const requestDocument = useCallback((): Promise<EditorDocument> => {
|
|
353
|
+
const requestId = nextRequestId();
|
|
354
|
+
|
|
355
|
+
return new Promise<EditorDocument>((resolve, reject) => {
|
|
356
|
+
pendingDocumentRequests.current.set(requestId, { resolve, reject });
|
|
357
|
+
send({ type: "getDocument", requestId });
|
|
358
|
+
|
|
359
|
+
setTimeout(() => {
|
|
360
|
+
if (!pendingDocumentRequests.current.has(requestId)) return;
|
|
361
|
+
pendingDocumentRequests.current.delete(requestId);
|
|
362
|
+
reject(new Error("NoteEditor: timed out waiting for the WebView bundle to respond"));
|
|
363
|
+
}, 5000);
|
|
364
|
+
});
|
|
365
|
+
}, [send]);
|
|
366
|
+
|
|
367
|
+
const onImageUploadRef = useRef(onImageUpload);
|
|
368
|
+
const onImageRemovedRef = useRef(onImageRemoved);
|
|
369
|
+
const onChangeRef = useRef(onChange);
|
|
370
|
+
const onLinkPressRef = useRef(onLinkPress);
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
onImageUploadRef.current = onImageUpload;
|
|
373
|
+
}, [onImageUpload]);
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
onImageRemovedRef.current = onImageRemoved;
|
|
376
|
+
}, [onImageRemoved]);
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
onChangeRef.current = onChange;
|
|
379
|
+
}, [onChange]);
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
onLinkPressRef.current = onLinkPress;
|
|
382
|
+
}, [onLinkPress]);
|
|
383
|
+
|
|
384
|
+
const insertImage = useCallback(async () => {
|
|
385
|
+
const upload = onImageUploadRef.current;
|
|
386
|
+
if (!upload) return;
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const result = await upload();
|
|
390
|
+
if (!result) return;
|
|
391
|
+
send({ type: "insertImage", src: result.src, alt: result.alt ?? null });
|
|
392
|
+
} catch (error) {
|
|
393
|
+
// eslint-disable-next-line no-console
|
|
394
|
+
console.error("@medway-ui/native NoteEditor: onImageUpload failed", error);
|
|
395
|
+
}
|
|
396
|
+
}, [send]);
|
|
397
|
+
|
|
398
|
+
const handleMessage = useCallback(
|
|
399
|
+
(event: WebViewMessageEvent) => {
|
|
400
|
+
let message: unknown;
|
|
401
|
+
try {
|
|
402
|
+
message = JSON.parse(event.nativeEvent.data);
|
|
403
|
+
} catch {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (!isEditorMessage(message)) return;
|
|
407
|
+
|
|
408
|
+
const editorMessage = message as EditorToNativeMessage;
|
|
409
|
+
switch (editorMessage.type) {
|
|
410
|
+
case "ready":
|
|
411
|
+
// A protocol mismatch means a stale `editorHtml.generated.ts`.
|
|
412
|
+
// Refused loudly rather than letting commands quietly not match
|
|
413
|
+
// what the bundle understands.
|
|
414
|
+
if (editorMessage.protocol !== EDITOR_PROTOCOL_VERSION) {
|
|
415
|
+
// eslint-disable-next-line no-console
|
|
416
|
+
console.error(
|
|
417
|
+
`@medway-ui/native NoteEditor: editor bundle reports protocol ${editorMessage.protocol}, expected ${EDITOR_PROTOCOL_VERSION}. Rebuild editor-bundle (its generated HTML is stale).`
|
|
418
|
+
);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
setIsReady(true);
|
|
422
|
+
return;
|
|
423
|
+
case "state":
|
|
424
|
+
setSelectionState(editorMessage.state);
|
|
425
|
+
return;
|
|
426
|
+
case "slash":
|
|
427
|
+
setSlashState(editorMessage.slash);
|
|
428
|
+
return;
|
|
429
|
+
case "change":
|
|
430
|
+
// Already debounced by the bundle (`CHANGE_DEBOUNCE_MS`). The extra
|
|
431
|
+
// round trip happens only when a host asked for `onChange`, which
|
|
432
|
+
// keeps serialization per-settled-keystroke, not per-keystroke.
|
|
433
|
+
if (onChangeRef.current) {
|
|
434
|
+
requestDocument()
|
|
435
|
+
.then((doc) => onChangeRef.current?.(doc))
|
|
436
|
+
.catch(() => {});
|
|
437
|
+
}
|
|
438
|
+
return;
|
|
439
|
+
case "document": {
|
|
440
|
+
const pending = pendingDocumentRequests.current.get(editorMessage.requestId);
|
|
441
|
+
if (!pending) return;
|
|
442
|
+
pendingDocumentRequests.current.delete(editorMessage.requestId);
|
|
443
|
+
// `isEditorMessage` only established that `doc` is an object. The
|
|
444
|
+
// whitelist/depth pass is what makes it an `EditorDocument`, and it
|
|
445
|
+
// runs on the way out too, not just on `setContent` — the WebView
|
|
446
|
+
// renders student content, so what it hands back is untrusted.
|
|
447
|
+
pending.resolve(normalizeDocument(editorMessage.doc));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
case "resize":
|
|
451
|
+
if (isEmbedded) setContentHeight(editorMessage.height);
|
|
452
|
+
return;
|
|
453
|
+
case "linkPress":
|
|
454
|
+
onLinkPressRef.current?.(editorMessage.href);
|
|
455
|
+
return;
|
|
456
|
+
case "backspaceAtDocStart":
|
|
457
|
+
focusTitleAtEnd();
|
|
458
|
+
return;
|
|
459
|
+
case "imageRemoved":
|
|
460
|
+
onImageRemovedRef.current?.(editorMessage.src);
|
|
461
|
+
return;
|
|
462
|
+
case "error":
|
|
463
|
+
// eslint-disable-next-line no-console
|
|
464
|
+
console.error("@medway-ui/native NoteEditor:", editorMessage.message);
|
|
465
|
+
return;
|
|
466
|
+
default:
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
[focusTitleAtEnd, isEmbedded, requestDocument]
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
// Fires once, when the bundle reports `ready`: the initial document, layout
|
|
474
|
+
// mode and image-delete grace period are all read-once.
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
if (!isReady) return;
|
|
477
|
+
|
|
478
|
+
send({
|
|
479
|
+
type: "setContent",
|
|
480
|
+
doc: initialDocument ?? EMPTY_DOCUMENT,
|
|
481
|
+
editable,
|
|
482
|
+
focus: autofocusTitleRef.current ? false : Boolean(autofocus),
|
|
483
|
+
focusPosition: typeof autofocus === "string" ? autofocus : undefined,
|
|
484
|
+
imageDeleteGraceMs,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
if (isEmbedded) send({ type: "setLayout", embedded: true });
|
|
488
|
+
// `editable`/`autofocus`/`imageDeleteGraceMs` are excluded from deps on
|
|
489
|
+
// purpose: they seed the *initial* editor, like `document`.
|
|
490
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
491
|
+
}, [isReady, send]);
|
|
492
|
+
|
|
493
|
+
// `editable` is the one prop that stays reactive after mount.
|
|
494
|
+
useEffect(() => {
|
|
495
|
+
if (!isReady) return;
|
|
496
|
+
send({ type: "setEditable", editable });
|
|
497
|
+
}, [isReady, editable, send]);
|
|
498
|
+
|
|
499
|
+
const theme = useNoteEditorTheme();
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
if (!isReady) return;
|
|
502
|
+
send({ type: "setTheme", theme });
|
|
503
|
+
}, [isReady, theme, send]);
|
|
504
|
+
|
|
505
|
+
useImperativeHandle(
|
|
506
|
+
ref,
|
|
507
|
+
() => ({
|
|
508
|
+
focus: (position) => send({ type: "focus", position: position ?? "end" }),
|
|
509
|
+
blur: () => send({ type: "blur" }),
|
|
510
|
+
getDocument: () => requestDocument(),
|
|
511
|
+
getSaveData: async () => ({ title, json: await requestDocument() }),
|
|
512
|
+
setDocument: (doc, options) =>
|
|
513
|
+
send({
|
|
514
|
+
type: "setContent",
|
|
515
|
+
doc,
|
|
516
|
+
editable,
|
|
517
|
+
focus: Boolean(options?.focus),
|
|
518
|
+
reset: false,
|
|
519
|
+
}),
|
|
520
|
+
}),
|
|
521
|
+
[title, editable, requestDocument, send]
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
|
525
|
+
const [linkValue, setLinkValue] = useState("");
|
|
526
|
+
|
|
527
|
+
const openLinkDialog = useCallback(() => {
|
|
528
|
+
setLinkValue(selectionState.linkHref ?? "");
|
|
529
|
+
setLinkDialogOpen(true);
|
|
530
|
+
}, [selectionState.linkHref]);
|
|
531
|
+
|
|
532
|
+
const confirmLink = useCallback(() => {
|
|
533
|
+
const href = linkValue.trim();
|
|
534
|
+
send(
|
|
535
|
+
href
|
|
536
|
+
? { type: "command", command: { name: "setLink", href } }
|
|
537
|
+
: { type: "command", command: { name: "unsetLink" } }
|
|
538
|
+
);
|
|
539
|
+
setLinkDialogOpen(false);
|
|
540
|
+
}, [linkValue, send]);
|
|
541
|
+
|
|
542
|
+
const runToolbarCommand = useCallback(
|
|
543
|
+
(command: EditorCommand) => send({ type: "command", command }),
|
|
544
|
+
[send]
|
|
545
|
+
);
|
|
546
|
+
const toolbarGroups = useMemo(
|
|
547
|
+
() => buildToolbarGroups(selectionState, openLinkDialog),
|
|
548
|
+
[selectionState, openLinkDialog]
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
const handleSlashSelect = useCallback(
|
|
552
|
+
(id: SlashCommandId) => {
|
|
553
|
+
send({ type: "runSlashCommand", commandId: id });
|
|
554
|
+
if (id === "image") insertImage();
|
|
555
|
+
},
|
|
556
|
+
[send, insertImage]
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
const containerHeight = isEmbedded ? contentHeight : undefined;
|
|
560
|
+
|
|
561
|
+
return (
|
|
562
|
+
<View
|
|
563
|
+
style={[
|
|
564
|
+
isEmbedded ? styles.containerEmbedded : styles.container,
|
|
565
|
+
isEmbedded ? { height: containerHeight } : null,
|
|
566
|
+
backgroundColor ? { backgroundColor } : null,
|
|
567
|
+
]}
|
|
568
|
+
>
|
|
569
|
+
{toolbar && (
|
|
570
|
+
<NoteEditorToolbar
|
|
571
|
+
groups={toolbarGroups}
|
|
572
|
+
state={selectionState}
|
|
573
|
+
onCommand={runToolbarCommand}
|
|
574
|
+
onImagePress={onImageUpload ? insertImage : undefined}
|
|
575
|
+
/>
|
|
576
|
+
)}
|
|
577
|
+
|
|
578
|
+
<TextInput
|
|
579
|
+
ref={titleInputRef}
|
|
580
|
+
multiline
|
|
581
|
+
value={title}
|
|
582
|
+
onChangeText={(value) => setTitle(sanitizeTitle(value))}
|
|
583
|
+
onKeyPress={(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
584
|
+
// Enter moves focus into the content's first block, never inserts
|
|
585
|
+
// a line break; `sanitizeTitle` is the backstop if the platform
|
|
586
|
+
// commits the `\n` before this handler runs.
|
|
587
|
+
if (event.nativeEvent.key !== "Enter") return;
|
|
588
|
+
send({ type: "focus", position: "start" });
|
|
589
|
+
}}
|
|
590
|
+
selection={titleSelection}
|
|
591
|
+
onSelectionChange={() => {
|
|
592
|
+
if (titleSelection) setTitleSelection(undefined);
|
|
593
|
+
}}
|
|
594
|
+
placeholder={titlePlaceholder}
|
|
595
|
+
placeholderTextColor={titlePlaceholderColor}
|
|
596
|
+
editable={editable}
|
|
597
|
+
testID={testID ? `${testID}-title` : undefined}
|
|
598
|
+
accessibilityLabel="Título da anotação"
|
|
599
|
+
className="mb-1 p-0 text-title-lg font-bold leading-title-lg text-content-title"
|
|
600
|
+
/>
|
|
601
|
+
|
|
602
|
+
<View style={{ flex: isEmbedded ? undefined : 1 }}>
|
|
603
|
+
<WebView
|
|
604
|
+
ref={webViewRef}
|
|
605
|
+
testID={testID}
|
|
606
|
+
source={{ html: EDITOR_HTML }}
|
|
607
|
+
// Anchored: `react-native-webview` treats each entry as an
|
|
608
|
+
// unanchored pattern, so a bare "about:blank" also admits
|
|
609
|
+
// "about:blankse.evil.com".
|
|
610
|
+
originWhitelist={["^about:blank$"]}
|
|
611
|
+
onMessage={handleMessage}
|
|
612
|
+
// Links already leave the editor via the `linkPress` message, so
|
|
613
|
+
// nothing rendered here needs to navigate. Allowing only the
|
|
614
|
+
// initial `about:blank` load closes off `data:`/script-driven
|
|
615
|
+
// navigation, which would otherwise bypass the `<a>` click handler
|
|
616
|
+
// in `editor-bundle/src/main.ts`.
|
|
617
|
+
onShouldStartLoadWithRequest={({ url }) =>
|
|
618
|
+
!url || url === "about:blank"
|
|
619
|
+
}
|
|
620
|
+
javaScriptEnabled
|
|
621
|
+
domStorageEnabled={false}
|
|
622
|
+
allowFileAccess={false}
|
|
623
|
+
allowFileAccessFromFileURLs={false}
|
|
624
|
+
allowUniversalAccessFromFileURLs={false}
|
|
625
|
+
allowsLinkPreview={false}
|
|
626
|
+
setSupportMultipleWindows={false}
|
|
627
|
+
mixedContentMode="never"
|
|
628
|
+
cacheEnabled={false}
|
|
629
|
+
hideKeyboardAccessoryView
|
|
630
|
+
keyboardDisplayRequiresUserAction={false}
|
|
631
|
+
automaticallyAdjustContentInsets={false}
|
|
632
|
+
contentInsetAdjustmentBehavior="never"
|
|
633
|
+
textInteractionEnabled
|
|
634
|
+
// Embedded, the native list owns the scroll: two nested scrollers
|
|
635
|
+
// put the caret in a scroll view ProseMirror was never told about.
|
|
636
|
+
scrollEnabled={!isEmbedded}
|
|
637
|
+
nestedScrollEnabled={false}
|
|
638
|
+
androidLayerType={Platform.OS === "android" ? "hardware" : undefined}
|
|
639
|
+
style={[
|
|
640
|
+
styles.webView,
|
|
641
|
+
isEmbedded ? { flex: undefined, height: containerHeight } : null,
|
|
642
|
+
backgroundColor ? { backgroundColor } : null,
|
|
643
|
+
]}
|
|
644
|
+
/>
|
|
645
|
+
|
|
646
|
+
{slashState.active && slashState.caret ? (
|
|
647
|
+
<NoteEditorSlashMenu
|
|
648
|
+
slash={slashState}
|
|
649
|
+
canUploadImage={Boolean(onImageUpload)}
|
|
650
|
+
onSelect={handleSlashSelect}
|
|
651
|
+
/>
|
|
652
|
+
) : null}
|
|
653
|
+
</View>
|
|
654
|
+
|
|
655
|
+
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
|
|
656
|
+
<DialogContainer>
|
|
657
|
+
<DialogHeader>
|
|
658
|
+
<DialogTitle>Inserir link</DialogTitle>
|
|
659
|
+
</DialogHeader>
|
|
660
|
+
<DialogContent>
|
|
661
|
+
<Input
|
|
662
|
+
value={linkValue}
|
|
663
|
+
onChangeText={setLinkValue}
|
|
664
|
+
placeholder="https://"
|
|
665
|
+
label="URL"
|
|
666
|
+
autoCapitalize="none"
|
|
667
|
+
autoCorrect={false}
|
|
668
|
+
keyboardType="url"
|
|
669
|
+
/>
|
|
670
|
+
</DialogContent>
|
|
671
|
+
<DialogFooter>
|
|
672
|
+
<Button onPress={confirmLink}>Confirmar</Button>
|
|
673
|
+
</DialogFooter>
|
|
674
|
+
</DialogContainer>
|
|
675
|
+
</Dialog>
|
|
676
|
+
</View>
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
NoteEditor.displayName = "NoteEditor";
|
|
682
|
+
|
|
683
|
+
function NoteEditorToolbar({
|
|
684
|
+
groups,
|
|
685
|
+
state,
|
|
686
|
+
onCommand,
|
|
687
|
+
onImagePress,
|
|
688
|
+
}: {
|
|
689
|
+
groups: ToolbarItem[][];
|
|
690
|
+
state: EditorSelectionState;
|
|
691
|
+
onCommand: (command: EditorCommand) => void;
|
|
692
|
+
onImagePress?: () => void;
|
|
693
|
+
}) {
|
|
694
|
+
const iconColor = useThemeColor("gray-foreground");
|
|
695
|
+
const activeIconColor = useThemeColor("primary-foreground");
|
|
696
|
+
|
|
697
|
+
return (
|
|
698
|
+
<View className="mb-2 flex-row flex-wrap items-center gap-0.5 border-b border-gray-stroke-muted pb-2">
|
|
699
|
+
{groups.map((group, groupIndex) => (
|
|
700
|
+
<View key={groupIndex} className="flex-row items-center gap-0.5">
|
|
701
|
+
{groupIndex > 0 ? <View className="mx-1 h-5 w-px bg-gray-stroke-muted" /> : null}
|
|
702
|
+
{group.map((item) => {
|
|
703
|
+
const active = item.activeKey ? state.active[item.activeKey] : false;
|
|
704
|
+
const disabled = item.disabled?.(state) ?? false;
|
|
705
|
+
const Icon = item.Icon;
|
|
706
|
+
|
|
707
|
+
return (
|
|
708
|
+
<Pressable
|
|
709
|
+
key={item.id}
|
|
710
|
+
accessibilityRole="button"
|
|
711
|
+
accessibilityLabel={item.label}
|
|
712
|
+
accessibilityState={{ disabled, selected: active }}
|
|
713
|
+
disabled={disabled}
|
|
714
|
+
onPress={item.onPress ?? (() => item.command && onCommand(item.command))}
|
|
715
|
+
className={cn(
|
|
716
|
+
"h-8 w-8 items-center justify-center rounded-md",
|
|
717
|
+
disabled && "opacity-40",
|
|
718
|
+
active && "bg-primary-background-soft"
|
|
719
|
+
)}
|
|
720
|
+
>
|
|
721
|
+
<Icon width={16} height={16} color={active ? activeIconColor : iconColor} />
|
|
722
|
+
</Pressable>
|
|
723
|
+
);
|
|
724
|
+
})}
|
|
725
|
+
</View>
|
|
726
|
+
))}
|
|
727
|
+
|
|
728
|
+
{onImagePress ? (
|
|
729
|
+
<View className="flex-row items-center gap-0.5">
|
|
730
|
+
<View className="mx-1 h-5 w-px bg-gray-stroke-muted" />
|
|
731
|
+
<Pressable
|
|
732
|
+
accessibilityRole="button"
|
|
733
|
+
accessibilityLabel="Inserir imagem"
|
|
734
|
+
onPress={onImagePress}
|
|
735
|
+
className="h-8 w-8 items-center justify-center rounded-md"
|
|
736
|
+
>
|
|
737
|
+
<ImageIcon width={16} height={16} color={iconColor} />
|
|
738
|
+
</Pressable>
|
|
739
|
+
</View>
|
|
740
|
+
) : null}
|
|
741
|
+
</View>
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Positioned from the `caret` rect the bundle reports. No arrow-key navigation:
|
|
747
|
+
* a touch UI has no keyboard focus to drive one, so every item is tappable.
|
|
748
|
+
*
|
|
749
|
+
* `caret` is in CSS pixels inside the WebView viewport, treated here as `dp` —
|
|
750
|
+
* valid as long as the WebView isn't scaled.
|
|
751
|
+
*/
|
|
752
|
+
function NoteEditorSlashMenu({
|
|
753
|
+
slash,
|
|
754
|
+
canUploadImage,
|
|
755
|
+
onSelect,
|
|
756
|
+
}: {
|
|
757
|
+
slash: SlashMenuState;
|
|
758
|
+
canUploadImage: boolean;
|
|
759
|
+
onSelect: (id: SlashCommandId) => void;
|
|
760
|
+
}) {
|
|
761
|
+
if (!slash.caret) return null;
|
|
762
|
+
|
|
763
|
+
const commandIds = visibleSlashCommandIds(slash.commandIds, canUploadImage);
|
|
764
|
+
|
|
765
|
+
return (
|
|
766
|
+
<View
|
|
767
|
+
className="absolute z-50 max-h-72 w-64 overflow-hidden rounded-md border border-gray-stroke-muted bg-surface-elevated p-1 shadow-lg"
|
|
768
|
+
style={{ left: slash.caret.x, top: slash.caret.y + slash.caret.height + 6 }}
|
|
769
|
+
>
|
|
770
|
+
{commandIds.length === 0 ? (
|
|
771
|
+
<Text className="px-3 py-2 text-gray-foreground-muted">Nenhum resultado</Text>
|
|
772
|
+
) : (
|
|
773
|
+
commandIds.map((id) => {
|
|
774
|
+
const command = getSlashCommand(id);
|
|
775
|
+
if (!command) return null;
|
|
776
|
+
|
|
777
|
+
return (
|
|
778
|
+
<Pressable
|
|
779
|
+
key={id}
|
|
780
|
+
accessibilityRole="button"
|
|
781
|
+
onPress={() => onSelect(id)}
|
|
782
|
+
className="rounded-sm px-3 py-1.5 active:bg-secondary-background-soft-emphasis"
|
|
783
|
+
>
|
|
784
|
+
<Text className="font-medium text-gray-foreground">{command.label}</Text>
|
|
785
|
+
<Text className="text-xs text-gray-foreground-muted">{command.description}</Text>
|
|
786
|
+
</Pressable>
|
|
787
|
+
);
|
|
788
|
+
})
|
|
789
|
+
)}
|
|
790
|
+
</View>
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const styles = StyleSheet.create({
|
|
795
|
+
container: { flex: 1 },
|
|
796
|
+
containerEmbedded: { flex: 0 },
|
|
797
|
+
// `opacity` below 1 forces the WebView onto its own layer, which stops the
|
|
798
|
+
// white flash Android paints before the theme reaches the document.
|
|
799
|
+
webView: { flex: 1, opacity: 0.99 },
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
export default memo(NoteEditor);
|