@openeditor/native 0.0.23 → 0.0.25

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
@@ -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,6 +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);
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
+ };
301
335
  const nativeSeedDocument = createDocument([
302
336
  textBlock("heading", "OpenEditor Native", { level: 1 }),
303
337
  textBlock("paragraph", "This native surface is intentionally limited to engine-owned blocks for now."),
@@ -355,28 +389,29 @@ const renderNativeViewerNode = (node, key, theme, renderers, onOpenPage, attachm
355
389
  case "bulletList":
356
390
  case "orderedList":
357
391
  case "taskList":
392
+ case "toggleList":
358
393
  return _jsx(View, { style: { marginBottom: 12 }, children: children }, key);
359
394
  case "blockquote":
360
- return _jsx(View, { style: { borderLeftColor: theme.accent, borderLeftWidth: 3, marginBottom: 12, paddingLeft: 12 }, children: children }, key);
395
+ return _jsx(View, { style: { borderLeftColor: theme.structuralLine, borderLeftWidth: 3, marginBottom: 12, paddingLeft: 12 }, children: children }, key);
361
396
  case "codeBlock":
362
397
  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
398
  case "callout": {
364
399
  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);
400
+ 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
401
  }
367
402
  case "divider":
368
- return _jsx(View, { style: { backgroundColor: theme.border, height: 1, marginVertical: 16 } }, key);
403
+ return _jsx(View, { style: { backgroundColor: theme.structuralLine, height: 1, marginVertical: 16 } }, key);
369
404
  case "image":
370
405
  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
406
  case "columns":
372
407
  return _jsx(View, { style: { flexDirection: "row", gap: 12, marginBottom: 12 }, children: children }, key);
373
408
  case "table":
374
- return _jsx(View, { style: { borderColor: theme.border, borderRadius: 8, borderWidth: 1, marginBottom: 12, overflow: "hidden" }, children: children }, key);
409
+ return _jsx(View, { style: { borderColor: theme.structuralLine, borderRadius: 8, borderWidth: 1, marginBottom: 12, overflow: "hidden" }, children: children }, key);
375
410
  case "page": {
376
411
  const pageId = typeof node.attrs?.pageId === "string" ? node.attrs.pageId : "";
377
412
  const title = node.content?.map((child) => child.text ?? "").join("") || "Untitled";
378
413
  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" })] });
414
+ 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
415
  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
416
  }
382
417
  case "attachment": {
@@ -387,20 +422,29 @@ const renderNativeViewerNode = (node, key, theme, renderers, onOpenPage, attachm
387
422
  size: typeof node.attrs?.size === "number" ? node.attrs.size : null,
388
423
  url: typeof node.attrs?.url === "string" ? node.attrs.url : null,
389
424
  };
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);
425
+ const size = formatAttachmentSize(snapshot.size);
426
+ const type = attachmentTypeLabel(snapshot.mimeType, snapshot.name);
427
+ 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" })] });
428
+ 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
429
  }
393
430
  default:
394
431
  if (node.type === "column") {
395
432
  return _jsx(View, { style: { flex: 1 }, children: children }, key);
396
433
  }
397
434
  if (node.type === "tableRow") {
398
- return _jsx(View, { style: { borderBottomColor: theme.border, borderBottomWidth: StyleSheet.hairlineWidth, flexDirection: "row" }, children: children }, key);
435
+ return _jsx(View, { style: { borderBottomColor: theme.structuralLine, borderBottomWidth: StyleSheet.hairlineWidth, flexDirection: "row" }, children: children }, key);
399
436
  }
400
437
  if (node.type === "tableHeader" || node.type === "tableCell") {
401
- return _jsx(View, { style: { flex: 1, padding: 10 }, children: children }, key);
438
+ return _jsx(View, { style: { backgroundColor: theme.blockSurface, flex: 1, padding: 10 }, children: children }, key);
439
+ }
440
+ if (node.type === "taskItem") {
441
+ const checked = node.attrs?.checked === true;
442
+ 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
443
  }
403
- if (node.type === "listItem" || node.type === "taskItem") {
444
+ if (node.type === "toggleListItem") {
445
+ return _jsx(NativeToggleListItem, { initiallyOpen: node.attrs?.open !== false, theme: theme, children: children }, key);
446
+ }
447
+ if (node.type === "listItem") {
404
448
  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
449
  }
406
450
  return _jsx(View, { children: children }, key);
@@ -433,6 +477,7 @@ export const OpenEditorNative = ({ document = nativeSeedDocument, editable = tru
433
477
  const [linkRequest, setLinkRequest] = useState(null);
434
478
  const [linkUrl, setLinkUrl] = useState("https://");
435
479
  const [imageRequest, setImageRequest] = useState(null);
480
+ const [emojiRequest, setEmojiRequest] = useState(null);
436
481
  const [imageUrl, setImageUrl] = useState("https://placehold.co/960x420/png");
437
482
  const [attachmentUpload, setAttachmentUpload] = useState(null);
438
483
  const attachmentAbortRef = useRef(null);
@@ -597,9 +642,22 @@ export const OpenEditorNative = ({ document = nativeSeedDocument, editable = tru
597
642
  }, onRequestImage: (request) => {
598
643
  setImageUrl("https://placehold.co/960x420/png");
599
644
  setImageRequest(request);
600
- }, onOpenPage: onOpenPage, onOpenAttachment: (attachment) => void attachmentRuntime?.openAttachment?.(attachment), onSelectionChange: setSelection, onContentChangeJSON: (json) => {
645
+ }, onOpenPage: onOpenPage, onRequestEmoji: setEmojiRequest, onOpenAttachment: (attachment) => void attachmentRuntime?.openAttachment?.(attachment), onSelectionChange: setSelection, onContentChangeJSON: (json) => {
601
646
  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 })] }));
