@openeditor/native 0.0.34 → 0.0.35

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 CHANGED
@@ -9,9 +9,10 @@ lightweight state/revision events; document JSON only crosses it when the host
9
9
  explicitly calls `getDocument()` or `flushDocument()`.
10
10
 
11
11
  On iOS, the host hides WKWebView's default previous/next/Done accessory bar so
12
- the OpenEditor toolbar is the only bar above the software keyboard. The toolbar
13
- is mounted only while the editable WebView is focused and the software keyboard
14
- is visible. Consumers can opt back into the system bar with
12
+ the OpenEditor toolbar is the only bar above the software keyboard. The SDK
13
+ sticks that toolbar to the keyboard, reserves its document inset, and enables
14
+ interactive keyboard dismissal while the editor scrolls. Consumers can opt
15
+ back into the system bar with
15
16
  `webViewProps={{ hideKeyboardAccessoryView: false }}`.
16
17
 
17
18
  The `theme` prop accepts the same OpenEditor theme-token contract used by the
@@ -19,8 +20,36 @@ web React surface.
19
20
 
20
21
  ## Host integration
21
22
 
22
- Install the `react-native-webview` peer dependency. The self-contained offline
23
- runtime is packaged with the component, so Metro asset customization is not required:
23
+ Install the native peer dependencies:
24
+
25
+ ```sh
26
+ pnpm add expo-glass-effect react-native-keyboard-controller react-native-reanimated react-native-svg react-native-webview
27
+ ```
28
+
29
+ For Expo, install the SDK plugin so iOS WebViews receive interactive keyboard
30
+ dismissal:
31
+
32
+ ```json
33
+ {
34
+ "expo": {
35
+ "plugins": ["@openeditor/native/plugin"]
36
+ }
37
+ }
38
+ ```
39
+
40
+ Rebuild the native application after adding the plugin or a native dependency.
41
+ Mount the SDK provider once at the app root:
42
+
43
+ ```tsx
44
+ import { OpenEditorNativeProvider } from "@openeditor/native";
45
+
46
+ export default function RootLayout() {
47
+ return <OpenEditorNativeProvider>{/* app routes */}</OpenEditorNativeProvider>;
48
+ }
49
+ ```
50
+
51
+ The self-contained offline runtime is packaged with the component, so Metro
52
+ asset customization is not required:
24
53
 
25
54
  ```tsx
26
55
  const editor = useRef<OpenEditorNativeController>(null);
@@ -46,6 +75,18 @@ const editor = useRef<OpenEditorNativeController>(null);
46
75
  />
47
76
  ```
48
77
 
78
+ The default toolbar is a keyboard-sticky, inset pill that meets the keyboard
79
+ edge without a synthetic spacer. It uses native iOS glass and a theme-aware
80
+ material fallback elsewhere. A custom `renderToolbar` returns only the toolbar
81
+ content; the SDK still owns its glass surface and positioning, so do not add
82
+ another `KeyboardStickyView` or outer background. Use `toolbarContentInset`
83
+ when the custom toolbar needs more than the default 76-point document clearance,
84
+ and `toolbarOffset` only when the keyboard edge itself needs a different gap.
85
+
86
+ The editor host must reach the screen's bottom edge. Do not wrap the editor in
87
+ a bottom-padding `SafeAreaView`; the keyboard owns that edge while editing, and
88
+ reserving the home-indicator inset there creates a visible toolbar gap.
89
+
49
90
  The block picker derives its available page, image, and attachment commands
50
91
  from the supplied effect handlers. If the application cannot upload media to a
51
92
  durable URL, omit the corresponding picker effect rather than returning a
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { OpenEditorNative, type OpenEditorNativeContentInsets, type OpenEditorNativeProps, type OpenEditorNativeTheme, } from "./native-editor.js";
2
+ export { OpenEditorNativeProvider, type OpenEditorNativeProviderProps, } from "./native-provider.js";
2
3
  export { type OpenEditorNativeController, type OpenEditorNativeControllerEvent, type OpenEditorNativeEffectHandlers, } from "./controller.js";
3
4
  export { OpenEditorNativeToolbar, defaultOpenEditorNativeToolbarItems, openEditorNativeBlockPickerItems, type OpenEditorNativeToolbarItem, type OpenEditorNativeToolbarProps, } from "./toolbar-host.js";
5
+ export { resolveOpenEditorNativeTheme, type OpenEditorNativeColorScheme, type ResolvedOpenEditorNativeTheme, } from "./theme.js";
4
6
  export type { OpenEditorRuntimeCommand, OpenEditorRuntimeState } from "@openeditor/embedded-runtime";
package/dist/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  export { OpenEditorNative, } from "./native-editor.js";
2
+ export { OpenEditorNativeProvider, } from "./native-provider.js";
2
3
  export { OpenEditorNativeToolbar, defaultOpenEditorNativeToolbarItems, openEditorNativeBlockPickerItems, } from "./toolbar-host.js";
4
+ export { resolveOpenEditorNativeTheme, } from "./theme.js";
@@ -1,21 +1,20 @@
1
- import type { OpenEditorDocument } from "@openeditor/core";
1
+ import type { OpenEditorDocument, OpenEditorPageSnapshot } from "@openeditor/core";
2
+ import type { GlassColorScheme } from "expo-glass-effect";
2
3
  import { type OpenEditorRuntimeState, type OpenEditorNativeEffectName, type OpenEditorTheme } from "@openeditor/embedded-runtime";
3
4
  import { type ReactNode, type RefAttributes } from "react";
4
5
  import { type StyleProp, type ViewStyle } from "react-native";
5
6
  import { type WebViewProps } from "react-native-webview";
6
7
  import { type OpenEditorNativeController, type OpenEditorNativeEffectHandlers } from "./controller.js";
7
8
  import { type OpenEditorNativeToolbarItem } from "./toolbar-host.js";
9
+ import { type OpenEditorNativeContentInsets } from "./native-layout.js";
10
+ export type { OpenEditorNativeContentInsets } from "./native-layout.js";
8
11
  export type OpenEditorNativeTheme = Partial<OpenEditorTheme>;
9
- export type OpenEditorNativeContentInsets = {
10
- top: number;
11
- right: number;
12
- bottom: number;
13
- left: number;
14
- };
15
12
  type EdgeInsets = OpenEditorNativeContentInsets;
16
13
  type OwnedWebViewProps = "source" | "onMessage" | "style" | "scrollEnabled" | "nestedScrollEnabled";
17
14
  export type OpenEditorNativeProps = {
18
15
  initialDocument: OpenEditorDocument;
16
+ /** Page metadata rendered inside the shared, scroll-owned document surface. */
17
+ page?: OpenEditorPageSnapshot;
19
18
  editable?: boolean;
20
19
  placeholder?: string;
21
20
  theme?: OpenEditorNativeTheme;
@@ -24,7 +23,9 @@ export type OpenEditorNativeProps = {
24
23
  toolbarItems?: readonly OpenEditorNativeToolbarItem[];
25
24
  renderToolbar?: (context: {
26
25
  availableNativeEffects: readonly OpenEditorNativeEffectName[];
26
+ blockHandlesVisible: boolean;
27
27
  controller: OpenEditorNativeController;
28
+ setBlockHandlesVisible: (visible: boolean) => void;
28
29
  state: OpenEditorRuntimeState;
29
30
  }) => ReactNode;
30
31
  nativeEffects?: OpenEditorNativeEffectHandlers;
@@ -34,10 +35,14 @@ export type OpenEditorNativeProps = {
34
35
  }) => void;
35
36
  onReady?: (controller: OpenEditorNativeController) => void;
36
37
  onError?: (error: Error) => void;
37
- keyboardVerticalOffset?: number;
38
- keyboardAvoidanceEnabled?: boolean;
38
+ toolbarContentInset?: number;
39
+ toolbarOffset?: {
40
+ closed?: number;
41
+ opened?: number;
42
+ };
43
+ toolbarColorScheme?: GlassColorScheme;
44
+ toolbarContainerStyle?: StyleProp<ViewStyle>;
39
45
  style?: StyleProp<ViewStyle>;
40
46
  webViewProps?: Omit<WebViewProps, OwnedWebViewProps>;
41
47
  };
42
48
  export declare const OpenEditorNative: import("react").ForwardRefExoticComponent<OpenEditorNativeProps & RefAttributes<OpenEditorNativeController>>;
43
- export {};
@@ -1,14 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { getOpenEditorThemeEntries, openEditorThemeCssName, openEditorThemeTokenNames, } from "@openeditor/embedded-runtime";
3
2
  import { openEditorEmbeddedSurfaceHtml } from "@openeditor/embedded-surface/html";
4
3
  import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react";
5
- import { Keyboard, KeyboardAvoidingView, Platform, StyleSheet, View, } from "react-native";
4
+ import { Keyboard, Platform, StyleSheet, useColorScheme, View, } from "react-native";
5
+ import { KeyboardStickyView } from "react-native-keyboard-controller";
6
6
  import { WebView, } from "react-native-webview";
