@medway-ui/native 1.1.2 → 1.3.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.
Files changed (244) hide show
  1. package/lib/commonjs/components/Alert.js +5 -4
  2. package/lib/commonjs/components/Alert.js.map +1 -1
  3. package/lib/commonjs/components/Avatar.js +2 -1
  4. package/lib/commonjs/components/Avatar.js.map +1 -1
  5. package/lib/commonjs/components/Badge.js +2 -1
  6. package/lib/commonjs/components/Badge.js.map +1 -1
  7. package/lib/commonjs/components/Button.js +3 -2
  8. package/lib/commonjs/components/Button.js.map +1 -1
  9. package/lib/commonjs/components/CardNews.js +7 -6
  10. package/lib/commonjs/components/CardNews.js.map +1 -1
  11. package/lib/commonjs/components/Carousel.js +122 -0
  12. package/lib/commonjs/components/Carousel.js.map +1 -0
  13. package/lib/commonjs/components/Checkbox.js +11 -2
  14. package/lib/commonjs/components/Checkbox.js.map +1 -1
  15. package/lib/commonjs/components/DropdownMenu.js +225 -0
  16. package/lib/commonjs/components/DropdownMenu.js.map +1 -0
  17. package/lib/commonjs/components/EmptyState.js +19 -20
  18. package/lib/commonjs/components/EmptyState.js.map +1 -1
  19. package/lib/commonjs/components/Feed.js +82 -0
  20. package/lib/commonjs/components/Feed.js.map +1 -0
  21. package/lib/commonjs/components/FeedNotice.js +76 -0
  22. package/lib/commonjs/components/FeedNotice.js.map +1 -0
  23. package/lib/commonjs/components/Input.js +21 -9
  24. package/lib/commonjs/components/Input.js.map +1 -1
  25. package/lib/commonjs/components/InvitePopover.js +196 -0
  26. package/lib/commonjs/components/InvitePopover.js.map +1 -0
  27. package/lib/commonjs/components/Label.js +2 -1
  28. package/lib/commonjs/components/Label.js.map +1 -1
  29. package/lib/commonjs/components/LeaderboardCard.js +58 -0
  30. package/lib/commonjs/components/LeaderboardCard.js.map +1 -0
  31. package/lib/commonjs/components/MemberRow.js +131 -0
  32. package/lib/commonjs/components/MemberRow.js.map +1 -0
  33. package/lib/commonjs/components/PageHeader.js +3 -2
  34. package/lib/commonjs/components/PageHeader.js.map +1 -1
  35. package/lib/commonjs/components/PostForm.js +182 -0
  36. package/lib/commonjs/components/PostForm.js.map +1 -0
  37. package/lib/commonjs/components/PrizeCollection.js +135 -0
  38. package/lib/commonjs/components/PrizeCollection.js.map +1 -0
  39. package/lib/commonjs/components/RadioGroup.js +22 -0
  40. package/lib/commonjs/components/RadioGroup.js.map +1 -1
  41. package/lib/commonjs/components/SectionHeader.js +36 -0
  42. package/lib/commonjs/components/SectionHeader.js.map +1 -0
  43. package/lib/commonjs/components/Switch.js +33 -19
  44. package/lib/commonjs/components/Switch.js.map +1 -1
  45. package/lib/commonjs/components/Tabs.js +156 -0
  46. package/lib/commonjs/components/Tabs.js.map +1 -0
  47. package/lib/commonjs/components/Tag.js +2 -1
  48. package/lib/commonjs/components/Tag.js.map +1 -1
  49. package/lib/commonjs/components/Text.js +60 -0
  50. package/lib/commonjs/components/Text.js.map +1 -0
  51. package/lib/commonjs/components/Toast.js +3 -2
  52. package/lib/commonjs/components/Toast.js.map +1 -1
  53. package/lib/commonjs/components/Tooltip.js +2 -2
  54. package/lib/commonjs/components/Tooltip.js.map +1 -1
  55. package/lib/commonjs/editor/NoteEditor.js +710 -0
  56. package/lib/commonjs/editor/NoteEditor.js.map +1 -0
  57. package/lib/commonjs/editor/editorHtml.generated.js +21 -0
  58. package/lib/commonjs/editor/editorHtml.generated.js.map +1 -0
  59. package/lib/commonjs/editor/index.js +33 -0
  60. package/lib/commonjs/editor/index.js.map +1 -0
  61. package/lib/commonjs/editor/protocol.js +88 -0
  62. package/lib/commonjs/editor/protocol.js.map +1 -0
  63. package/lib/commonjs/hooks/useThemeColor.js +66 -29
  64. package/lib/commonjs/hooks/useThemeColor.js.map +1 -1
  65. package/lib/commonjs/index.js +319 -1
  66. package/lib/commonjs/index.js.map +1 -1
  67. package/lib/commonjs/lib/overlay-host.js +101 -0
  68. package/lib/commonjs/lib/overlay-host.js.map +1 -0
  69. package/lib/commonjs/lib/typography.js +26 -0
  70. package/lib/commonjs/lib/typography.js.map +1 -0
  71. package/lib/commonjs/nativewind-env.d.js +0 -1
  72. package/lib/commonjs/nativewind-env.d.js.map +1 -1
  73. package/lib/module/components/Alert.js +3 -2
  74. package/lib/module/components/Alert.js.map +1 -1
  75. package/lib/module/components/Avatar.js +2 -1
  76. package/lib/module/components/Avatar.js.map +1 -1
  77. package/lib/module/components/Badge.js +2 -1
  78. package/lib/module/components/Badge.js.map +1 -1
  79. package/lib/module/components/Button.js +2 -1
  80. package/lib/module/components/Button.js.map +1 -1
  81. package/lib/module/components/CardNews.js +2 -1
  82. package/lib/module/components/CardNews.js.map +1 -1
  83. package/lib/module/components/Carousel.js +117 -0
  84. package/lib/module/components/Carousel.js.map +1 -0
  85. package/lib/module/components/Checkbox.js +12 -2
  86. package/lib/module/components/Checkbox.js.map +1 -1
  87. package/lib/module/components/DropdownMenu.js +216 -0
  88. package/lib/module/components/DropdownMenu.js.map +1 -0
  89. package/lib/module/components/EmptyState.js +19 -20
  90. package/lib/module/components/EmptyState.js.map +1 -1
  91. package/lib/module/components/Feed.js +77 -0
  92. package/lib/module/components/Feed.js.map +1 -0
  93. package/lib/module/components/FeedNotice.js +73 -0
  94. package/lib/module/components/FeedNotice.js.map +1 -0
  95. package/lib/module/components/Input.js +21 -9
  96. package/lib/module/components/Input.js.map +1 -1
  97. package/lib/module/components/InvitePopover.js +183 -0
  98. package/lib/module/components/InvitePopover.js.map +1 -0
  99. package/lib/module/components/Label.js +2 -1
  100. package/lib/module/components/Label.js.map +1 -1
  101. package/lib/module/components/LeaderboardCard.js +53 -0
  102. package/lib/module/components/LeaderboardCard.js.map +1 -0
  103. package/lib/module/components/MemberRow.js +127 -0
  104. package/lib/module/components/MemberRow.js.map +1 -0
  105. package/lib/module/components/PageHeader.js +2 -1
  106. package/lib/module/components/PageHeader.js.map +1 -1
  107. package/lib/module/components/PostForm.js +174 -0
  108. package/lib/module/components/PostForm.js.map +1 -0
  109. package/lib/module/components/PrizeCollection.js +127 -0
  110. package/lib/module/components/PrizeCollection.js.map +1 -0
  111. package/lib/module/components/RadioGroup.js +21 -1
  112. package/lib/module/components/RadioGroup.js.map +1 -1
  113. package/lib/module/components/SectionHeader.js +33 -0
  114. package/lib/module/components/SectionHeader.js.map +1 -0
  115. package/lib/module/components/Switch.js +34 -20
  116. package/lib/module/components/Switch.js.map +1 -1
  117. package/lib/module/components/Tabs.js +148 -0
  118. package/lib/module/components/Tabs.js.map +1 -0
  119. package/lib/module/components/Tag.js +2 -1
  120. package/lib/module/components/Tag.js.map +1 -1
  121. package/lib/module/components/Text.js +58 -0
  122. package/lib/module/components/Text.js.map +1 -0
  123. package/lib/module/components/Toast.js +2 -1
  124. package/lib/module/components/Toast.js.map +1 -1
  125. package/lib/module/components/Tooltip.js +1 -1
  126. package/lib/module/components/Tooltip.js.map +1 -1
  127. package/lib/module/editor/NoteEditor.js +705 -0
  128. package/lib/module/editor/NoteEditor.js.map +1 -0
  129. package/lib/module/editor/editorHtml.generated.js +17 -0
  130. package/lib/module/editor/editorHtml.generated.js.map +1 -0
  131. package/lib/module/editor/index.js +5 -0
  132. package/lib/module/editor/index.js.map +1 -0
  133. package/lib/module/editor/protocol.js +83 -0
  134. package/lib/module/editor/protocol.js.map +1 -0
  135. package/lib/module/hooks/useThemeColor.js +66 -31
  136. package/lib/module/hooks/useThemeColor.js.map +1 -1
  137. package/lib/module/index.js +33 -3
  138. package/lib/module/index.js.map +1 -1
  139. package/lib/module/lib/overlay-host.js +96 -0
  140. package/lib/module/lib/overlay-host.js.map +1 -0
  141. package/lib/module/lib/typography.js +22 -0
  142. package/lib/module/lib/typography.js.map +1 -0
  143. package/lib/module/nativewind-env.d.js +0 -1
  144. package/lib/module/nativewind-env.d.js.map +1 -1
  145. package/lib/typescript/components/Alert.d.ts.map +1 -1
  146. package/lib/typescript/components/Avatar.d.ts.map +1 -1
  147. package/lib/typescript/components/Badge.d.ts.map +1 -1
  148. package/lib/typescript/components/Button.d.ts.map +1 -1
  149. package/lib/typescript/components/CardNews.d.ts.map +1 -1
  150. package/lib/typescript/components/Carousel.d.ts +29 -0
  151. package/lib/typescript/components/Carousel.d.ts.map +1 -0
  152. package/lib/typescript/components/Checkbox.d.ts.map +1 -1
  153. package/lib/typescript/components/DropdownMenu.d.ts +39 -0
  154. package/lib/typescript/components/DropdownMenu.d.ts.map +1 -0
  155. package/lib/typescript/components/EmptyState.d.ts +1 -1
  156. package/lib/typescript/components/EmptyState.d.ts.map +1 -1
  157. package/lib/typescript/components/Feed.d.ts +22 -0
  158. package/lib/typescript/components/Feed.d.ts.map +1 -0
  159. package/lib/typescript/components/FeedNotice.d.ts +17 -0
  160. package/lib/typescript/components/FeedNotice.d.ts.map +1 -0
  161. package/lib/typescript/components/Input.d.ts +3 -2
  162. package/lib/typescript/components/Input.d.ts.map +1 -1
  163. package/lib/typescript/components/InvitePopover.d.ts +46 -0
  164. package/lib/typescript/components/InvitePopover.d.ts.map +1 -0
  165. package/lib/typescript/components/Label.d.ts.map +1 -1
  166. package/lib/typescript/components/LeaderboardCard.d.ts +16 -0
  167. package/lib/typescript/components/LeaderboardCard.d.ts.map +1 -0
  168. package/lib/typescript/components/MemberRow.d.ts +40 -0
  169. package/lib/typescript/components/MemberRow.d.ts.map +1 -0
  170. package/lib/typescript/components/PageHeader.d.ts.map +1 -1
  171. package/lib/typescript/components/PostForm.d.ts +50 -0
  172. package/lib/typescript/components/PostForm.d.ts.map +1 -0
  173. package/lib/typescript/components/PrizeCollection.d.ts +35 -0
  174. package/lib/typescript/components/PrizeCollection.d.ts.map +1 -0
  175. package/lib/typescript/components/RadioGroup.d.ts +10 -2
  176. package/lib/typescript/components/RadioGroup.d.ts.map +1 -1
  177. package/lib/typescript/components/SectionHeader.d.ts +12 -0
  178. package/lib/typescript/components/SectionHeader.d.ts.map +1 -0
  179. package/lib/typescript/components/Switch.d.ts.map +1 -1
  180. package/lib/typescript/components/Tabs.d.ts +32 -0
  181. package/lib/typescript/components/Tabs.d.ts.map +1 -0
  182. package/lib/typescript/components/Tag.d.ts.map +1 -1
  183. package/lib/typescript/components/Text.d.ts +23 -0
  184. package/lib/typescript/components/Text.d.ts.map +1 -0
  185. package/lib/typescript/components/Toast.d.ts +1 -1
  186. package/lib/typescript/components/Toast.d.ts.map +1 -1
  187. package/lib/typescript/components/Tooltip.d.ts.map +1 -1
  188. package/lib/typescript/editor/NoteEditor.d.ts +82 -0
  189. package/lib/typescript/editor/NoteEditor.d.ts.map +1 -0
  190. package/lib/typescript/editor/editorHtml.generated.d.ts +13 -0
  191. package/lib/typescript/editor/editorHtml.generated.d.ts.map +1 -0
  192. package/lib/typescript/editor/index.d.ts +6 -0
  193. package/lib/typescript/editor/index.d.ts.map +1 -0
  194. package/lib/typescript/editor/protocol.d.ts +153 -0
  195. package/lib/typescript/editor/protocol.d.ts.map +1 -0
  196. package/lib/typescript/hooks/useThemeColor.d.ts +4 -2
  197. package/lib/typescript/hooks/useThemeColor.d.ts.map +1 -1
  198. package/lib/typescript/index.d.ts +28 -4
  199. package/lib/typescript/index.d.ts.map +1 -1
  200. package/lib/typescript/lib/overlay-host.d.ts +23 -0
  201. package/lib/typescript/lib/overlay-host.d.ts.map +1 -0
  202. package/lib/typescript/lib/typography.d.ts +12 -0
  203. package/lib/typescript/lib/typography.d.ts.map +1 -0
  204. package/package.json +36 -3
  205. package/setup/MedwayUiProvider.tsx +15 -3
  206. package/setup/metro.js +27 -5
  207. package/setup/tailwind.js +52 -4
  208. package/setup/tokens.native.css +8 -3
  209. package/src/components/Alert.tsx +4 -2
  210. package/src/components/Avatar.tsx +3 -1
  211. package/src/components/Badge.tsx +3 -1
  212. package/src/components/Button.tsx +3 -7
  213. package/src/components/CardNews.tsx +2 -1
  214. package/src/components/Carousel.tsx +152 -0
  215. package/src/components/Checkbox.tsx +14 -4
  216. package/src/components/DropdownMenu.tsx +312 -0
  217. package/src/components/EmptyState.tsx +21 -23
  218. package/src/components/Feed.tsx +96 -0
  219. package/src/components/FeedNotice.tsx +112 -0
  220. package/src/components/Input.tsx +11 -6
  221. package/src/components/InvitePopover.tsx +240 -0
  222. package/src/components/Label.tsx +3 -1
  223. package/src/components/LeaderboardCard.tsx +70 -0
  224. package/src/components/MemberRow.tsx +157 -0
  225. package/src/components/PageHeader.tsx +3 -1
  226. package/src/components/PostForm.tsx +250 -0
  227. package/src/components/PrizeCollection.tsx +190 -0
  228. package/src/components/RadioGroup.tsx +32 -3
  229. package/src/components/SectionHeader.tsx +45 -0
  230. package/src/components/Switch.tsx +65 -59
  231. package/src/components/Tabs.tsx +182 -0
  232. package/src/components/Tag.tsx +3 -1
  233. package/src/components/Text.tsx +66 -0
  234. package/src/components/Toast.tsx +3 -1
  235. package/src/components/Tooltip.tsx +2 -1
  236. package/src/editor/NoteEditor.tsx +802 -0
  237. package/src/editor/editorHtml.generated.ts +14 -0
  238. package/src/editor/index.ts +13 -0
  239. package/src/editor/protocol.ts +184 -0
  240. package/src/hooks/useThemeColor.ts +77 -32
  241. package/src/index.ts +153 -6
  242. package/src/lib/overlay-host.tsx +102 -0
  243. package/src/lib/typography.ts +21 -0
  244. package/src/nativewind-env.d.ts +2 -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);