647
+ }, 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) => {
648
+ if (!emojiRequest)
649
+ return;
650
+ const attribute = emojiRequest.nodeType === "page" ? "icon" : "emoji";
651
+ editorRef.current?.updateNodeAttrs(emojiRequest.docPos, {
652
+ ...emojiRequest.attrs,
653
+ [attribute]: emoji,
654
+ });
655
+ setEmojiRequest(null);
656
+ }, onRequestClose: () => setEmojiRequest(null), theme: {
657
+ surfaceMuted: resolvedTheme.surfaceMuted,
658
+ text: resolvedTheme.text,
659
+ muted: resolvedTheme.muted,
660
+ }, visible: emojiRequest !== null })] }));
603
661
  };
604
662
  export const OpenEditorNativeViewer = ({ document = nativeSeedDocument, theme, renderers, onOpenPage, attachmentRuntime, }) => {
605
663
  const resolvedTheme = {
@@ -627,6 +685,82 @@ const styles = StyleSheet.create({
627
685
  gap: 4,
628
686
  padding: 16,
629
687
  },
688
+ viewerListRow: {
689
+ alignItems: "flex-start",
690
+ flexDirection: "row",
691
+ gap: 8,
692
+ marginBottom: 6,
693
+ },
694
+ viewerListControl: {
695
+ alignItems: "center",
696
+ height: 24,
697
+ justifyContent: "center",
698
+ width: 20,
699
+ },
700
+ viewerListContent: {
701
+ flex: 1,
702
+ },
703
+ viewerToggleCaret: {
704
+ borderBottomColor: "transparent",
705
+ borderBottomWidth: 4,
706
+ borderLeftWidth: 6,
707
+ borderTopColor: "transparent",
708
+ borderTopWidth: 4,
709
+ height: 0,
710
+ transform: [{ rotate: "0deg" }],
711
+ width: 0,
712
+ },
713
+ viewerToggleCaretOpen: {
714
+ transform: [{ rotate: "90deg" }],
715
+ },
716
+ viewerTaskCheckbox: {
717
+ alignItems: "center",
718
+ borderRadius: 4,
719
+ height: 20,
720
+ justifyContent: "center",
721
+ marginTop: 2,
722
+ width: 20,
723
+ },
724
+ viewerTaskCheck: {
725
+ fontSize: 13,
726
+ fontWeight: "800",
727
+ lineHeight: 16,
728
+ },
729
+ viewerPageIcon: {
730
+ fontSize: 18,
731
+ lineHeight: 24,
732
+ textAlign: "center",
733
+ width: 24,
734
+ },
735
+ viewerPageTitle: {
736
+ flex: 1,
737
+ fontWeight: "600",
738
+ lineHeight: 24,
739
+ textDecorationLine: "underline",
740
+ },
741
+ viewerAttachment: {
742
+ alignItems: "center",
743
+ borderRadius: 12,
744
+ borderWidth: 1,
745
+ flexDirection: "row",
746
+ gap: 12,
747
+ marginBottom: 8,
748
+ minHeight: 68,
749
+ paddingHorizontal: 12,
750
+ paddingVertical: 10,
751
+ },
752
+ viewerAttachmentIcon: {
753
+ alignItems: "center",
754
+ borderRadius: 9,
755
+ height: 48,
756
+ justifyContent: "center",
757
+ width: 42,
758
+ },
759
+ viewerAttachmentType: {
760
+ fontSize: 9,
761
+ fontWeight: "800",
762
+ letterSpacing: 0.4,
763
+ },
630
764
  nativeEditor: {
631
765
  minHeight: 320,
632
766
  },
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.23",
3
+ "version": "0.0.25",
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.23",
31
- "@openeditor/exporters": "0.0.23",
32
- "@openeditor/extensions": "0.0.23",
31
+ "@openeditor/core": "0.0.25",
32
+ "@openeditor/exporters": "0.0.25",
33
+ "@openeditor/extensions": "0.0.25",
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.23",
40
+ "@openeditor/react-native-prose-editor": "0.0.25",
38
41
  "react-native-keyboard-controller": ">=1.21",
39
42
  "react": ">=19",
40
43
  "react-native": ">=0.85"