7
7
  import { emptyOpenEditorNativeState, isOpenEditorNativeLifecycleCancellation, OpenEditorNativeBridge, } from "./controller.js";
8
8
  import { OpenEditorNativeToolbar, } from "./toolbar-host.js";
9
- const ZERO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
9
+ import { OpenEditorNativeToolbarSurface } from "./toolbar-surface.js";
10
+ import { OPENEDITOR_NATIVE_TOOLBAR_OFFSET, OPENEDITOR_NATIVE_TOOLBAR_CONTENT_INSET, OPENEDITOR_NATIVE_TOOLBAR_HEIGHT, OPENEDITOR_NATIVE_TOOLBAR_RADIUS, resolveOpenEditorNativeContentInsets, } from "./native-layout.js";
11
+ import { resolveOpenEditorNativeTheme } from "./theme.js";
12
+ const DEFAULT_INSETS = { top: 0, right: 16, bottom: 0, left: 16 };
10
13
  const HostWebView = WebView;
11
- export const OpenEditorNative = forwardRef(function OpenEditorNative({ initialDocument, editable = true, placeholder = "Start writing…", theme, contentInsets = ZERO_INSETS, showToolbar = true, toolbarItems, renderToolbar, nativeEffects, onDocumentChanged, onReady, onError, keyboardVerticalOffset = 0, keyboardAvoidanceEnabled = true, style, webViewProps, }, forwardedRef) {
14
+ export const OpenEditorNative = forwardRef(function OpenEditorNative({ initialDocument, page, editable = true, placeholder = "Start writing…", theme, contentInsets = DEFAULT_INSETS, showToolbar = true, toolbarItems, renderToolbar, nativeEffects, onDocumentChanged, onReady, onError, toolbarContentInset = OPENEDITOR_NATIVE_TOOLBAR_CONTENT_INSET, toolbarOffset = OPENEDITOR_NATIVE_TOOLBAR_OFFSET, toolbarColorScheme, toolbarContainerStyle, style, webViewProps, }, forwardedRef) {
15
+ const systemColorScheme = useColorScheme();
16
+ const resolvedColorScheme = systemColorScheme === "dark" ? "dark" : "light";
17
+ const resolvedTheme = useMemo(() => resolveOpenEditorNativeTheme(theme, resolvedColorScheme), [resolvedColorScheme, theme]);
18
+ const resolvedToolbarColorScheme = toolbarColorScheme ?? resolvedColorScheme;
12
19
  const webViewRef = useRef(null);
13
20
  const sessionIdRef = useRef(`native_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`);
14
21
  const callbacksRef = useRef({
@@ -19,9 +26,11 @@ export const OpenEditorNative = forwardRef(function OpenEditorNative({ initialDo
19
26
  });
20
27
  callbacksRef.current = { nativeEffects, onDocumentChanged, onError, onReady };
21
28
  const initialDocumentRef = useRef(initialDocument);
22
- const initialConfigRef = useRef({ editable, placeholder, theme });
29
+ const initialConfigRef = useRef({ editable, placeholder, theme: resolvedTheme });
23
30
  const initializedRef = useRef(false);
31
+ const bridgeLifetimeRef = useRef(0);
24
32
  const [editorState, setEditorState] = useState(emptyOpenEditorNativeState);
33
+ const [blockHandlesVisible, setBlockHandlesVisible] = useState(false);
25
34
  const [keyboardVisible, setKeyboardVisible] = useState(() => Keyboard.isVisible?.() ?? false);
26
35
  const [bridge] = useState(() => new OpenEditorNativeBridge({
27
36
  sessionId: sessionIdRef.current,
@@ -37,22 +46,30 @@ export const OpenEditorNative = forwardRef(function OpenEditorNative({ initialDo
37
46
  baseUrl: "https://openeditor.local/",
38
47
  }), []);
39
48
  useImperativeHandle(forwardedRef, () => bridge, [bridge]);
40
- const resolvedInsets = {
41
- top: contentInsets.top ?? 0,
42
- right: contentInsets.right ?? 0,
43
- bottom: contentInsets.bottom ?? 0,
44
- left: contentInsets.left ?? 0,
45
- };
49
+ const resolvedInsets = resolveOpenEditorNativeContentInsets({
50
+ contentInsets,
51
+ editable,
52
+ showToolbar,
53
+ toolbarContentInset,
54
+ });
46
55
  const availableNativeEffects = Object.keys(nativeEffects ?? {});
47
56
  const hostConfig = JSON.stringify({
48
57
  availableNativeEffects,
58
+ blockHandles: editable && blockHandlesVisible,
49
59
  contentInsets: resolvedInsets,
60
+ page,
50
61
  placeholder,
51
62
  sessionId: sessionIdRef.current,
52
- theme,
63
+ theme: resolvedTheme,
53
64
  });
54
65
  const injectedConfig = `window.__OPENEDITOR_NATIVE_HOST__=${hostConfig};true;`;
55
- const themeUpdateScript = `(()=>{const root=document.documentElement;for(const name of ${JSON.stringify(openEditorThemeTokenNames.map(openEditorThemeCssName))})root.style.removeProperty(name);for(const [name,value] of ${JSON.stringify(getOpenEditorThemeEntries(theme ?? {}))})root.style.setProperty(name,value);const background=${JSON.stringify(theme?.surface ?? "")};const text=${JSON.stringify(theme?.text ?? "")};background?root.style.setProperty("--oe-native-background",background):root.style.removeProperty("--oe-native-background");text?root.style.setProperty("--oe-native-text",text):root.style.removeProperty("--oe-native-text");})();true;`;
66
+ const hostConfigUpdateScript = `window.dispatchEvent(new CustomEvent("openeditor:host-config",{detail:${JSON.stringify({
67
+ blockHandles: editable && blockHandlesVisible,
68
+ contentInsets: resolvedInsets,
69
+ page,
70
+ placeholder,
71
+ theme: resolvedTheme,
72
+ })}}));true;`;
56
73
  const initializeRuntime = useCallback(() => {
57
74
  if (!bridge.runtimeReady || initializedRef.current)
58
75
  return;
@@ -79,13 +96,11 @@ export const OpenEditorNative = forwardRef(function OpenEditorNative({ initialDo
79
96
  });
80
97
  }, [bridge]);
81
98
  useEffect(() => {
82
- webViewRef.current?.injectJavaScript(themeUpdateScript);
83
- }, [themeUpdateScript]);
99
+ webViewRef.current?.injectJavaScript(hostConfigUpdateScript);
100
+ }, [hostConfigUpdateScript]);
84
101
  useEffect(() => {
85
- const showSubscription = Keyboard.addListener("keyboardDidShow", () => setKeyboardVisible(true));
86
- const hideSubscription = Keyboard.addListener("keyboardDidHide", () => {
87
- setKeyboardVisible(false);
88
- });
102
+ const showSubscription = Keyboard.addListener(Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow", () => setKeyboardVisible(true));
103
+ const hideSubscription = Keyboard.addListener(Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide", () => setKeyboardVisible(false));
89
104
  return () => {
90
105
  showSubscription.remove();
91
106
  hideSubscription.remove();
@@ -114,11 +129,17 @@ export const OpenEditorNative = forwardRef(function OpenEditorNative({ initialDo
114
129
  callbacksRef.current.onError?.(new Error(`${event.code}: ${event.message}`));
115
130
  }
116
131
  });
132
+ return unsubscribe;
133
+ }, [bridge, initializeRuntime]);
134
+ useEffect(() => {
135
+ const lifetime = ++bridgeLifetimeRef.current;
117
136
  return () => {
118
- unsubscribe();
119
- bridge.dispose();
137
+ queueMicrotask(() => {
138
+ if (bridgeLifetimeRef.current === lifetime)
139
+ bridge.dispose();
140
+ });
120
141
  };
121
- }, [bridge, initializeRuntime]);
142
+ }, [bridge]);
122
143
  useEffect(() => {
123
144
  let active = true;
124
145
  void bridge
@@ -140,11 +161,18 @@ export const OpenEditorNative = forwardRef(function OpenEditorNative({ initialDo
140
161
  }
141
162
  initializeRuntime();
142
163
  };
164
+ const toolbarEnabled = showToolbar && editable;
143
165
  const shouldRenderToolbar = showToolbar && editable && editorState.focused && keyboardVisible;
144
- const toolbar = renderToolbar && shouldRenderToolbar ? (renderToolbar({ availableNativeEffects, controller: bridge, state: editorState })) : shouldRenderToolbar ? (_jsx(OpenEditorNativeToolbar, { availableNativeEffects: availableNativeEffects, controller: bridge, items: toolbarItems, onCommandError: (error) => callbacksRef.current.onError?.(error), state: editorState, theme: theme })) : null;
145
- return (_jsxs(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : "height", enabled: keyboardAvoidanceEnabled, keyboardVerticalOffset: keyboardVerticalOffset, style: [
166
+ const toolbar = renderToolbar && shouldRenderToolbar ? (renderToolbar({
167
+ availableNativeEffects,
168
+ blockHandlesVisible,
169
+ controller: bridge,
170
+ setBlockHandlesVisible,
171
+ state: editorState,
172
+ })) : !renderToolbar && toolbarEnabled ? (_jsx(OpenEditorNativeToolbar, { availableNativeEffects: availableNativeEffects, blockHandlesVisible: blockHandlesVisible, controller: bridge, items: toolbarItems, onBlockHandlesVisibleChange: setBlockHandlesVisible, onCommandError: (error) => callbacksRef.current.onError?.(error), state: editorState, style: styles.toolbarContent, theme: resolvedTheme, visible: shouldRenderToolbar })) : null;
173
+ return (_jsxs(View, { style: [
146
174
  styles.host,
147
- { backgroundColor: theme?.surface ?? "#ffffff" },
175
+ { backgroundColor: resolvedTheme.surface },
148
176
  style,
149
177
  ], children: [_jsx(View, { style: styles.webViewContainer, children: _jsx(HostWebView, { ...webViewProps, allowsBackForwardNavigationGestures: false, bounces: webViewProps?.bounces ?? true, injectedJavaScriptBeforeContentLoaded: injectedConfig, javaScriptEnabled: true, hideKeyboardAccessoryView: webViewProps?.hideKeyboardAccessoryView ?? true, nestedScrollEnabled: true, onLoadStart: (event) => {
150
178
  initializedRef.current = false;
@@ -160,12 +188,27 @@ export const OpenEditorNative = forwardRef(function OpenEditorNative({ initialDo
160
188
  ], ref: webViewRef, scrollEnabled: true, source: runtimeSource, style: [
161
189
  styles.webView,
162
190
  {
163
- backgroundColor: theme?.surface ?? "#ffffff",
191
+ backgroundColor: resolvedTheme.surface,
164
192
  },
165
- ] }) }), toolbar] }));
193
+ ] }) }), toolbarEnabled ? (_jsx(KeyboardStickyView, { offset: toolbarOffset, pointerEvents: "box-none", style: [styles.toolbarContainer, toolbarContainerStyle], children: _jsx(OpenEditorNativeToolbarSurface, { active: shouldRenderToolbar, colorScheme: resolvedToolbarColorScheme, theme: resolvedTheme, children: toolbar }) })) : null] }));
166
194
  });
167
195
  const styles = StyleSheet.create({
168
196
  host: { flex: 1, minHeight: 0 },
169
197
  webViewContainer: { flex: 1, minHeight: 0, overflow: "hidden" },
170
198
  webView: { flex: 1 },
199
+ toolbarContainer: {
200
+ bottom: 0,
201
+ borderRadius: OPENEDITOR_NATIVE_TOOLBAR_RADIUS,
202
+ height: OPENEDITOR_NATIVE_TOOLBAR_HEIGHT,
203
+ left: 12,
204
+ overflow: "hidden",
205
+ position: "absolute",
206
+ right: 12,
207
+ zIndex: 20,
208
+ },
209
+ toolbarContent: {
210
+ backgroundColor: "transparent",
211
+ borderWidth: 0,
212
+ minHeight: OPENEDITOR_NATIVE_TOOLBAR_HEIGHT,
213
+ },
171
214
  });
@@ -0,0 +1,20 @@
1
+ export type OpenEditorNativeContentInsets = {
2
+ top: number;
3
+ right: number;
4
+ bottom: number;
5
+ left: number;
6
+ };
7
+ export declare const OPENEDITOR_NATIVE_TOOLBAR_HEIGHT = 46;
8
+ export declare const OPENEDITOR_NATIVE_TOOLBAR_RADIUS: number;
9
+ export declare const OPENEDITOR_NATIVE_TOOLBAR_CONTENT_INSET = 70;
10
+ export declare const OPENEDITOR_NATIVE_TOOLBAR_OFFSET: {
11
+ readonly closed: 0;
12
+ /** Keep the intentional breathing room above the software keyboard. */
13
+ readonly opened: -8;
14
+ };
15
+ export declare const resolveOpenEditorNativeContentInsets: ({ contentInsets, editable, showToolbar, toolbarContentInset, }: {
16
+ contentInsets: Partial<OpenEditorNativeContentInsets>;
17
+ editable: boolean;
18
+ showToolbar: boolean;
19
+ toolbarContentInset: number;
20
+ }) => OpenEditorNativeContentInsets;
@@ -0,0 +1,15 @@
1
+ export const OPENEDITOR_NATIVE_TOOLBAR_HEIGHT = 46;
2
+ export const OPENEDITOR_NATIVE_TOOLBAR_RADIUS = OPENEDITOR_NATIVE_TOOLBAR_HEIGHT / 2;
3
+ export const OPENEDITOR_NATIVE_TOOLBAR_CONTENT_INSET = 70;
4
+ export const OPENEDITOR_NATIVE_TOOLBAR_OFFSET = {
5
+ closed: 0,
6
+ /** Keep the intentional breathing room above the software keyboard. */
7
+ opened: -8,
8
+ };
9
+ export const resolveOpenEditorNativeContentInsets = ({ contentInsets, editable, showToolbar, toolbarContentInset, }) => ({
10
+ top: contentInsets.top ?? 0,
11
+ right: contentInsets.right ?? 0,
12
+ bottom: (contentInsets.bottom ?? 0) +
13
+ (showToolbar && editable ? toolbarContentInset : 0),
14
+ left: contentInsets.left ?? 0,
15
+ });
@@ -0,0 +1,9 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ export type OpenEditorNativeProviderProps = {
3
+ children: ReactNode;
4
+ };
5
+ /**
6
+ * Owns the native keyboard animation context used by OpenEditor's toolbar.
7
+ * Mount once above every OpenEditorNative instance, normally at the app root.
8
+ */
9
+ export declare function OpenEditorNativeProvider({ children }: OpenEditorNativeProviderProps): ReactElement;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { KeyboardProvider } from "react-native-keyboard-controller";
3
+ /**
4
+ * Owns the native keyboard animation context used by OpenEditor's toolbar.
5
+ * Mount once above every OpenEditorNative instance, normally at the app root.
6
+ */
7
+ export function OpenEditorNativeProvider({ children }) {
8
+ return _jsx(KeyboardProvider, { children: children });
9
+ }
@@ -0,0 +1,32 @@
1
+ import type { OpenEditorTheme } from "@openeditor/embedded-runtime";
2
+ export type OpenEditorNativeColorScheme = "light" | "dark";
3
+ declare const LIGHT_THEME: {
4
+ surface: string;
5
+ surfaceRaised: string;
6
+ surfaceMuted: string;
7
+ interactionHover: string;
8
+ interactionSelected: string;
9
+ blockSurface: string;
10
+ text: string;
11
+ textSoft: string;
12
+ heading: string;
13
+ muted: string;
14
+ placeholder: string;
15
+ border: string;
16
+ borderStrong: string;
17
+ structuralLine: string;
18
+ accent: string;
19
+ accentText: string;
20
+ accentStrong: string;
21
+ buttonBackground: string;
22
+ buttonText: string;
23
+ codeBackground: string;
24
+ codeText: string;
25
+ link: string;
26
+ linkHover: string;
27
+ shadow: string;
28
+ fontSans: string;
29
+ };
30
+ export type ResolvedOpenEditorNativeTheme = typeof LIGHT_THEME;
31
+ export declare const resolveOpenEditorNativeTheme: (theme: Partial<OpenEditorTheme> | undefined, colorScheme: OpenEditorNativeColorScheme) => ResolvedOpenEditorNativeTheme;
32
+ export {};
package/dist/theme.js ADDED
@@ -0,0 +1,58 @@
1
+ const LIGHT_THEME = {
2
+ surface: "#ffffff",
3
+ surfaceRaised: "#ffffff",
4
+ surfaceMuted: "#f5f5f5",
5
+ interactionHover: "#f5f5f5",
6
+ interactionSelected: "#e5e5e5",
7
+ blockSurface: "#fafafa",
8
+ text: "#171717",
9
+ textSoft: "#404040",
10
+ heading: "#0a0a0a",
11
+ muted: "#737373",
12
+ placeholder: "#a3a3a3",
13
+ border: "#e5e5e5",
14
+ borderStrong: "#d4d4d4",
15
+ structuralLine: "#e5e5e5",
16
+ accent: "#171717",
17
+ accentText: "#ffffff",
18
+ accentStrong: "#0a0a0a",
19
+ buttonBackground: "#ffffff",
20
+ buttonText: "#171717",
21
+ codeBackground: "#f5f5f5",
22
+ codeText: "#262626",
23
+ link: "#1d4ed8",
24
+ linkHover: "#1e40af",
25
+ shadow: "rgba(0,0,0,0.18)",
26
+ fontSans: '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif',
27
+ };
28
+ const DARK_THEME = {
29
+ surface: "#111111",
30
+ surfaceRaised: "#1c1c1e",
31
+ surfaceMuted: "#27272a",
32
+ interactionHover: "#333336",
33
+ interactionSelected: "#3a3a3c",
34
+ blockSurface: "#1c1c1e",
35
+ text: "#f5f5f5",
36
+ textSoft: "#d4d4d4",
37
+ heading: "#fafafa",
38
+ muted: "#a3a3a3",
39
+ placeholder: "#737373",
40
+ border: "#333333",
41
+ borderStrong: "#525252",
42
+ structuralLine: "#333333",
43
+ accent: "#f5f5f5",
44
+ accentText: "#111111",
45
+ accentStrong: "#ffffff",
46
+ buttonBackground: "#27272a",
47
+ buttonText: "#f5f5f5",
48
+ codeBackground: "#1f1f1f",
49
+ codeText: "#e5e5e5",
50
+ link: "#60a5fa",
51
+ linkHover: "#93c5fd",
52
+ shadow: "rgba(0,0,0,0.60)",
53
+ fontSans: '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif',
54
+ };
55
+ export const resolveOpenEditorNativeTheme = (theme, colorScheme) => ({
56
+ ...(colorScheme === "dark" ? DARK_THEME : LIGHT_THEME),
57
+ ...theme,
58
+ });
@@ -13,5 +13,8 @@ export type OpenEditorNativeToolbarProps = {
13
13
  theme?: OpenEditorNativeTheme;
14
14
  style?: StyleProp<ViewStyle>;
15
15
  onCommandError?: (error: Error) => void;
16
+ visible?: boolean;
17
+ blockHandlesVisible?: boolean;
18
+ onBlockHandlesVisibleChange?: (visible: boolean) => void;
16
19
  };
17
- export declare const OpenEditorNativeToolbar: import("react").MemoExoticComponent<({ availableNativeEffects, controller, state, items, theme, style, onCommandError, }: OpenEditorNativeToolbarProps) => import("react").JSX.Element>;
20
+ export declare const OpenEditorNativeToolbar: import("react").MemoExoticComponent<({ availableNativeEffects, controller, state, items, theme, style, onCommandError, visible, blockHandlesVisible, onBlockHandlesVisibleChange, }: OpenEditorNativeToolbarProps) => import("react").JSX.Element>;
@@ -1,6 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { BottomSheet as UniversalBottomSheet, Button as NativeButton, Column as NativeColumn, ListItem as NativeListItem, RNHostView as NativeRNHostView, Row as NativeRow, ScrollView as NativeScrollView, Spacer as NativeSpacer, Text as NativeText, } from "@expo/ui";
3
+ import { BottomSheet as SwiftUIBottomSheet, Group as SwiftUIGroup, Host as SwiftUIHost, } from "@expo/ui/swift-ui";
4
+ import { accessibilityLabel as nativeAccessibilityLabel, frame as nativeFrame, glassEffect, padding as nativePadding, presentationDetents, presentationDragIndicator, } from "@expo/ui/swift-ui/modifiers";
5
+ import { openEditorIcons } from "@openeditor/icons";
6
+ import { HugeiconsIcon } from "@hugeicons/react-native";
2
7
  import { memo, useRef, useState } from "react";
3
- import { Keyboard, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
8
+ import { Keyboard, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
4
9
  import { defaultOpenEditorNativeToolbarItems, openEditorNativeBlockPickerItems, openEditorNativeTableToolbarItems, } from "./toolbar-items.js";
5
10
  export { defaultOpenEditorNativeToolbarItems, openEditorNativeBlockPickerItems, } from "./toolbar-items.js";
6
11
  const isItemActive = (item, state) => (item.activeWhen?.block !== undefined &&
@@ -8,11 +13,33 @@ const isItemActive = (item, state) => (item.activeWhen?.block !== undefined &&
8
13
  (item.activeWhen?.headingLevel === undefined ||
9
14
  state.headingLevel === item.activeWhen.headingLevel)) ||
10
15
  (item.activeWhen?.mark !== undefined &&
11
- state.activeMarks.includes(item.activeWhen.mark));
16
+ state.activeMarks.includes(item.activeWhen.mark)) ||
17
+ (item.activeWhen?.table !== undefined &&
18
+ state.table?.[item.activeWhen.table] === true);
12
19
  const isItemDisabled = (item, state) => !state.editable ||
13
20
  (item.disabledWhen === "cannotUndo" && !state.canUndo) ||
14
- (item.disabledWhen === "cannotRedo" && !state.canRedo);
15
- export const OpenEditorNativeToolbar = memo(function OpenEditorNativeToolbar({ availableNativeEffects = [], controller, state, items = defaultOpenEditorNativeToolbarItems, theme, style, onCommandError, }) {
21
+ (item.disabledWhen === "cannotRedo" && !state.canRedo) ||
22
+ (item.disabledWhen === "cannotMergeCells" && !state.table?.canMergeCells);
23
+ const TOOLBAR_BUTTON_SIZE = 32;
24
+ const IOS_BLOCK_PICKER_PRESENTATION_MODIFIERS = [
25
+ nativeFrame({ alignment: "topLeading", maxWidth: Infinity }),
26
+ nativePadding({ leading: 16, top: 16, trailing: 16 }),
27
+ presentationDragIndicator("visible"),
28
+ presentationDetents(["medium", "large"]),
29
+ ];
30
+ /**
31
+ * Expo's universal wrapper maps `onDismiss` to the binding-change event on
32
+ * iOS, losing SwiftUI's actual post-animation dismissal callback. Editor
33
+ * commands need that real lifecycle boundary so focus-owning native effects do
34
+ * not race the outgoing sheet.
35
+ */
36
+ const NativeBlockPickerSheet = ({ children, isPresented, onDismiss, onIsPresentedChange, }) => {
37
+ if (Platform.OS !== "ios") {
38
+ return (_jsx(UniversalBottomSheet, { isPresented: isPresented, onDismiss: onDismiss, showDragIndicator: true, snapPoints: ["half", "full"], testID: "openeditor-block-picker", children: children }));
39
+ }
40
+ return (_jsx(SwiftUIHost, { pointerEvents: "none", style: { position: "absolute" }, children: _jsx(SwiftUIBottomSheet, { isPresented: isPresented, onDismiss: onDismiss, onIsPresentedChange: onIsPresentedChange, testID: "openeditor-block-picker", children: _jsx(SwiftUIGroup, { modifiers: IOS_BLOCK_PICKER_PRESENTATION_MODIFIERS, children: children }) }) }));
41
+ };
42
+ export const OpenEditorNativeToolbar = memo(function OpenEditorNativeToolbar({ availableNativeEffects = [], controller, state, items = defaultOpenEditorNativeToolbarItems, theme, style, onCommandError, visible = true, blockHandlesVisible = false, onBlockHandlesVisibleChange, }) {
16
43
  const [blockPickerOpen, setBlockPickerOpen] = useState(false);
17
44
  const [linkEditorOpen, setLinkEditorOpen] = useState(false);
18
45
  const [linkValue, setLinkValue] = useState("");
@@ -42,34 +69,35 @@ export const OpenEditorNativeToolbar = memo(function OpenEditorNativeToolbar({ a
42
69
  return availableNativeEffects.includes("createPage");
43
70
  return true;
44
71
  });
45
- return (_jsxs(View, { style: [
46
- styles.root,
47
- { backgroundColor: colors.background, borderColor: colors.border },
48
- style,
49
- ], children: [_jsx(ScrollView, { contentContainerStyle: styles.content, horizontal: true, keyboardShouldPersistTaps: "always", showsHorizontalScrollIndicator: false, children: state.activeNodes.includes("table")
50
- ? openEditorNativeTableToolbarItems.map((item) => {
72
+ const blockPickerGroups = blockPickerItems.reduce((groups, item) => {
73
+ const current = groups.at(-1);
74
+ if (current?.label === item.group)
75
+ current.items.push(item);
76
+ else
77
+ groups.push({ label: item.group, items: [item] });
78
+ return groups;
79
+ }, []);
80
+ const visibleToolbarItems = state.activeNodes.includes("table")
81
+ ? [
82
+ ...defaultOpenEditorNativeToolbarItems.filter((item) => item.action === "toggleBlockHandles"),
83
+ ...openEditorNativeTableToolbarItems.filter((item) => item.key === "table-split-cell"
84
+ ? state.table?.canSplitCell
85
+ : item.key !== "table-merge-cells" || !state.table?.canSplitCell),
86
+ ]
87
+ : items;
88
+ return (_jsxs(_Fragment, { children: [visible ? (_jsx(View, { style: [styles.root, style], children: _jsx(ScrollView, { contentContainerStyle: styles.content, horizontal: true, keyboardShouldPersistTaps: "always", showsHorizontalScrollIndicator: false, children: visibleToolbarItems.map((item) => {
89
+ const active = item.action === "toggleBlockHandles"
90
+ ? blockHandlesVisible
91
+ : isItemActive(item, state);
51
92
  const disabled = isItemDisabled(item, state);
52
- return (_jsx(Pressable, { accessibilityLabel: item.label, accessibilityRole: "button", accessibilityState: { disabled }, disabled: disabled, onPress: () => {
53
- if (!item.command) {
54
- Keyboard.dismiss();
55
- runCommand(controller.blur());
93
+ const accessibilityLabel = item.action === "toggleBlockHandles" && blockHandlesVisible
94
+ ? "Hide block handles"
95
+ : item.label;
96
+ return (_jsx(Pressable, { accessibilityLabel: accessibilityLabel, accessibilityRole: "button", accessibilityState: { disabled, selected: active }, disabled: disabled, hitSlop: 6, onPress: () => {
97
+ if (item.action === "toggleBlockHandles") {
98
+ onBlockHandlesVisibleChange?.(!blockHandlesVisible);
56
99
  return;
57
100
  }
58
- runCommand(controller.command(item.command));
59
- }, style: ({ pressed }) => [
60
- styles.button,
61
- { borderColor: colors.border },
62
- pressed && styles.pressed,
63
- disabled && styles.disabled,
64
- ], children: _jsx(Text, { style: [
65
- styles.label,
66
- { color: disabled ? colors.muted : colors.text },
67
- ], children: item.label }) }, item.key));
68
- })
69
- : items.map((item) => {
70
- const active = isItemActive(item, state);
71
- const disabled = isItemDisabled(item, state);
72
- return (_jsx(Pressable, { accessibilityLabel: item.label, accessibilityRole: "button", accessibilityState: { disabled, selected: active }, disabled: disabled, onPress: () => {
73
101
  if (item.action === "openBlockPicker") {
74
102
  pickerSelectionRef.current =
75
103
  state.selection.type === "text"
@@ -94,43 +122,51 @@ export const OpenEditorNativeToolbar = memo(function OpenEditorNativeToolbar({ a
94
122
  runCommand(controller.command(item.command));
95
123
  }, style: ({ pressed }) => [
96
124
  styles.button,
97
- { borderColor: active ? colors.accent : colors.border },
98
125
  active && { backgroundColor: colors.accent },
99
126
  pressed && styles.pressed,
100
127
  disabled && styles.disabled,
101
- ], children: _jsx(Text, { style: [
102
- styles.label,
103
- {
104
- color: active
105
- ? colors.accentText
106
- : disabled
107
- ? colors.muted
108
- : colors.text,
109
- },
110
- ], children: item.label }) }, item.key));
111
- }) }), _jsx(Modal, { animationType: "slide", onDismiss: () => {
128
+ ], children: _jsx(HugeiconsIcon, { accessible: false, color: active
129
+ ? colors.accentText
130
+ : disabled
131
+ ? colors.muted
132
+ : colors.text, icon: openEditorIcons[item.icon], size: 20, strokeWidth: 1.8 }) }, item.key));
133
+ }) }) })) : null, _jsx(NativeBlockPickerSheet, { isPresented: blockPickerOpen, onDismiss: () => {
112
134
  const block = pendingBlockRef.current;
113
135
  pendingBlockRef.current = null;
114
136
  pickerSelectionRef.current = undefined;
115
- if (block)
116
- runCommand(controller.command(block));
117
- }, onRequestClose: () => setBlockPickerOpen(false), presentationStyle: "pageSheet", visible: blockPickerOpen, children: _jsxs(View, { style: [styles.sheet, { backgroundColor: colors.background }], children: [_jsxs(View, { style: [styles.sheetHeader, { borderBottomColor: colors.border }], children: [_jsx(Text, { accessibilityRole: "header", style: [styles.sheetTitle, { color: colors.text }], children: "Insert block" }), _jsx(Pressable, { accessibilityLabel: "Close block picker", accessibilityRole: "button", onPress: () => setBlockPickerOpen(false), children: _jsx(Text, { style: [styles.sheetClose, { color: colors.accent }], children: "Done" }) })] }), _jsx(ScrollView, { contentContainerStyle: styles.sheetContent, children: blockPickerItems.map((item, index) => {
118
- const previousGroup = index > 0
119
- ? blockPickerItems[index - 1]?.group
120
- : undefined;
121
- return (_jsxs(View, { children: [item.group !== previousGroup ? (_jsx(Text, { style: [styles.groupLabel, { color: colors.muted }], children: item.group })) : null, _jsx(Pressable, { accessibilityLabel: `Insert ${item.label}`, accessibilityRole: "button", onPress: () => {
137
+ if (block) {
138
+ runCommand(controller.command(block).then(() => controller.focus()));
139
+ }
140
+ }, onIsPresentedChange: setBlockPickerOpen, children: _jsxs(NativeColumn, { alignment: "start", spacing: 0, style: { height: "100%", width: "100%" }, children: [_jsxs(NativeRow, { alignment: "center", spacing: 8, style: { paddingBottom: 10, width: "100%" }, children: [_jsx(NativeButton, { modifiers: Platform.OS === "ios"
141
+ ? [
142
+ nativeAccessibilityLabel("Cancel"),
143
+ glassEffect({
144
+ glass: { interactive: true, variant: "clear" },
145
+ shape: "circle",
146
+ }),
147
+ ]
148
+ : undefined, onPress: () => setBlockPickerOpen(false), style: { borderRadius: 18, height: 36, width: 36 }, testID: "close-block-picker", variant: "text", children: _jsx(NativeRNHostView, { matchContents: true, children: _jsx(View, { style: { height: 18, width: 18 }, children: _jsx(HugeiconsIcon, { accessible: false, color: colors.text, icon: openEditorIcons.close, size: 18, strokeWidth: 1.8 }) }) }) }), _jsx(NativeSpacer, { flexible: true }), _jsx(NativeText, { testID: "block-picker-title", textStyle: { fontSize: 17, fontWeight: "600" }, children: "Insert block" }), _jsx(NativeSpacer, { flexible: true }), _jsx(NativeButton, { modifiers: Platform.OS === "ios"
149
+ ? [
150
+ nativeAccessibilityLabel("Done"),
151
+ glassEffect({
152
+ glass: { interactive: true, variant: "clear" },
153
+ shape: "circle",
154
+ }),
155
+ ]
156
+ : undefined, onPress: () => setBlockPickerOpen(false), style: { borderRadius: 18, height: 36, width: 36 }, testID: "done-block-picker", variant: "text", children: _jsx(NativeRNHostView, { matchContents: true, children: _jsx(View, { style: { height: 18, width: 18 }, children: _jsx(HugeiconsIcon, { accessible: false, color: colors.text, icon: openEditorIcons.check, size: 18, strokeWidth: 1.8 }) }) }) })] }), _jsx(NativeScrollView, { showsIndicators: true, style: { height: "100%", width: "100%" }, children: _jsx(NativeColumn, { alignment: "start", spacing: 20, style: { width: "100%" }, children: blockPickerGroups.map((group) => (_jsxs(NativeColumn, { alignment: "start", spacing: 4, style: { width: "100%" }, children: [_jsx(NativeText, { textStyle: {
157
+ color: colors.muted,
158
+ fontSize: 12,
159
+ fontWeight: "600",
160
+ }, children: group.label }), group.items.map((item) => (_jsx(NativeListItem, { leading: (_jsx(HugeiconsIcon, { accessible: false, color: colors.text, icon: openEditorIcons[item.icon], size: 20, strokeWidth: 1.8 })), modifiers: Platform.OS === "ios"
161
+ ? [nativePadding({ vertical: 10 })]
162
+ : undefined, onPress: () => {
122
163
  pendingBlockRef.current = {
123
164
  type: "insertBlock",
124
165
  block: item.key,
125
166
  selection: pickerSelectionRef.current,
126
167
  };
127
168
  setBlockPickerOpen(false);
128
- }, style: ({ pressed }) => [
129
- styles.blockPickerItem,
130
- { borderBottomColor: colors.border },
131
- pressed && styles.pressed,
132
- ], children: _jsx(Text, { style: [styles.blockPickerLabel, { color: colors.text }], children: item.label }) })] }, item.key));
133
- }) })] }) }), _jsx(Modal, { animationType: "fade", onRequestClose: () => setLinkEditorOpen(false), presentationStyle: "formSheet", visible: linkEditorOpen, children: _jsxs(View, { style: [styles.linkSheet, { backgroundColor: colors.background }], children: [_jsx(Text, { accessibilityRole: "header", style: [styles.sheetTitle, { color: colors.text }], children: "Edit link" }), _jsx(TextInput, { accessibilityLabel: "Link URL", autoCapitalize: "none", autoCorrect: false, onChangeText: setLinkValue, placeholder: "https://example.com", placeholderTextColor: colors.muted, style: [
169
+ }, testID: `insert-${item.key}`, trailing: (_jsx(HugeiconsIcon, { accessible: false, color: colors.muted, icon: openEditorIcons.chevronRight, size: 16, strokeWidth: 1.8 })), children: _jsx(NativeText, { children: item.label }, `${item.key}-label`) }, item.key)))] }, group.label))) }) })] }) }), _jsx(Modal, { animationType: "fade", onRequestClose: () => setLinkEditorOpen(false), presentationStyle: "formSheet", visible: linkEditorOpen, children: _jsxs(View, { style: [styles.linkSheet, { backgroundColor: colors.background }], children: [_jsx(Text, { accessibilityRole: "header", style: [styles.sheetTitle, { color: colors.text }], children: "Edit link" }), _jsx(TextInput, { accessibilityLabel: "Link URL", autoCapitalize: "none", autoCorrect: false, onChangeText: setLinkValue, placeholder: "https://example.com", placeholderTextColor: colors.muted, style: [
134
170
  styles.linkInput,
135
171
  { borderColor: colors.border, color: colors.text },
136
172
  ], value: linkValue }), _jsxs(View, { style: styles.linkActions, children: [_jsx(Pressable, { accessibilityLabel: "Cancel link editing", accessibilityRole: "button", onPress: () => setLinkEditorOpen(false), style: styles.linkAction, children: _jsx(Text, { style: { color: colors.text }, children: "Cancel" }) }), _jsx(Pressable, { accessibilityLabel: "Remove", accessibilityRole: "button", onPress: () => {
@@ -144,55 +180,24 @@ export const OpenEditorNativeToolbar = memo(function OpenEditorNativeToolbar({ a
144
180
  });
145
181
  const styles = StyleSheet.create({
146
182
  root: {
147
- borderWidth: StyleSheet.hairlineWidth,
148
- minHeight: 52,
183
+ minHeight: 46,
149
184
  },
150
185
  content: {
151
186
  alignItems: "center",
152
- gap: 8,
153
- paddingHorizontal: 10,
187
+ gap: 6,
188
+ paddingHorizontal: 8,
154
189
  paddingVertical: 7,
155
190
  },
156
191
  button: {
157
192
  alignItems: "center",
158
- borderRadius: 8,
159
- borderWidth: 1,
193
+ borderRadius: TOOLBAR_BUTTON_SIZE / 2,
194
+ height: TOOLBAR_BUTTON_SIZE,
160
195
  justifyContent: "center",
161
- minHeight: 36,
162
- minWidth: 40,
163
- paddingHorizontal: 10,
164
- },
165
- label: {
166
- fontSize: 13,
167
- fontWeight: "600",
196
+ width: TOOLBAR_BUTTON_SIZE,
168
197
  },
169
198
  pressed: { opacity: 0.64 },
170
199
  disabled: { opacity: 0.42 },
171
- sheet: { flex: 1 },
172
- sheetHeader: {
173
- alignItems: "center",
174
- borderBottomWidth: StyleSheet.hairlineWidth,
175
- flexDirection: "row",
176
- justifyContent: "space-between",
177
- minHeight: 56,
178
- paddingHorizontal: 20,
179
- },
180
200
  sheetTitle: { fontSize: 18, fontWeight: "700" },
181
- sheetClose: { fontSize: 16, fontWeight: "600" },
182
- sheetContent: { paddingBottom: 32, paddingHorizontal: 20 },
183
- groupLabel: {
184
- fontSize: 12,
185
- fontWeight: "700",
186
- paddingBottom: 5,
187
- paddingTop: 20,
188
- textTransform: "uppercase",
189
- },
190
- blockPickerItem: {
191
- borderBottomWidth: StyleSheet.hairlineWidth,
192
- minHeight: 48,
193
- justifyContent: "center",
194
- },
195
- blockPickerLabel: { fontSize: 16, fontWeight: "500" },
196
201
  linkSheet: { flex: 1, gap: 20, justifyContent: "center", padding: 24 },
197
202
  linkInput: {
198
203
  borderRadius: 10,
@@ -1,88 +1,109 @@
1
1
  import type { OpenEditorRuntimeCommand } from "@openeditor/embedded-runtime";
2
+ import type { OpenEditorIconName } from "@openeditor/icons";
2
3
  export type OpenEditorNativeToolbarItem = {
3
4
  key: string;
4
5
  label: string;
6
+ icon: OpenEditorIconName;
5
7
  command?: OpenEditorRuntimeCommand;
6
- action?: "openBlockPicker" | "openLinkEditor" | "dismissKeyboard";
8
+ action?: "openBlockPicker" | "openLinkEditor" | "toggleBlockHandles" | "dismissKeyboard";
7
9
  activeWhen?: {
8
10
  block?: string;
9
11
  headingLevel?: number;
10
12
  mark?: string;
13
+ table?: "headerRow" | "headerColumn";
11
14
  };
12
- disabledWhen?: "cannotUndo" | "cannotRedo";
15
+ disabledWhen?: "cannotUndo" | "cannotRedo" | "cannotMergeCells";
13
16
  };
14
17
  export declare const defaultOpenEditorNativeToolbarItems: readonly OpenEditorNativeToolbarItem[];
15
18
  export declare const openEditorNativeBlockPickerItems: readonly [{
16
19
  readonly key: "paragraph";
17
- readonly label: "Paragraph";
20
+ readonly label: "Text";
18
21
  readonly group: "Text";
22
+ readonly icon: "text";
19
23
  }, {
20
24
  readonly key: "heading1";
21
25
  readonly label: "Heading 1";
22
26
  readonly group: "Text";
27
+ readonly icon: "heading1";
23
28
  }, {
24
29
  readonly key: "heading2";
25
30
  readonly label: "Heading 2";
26
31
  readonly group: "Text";
32
+ readonly icon: "heading2";
27
33
  }, {
28
34
  readonly key: "heading3";
29
35
  readonly label: "Heading 3";
30
36
  readonly group: "Text";
37
+ readonly icon: "heading3";
31
38
  }, {
32
39
  readonly key: "bulletList";
33
40
  readonly label: "Bullet list";
34
41
  readonly group: "Text";
42
+ readonly icon: "bulletList";
35
43
  }, {
36
44
  readonly key: "orderedList";
37
45
  readonly label: "Numbered list";
38
46
  readonly group: "Text";
47
+ readonly icon: "orderedList";
39
48
  }, {
40
49
  readonly key: "taskList";
41
50
  readonly label: "Task list";
42
51
  readonly group: "Text";
52
+ readonly icon: "taskList";
43
53
  }, {
44
54
  readonly key: "toggleList";
45
55
  readonly label: "Toggle list";
46
56
  readonly group: "Text";
57
+ readonly icon: "toggleList";
47
58
  }, {
48
59
  readonly key: "blockquote";
49
60
  readonly label: "Quote";
50
61
  readonly group: "Text";
62
+ readonly icon: "blockquote";
51
63
  }, {
52
64
  readonly key: "codeBlock";
53
65
  readonly label: "Code block";
54
66
  readonly group: "Text";
67
+ readonly icon: "codeBlock";
55
68
  }, {
56
69
  readonly key: "divider";
57
70
  readonly label: "Divider";
58
71
  readonly group: "Structure";
72
+ readonly icon: "divider";
59
73
  }, {
60
74
  readonly key: "columns";
61
75
  readonly label: "Columns";
62
76
  readonly group: "Layout";
77
+ readonly icon: "columns";
63
78
  }, {
64
79
  readonly key: "table";
65
80
  readonly label: "Table";
66
81
  readonly group: "Layout";
82
+ readonly icon: "table";
67
83
  }, {
68
84
  readonly key: "callout";
69
85
  readonly label: "Callout";
70
86
  readonly group: "Embed";
87
+ readonly icon: "callout";
71
88
  }, {
72
89
  readonly key: "diagram";
73
90
  readonly label: "Diagram";
74
91
  readonly group: "Embed";
92
+ readonly icon: "diagram";
75
93
  }, {
76
94
  readonly key: "page";
77
95
  readonly label: "Page";
78
96
  readonly group: "Embed";
97
+ readonly icon: "page";
79
98
  }, {
80
99
  readonly key: "image";
81
100
  readonly label: "Image";
82
101
  readonly group: "Media";
102
+ readonly icon: "image";
83
103
  }, {
84
104
  readonly key: "attachment";
85
105
  readonly label: "File";
86
106
  readonly group: "Media";
107
+ readonly icon: "attachment";
87
108
  }];
88
109
  export declare const openEditorNativeTableToolbarItems: readonly OpenEditorNativeToolbarItem[];
@@ -1,52 +1,57 @@
1
1
  export const defaultOpenEditorNativeToolbarItems = [
2
- { key: "insert", label: "+ Block", action: "openBlockPicker" },
3
- { key: "paragraph", label: "Text", command: { type: "setParagraph" }, activeWhen: { block: "paragraph" } },
4
- { key: "heading-1", label: "H1", command: { type: "toggleHeading", level: 1 }, activeWhen: { block: "heading", headingLevel: 1 } },
5
- { key: "heading-2", label: "H2", command: { type: "toggleHeading", level: 2 }, activeWhen: { block: "heading", headingLevel: 2 } },
6
- { key: "bold", label: "B", command: { type: "toggleMark", mark: "bold" }, activeWhen: { mark: "bold" } },
7
- { key: "italic", label: "I", command: { type: "toggleMark", mark: "italic" }, activeWhen: { mark: "italic" } },
8
- { key: "underline", label: "U", command: { type: "toggleMark", mark: "underline" }, activeWhen: { mark: "underline" } },
9
- { key: "strike", label: "S", command: { type: "toggleMark", mark: "strike" }, activeWhen: { mark: "strike" } },
10
- { key: "code", label: "Code", command: { type: "toggleMark", mark: "code" }, activeWhen: { mark: "code" } },
11
- { key: "link", label: "Link", action: "openLinkEditor", activeWhen: { mark: "link" } },
12
- { key: "bullet-list", label: "• List", command: { type: "toggleList", list: "bullet" }, activeWhen: { block: "bulletList" } },
13
- { key: "ordered-list", label: "1. List", command: { type: "toggleList", list: "ordered" }, activeWhen: { block: "orderedList" } },
14
- { key: "task-list", label: "Tasks", command: { type: "toggleList", list: "task" }, activeWhen: { block: "taskList" } },
15
- { key: "quote", label: "Quote", command: { type: "toggleBlockquote" }, activeWhen: { block: "blockquote" } },
16
- { key: "code-block", label: "Code block", command: { type: "toggleCodeBlock" }, activeWhen: { block: "codeBlock" } },
17
- { key: "undo", label: "Undo", command: { type: "undo" }, disabledWhen: "cannotUndo" },
18
- { key: "redo", label: "Redo", command: { type: "redo" }, disabledWhen: "cannotRedo" },
19
- { key: "keyboard", label: "Done", action: "dismissKeyboard" },
2
+ { key: "insert", label: "Insert block", icon: "addBlock", action: "openBlockPicker" },
3
+ { key: "handles", label: "Show block handles", icon: "dragHandle", action: "toggleBlockHandles" },
4
+ { key: "paragraph", label: "Text", icon: "text", command: { type: "setParagraph" }, activeWhen: { block: "paragraph" } },
5
+ { key: "heading-1", label: "Heading 1", icon: "heading1", command: { type: "toggleHeading", level: 1 }, activeWhen: { block: "heading", headingLevel: 1 } },
6
+ { key: "heading-2", label: "Heading 2", icon: "heading2", command: { type: "toggleHeading", level: 2 }, activeWhen: { block: "heading", headingLevel: 2 } },
7
+ { key: "bold", label: "Bold", icon: "bold", command: { type: "toggleMark", mark: "bold" }, activeWhen: { mark: "bold" } },
8
+ { key: "italic", label: "Italic", icon: "italic", command: { type: "toggleMark", mark: "italic" }, activeWhen: { mark: "italic" } },
9
+ { key: "underline", label: "Underline", icon: "underline", command: { type: "toggleMark", mark: "underline" }, activeWhen: { mark: "underline" } },
10
+ { key: "strike", label: "Strikethrough", icon: "strike", command: { type: "toggleMark", mark: "strike" }, activeWhen: { mark: "strike" } },
11
+ { key: "code", label: "Inline code", icon: "code", command: { type: "toggleMark", mark: "code" }, activeWhen: { mark: "code" } },
12
+ { key: "link", label: "Link", icon: "link", action: "openLinkEditor", activeWhen: { mark: "link" } },
13
+ { key: "bullet-list", label: "Bullet list", icon: "bulletList", command: { type: "toggleList", list: "bullet" }, activeWhen: { block: "bulletList" } },
14
+ { key: "ordered-list", label: "Numbered list", icon: "orderedList", command: { type: "toggleList", list: "ordered" }, activeWhen: { block: "orderedList" } },
15
+ { key: "task-list", label: "Task list", icon: "taskList", command: { type: "toggleList", list: "task" }, activeWhen: { block: "taskList" } },
16
+ { key: "quote", label: "Quote", icon: "blockquote", command: { type: "toggleBlockquote" }, activeWhen: { block: "blockquote" } },
17
+ { key: "code-block", label: "Code block", icon: "codeBlock", command: { type: "toggleCodeBlock" }, activeWhen: { block: "codeBlock" } },
18
+ { key: "undo", label: "Undo", icon: "undo", command: { type: "undo" }, disabledWhen: "cannotUndo" },
19
+ { key: "redo", label: "Redo", icon: "redo", command: { type: "redo" }, disabledWhen: "cannotRedo" },
20
+ { key: "keyboard", label: "Dismiss keyboard", icon: "keyboard", action: "dismissKeyboard" },
20
21
  ];
21
22
  export const openEditorNativeBlockPickerItems = [
22
- { key: "paragraph", label: "Paragraph", group: "Text" },
23
- { key: "heading1", label: "Heading 1", group: "Text" },
24
- { key: "heading2", label: "Heading 2", group: "Text" },
25
- { key: "heading3", label: "Heading 3", group: "Text" },
26
- { key: "bulletList", label: "Bullet list", group: "Text" },
27
- { key: "orderedList", label: "Numbered list", group: "Text" },
28
- { key: "taskList", label: "Task list", group: "Text" },
29
- { key: "toggleList", label: "Toggle list", group: "Text" },
30
- { key: "blockquote", label: "Quote", group: "Text" },
31
- { key: "codeBlock", label: "Code block", group: "Text" },
32
- { key: "divider", label: "Divider", group: "Structure" },
33
- { key: "columns", label: "Columns", group: "Layout" },
34
- { key: "table", label: "Table", group: "Layout" },
35
- { key: "callout", label: "Callout", group: "Embed" },
36
- { key: "diagram", label: "Diagram", group: "Embed" },
37
- { key: "page", label: "Page", group: "Embed" },
38
- { key: "image", label: "Image", group: "Media" },
39
- { key: "attachment", label: "File", group: "Media" },
23
+ { key: "paragraph", label: "Text", group: "Text", icon: "text" },
24
+ { key: "heading1", label: "Heading 1", group: "Text", icon: "heading1" },
25
+ { key: "heading2", label: "Heading 2", group: "Text", icon: "heading2" },
26
+ { key: "heading3", label: "Heading 3", group: "Text", icon: "heading3" },
27
+ { key: "bulletList", label: "Bullet list", group: "Text", icon: "bulletList" },
28
+ { key: "orderedList", label: "Numbered list", group: "Text", icon: "orderedList" },
29
+ { key: "taskList", label: "Task list", group: "Text", icon: "taskList" },
30
+ { key: "toggleList", label: "Toggle list", group: "Text", icon: "toggleList" },
31
+ { key: "blockquote", label: "Quote", group: "Text", icon: "blockquote" },
32
+ { key: "codeBlock", label: "Code block", group: "Text", icon: "codeBlock" },
33
+ { key: "divider", label: "Divider", group: "Structure", icon: "divider" },
34
+ { key: "columns", label: "Columns", group: "Layout", icon: "columns" },
35
+ { key: "table", label: "Table", group: "Layout", icon: "table" },
36
+ { key: "callout", label: "Callout", group: "Embed", icon: "callout" },
37
+ { key: "diagram", label: "Diagram", group: "Embed", icon: "diagram" },
38
+ { key: "page", label: "Page", group: "Embed", icon: "page" },
39
+ { key: "image", label: "Image", group: "Media", icon: "image" },
40
+ { key: "attachment", label: "File", group: "Media", icon: "attachment" },
40
41
  ];
41
42
  export const openEditorNativeTableToolbarItems = [
42
- { key: "table-row-before", label: "+ Row ", command: { type: "addTableRow", after: false } },
43
- { key: "table-row-after", label: "+ Row ", command: { type: "addTableRow" } },
44
- { key: "table-delete-row", label: " Row", command: { type: "deleteTableRow" } },
45
- { key: "table-column-before", label: "+ Col ", command: { type: "addTableColumn", after: false } },
46
- { key: "table-column-after", label: "+ Col ", command: { type: "addTableColumn" } },
47
- { key: "table-delete-column", label: " Col", command: { type: "deleteTableColumn" } },
48
- { key: "table-delete", label: "Delete table", command: { type: "deleteTable" } },
49
- { key: "table-undo", label: "Undo", command: { type: "undo" }, disabledWhen: "cannotUndo" },
50
- { key: "table-redo", label: "Redo", command: { type: "redo" }, disabledWhen: "cannotRedo" },
51
- { key: "table-keyboard", label: "Done", action: "dismissKeyboard" },
43
+ { key: "table-row-before", label: "Insert row above", icon: "tableRowInsertBefore", command: { type: "addTableRow", after: false } },
44
+ { key: "table-row-after", label: "Insert row below", icon: "tableRowInsertAfter", command: { type: "addTableRow" } },
45
+ { key: "table-delete-row", label: "Delete row", icon: "tableRowDelete", command: { type: "deleteTableRow" } },
46
+ { key: "table-column-before", label: "Insert column left", icon: "tableColumnInsertBefore", command: { type: "addTableColumn", after: false } },
47
+ { key: "table-column-after", label: "Insert column right", icon: "tableColumnInsertAfter", command: { type: "addTableColumn" } },
48
+ { key: "table-delete-column", label: "Delete column", icon: "tableColumnDelete", command: { type: "deleteTableColumn" } },
49
+ { key: "table-header-row", label: "Toggle header row", icon: "tableHeaderRow", command: { type: "toggleTableHeaderRow" }, activeWhen: { table: "headerRow" } },
50
+ { key: "table-header-column", label: "Toggle header column", icon: "tableHeaderColumn", command: { type: "toggleTableHeaderColumn" }, activeWhen: { table: "headerColumn" } },
51
+ { key: "table-merge-cells", label: "Merge selected cells", icon: "mergeCells", command: { type: "mergeTableCells" }, disabledWhen: "cannotMergeCells" },
52
+ { key: "table-split-cell", label: "Split cell", icon: "splitCells", command: { type: "splitTableCell" } },
53
+ { key: "table-delete", label: "Delete table", icon: "delete", command: { type: "deleteTable" } },
54
+ { key: "table-undo", label: "Undo", icon: "undo", command: { type: "undo" }, disabledWhen: "cannotUndo" },
55
+ { key: "table-redo", label: "Redo", icon: "redo", command: { type: "redo" }, disabledWhen: "cannotRedo" },
56
+ { key: "table-keyboard", label: "Dismiss keyboard", icon: "keyboard", action: "dismissKeyboard" },
52
57
  ];
@@ -0,0 +1,17 @@
1
+ import { type GlassColorScheme } from "expo-glass-effect";
2
+ import type { ReactNode } from "react";
3
+ import { type StyleProp, type ViewStyle } from "react-native";
4
+ import type { OpenEditorNativeTheme } from "./native-editor.js";
5
+ export type OpenEditorNativeToolbarSurfaceProps = {
6
+ active?: boolean;
7
+ children: ReactNode;
8
+ colorScheme?: GlassColorScheme;
9
+ style?: StyleProp<ViewStyle>;
10
+ theme?: OpenEditorNativeTheme;
11
+ };
12
+ /**
13
+ * The platform material surrounding OpenEditor's keyboard toolbar.
14
+ * iOS uses the system glass implementation when available; other runtimes
15
+ * receive the same geometry with a theme-aware opaque fallback.
16
+ */
17
+ export declare function OpenEditorNativeToolbarSurface({ active, children, colorScheme, style, theme, }: OpenEditorNativeToolbarSurfaceProps): import("react").JSX.Element;
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { GlassView, isGlassEffectAPIAvailable, } from "expo-glass-effect";
3
+ import { Platform, StyleSheet, View, } from "react-native";
4
+ import { OPENEDITOR_NATIVE_TOOLBAR_HEIGHT, OPENEDITOR_NATIVE_TOOLBAR_RADIUS, } from "./native-layout.js";
5
+ /**
6
+ * The platform material surrounding OpenEditor's keyboard toolbar.
7
+ * iOS uses the system glass implementation when available; other runtimes
8
+ * receive the same geometry with a theme-aware opaque fallback.
9
+ */
10
+ export function OpenEditorNativeToolbarSurface({ active = true, children, colorScheme = "auto", style, theme, }) {
11
+ if (Platform.OS === "ios" && isGlassEffectAPIAvailable()) {
12
+ return (_jsx(GlassView, { colorScheme: colorScheme, glassEffectStyle: active ? "regular" : "none", isInteractive: true, style: [styles.surface, style], children: children }));
13
+ }
14
+ return (_jsx(View, { style: [
15
+ styles.surface,
16
+ styles.fallback,
17
+ {
18
+ backgroundColor: theme?.surfaceRaised ?? theme?.surfaceMuted ?? theme?.surface ?? "#ffffff",
19
+ borderColor: theme?.border ?? "#e5e7eb",
20
+ },
21
+ style,
22
+ ], children: children }));
23
+ }
24
+ const styles = StyleSheet.create({
25
+ surface: {
26
+ borderRadius: OPENEDITOR_NATIVE_TOOLBAR_RADIUS,
27
+ height: OPENEDITOR_NATIVE_TOOLBAR_HEIGHT,
28
+ overflow: "hidden",
29
+ },
30
+ fallback: {
31
+ borderWidth: StyleSheet.hairlineWidth,
32
+ },
33
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openeditor/native",
3
- "version": "0.0.34",
3
+ "version": "0.0.35",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "repository": {
@@ -15,7 +15,8 @@
15
15
  "access": "public"
16
16
  },
17
17
  "files": [
18
- "dist"
18
+ "dist",
19
+ "plugin.cjs"
19
20
  ],
20
21
  "exports": {
21
22
  ".": {
@@ -23,16 +24,26 @@
23
24
  "types": "./dist/index.d.ts",
24
25
  "import": "./dist/index.js"
25
26
  },
27
+ "./plugin": "./plugin.cjs",
26
28
  "./package.json": "./package.json"
27
29
  },
28
30
  "dependencies": {
29
- "@openeditor/core": "0.0.34",
30
- "@openeditor/embedded-runtime": "0.0.34",
31
- "@openeditor/embedded-surface": "0.0.34"
31
+ "@expo/config-plugins": "57.0.6",
32
+ "@expo/ui": "~57.0.7",
33
+ "@hugeicons/react-native": "^1.0.15",
34
+ "@openeditor/core": "0.0.35",
35
+ "@openeditor/embedded-runtime": "0.0.35",
36
+ "@openeditor/embedded-surface": "0.0.35",
37
+ "@openeditor/icons": "0.0.35"
32
38
  },
33
39
  "peerDependencies": {
40
+ "expo": ">=57",
34
41
  "react": ">=19",
35
42
  "react-native": ">=0.85",
43
+ "expo-glass-effect": ">=57",
44
+ "react-native-keyboard-controller": ">=1.21",
45
+ "react-native-reanimated": ">=3",
46
+ "react-native-svg": ">=15",
36
47
  "react-native-webview": ">=13"
37
48
  }
38
49
  }
package/plugin.cjs ADDED
@@ -0,0 +1,79 @@
1
+ const { withAppDelegate } = require("@expo/config-plugins");
2
+
3
+ const WEBKIT_IMPORT = "import WebKit\n";
4
+ const OBSERVER_PROPERTY =
5
+ " private var openEditorKeyboardWillShowObserver: NSObjectProtocol?\n";
6
+ const OBSERVER_SETUP = ` openEditorKeyboardWillShowObserver = NotificationCenter.default.addObserver(
7
+ forName: UIResponder.keyboardWillShowNotification,
8
+ object: nil,
9
+ queue: .main
10
+ ) { [weak self] _ in
11
+ guard let rootView = self?.window?.rootViewController?.view else { return }
12
+ self?.enableOpenEditorInteractiveKeyboardDismissal(in: rootView)
13
+ }
14
+
15
+ `;
16
+ const DISMISSAL_METHOD = ` deinit {
17
+ if let openEditorKeyboardWillShowObserver {
18
+ NotificationCenter.default.removeObserver(openEditorKeyboardWillShowObserver)
19
+ }
20
+ }
21
+
22
+ private func enableOpenEditorInteractiveKeyboardDismissal(in view: UIView) {
23
+ if let webView = view as? WKWebView {
24
+ webView.scrollView.keyboardDismissMode = .interactive
25
+ }
26
+ view.subviews.forEach { enableOpenEditorInteractiveKeyboardDismissal(in: $0) }
27
+ }
28
+
29
+ `;
30
+
31
+ const applyOpenEditorKeyboardDismissal = (contents) => {
32
+ for (const anchor of [
33
+ "import React\n",
34
+ "class AppDelegate: ExpoAppDelegate {\n",
35
+ " return super.application(application, didFinishLaunchingWithOptions: launchOptions)\n",
36
+ " // Linking API\n",
37
+ ]) {
38
+ if (!contents.includes(anchor)) {
39
+ throw new Error(
40
+ `OpenEditor's Expo plugin could not find ${JSON.stringify(anchor)} in AppDelegate.swift.`,
41
+ );
42
+ }
43
+ }
44
+
45
+ let next = contents;
46
+ if (!next.includes("import WebKit")) {
47
+ next = next.replace("import React\n", `import React\n${WEBKIT_IMPORT}`);
48
+ }
49
+ if (!next.includes("openEditorKeyboardWillShowObserver")) {
50
+ next = next.replace(
51
+ "class AppDelegate: ExpoAppDelegate {\n",
52
+ `class AppDelegate: ExpoAppDelegate {\n${OBSERVER_PROPERTY}`,
53
+ );
54
+ next = next.replace(
55
+ " return super.application(application, didFinishLaunchingWithOptions: launchOptions)\n",
56
+ `${OBSERVER_SETUP} return super.application(application, didFinishLaunchingWithOptions: launchOptions)\n`,
57
+ );
58
+ next = next.replace(
59
+ " // Linking API\n",
60
+ `${DISMISSAL_METHOD} // Linking API\n`,
61
+ );
62
+ }
63
+ return next;
64
+ };
65
+
66
+ const withOpenEditorNative = (config) =>
67
+ withAppDelegate(config, (mod) => {
68
+ if (mod.modResults.language !== "swift") {
69
+ throw new Error("OpenEditor's Expo plugin requires a Swift AppDelegate.");
70
+ }
71
+ mod.modResults.contents = applyOpenEditorKeyboardDismissal(
72
+ mod.modResults.contents,
73
+ );
74
+ return mod;
75
+ });
76
+
77
+ module.exports = withOpenEditorNative;
78
+ module.exports.applyOpenEditorKeyboardDismissal =
79
+ applyOpenEditorKeyboardDismissal;