@openeditor/native 0.0.24 → 0.0.26

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
@@ -8,8 +8,13 @@ Pass `attachmentRuntime` to use the system picker and host storage lifecycle. Th
8
8
 
9
9
  - `OpenEditorNative` as the primary editing surface
10
10
  - `OpenEditorNativeViewer` for read-only rendering
11
+ - `OpenEditorNativeEmojiPicker` for standalone native emoji selection
11
12
  - native document bridge helpers where renderer adaptation is required
12
13
 
14
+ Callout and Page emoji are editable in `OpenEditorNative`: pressing the emoji
15
+ opens the native picker and commits the selected Unicode value through editor
16
+ history. Inline emoji continue to use the device keyboard.
17
+
13
18
  ## Required peers
14
19
 
15
20
  - `@openeditor/react-native-prose-editor`
@@ -33,6 +38,26 @@ export function Example() {
33
38
  }
34
39
  ```
35
40
 
41
+ ## Standalone emoji picker
42
+
43
+ The editor wires this automatically for callouts and pages. Hosts can also use
44
+ the same picker for page headers or other product UI:
45
+
46
+ ```tsx
47
+ import { OpenEditorNativeEmojiPicker } from "@openeditor/native";
48
+
49
+ <OpenEditorNativeEmojiPicker
50
+ visible={pickerOpen}
51
+ onEmojiSelect={setPageIcon}
52
+ onRequestClose={() => setPickerOpen(false)}
53
+ />;
54
+ ```
55
+
56
+ The native picker shares OpenEditor’s Emojibase dataset with the Frimousse web
57
+ picker, while using a platform-native medium bottom sheet and native controls
58
+ for scrolling, search, and touch handling. Long-press an emoji that supports
59
+ skin tones to choose a variant from the platform-native context menu.
60
+
36
61
  ## Internal notes
37
62
 
38
63
  - The native prose editor engine remains an implementation detail behind this package.
@@ -0,0 +1,8 @@
1
+ import { type OpenEditorNativeEmoji } from "./emoji-data.js";
2
+ type EmojiButtonProps = {
3
+ item: OpenEditorNativeEmoji;
4
+ size: number;
5
+ onSelect: (emoji: string) => void;
6
+ };
7
+ export declare const EmojiButton: (props: EmojiButtonProps) => import("react").JSX.Element;
8
+ export {};
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { MenuView } from "@expo/ui/community/menu";
3
+ import { useMemo } from "react";
4
+ import { Pressable, Text } from "react-native";
5
+ import { emojiForNativeSkinTone, openEditorNativeEmojiSkinToneOptions, } from "./emoji-data.js";
6
+ const EmojiTrigger = ({ item, size, onSelect }) => (_jsx(Pressable, { accessibilityLabel: item.emoji, accessibilityRole: "button", onPress: () => onSelect(item.emoji), style: { alignItems: "center", height: 44, justifyContent: "center", width: size }, children: _jsx(Text, { style: { fontSize: 27 }, children: item.emoji }) }));
7
+ const ToneEmojiButton = ({ item, size, onSelect }) => {
8
+ const actions = useMemo(() => openEditorNativeEmojiSkinToneOptions.map((option) => {
9
+ const emoji = emojiForNativeSkinTone(item, option.tone);
10
+ return { id: emoji, title: `${emoji} ${option.label}` };
11
+ }), [item]);
12
+ return (_jsx(MenuView, { actions: actions, onPressAction: ({ nativeEvent }) => onSelect(nativeEvent.event), shouldOpenOnLongPress: true, style: { height: 44, width: size }, children: _jsx(EmojiTrigger, { item: item, onSelect: onSelect, size: size }) }));
13
+ };
14
+ export const EmojiButton = (props) => (props.item.skins?.length ? _jsx(ToneEmojiButton, { ...props }) : _jsx(EmojiTrigger, { ...props }));
@@ -0,0 +1,44 @@
1
+ export type OpenEditorNativeEmojiSkinTone = 0 | 1 | 2 | 3 | 4 | 5;
2
+ export type OpenEditorNativeEmoji = {
3
+ emoji: string;
4
+ label: string;
5
+ search: string;
6
+ group: number;
7
+ order: number;
8
+ skins?: readonly {
9
+ emoji: string;
10
+ tone?: number;
11
+ }[];
12
+ };
13
+ export type OpenEditorNativeEmojiSection = {
14
+ key: string;
15
+ title: string;
16
+ data: readonly OpenEditorNativeEmoji[];
17
+ };
18
+ export declare const openEditorNativeEmojiSkinToneOptions: readonly [{
19
+ readonly tone: 0;
20
+ readonly emoji: "✋";
21
+ readonly label: "Default skin tone";
22
+ }, {
23
+ readonly tone: 1;
24
+ readonly emoji: "✋🏻";
25
+ readonly label: "Light skin tone";
26
+ }, {
27
+ readonly tone: 2;
28
+ readonly emoji: "✋🏼";
29
+ readonly label: "Medium-light skin tone";
30
+ }, {
31
+ readonly tone: 3;
32
+ readonly emoji: "✋🏽";
33
+ readonly label: "Medium skin tone";
34
+ }, {
35
+ readonly tone: 4;
36
+ readonly emoji: "✋🏾";
37
+ readonly label: "Medium-dark skin tone";
38
+ }, {
39
+ readonly tone: 5;
40
+ readonly emoji: "✋🏿";
41
+ readonly label: "Dark skin tone";
42
+ }];
43
+ export declare const emojiForNativeSkinTone: (item: OpenEditorNativeEmoji, skinTone: OpenEditorNativeEmojiSkinTone) => string;
44
+ export declare const getOpenEditorNativeEmojiSections: (query?: string) => readonly OpenEditorNativeEmojiSection[];
@@ -0,0 +1,50 @@
1
+ import emojiDataJson from "emojibase-data/en/data.json";
2
+ import emojiMessagesJson from "emojibase-data/en/messages.json";
3
+ const normalizeSearch = (value) => value.toLocaleLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").trim();
4
+ const rawEmojiData = emojiDataJson;
5
+ const rawMessages = emojiMessagesJson;
6
+ const groupMessages = new Map(rawMessages.groups.map((group) => [group.order, { key: group.key, title: group.message }]));
7
+ const nativeEmoji = rawEmojiData
8
+ .filter((item) => typeof item.group === "number" && item.group !== 2)
9
+ .map((item) => ({
10
+ emoji: item.emoji,
11
+ label: item.label,
12
+ search: normalizeSearch([item.label, ...(item.tags ?? [])].join(" ")),
13
+ group: item.group,
14
+ order: item.order ?? Number.MAX_SAFE_INTEGER,
15
+ skins: item.skins,
16
+ }))
17
+ .sort((left, right) => left.order - right.order);
18
+ export const openEditorNativeEmojiSkinToneOptions = [
19
+ { tone: 0, emoji: "✋", label: "Default skin tone" },
20
+ { tone: 1, emoji: "✋🏻", label: "Light skin tone" },
21
+ { tone: 2, emoji: "✋🏼", label: "Medium-light skin tone" },
22
+ { tone: 3, emoji: "✋🏽", label: "Medium skin tone" },
23
+ { tone: 4, emoji: "✋🏾", label: "Medium-dark skin tone" },
24
+ { tone: 5, emoji: "✋🏿", label: "Dark skin tone" },
25
+ ];
26
+ export const emojiForNativeSkinTone = (item, skinTone) => {
27
+ if (skinTone === 0)
28
+ return item.emoji;
29
+ return item.skins?.find((skin) => skin.tone === skinTone)?.emoji ?? item.emoji;
30
+ };
31
+ export const getOpenEditorNativeEmojiSections = (query = "") => {
32
+ const normalizedQuery = normalizeSearch(query);
33
+ const groups = new Map();
34
+ for (const item of nativeEmoji) {
35
+ if (normalizedQuery && !item.search.includes(normalizedQuery))
36
+ continue;
37
+ const group = groups.get(item.group);
38
+ if (group)
39
+ group.push(item);
40
+ else
41
+ groups.set(item.group, [item]);
42
+ }
43
+ return [...groups.entries()]
44
+ .sort(([left], [right]) => left - right)
45
+ .map(([group, data]) => ({
46
+ key: groupMessages.get(group)?.key ?? String(group),
47
+ title: groupMessages.get(group)?.title ?? "Emoji",
48
+ data,
49
+ }));
50
+ };
@@ -0,0 +1,12 @@
1
+ export type OpenEditorNativeEmojiPickerTheme = {
2
+ surfaceMuted?: string;
3
+ text?: string;
4
+ muted?: string;
5
+ };
6
+ export type OpenEditorNativeEmojiPickerProps = {
7
+ visible: boolean;
8
+ onEmojiSelect: (emoji: string) => void;
9
+ onRequestClose: () => void;
10
+ theme?: OpenEditorNativeEmojiPickerTheme;
11
+ };
12
+ export declare const OpenEditorNativeEmojiPicker: ({ visible, onEmojiSelect, onRequestClose, theme, }: OpenEditorNativeEmojiPickerProps) => import("react").JSX.Element;
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { BottomSheet, RNHostView } from "@expo/ui";
3
+ import { useMemo, useState } from "react";
4
+ import { ScrollView, Text, TextInput, useWindowDimensions, View, } from "react-native";
5
+ import { getOpenEditorNativeEmojiSections, } from "./emoji-data.js";
6
+ import { EmojiButton } from "./emoji-button.js";
7
+ const toRows = (items, columns = 8) => {
8
+ const rows = [];
9
+ for (let index = 0; index < items.length; index += columns) {
10
+ rows.push(items.slice(index, index + columns));
11
+ }
12
+ return rows;
13
+ };
14
+ const DEFAULT_THEME = {
15
+ surfaceMuted: "#eeeeee",
16
+ text: "#171717",
17
+ muted: "#737373",
18
+ };
19
+ export const OpenEditorNativeEmojiPicker = ({ visible, onEmojiSelect, onRequestClose, theme, }) => {
20
+ const { height: windowHeight, width: windowWidth } = useWindowDimensions();
21
+ const [query, setQuery] = useState("");
22
+ const resolvedTheme = { ...DEFAULT_THEME, ...theme };
23
+ const nativeScrollHeight = Math.max(240, Math.floor(windowHeight * 0.5) - 32);
24
+ const contentWidth = Math.max(240, Math.min(windowWidth - 32, 520));
25
+ const emojiButtonWidth = contentWidth / 8;
26
+ const sections = useMemo(() => getOpenEditorNativeEmojiSections(query).map((section) => ({
27
+ ...section,
28
+ data: toRows(section.data),
29
+ })), [query]);
30
+ const close = () => {
31
+ setQuery("");
32
+ onRequestClose();
33
+ };
34
+ const selectEmoji = (selectedEmoji) => {
35
+ onEmojiSelect(selectedEmoji);
36
+ close();
37
+ };
38
+ return (_jsx(BottomSheet, { isPresented: visible, onDismiss: close, snapPoints: ["half"], children: _jsx(RNHostView, { matchContents: true, children: _jsxs(ScrollView, { contentContainerStyle: { paddingBottom: 32 }, keyboardShouldPersistTaps: "handled", style: { height: nativeScrollHeight, width: contentWidth }, children: [_jsx(TextInput, { autoCapitalize: "none", autoCorrect: false, onChangeText: setQuery, placeholder: "Search emoji\u2026", placeholderTextColor: resolvedTheme.muted, returnKeyType: "search", style: {
39
+ backgroundColor: resolvedTheme.surfaceMuted,
40
+ borderRadius: 10,
41
+ color: resolvedTheme.text,
42
+ fontSize: 16,
43
+ height: 44,
44
+ paddingHorizontal: 12,
45
+ width: contentWidth,
46
+ } }), sections.length === 0 ? (_jsx(Text, { style: {
47
+ color: resolvedTheme.muted,
48
+ fontSize: 15,
49
+ height: 160,
50
+ paddingTop: 48,
51
+ textAlign: "center",
52
+ width: contentWidth,
53
+ }, children: "No emoji found." })) : sections.map((section) => (_jsxs(View, { style: { width: contentWidth }, children: [_jsx(Text, { style: {
54
+ color: resolvedTheme.muted,
55
+ fontSize: 12,
56
+ fontWeight: "600",
57
+ height: 32,
58
+ paddingHorizontal: 8,
59
+ paddingVertical: 8,
60
+ width: contentWidth,
61
+ }, children: section.title }), section.data.map((row, rowIndex) => (_jsx(View, { style: {
62
+ alignItems: "center",
63
+ flexDirection: "row",
64
+ height: 44,
65
+ width: contentWidth,
66
+ }, children: row.map((item) => (_jsx(EmojiButton, { item: item, onSelect: selectEmoji, size: emojiButtonWidth }, item.emoji))) }, `${section.key}-${rowIndex}`)))] }, section.key)))] }) }) }));
67
+ };
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ import { type OpenEditorDocument, type OpenEditorAttachmentRuntime, type ProseMi
2
2
  import { type ReactNode } from "react";
3
3
  import type { NativeRichTextEditorToolbarPlacement } from "@openeditor/react-native-prose-editor";
4
4
  export { normalizeNativeThemeColor } from "./theme.js";
5
+ export { OpenEditorNativeEmojiPicker, type OpenEditorNativeEmojiPickerProps, type OpenEditorNativeEmojiPickerTheme, } from "./emoji-picker.js";
6
+ export { emojiForNativeSkinTone, getOpenEditorNativeEmojiSections, openEditorNativeEmojiSkinToneOptions, type OpenEditorNativeEmoji, type OpenEditorNativeEmojiSection, type OpenEditorNativeEmojiSkinTone, } from "./emoji-data.js";
5
7
  export type OpenEditorNativeProps = {
6
8
  document?: OpenEditorDocument;
7
9
  editable?: boolean;
@@ -44,12 +46,14 @@ export type OpenEditorNativeTheme = {
44
46
  background: string;
45
47
  surface: string;
46
48
  surfaceMuted: string;
49
+ blockSurface?: string;
47
50
  text: string;
48
51
  textSoft?: string;
49
52
  muted: string;
50
53
  placeholder?: string;
51
54
  border: string;
52
55
  borderStrong?: string;
56
+ structuralLine?: string;
53
57
  accent: string;
54
58
  accentText: string;
55
59
  accentStrong?: string;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { DEFAULT_CALLOUT_EMOJI, DEFAULT_PAGE_EMOJI, createDocument, findBlockSpecForNode, moveTopLevelBlock, normalizeEmoji, textBlock, } from "@openeditor/core";
2
+ import { DEFAULT_CALLOUT_EMOJI, DEFAULT_PAGE_EMOJI, createDocument, findBlockSpecForNode, moveTopLevelBlock, normalizeEmoji, } from "@openeditor/core";
3
3
  import { useMemo, useRef, useState } from "react";
4
4
  import { Image as NativeImage, Keyboard, Modal, Platform, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
5
5
  import { fromNativeEditorDocument, sanitizeNativeEditorDocument, toNativeEditorDocument, } from "./document.js";
@@ -7,23 +7,30 @@ import { resolveSelectedTopLevelIndex } from "./selection.js";
7
7
  import { createOpenEditorNativeToolbarItems, defaultDocumentForToolbarAction, openEditorNativeToolbarActionKeys, } from "./toolbar.js";
8
8
  import { normalizeNativeThemeColor } from "./theme.js";
9
9
  export { normalizeNativeThemeColor } from "./theme.js";
10
+ import { OpenEditorNativeEmojiPicker } from "./emoji-picker.js";
11
+ export { OpenEditorNativeEmojiPicker, } from "./emoji-picker.js";
12
+ export { emojiForNativeSkinTone, getOpenEditorNativeEmojiSections, openEditorNativeEmojiSkinToneOptions, } from "./emoji-data.js";
10
13
  import { defaultBlockRegistry } from "@openeditor/extensions";
14
+ const defaultLightNeutralSurface = "#eeeeee";
15
+ const defaultLightStructuralLine = "#d4d4d4";
11
16
  const defaultNativeTheme = {
12
17
  background: "#fafafa",
13
18
  surface: "#ffffff",
14
- surfaceMuted: "#f5f5f5",
19
+ surfaceMuted: defaultLightNeutralSurface,
20
+ blockSurface: defaultLightNeutralSurface,
15
21
  text: "#171717",
16
22
  textSoft: "#262626",
17
23
  muted: "#737373",
18
24
  placeholder: "#a3a3a3",
19
- border: "#e5e5e5",
20
- borderStrong: "#d4d4d4",
21
- accent: "#f5f5f5",
25
+ border: defaultLightNeutralSurface,
26
+ borderStrong: defaultLightStructuralLine,
27
+ structuralLine: defaultLightStructuralLine,
28
+ accent: defaultLightNeutralSurface,
22
29
  accentText: "#171717",
23
30
  accentStrong: "#171717",
24
31
  primary: "#171717",
25
32
  primaryText: "#ffffff",
26
- codeBackground: "#f5f5f5",
33
+ codeBackground: defaultLightNeutralSurface,
27
34
  codeText: "#171717",
28
35
  debugBackground: "#171717",
29
36
  debugText: "#d4d4d4",
@@ -186,6 +193,10 @@ const openEditorNativeSchema = {
186
193
  };
187
194
  const toNativeEditorTheme = (theme) => ({
188
195
  backgroundColor: normalizeNativeThemeColor(theme.background),
196
+ blockSurfaceColor: normalizeNativeThemeColor(theme.blockSurface),
197
+ structuralLineColor: normalizeNativeThemeColor(theme.structuralLine),
198
+ accentStrongColor: normalizeNativeThemeColor(theme.accentStrong),
199
+ accentStrongTextColor: normalizeNativeThemeColor(theme.primaryText),
189
200
  borderRadius: 0,
190
201
  placeholderColor: normalizeNativeThemeColor(theme.placeholder),
191
202
  contentInsets: {
@@ -222,7 +233,7 @@ const toNativeEditorTheme = (theme) => ({
222
233
  itemSpacing: 6,
223
234
  },
224
235
  blockquote: {
225
- borderColor: normalizeNativeThemeColor(theme.accent),
236
+ borderColor: normalizeNativeThemeColor(theme.structuralLine),
226
237
  borderWidth: 3,
227
238
  text: {
228
239
  color: normalizeNativeThemeColor(theme.textSoft),
@@ -242,14 +253,14 @@ const toNativeEditorTheme = (theme) => ({
242
253
  },
243
254
  },
244
255
  table: {
245
- cellBackgroundColor: normalizeNativeThemeColor(theme.surface),
246
- headerBackgroundColor: normalizeNativeThemeColor(theme.surfaceMuted),
247
- borderColor: normalizeNativeThemeColor(theme.border),
256
+ cellBackgroundColor: normalizeNativeThemeColor(theme.blockSurface),
257
+ headerBackgroundColor: normalizeNativeThemeColor(theme.blockSurface),
258
+ borderColor: normalizeNativeThemeColor(theme.structuralLine),
248
259
  selectionBackgroundColor: normalizeNativeThemeColor(theme.accent),
249
260
  selectionBorderColor: normalizeNativeThemeColor(theme.accentStrong),
250
261
  },
251
262
  horizontalRule: {
252
- color: normalizeNativeThemeColor(theme.border),
263
+ color: normalizeNativeThemeColor(theme.structuralLine),
253
264
  thickness: 1,
254
265
  verticalMargin: 16,
255
266
  },
@@ -298,42 +309,29 @@ const isSafeInputUrl = (value, allowDataImage = false) => {
298
309
  return true;
299
310
  };
300
311
  const documentsMatch = (left, right) => JSON.stringify(left) === JSON.stringify(right);
301
- const nativeSeedDocument = createDocument([
302
- textBlock("heading", "OpenEditor Native", { level: 1 }),
303
- textBlock("paragraph", "This native surface is intentionally limited to engine-owned blocks for now."),
304
- {
305
- type: "bulletList",
306
- content: [
307
- { type: "listItem", content: [textBlock("paragraph", "Paragraphs and headings")] },
308
- { type: "listItem", content: [textBlock("paragraph", "Bullet and numbered lists")] },
309
- { type: "listItem", content: [textBlock("paragraph", "Task lists, code blocks, columns, quotes, dividers, and images")] },
310
- ],
311
- },
312
- {
313
- type: "taskList",
314
- content: [
315
- { type: "taskItem", attrs: { checked: true }, content: [textBlock("paragraph", "Engine-owned structural list items")] },
316
- { type: "taskItem", attrs: { checked: false }, content: [textBlock("paragraph", "Native task lists now live in the engine")] },
317
- ],
318
- },
319
- {
320
- type: "codeBlock",
321
- content: [{ type: "text", text: "const native = 'owned by the engine';" }],
322
- },
323
- {
324
- type: "blockquote",
325
- content: [textBlock("paragraph", "Structural blocks return once the native engine owns them.")],
326
- },
327
- {
328
- type: "columns",
329
- content: [
330
- { type: "column", content: [textBlock("paragraph", "Columns now flow through the native engine.")] },
331
- { type: "column", content: [textBlock("paragraph", "Tables stay blocked until the native engine supports them cleanly.")] },
332
- ],
333
- },
334
- { type: "horizontalRule" },
335
- { type: "image", attrs: { src: "https://placehold.co/960x420/png", alt: "Placeholder" } },
336
- ]);
312
+ const attachmentTypeLabel = (mimeType, name) => {
313
+ const subtype = mimeType?.split("/").at(-1)?.split("+")[0]?.trim();
314
+ const extension = name.includes(".") ? name.split(".").at(-1)?.trim() : undefined;
315
+ return (subtype || extension || "FILE").slice(0, 4).toUpperCase();
316
+ };
317
+ const formatAttachmentSize = (size) => {
318
+ if (size === null || !Number.isFinite(size) || size < 0)
319
+ return null;
320
+ if (size < 1024)
321
+ return `${Math.round(size)} B`;
322
+ if (size < 1024 * 1024)
323
+ return `${(size / 1024).toFixed(size < 10 * 1024 ? 1 : 0)} KB`;
324
+ return `${(size / (1024 * 1024)).toFixed(size < 10 * 1024 * 1024 ? 1 : 0)} MB`;
325
+ };
326
+ const NativeToggleListItem = ({ children, initiallyOpen, theme, }) => {
327
+ const [open, setOpen] = useState(initiallyOpen);
328
+ const childArray = Array.isArray(children) ? children : [children];
329
+ return (_jsxs(View, { style: styles.viewerListRow, children: [_jsx(Pressable, { accessibilityLabel: open ? "Collapse toggle item" : "Expand toggle item", accessibilityRole: "button", hitSlop: 8, onPress: () => setOpen((current) => !current), style: styles.viewerListControl, children: _jsx(View, { style: [
330
+ styles.viewerToggleCaret,
331
+ { borderLeftColor: theme.muted },
332
+ open ? styles.viewerToggleCaretOpen : null,
333
+ ] }) }), _jsx(View, { style: styles.viewerListContent, children: open ? childArray : childArray.slice(0, 1) })] }));
334
+ };
337
335
  const renderNativeViewerNode = (node, key, theme, renderers, onOpenPage, attachmentRuntime) => {
338
336
  if (node.type === "text") {
339
337
  return _jsx(Text, { style: { color: theme.text }, children: node.text ?? "" }, key);
@@ -355,28 +353,29 @@ const renderNativeViewerNode = (node, key, theme, renderers, onOpenPage, attachm
355
353
  case "bulletList":
356
354
  case "orderedList":
357
355
  case "taskList":
356
+ case "toggleList":
358
357
  return _jsx(View, { style: { marginBottom: 12 }, children: children }, key);
359
358
  case "blockquote":
360
- return _jsx(View, { style: { borderLeftColor: theme.accent, borderLeftWidth: 3, marginBottom: 12, paddingLeft: 12 }, children: children }, key);
359
+ return _jsx(View, { style: { borderLeftColor: theme.structuralLine, borderLeftWidth: 3, marginBottom: 12, paddingLeft: 12 }, children: children }, key);
361
360
  case "codeBlock":
362
361
  return _jsx(View, { style: { backgroundColor: theme.codeBackground, borderRadius: 8, marginBottom: 12, padding: 12 }, children: _jsx(Text, { style: { color: theme.codeText }, children: node.content?.map((child) => child.text ?? "").join("") }) }, key);
363
362
  case "callout": {
364
363
  const emoji = normalizeEmoji(node.attrs?.emoji, DEFAULT_CALLOUT_EMOJI);
365
- return _jsxs(View, { style: { backgroundColor: theme.codeBackground, borderRadius: 10, flexDirection: "row", gap: 10, marginBottom: 12, padding: 14 }, children: [_jsx(Text, { style: { fontSize: 20 }, children: emoji }), _jsx(View, { style: { flex: 1 }, children: children })] }, key);
364
+ return _jsxs(View, { style: { backgroundColor: theme.blockSurface, borderRadius: 12, flexDirection: "row", gap: 10, marginBottom: 12, padding: 14 }, children: [_jsx(Text, { style: { fontSize: 20 }, children: emoji }), _jsx(View, { style: { flex: 1 }, children: children })] }, key);
366
365
  }
367
366
  case "divider":
368
- return _jsx(View, { style: { backgroundColor: theme.border, height: 1, marginVertical: 16 } }, key);
367
+ return _jsx(View, { style: { backgroundColor: theme.structuralLine, height: 1, marginVertical: 16 } }, key);
369
368
  case "image":
370
369
  return (_jsx(NativeImage, { source: { uri: typeof node.attrs?.src === "string" ? node.attrs.src : "" }, accessibilityLabel: typeof node.attrs?.alt === "string" ? node.attrs.alt : undefined, style: { borderRadius: 8, height: 220, marginBottom: 12, width: "100%" }, resizeMode: "cover" }, key));
371
370
  case "columns":
372
371
  return _jsx(View, { style: { flexDirection: "row", gap: 12, marginBottom: 12 }, children: children }, key);
373
372
  case "table":
374
- return _jsx(View, { style: { borderColor: theme.border, borderRadius: 8, borderWidth: 1, marginBottom: 12, overflow: "hidden" }, children: children }, key);
373
+ return _jsx(View, { style: { borderColor: theme.structuralLine, borderRadius: 8, borderWidth: 1, marginBottom: 12, overflow: "hidden" }, children: children }, key);
375
374
  case "page": {
376
375
  const pageId = typeof node.attrs?.pageId === "string" ? node.attrs.pageId : "";
377
376
  const title = node.content?.map((child) => child.text ?? "").join("") || "Untitled";
378
377
  const icon = normalizeEmoji(node.attrs?.icon, DEFAULT_PAGE_EMOJI);
379
- const content = _jsxs(_Fragment, { children: [_jsx(Text, { style: { fontSize: 18 }, children: icon }), _jsx(Text, { style: { color: theme.text, flex: 1, fontWeight: "600", textDecorationLine: "underline" }, children: title }), _jsx(Text, { style: { color: theme.muted }, children: "\u2192" })] });
378
+ const content = _jsxs(_Fragment, { children: [_jsx(Text, { style: [styles.viewerPageIcon, { color: theme.text }], children: icon }), _jsx(Text, { style: [styles.viewerPageTitle, { color: theme.text, textDecorationColor: theme.muted }], children: title }), _jsx(Text, { style: { color: theme.muted }, children: "\u2192" })] });
380
379
  return onOpenPage && pageId ? (_jsx(Pressable, { accessibilityRole: "button", onPress: () => onOpenPage({ pageId, title, icon, href: typeof node.attrs?.href === "string" ? node.attrs.href : null }), style: { alignItems: "center", borderRadius: 8, flexDirection: "row", gap: 8, marginBottom: 8, padding: 8 }, children: content }, key)) : _jsx(View, { style: { alignItems: "center", flexDirection: "row", gap: 8, marginBottom: 8, padding: 8 }, children: content }, key);
381
380
  }
382
381
  case "attachment": {
@@ -387,20 +386,29 @@ const renderNativeViewerNode = (node, key, theme, renderers, onOpenPage, attachm
387
386
  size: typeof node.attrs?.size === "number" ? node.attrs.size : null,
388
387
  url: typeof node.attrs?.url === "string" ? node.attrs.url : null,
389
388
  };
390
- const content = _jsxs(_Fragment, { children: [_jsx(Text, { style: { fontSize: 22 }, children: "\uD83D\uDCCE" }), _jsxs(View, { style: { flex: 1 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: theme.text, fontWeight: "600" }, children: snapshot.name }), _jsx(Text, { style: { color: theme.muted, fontSize: 12 }, children: snapshot.mimeType ?? "File" })] }), _jsx(Text, { style: { color: theme.muted }, children: "Open" })] });
391
- return attachmentRuntime?.openAttachment ? _jsx(Pressable, { accessibilityLabel: `Open ${snapshot.name}`, accessibilityRole: "button", onPress: () => void attachmentRuntime.openAttachment?.(snapshot), style: { alignItems: "center", borderColor: theme.border, borderRadius: 10, borderWidth: 1, flexDirection: "row", gap: 10, marginBottom: 8, minHeight: 64, padding: 10 }, children: content }, key) : _jsx(View, { style: { alignItems: "center", borderColor: theme.border, borderRadius: 10, borderWidth: 1, flexDirection: "row", gap: 10, marginBottom: 8, minHeight: 64, padding: 10 }, children: content }, key);
389
+ const size = formatAttachmentSize(snapshot.size);
390
+ const type = attachmentTypeLabel(snapshot.mimeType, snapshot.name);
391
+ const content = _jsxs(_Fragment, { children: [_jsx(View, { style: [styles.viewerAttachmentIcon, { backgroundColor: theme.blockSurface }], children: _jsx(Text, { style: [styles.viewerAttachmentType, { color: theme.muted }], children: type }) }), _jsxs(View, { style: { flex: 1 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: theme.text, fontWeight: "600" }, children: snapshot.name }), _jsx(Text, { style: { color: theme.muted, fontSize: 12, marginTop: 3 }, children: [size, type].filter(Boolean).join(" · ") })] }), _jsx(Text, { style: { color: theme.muted }, children: "\u2192" })] });
392
+ return attachmentRuntime?.openAttachment ? _jsx(Pressable, { accessibilityLabel: `Open ${snapshot.name}`, accessibilityRole: "button", onPress: () => void attachmentRuntime.openAttachment?.(snapshot), style: [styles.viewerAttachment, { backgroundColor: theme.surface, borderColor: theme.structuralLine }], children: content }, key) : _jsx(View, { style: [styles.viewerAttachment, { backgroundColor: theme.surface, borderColor: theme.structuralLine }], children: content }, key);
392
393
  }
393
394
  default:
394
395
  if (node.type === "column") {
395
396
  return _jsx(View, { style: { flex: 1 }, children: children }, key);
396
397
  }
397
398
  if (node.type === "tableRow") {
398
- return _jsx(View, { style: { borderBottomColor: theme.border, borderBottomWidth: StyleSheet.hairlineWidth, flexDirection: "row" }, children: children }, key);
399
+ return _jsx(View, { style: { borderBottomColor: theme.structuralLine, borderBottomWidth: StyleSheet.hairlineWidth, flexDirection: "row" }, children: children }, key);
399
400
  }
400
401
  if (node.type === "tableHeader" || node.type === "tableCell") {
401
- return _jsx(View, { style: { flex: 1, padding: 10 }, children: children }, key);
402
+ return _jsx(View, { style: { backgroundColor: theme.blockSurface, flex: 1, padding: 10 }, children: children }, key);
403
+ }
404
+ if (node.type === "taskItem") {
405
+ const checked = node.attrs?.checked === true;
406
+ return _jsxs(View, { style: styles.viewerListRow, children: [_jsx(View, { style: [styles.viewerTaskCheckbox, { backgroundColor: checked ? theme.accentStrong : theme.blockSurface }], children: checked ? _jsx(Text, { style: [styles.viewerTaskCheck, { color: theme.primaryText }], children: "\u2713" }) : null }), _jsx(View, { style: styles.viewerListContent, children: children })] }, key);
402
407
  }
403
- if (node.type === "listItem" || node.type === "taskItem") {
408
+ if (node.type === "toggleListItem") {
409
+ return _jsx(NativeToggleListItem, { initiallyOpen: node.attrs?.open !== false, theme: theme, children: children }, key);
410
+ }
411
+ if (node.type === "listItem") {
404
412
  return _jsxs(View, { style: { flexDirection: "row", gap: 8, marginBottom: 6 }, children: [_jsx(Text, { style: { color: theme.muted }, children: "\u2022" }), _jsx(View, { style: { flex: 1 }, children: children })] }, key);
405
413
  }
406
414
  return _jsx(View, { children: children }, key);
@@ -426,13 +434,14 @@ export const OpenEditorNativeToolbar = ({ actions = [
426
434
  onActionPress?.(action.key);
427
435
  }, style: [styles.toolbarButton, { backgroundColor: resolvedTheme.accent, borderColor: resolvedTheme.borderStrong }], children: _jsx(Text, { style: [styles.toolbarButtonText, { color: resolvedTheme.accentText }], children: action.label }) }, action.key))) }));
428
436
  };
429
- export const OpenEditorNative = ({ document = nativeSeedDocument, editable = true, placeholder = "Start writing...", showToolbar = true, theme, toolbarPlacement = "keyboard", onChange, onOpenPage, attachmentRuntime, }) => {
437
+ export const OpenEditorNative = ({ document = createDocument(), editable = true, placeholder = "Start writing...", showToolbar = true, theme, toolbarPlacement = "keyboard", onChange, onOpenPage, attachmentRuntime, }) => {
430
438
  const editorRef = useRef(null);
431
439
  const [currentDocument, setCurrentDocument] = useState(() => normalizeForNativeEditor(document));
432
440
  const [selection, setSelection] = useState();
433
441
  const [linkRequest, setLinkRequest] = useState(null);
434
442
  const [linkUrl, setLinkUrl] = useState("https://");
435
443
  const [imageRequest, setImageRequest] = useState(null);
444
+ const [emojiRequest, setEmojiRequest] = useState(null);
436
445
  const [imageUrl, setImageUrl] = useState("https://placehold.co/960x420/png");
437
446
  const [attachmentUpload, setAttachmentUpload] = useState(null);
438
447
  const attachmentAbortRef = useRef(null);
@@ -597,11 +606,24 @@ export const OpenEditorNative = ({ document = nativeSeedDocument, editable = tru
597
606
  }, onRequestImage: (request) => {
598
607
  setImageUrl("https://placehold.co/960x420/png");
599
608
  setImageRequest(request);
600
- }, onOpenPage: onOpenPage, onOpenAttachment: (attachment) => void attachmentRuntime?.openAttachment?.(attachment), onSelectionChange: setSelection, onContentChangeJSON: (json) => {
609
+ }, onOpenPage: onOpenPage, onRequestEmoji: setEmojiRequest, onOpenAttachment: (attachment) => void attachmentRuntime?.openAttachment?.(attachment), onSelectionChange: setSelection, onContentChangeJSON: (json) => {
601
610
  syncDocument(contentFromNativeJson(json), false);
602
- }, style: styles.nativeEditor }), _jsx(UrlDialog, { title: "Link", value: linkUrl, visible: linkRequest !== null, placeholder: "https://", confirmLabel: linkUrl.trim() ? "Apply" : "Remove", onChangeText: setLinkUrl, onCancel: () => setLinkRequest(null), onSubmit: submitLinkDialog, theme: resolvedTheme }), _jsx(Modal, { animationType: "fade", onRequestClose: () => { attachmentAbortRef.current?.abort(); setAttachmentUpload(null); }, transparent: true, visible: attachmentUpload !== null, children: _jsx(View, { style: [styles.dialogBackdrop, { backgroundColor: resolvedTheme.backdrop }], children: _jsxs(View, { accessibilityViewIsModal: true, style: [styles.dialog, { backgroundColor: resolvedTheme.surface, borderColor: resolvedTheme.border }], children: [_jsx(Text, { style: [styles.unavailableTitle, { color: resolvedTheme.text }], children: attachmentUpload?.input.name }), _jsx(Text, { style: [styles.unavailableText, { color: attachmentUpload?.error ? "#b42318" : resolvedTheme.muted }], children: attachmentUpload?.error ?? `Uploading ${Math.round((attachmentUpload?.progress ?? 0) * 100)}%` }), _jsxs(View, { style: styles.dialogActions, children: [attachmentUpload?.error ? _jsx(CommandButton, { label: "Retry", onPress: () => void uploadAttachment(), theme: resolvedTheme }) : null, _jsx(CommandButton, { label: "Cancel", onPress: () => { attachmentAbortRef.current?.abort(); setAttachmentUpload(null); }, theme: resolvedTheme })] })] }) }) }), _jsx(UrlDialog, { title: "Image", value: imageUrl, visible: imageRequest !== null, placeholder: "https://", confirmLabel: "Insert", onChangeText: setImageUrl, onCancel: () => setImageRequest(null), onSubmit: submitImageDialog, theme: resolvedTheme })] }));
611
+ }, style: styles.nativeEditor }), _jsx(UrlDialog, { title: "Link", value: linkUrl, visible: linkRequest !== null, placeholder: "https://", confirmLabel: linkUrl.trim() ? "Apply" : "Remove", onChangeText: setLinkUrl, onCancel: () => setLinkRequest(null), onSubmit: submitLinkDialog, theme: resolvedTheme }), _jsx(Modal, { animationType: "fade", onRequestClose: () => { attachmentAbortRef.current?.abort(); setAttachmentUpload(null); }, transparent: true, visible: attachmentUpload !== null, children: _jsx(View, { style: [styles.dialogBackdrop, { backgroundColor: resolvedTheme.backdrop }], children: _jsxs(View, { accessibilityViewIsModal: true, style: [styles.dialog, { backgroundColor: resolvedTheme.surface, borderColor: resolvedTheme.border }], children: [_jsx(Text, { style: [styles.unavailableTitle, { color: resolvedTheme.text }], children: attachmentUpload?.input.name }), _jsx(Text, { style: [styles.unavailableText, { color: attachmentUpload?.error ? "#b42318" : resolvedTheme.muted }], children: attachmentUpload?.error ?? `Uploading ${Math.round((attachmentUpload?.progress ?? 0) * 100)}%` }), _jsxs(View, { style: styles.dialogActions, children: [attachmentUpload?.error ? _jsx(CommandButton, { label: "Retry", onPress: () => void uploadAttachment(), theme: resolvedTheme }) : null, _jsx(CommandButton, { label: "Cancel", onPress: () => { attachmentAbortRef.current?.abort(); setAttachmentUpload(null); }, theme: resolvedTheme })] })] }) }) }), _jsx(UrlDialog, { title: "Image", value: imageUrl, visible: imageRequest !== null, placeholder: "https://", confirmLabel: "Insert", onChangeText: setImageUrl, onCancel: () => setImageRequest(null), onSubmit: submitImageDialog, theme: resolvedTheme }), _jsx(OpenEditorNativeEmojiPicker, { onEmojiSelect: (emoji) => {
612
+ if (!emojiRequest)
613
+ return;
614
+ const attribute = emojiRequest.nodeType === "page" ? "icon" : "emoji";
615
+ editorRef.current?.updateNodeAttrs(emojiRequest.docPos, {
616
+ ...emojiRequest.attrs,
617
+ [attribute]: emoji,
618
+ });
619
+ setEmojiRequest(null);
620
+ }, onRequestClose: () => setEmojiRequest(null), theme: {
621
+ surfaceMuted: resolvedTheme.surfaceMuted,
622
+ text: resolvedTheme.text,
623
+ muted: resolvedTheme.muted,
624
+ }, visible: emojiRequest !== null })] }));
603
625
  };
604
- export const OpenEditorNativeViewer = ({ document = nativeSeedDocument, theme, renderers, onOpenPage, attachmentRuntime, }) => {
626
+ export const OpenEditorNativeViewer = ({ document = createDocument(), theme, renderers, onOpenPage, attachmentRuntime, }) => {
605
627
  const resolvedTheme = {
606
628
  ...defaultNativeTheme,
607
629
  ...theme,
@@ -627,6 +649,82 @@ const styles = StyleSheet.create({
627
649
  gap: 4,
628
650
  padding: 16,
629
651
  },
652
+ viewerListRow: {
653
+ alignItems: "flex-start",
654
+ flexDirection: "row",
655
+ gap: 8,
656
+ marginBottom: 6,
657
+ },
658
+ viewerListControl: {
659
+ alignItems: "center",
660
+ height: 24,
661
+ justifyContent: "center",
662
+ width: 20,
663
+ },
664
+ viewerListContent: {
665
+ flex: 1,
666
+ },
667
+ viewerToggleCaret: {
668
+ borderBottomColor: "transparent",
669
+ borderBottomWidth: 4,
670
+ borderLeftWidth: 6,
671
+ borderTopColor: "transparent",
672
+ borderTopWidth: 4,
673
+ height: 0,
674
+ transform: [{ rotate: "0deg" }],
675
+ width: 0,
676
+ },
677
+ viewerToggleCaretOpen: {
678
+ transform: [{ rotate: "90deg" }],
679
+ },
680
+ viewerTaskCheckbox: {
681
+ alignItems: "center",
682
+ borderRadius: 4,
683
+ height: 20,
684
+ justifyContent: "center",
685
+ marginTop: 2,
686
+ width: 20,
687
+ },
688
+ viewerTaskCheck: {
689
+ fontSize: 13,
690
+ fontWeight: "800",
691
+ lineHeight: 16,
692
+ },
693
+ viewerPageIcon: {
694
+ fontSize: 18,
695
+ lineHeight: 24,
696
+ textAlign: "center",
697
+ width: 24,
698
+ },
699
+ viewerPageTitle: {
700
+ flex: 1,
701
+ fontWeight: "600",
702
+ lineHeight: 24,
703
+ textDecorationLine: "underline",
704
+ },
705
+ viewerAttachment: {
706
+ alignItems: "center",
707
+ borderRadius: 12,
708
+ borderWidth: 1,
709
+ flexDirection: "row",
710
+ gap: 12,
711
+ marginBottom: 8,
712
+ minHeight: 68,
713
+ paddingHorizontal: 12,
714
+ paddingVertical: 10,
715
+ },
716
+ viewerAttachmentIcon: {
717
+ alignItems: "center",
718
+ borderRadius: 9,
719
+ height: 48,
720
+ justifyContent: "center",
721
+ width: 42,
722
+ },
723
+ viewerAttachmentType: {
724
+ fontSize: 9,
725
+ fontWeight: "800",
726
+ letterSpacing: 0.4,
727
+ },
630
728
  nativeEditor: {
631
729
  minHeight: 320,
632
730
  },
package/dist/toolbar.d.ts CHANGED
@@ -24,6 +24,6 @@ export declare const createOpenEditorNativeToolbarItems: ({ canUndo, canRedo, }?
24
24
  export declare const openEditorNativeToolbarItems: readonly EditorToolbarItem[];
25
25
  export declare const nativeToolbarParityCoverage: () => {
26
26
  missingBlockControls: string[];
27
- missingMarkControls: ("bold" | "italic" | "underline" | "strike" | "link" | "code")[];
27
+ missingMarkControls: ("bold" | "link" | "strike" | "italic" | "underline" | "code")[];
28
28
  };
29
29
  export declare const defaultDocumentForToolbarAction: (key: string) => ProseMirrorDocument | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openeditor/native",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "repository": {
@@ -26,15 +26,18 @@
26
26
  "./package.json": "./package.json"
27
27
  },
28
28
  "dependencies": {
29
+ "@expo/ui": "~56.0.18",
29
30
  "@expo/vector-icons": "15.0.3",
30
- "@openeditor/core": "0.0.24",
31
- "@openeditor/exporters": "0.0.24",
32
- "@openeditor/extensions": "0.0.24",
31
+ "@openeditor/core": "0.0.26",
32
+ "@openeditor/exporters": "0.0.26",
33
+ "@openeditor/extensions": "0.0.26",
34
+ "emojibase": "17.0.0",
35
+ "emojibase-data": "16.0.2",
33
36
  "react": "19.2.3",
34
37
  "react-native": "0.85.3"
35
38
  },
36
39
  "peerDependencies": {
37
- "@openeditor/react-native-prose-editor": "0.0.24",
40
+ "@openeditor/react-native-prose-editor": "0.0.26",
38
41
  "react-native-keyboard-controller": ">=1.21",
39
42
  "react": ">=19",
40
43
  "react-native": ">=0.85"