@canvist/react-native 0.0.0-stage → 0.2.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/src/editor.tsx ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The editor component and hooks.
3
+ *
4
+ * @module
5
+ */
6
+ import type { CanvistError, Content, StarterKit } from "@canvist/headless";
7
+ import { starterKit } from "@canvist/headless/extensions";
8
+ import { nativeCanvistError, NativeEditor } from "@canvist/headless/native";
9
+ import {
10
+ type ReactElement,
11
+ type Ref,
12
+ type RefObject,
13
+ useCallback,
14
+ useEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ useSyncExternalStore,
19
+ } from "react";
20
+ import type { NativeSyntheticEvent, ViewProps } from "react-native";
21
+
22
+ import { type EditorViewInstance, nativeBridge } from "./bridge.ts";
23
+ import CanvistEditorView, {
24
+ type EditorChangeEvent,
25
+ type EditorErrorEvent,
26
+ type EditorFocusEvent,
27
+ type NativeProps,
28
+ } from "./CanvistEditorViewNativeComponent.ts";
29
+ import CanvistEditors from "./NativeCanvistEditors.ts";
30
+
31
+ /** The extensions the native editors compile in: TipTap's StarterKit, or some of it. */
32
+ export type NativeExtensions = readonly StarterKit[number][];
33
+
34
+ /**
35
+ * A native editor whose commands, queries, and documents are typed from
36
+ * the extensions `E`, with the same API as `@canvist/web`'s editor.
37
+ */
38
+ export type Editor<E extends NativeExtensions> = NativeEditor<E>;
39
+
40
+ /** Options for {@link useCanvistEditor} and {@link CanvistEditor}. */
41
+ export interface EditorOptions<E extends NativeExtensions> {
42
+ /** The extensions to register: every StarterKit extension by default. Read once. */
43
+ readonly extensions?: E;
44
+ /** The document to start with: one empty paragraph by default. Read once. */
45
+ readonly content?: Content<E>;
46
+ /** The body text size before the user's text size setting; the platform's body size by default. */
47
+ readonly fontSize?: number;
48
+ /** Called when the view could not create its editor or load `content`. */
49
+ readonly onError?: (error: CanvistError) => void;
50
+ }
51
+
52
+ /** Props for {@link CanvistView}: what {@link useCanvistEditor} returns, plus the view's own. */
53
+ export interface CanvistViewProps extends NativeProps {
54
+ readonly ref?: Ref<EditorViewInstance>;
55
+ }
56
+
57
+ /** What {@link useCanvistEditor} returns. */
58
+ export interface CanvistEditorHandle<E extends NativeExtensions> {
59
+ /** The editor, once the view has created it; `null` before that. */
60
+ readonly editor: Editor<E> | null;
61
+ /** Spread onto a {@link CanvistView}, alongside its style and accessibility props. */
62
+ readonly props: CanvistViewProps;
63
+ }
64
+
65
+ /**
66
+ * The native editor view. Render it with the props {@link useCanvistEditor}
67
+ * returns; give it a size, as it scrolls its own document.
68
+ */
69
+ export const CanvistView = CanvistEditorView;
70
+
71
+ let editors = 0;
72
+
73
+ /** A key for one view's editor, unique in this JavaScript runtime. */
74
+ function nextEditorId(): string {
75
+ editors += 1;
76
+ return `canvist-${editors}`;
77
+ }
78
+
79
+ /**
80
+ * Create an editor in the {@link CanvistView} you spread `props` onto.
81
+ * `extensions` and `content` are read when the view is created; later
82
+ * changes to them have no effect.
83
+ *
84
+ * ```tsx
85
+ * function Notes() {
86
+ * const { editor, props } = useCanvistEditor({ extensions: [bold, heading] });
87
+ *
88
+ * return (
89
+ * <>
90
+ * <Button title="Bold" onPress={() => editor?.commands.toggleBold()} />
91
+ * <CanvistView {...props} style={{ flex: 1 }} />
92
+ * </>
93
+ * );
94
+ * }
95
+ * ```
96
+ */
97
+ export function useCanvistEditor<
98
+ const E extends NativeExtensions = StarterKit,
99
+ >(options: EditorOptions<E> = {}): CanvistEditorHandle<E> {
100
+ const [editorId] = useState(nextEditorId);
101
+ const [editor, setEditor] = useState<Editor<E> | null>(null);
102
+ const initial = useRef(options);
103
+ const view = useRef<EditorViewInstance>(null);
104
+ // Events arrive through callbacks made once, so they read the editor here.
105
+ const current = useRef<Editor<E> | null>(null);
106
+ const onError = useLatest(options.onError);
107
+
108
+ const onEditorReady = useCallback(() => {
109
+ // Without extensions, `E` is the default: the whole StarterKit.
110
+ const extensions = (initial.current.extensions ?? starterKit) as E;
111
+ const created = new NativeEditor(
112
+ nativeBridge(CanvistEditors, editorId, () => view.current),
113
+ extensions,
114
+ );
115
+ current.current = created;
116
+ setEditor(created);
117
+ }, [editorId]);
118
+
119
+ const onEditorChange = useCallback(
120
+ (_event: NativeSyntheticEvent<EditorChangeEvent>) => {
121
+ current.current?.notifyChange();
122
+ },
123
+ [],
124
+ );
125
+
126
+ const onEditorFocus = useCallback(
127
+ (event: NativeSyntheticEvent<EditorFocusEvent>) => {
128
+ current.current?.notifyFocus(event.nativeEvent.focused);
129
+ },
130
+ [],
131
+ );
132
+
133
+ const onEditorError = useCallback(
134
+ (event: NativeSyntheticEvent<EditorErrorEvent>) => {
135
+ const { code, message } = event.nativeEvent;
136
+ const error = nativeCanvistError(code, message);
137
+
138
+ if (onError.current === undefined) {
139
+ console.error(error);
140
+ } else {
141
+ onError.current(error);
142
+ }
143
+ },
144
+ [onError],
145
+ );
146
+
147
+ const { fontSize } = options;
148
+ const props = useMemo((): CanvistViewProps => {
149
+ const { extensions = starterKit, content } = initial.current;
150
+
151
+ return {
152
+ ref: view,
153
+ editorId,
154
+ extensions: extensions.map((extension) => extension.name),
155
+ ...documentProps(content),
156
+ ...(fontSize === undefined ? {} : { fontSize }),
157
+ onEditorReady,
158
+ onEditorChange,
159
+ onEditorFocus,
160
+ onEditorError,
161
+ };
162
+ }, [
163
+ editorId,
164
+ fontSize,
165
+ onEditorReady,
166
+ onEditorChange,
167
+ onEditorFocus,
168
+ onEditorError,
169
+ ]);
170
+
171
+ return { editor, props };
172
+ }
173
+
174
+ /**
175
+ * Read `select(editor)` and re-render whenever it changes: after any edit,
176
+ * selection change, or change of focus. `select` must return a primitive
177
+ * or a value that stays the same object while nothing changed, as with
178
+ * `useSyncExternalStore`. Until the editor exists, the value is `fallback`.
179
+ */
180
+ export function useEditorState<E extends NativeExtensions, T>(
181
+ editor: Editor<E> | null,
182
+ select: (editor: Editor<E>) => T,
183
+ fallback: T,
184
+ ): T {
185
+ const subscribe = useCallback(
186
+ (listener: () => void) => editor?.subscribe(listener) ?? noop,
187
+ [editor],
188
+ );
189
+
190
+ return useSyncExternalStore(
191
+ subscribe,
192
+ () => editor === null ? fallback : select(editor),
193
+ () => fallback,
194
+ );
195
+ }
196
+
197
+ /** Props for {@link CanvistEditor}: the editor's options and the view's. */
198
+ export interface CanvistEditorProps<E extends NativeExtensions>
199
+ extends EditorOptions<E>, ViewProps {
200
+ /** Called once the editor is ready. */
201
+ readonly onReady?: (editor: Editor<E>) => void;
202
+ /** Called after every change to the document, the selection, or focus. */
203
+ readonly onUpdate?: (editor: Editor<E>) => void;
204
+ }
205
+
206
+ /**
207
+ * A native canvist editor: the iOS or Android `CanvistView`. Give it a
208
+ * size, as it scrolls its own document.
209
+ *
210
+ * ```tsx
211
+ * <CanvistEditor
212
+ * content={{ format: "markdown", input: "# Notes" }}
213
+ * onUpdate={(editor) => save(editor.export("markdown"))}
214
+ * style={{ flex: 1 }}
215
+ * />
216
+ * ```
217
+ */
218
+ export function CanvistEditor<const E extends NativeExtensions = StarterKit>(
219
+ props: CanvistEditorProps<E>,
220
+ ): ReactElement {
221
+ const { extensions, content, fontSize, onError, onReady, onUpdate, ...view } =
222
+ props;
223
+ const { editor, props: editorProps } = useCanvistEditor<E>({
224
+ ...(extensions === undefined ? {} : { extensions }),
225
+ ...(content === undefined ? {} : { content }),
226
+ ...(fontSize === undefined ? {} : { fontSize }),
227
+ ...(onError === undefined ? {} : { onError }),
228
+ });
229
+ const callbacks = useLatest({ onReady, onUpdate });
230
+
231
+ useEffect(() => {
232
+ if (editor === null) {
233
+ return;
234
+ }
235
+
236
+ callbacks.current.onReady?.(editor);
237
+
238
+ return editor.subscribe(() => callbacks.current.onUpdate?.(editor));
239
+ }, [editor, callbacks]);
240
+
241
+ return <CanvistView {...view} {...editorProps} />;
242
+ }
243
+
244
+ /** The view props for the starting document. */
245
+ function documentProps<E extends NativeExtensions>(
246
+ content: Content<E> | undefined,
247
+ ): Pick<NativeProps, "content" | "contentFormat"> {
248
+ if (content === undefined) {
249
+ return {};
250
+ }
251
+
252
+ return "format" in content
253
+ ? { contentFormat: content.format, content: content.input }
254
+ : { contentFormat: "json", content: JSON.stringify(content) };
255
+ }
256
+
257
+ /** A ref that always holds the latest `value`. */
258
+ function useLatest<T>(value: T): RefObject<T> {
259
+ const ref = useRef(value);
260
+
261
+ useEffect(() => {
262
+ ref.current = value;
263
+ });
264
+
265
+ return ref;
266
+ }
267
+
268
+ function noop(): void {}
package/src/mod.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The canvist rich-text editor for React Native.
3
+ *
4
+ * `CanvistEditor` shows the native iOS or Android editor: canvist's Rust
5
+ * engine, painted natively and edited through the platform's own text
6
+ * input, not a WebView. Commands, queries, and documents are typed from the
7
+ * extensions you register, with the same API as `@canvist/web`: calling a
8
+ * command you did not register is a type error. Calls are synchronous.
9
+ *
10
+ * ```tsx
11
+ * import { CanvistEditor, bold, heading } from "@canvist/react-native";
12
+ *
13
+ * <CanvistEditor
14
+ * extensions={[bold, heading]}
15
+ * content={{ format: "markdown", input: "# Notes" }}
16
+ * onReady={(editor) => editor.commands.toggleHeading({ level: 2 })}
17
+ * style={{ flex: 1 }}
18
+ * />
19
+ * ```
20
+ *
21
+ * @module
22
+ */
23
+ export type {
24
+ CanvistError,
25
+ CommandName,
26
+ CommandParams,
27
+ Commands,
28
+ Content,
29
+ DocJson,
30
+ ErrorCode,
31
+ ExtensionDescriptor,
32
+ Extensions,
33
+ Format,
34
+ Position,
35
+ Queries,
36
+ QueryName,
37
+ QueryParams,
38
+ QueryResult,
39
+ Range,
40
+ Registered,
41
+ StarterKit,
42
+ } from "@canvist/headless";
43
+ export * from "@canvist/headless/extensions";
44
+ export { isCanvistError } from "@canvist/headless/native";
45
+ export type {
46
+ EditorChangeEvent,
47
+ EditorErrorEvent,
48
+ EditorFocusEvent,
49
+ } from "./CanvistEditorViewNativeComponent.ts";
50
+ export {
51
+ CanvistEditor,
52
+ type CanvistEditorHandle,
53
+ type CanvistEditorProps,
54
+ CanvistView,
55
+ type CanvistViewProps,
56
+ type Editor,
57
+ type EditorOptions,
58
+ type NativeExtensions,
59
+ useCanvistEditor,
60
+ useEditorState,
61
+ } from "./editor.tsx";
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.