fix(mobile): harden iPadOS 26.5 paths against Fabric/WebView crashes

Detail preview now uses static HTML WebView instead of Dom TipTap. Stagger
TextInput and editor host mount/teardown for edit, Me, create, and back so
Fabric ShadowNode measure and Hermes GC no longer SIGSEGV on iPadOS 26.5.
This commit is contained in:
tianma-if committed 2026-08-06 17:41:30 +08:00
1 parent e46e66b46e
commit 494cce5e84
10 files changed
+850 -665

No files matched your search

+2 -2
View File
@@ -18,10 +18,10 @@
"icon": "./assets/icon.png",
"orientation": "default",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"newArchEnabled": false,
"ios": {
"bundleIdentifier": "org.edgeever.mobile",
"supportsTablet": false,
"supportsTablet": true,
"config": {
"usesNonExemptEncryption": false
},
+2
View File
@@ -40,6 +40,7 @@
"expo-sqlite": "~57.0.1",
"expo-status-bar": "latest",
"expo-updates": "~57.0.8",
"markdown-it": "10.0.0",
"mermaid": "^11.16.0",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -56,6 +57,7 @@
"semver": "^7.8.5"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/react": "latest",
"@types/semver": "^7.7.1",
"babel-preset-expo": "latest",
+23 -1
View File
@@ -1,16 +1,31 @@
import { Children, forwardRef, isValidElement, type ComponentRef, type ReactNode } from "react";
import {
Alert as NativeAlert,
Platform,
Pressable as NativePressable,
StyleSheet,
Text as NativeText,
TextInput as NativeTextInput,
type PressableProps,
type TextInputProps,
type TextProps,
type TextStyle,
} from "react-native";
import { translateCurrentMobileText, useMobileLocale } from "../lib/mobile-locale";
import { presentAppDialog } from "./app-dialog-controller";
/**
* iPadOS 26.5 + Fabric: measuring TextInput with the variable system font
* (UIFont systemFontOfSize:weight: → CopyVariationAxes) can SIGSEGV. Force a
* non-variable UIKit face and drop weight so layout never hits that path.
*/
const IOS_SAFE_TEXT_INPUT_STYLE: TextStyle = Platform.OS === "ios"
? {
fontFamily: "Helvetica",
fontWeight: "400",
}
: {};
const translateChildren = (children: ReactNode, translate: (value: string) => string): ReactNode =>
Children.map(children, (child) => {
if (typeof child === "string") {
@@ -38,15 +53,22 @@ export const Text = forwardRef<ComponentRef<typeof NativeText>, TextProps>(({ ch
Text.displayName = "LocalizedText";
export const TextInput = forwardRef<ComponentRef<typeof NativeTextInput>, TextInputProps>(({ accessibilityHint, accessibilityLabel, placeholder, ...props }, ref) => {
export const TextInput = forwardRef<ComponentRef<typeof NativeTextInput>, TextInputProps>(({ accessibilityHint, accessibilityLabel, placeholder, value, defaultValue, style, ...props }, ref) => {
const { translate } = useMobileLocale();
// Fabric TextInput measure on iPadOS 26.5 corrupts when value is non-string.
const safeValue = value === undefined || value === null ? value : String(value);
const safeDefault = defaultValue === undefined || defaultValue === null ? defaultValue : String(defaultValue);
return (
<NativeTextInput
{...props}
accessibilityHint={typeof accessibilityHint === "string" ? translate(accessibilityHint) : accessibilityHint}
accessibilityLabel={typeof accessibilityLabel === "string" ? translate(accessibilityLabel) : accessibilityLabel}
defaultValue={safeDefault}
placeholder={placeholder ? translate(placeholder) : placeholder}
ref={ref}
// Safe face last so callers cannot reintroduce variable system-font weights.
style={style ? StyleSheet.flatten([style, IOS_SAFE_TEXT_INPUT_STYLE]) : IOS_SAFE_TEXT_INPUT_STYLE}
value={safeValue}
/>
);
});
+175
View File
@@ -0,0 +1,175 @@
import MarkdownIt from "markdown-it";
const markdown = new MarkdownIt({
breaks: false,
html: false,
linkify: true,
typographer: false,
});
const lightCss = `
:root {
color-scheme: light;
--bg: #ffffff;
--fg: #0f172a;
--muted: #64748b;
--border: #dedede;
--th-bg: #f2f2f2;
--quote-bg: #f8fafc;
--quote-border: #94a3b8;
--code-bg: #f1f5f9;
--code-fg: #334155;
--pre-bg: #0f172a;
--pre-fg: #e2e8f0;
--link: #059669;
--hr: #e2e8f0;
}
`;
const darkCss = `
:root {
color-scheme: dark;
--bg: #0f172a;
--fg: #f8fafc;
--muted: #94a3b8;
--border: #334155;
--th-bg: #1e293b;
--quote-bg: #1e293b;
--quote-border: #64748b;
--code-bg: #1e293b;
--code-fg: #e2e8f0;
--pre-bg: #020617;
--pre-fg: #e2e8f0;
--link: #6ee7b7;
--hr: #334155;
}
`;
const sharedCss = `
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font: 400 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-text-size-adjust: 100%;
word-wrap: break-word;
}
body { padding: 4px 2px 48px; }
h1, h2, h3, h4, h5, h6 {
line-height: 1.3;
margin: 1.1em 0 0.45em;
font-weight: 800;
}
h1 { font-size: 1.6rem; }
h2 { font-size: 1.3rem; }
h3 { font-size: 1.12rem; }
h4, h5, h6 { font-size: 1rem; }
p { margin: 0 0 0.85em; }
strong { font-weight: 800; }
em { font-style: italic; }
a { color: var(--link); text-decoration: none; }
blockquote {
margin: 0.8em 0;
padding: 0.55em 0.9em;
border-left: 3px solid var(--quote-border);
background: var(--quote-bg);
color: var(--fg);
}
blockquote p:last-child { margin-bottom: 0; }
ul, ol { margin: 0.5em 0 0.9em; padding-left: 1.4em; }
li { margin: 0.25em 0; }
hr {
border: 0;
border-top: 1px solid var(--hr);
margin: 1.1em 0;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.92em;
background: var(--code-bg);
color: var(--code-fg);
border-radius: 4px;
padding: 0.1em 0.35em;
}
pre {
background: var(--pre-bg);
color: var(--pre-fg);
border-radius: 8px;
padding: 12px;
overflow-x: auto;
margin: 0.8em 0;
}
pre code {
background: transparent;
color: inherit;
padding: 0;
font-size: 0.88em;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0.8em 0 1em;
font-size: 0.95em;
}
th, td {
border: 1px solid var(--border);
padding: 0.45em 0.65em;
text-align: left;
vertical-align: top;
}
th {
background: var(--th-bg);
font-weight: 700;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
`;
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
/** Render note markdown to a self-contained HTML document for a static WebView (JS off). */
export const buildMemoDetailHtml = (
markdownSource: string,
theme: "light" | "dark" = "light",
meta?: { notebookName?: string; tags?: string[]; title?: string }
): string => {
const body = markdown.render(markdownSource || "");
const title = meta?.title?.trim() ? `<h1 class="note-title">${escapeHtml(meta.title.trim())}</h1>` : "";
const notebook = meta?.notebookName
? `<div class="note-meta"><span class="note-notebook">${escapeHtml(meta.notebookName)}</span>${
meta.tags?.length
? `<span class="note-tags">${escapeHtml(meta.tags.join(", "))}</span>`
: ""
}</div><hr class="note-divider" />`
: "";
const metaCss = `
h1.note-title { font-size: 1.55rem; margin: 0 0 0.55em; }
.note-meta { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; color: var(--muted); font-size: 0.92rem; margin-bottom: 0.75em; }
.note-notebook { font-weight: 600; }
.note-tags { opacity: 0.9; }
hr.note-divider { margin: 0.4em 0 1em; }
`;
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
${theme === "dark" ? darkCss : lightCss}
${sharedCss}
${metaCss}
</style>
</head>
<body>${title}${notebook}${body}</body>
</html>`;
};
+110 -477
View File
@@ -1,277 +1,29 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { resolveMemoContentDoc, type MemoDetail, type TiptapDoc } from "@edgeever/shared";
import { ActivityIndicator, Image as RNImage, Platform, StyleSheet, View, type ImageStyle, type StyleProp } from "react-native";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { resolveMemoContentMarkdown, type MemoDetail } from "@edgeever/shared";
import { ActivityIndicator, Platform, StyleSheet, View } from "react-native";
import { Modal } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { SvgXml } from "react-native-svg";
import { ChevronDown, ChevronLeft, ChevronRight, History, MoreHorizontal, Pencil, RotateCcw, Search, Share2, Tag, Trash2, X } from "../components/icons";
import { WebView } from "react-native-webview";
import { ChevronLeft, ChevronRight, History, MoreHorizontal, Pencil, RotateCcw, Search, Share2, Trash2, X } from "../components/icons";
import { Alert, Pressable, Text, TextInput } from "../components/LocalizedText";
import LocalTiptapEditor, { type LocalTiptapEditorRef } from "../components/LocalTiptapEditor";
import { MobileResourceActions } from "../components/MobileResourceActions";
import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom";
import {
openMobileResource,
parseMobileResourceTargetJson,
saveMobileResourceAs,
type MobileResourceTarget,
} from "../lib/mobile-attachments";
import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom";
import { buildMemoDetailHtml } from "../lib/mobile-markdown-html";
import { useMobileLocale } from "../lib/mobile-locale";
import { useMobileTheme } from "../lib/mobile-theme";
import { useSession } from "../lib/session";
import { beginEditorStartup } from "../lib/startup-performance";
import type { MobileSyncQueueItem } from "../lib/sync-queue";
import { getTextSearchMatches } from "./workspace-utils";
import { styles } from "./workspace-styles";
const ANDROID_SYSTEM_NAVIGATION_FALLBACK = 48;
const DEFAULT_MEMO_TITLE = "无标题笔记";
const RESOURCE_DATA_URL_CACHE_LIMIT = 32;
type SessionLike = { baseUrl: string; token: string } | null;
type AuthenticatedImageSource = {
headers?: { Authorization: string };
uri: string;
};
const isProtectedResourceSource = (source: string, session: SessionLike) => {
const baseUrl = session?.baseUrl.replace(/\/+$/, "") ?? "";
return source.startsWith("/api/v1/resources/")
|| Boolean(baseUrl && (source.startsWith(`${baseUrl}/api/v1/resources/`) || source.startsWith("/api/v1/resources/")));
};
/** Ensure protected resource URLs hit the blob route the API serves. */
const normalizeProtectedResourcePath = (source: string, session: SessionLike) => {
const baseUrl = session?.baseUrl.replace(/\/+$/, "") ?? "";
let path = source;
if (baseUrl && path.startsWith(`${baseUrl}/`)) {
path = path.slice(baseUrl.length);
}
if (!path.startsWith("/api/v1/resources/")) {
return path;
}
if (/\/blob(?:$|[?#])/.test(path)) {
return path;
}
const match = path.match(/^(\/api\/v1\/resources\/[^/?#]+)/);
return match ? `${match[1]}/blob` : path;
};
const getAuthenticatedResourceSource = (
source: string,
session: SessionLike
): AuthenticatedImageSource => {
const baseUrl = session?.baseUrl.replace(/\/+$/, "") ?? "";
const uri = source.startsWith("/") && baseUrl ? `${baseUrl}${source}` : source;
const isProtectedResource = isProtectedResourceSource(source, session)
|| Boolean(baseUrl && uri.startsWith(`${baseUrl}/api/v1/resources/`));
return {
uri,
...(session?.token && isProtectedResource ? { headers: { Authorization: `Bearer ${session.token}` } } : {}),
};
};
const blobToDataUrl = (blob: Blob) => new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("资源读取失败"));
reader.onloadend = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
return;
}
reject(new Error("资源读取失败"));
};
reader.readAsDataURL(blob);
});
const resourceDataUrlCache = new Map<string, Promise<string | null>>();
const loadProtectedResourceDataUrl = (
source: string,
session: SessionLike,
getResourceBlob: ((resourceUrl: string) => Promise<Blob>) | null | undefined
) => {
if (!getResourceBlob || !isProtectedResourceSource(source, session)) {
return Promise.resolve(null);
}
const path = normalizeProtectedResourcePath(source, session);
const cacheKey = `${session?.token ?? ""}\n${path}`;
const cached = resourceDataUrlCache.get(cacheKey);
if (cached) {
return cached;
}
if (resourceDataUrlCache.size >= RESOURCE_DATA_URL_CACHE_LIMIT) {
const oldestKey = resourceDataUrlCache.keys().next().value;
if (oldestKey) {
resourceDataUrlCache.delete(oldestKey);
}
}
const pending = getResourceBlob(path)
.then(blobToDataUrl)
.catch(() => {
resourceDataUrlCache.delete(cacheKey);
return null;
});
resourceDataUrlCache.set(cacheKey, pending);
return pending;
};
type CachedSvgResource = {
aspectRatio: number | null;
xml: string;
};
const AUTHENTICATED_SVG_CACHE_LIMIT = 24;
const authenticatedSvgCache = new Map<string, Promise<CachedSvgResource | null>>();
const getAuthenticatedSvgCacheKey = (source: AuthenticatedImageSource) =>
`${source.uri}\n${source.headers?.Authorization ?? ""}`;
const loadAuthenticatedSvg = (source: AuthenticatedImageSource) => {
const cacheKey = getAuthenticatedSvgCacheKey(source);
const cached = authenticatedSvgCache.get(cacheKey);
if (cached) {
return cached;
}
if (authenticatedSvgCache.size >= AUTHENTICATED_SVG_CACHE_LIMIT) {
const oldestKey = authenticatedSvgCache.keys().next().value;
if (oldestKey) {
authenticatedSvgCache.delete(oldestKey);
}
}
const pending = fetch(source.uri, { headers: source.headers })
.then(async (response) => {
if (!response.ok) {
throw new Error(`Resource request failed with ${response.status}`);
}
if (!response.headers.get("Content-Type")?.toLowerCase().includes("svg")) {
return null;
}
const xml = await response.text();
const viewBox = xml.match(/viewBox=["']\s*[\d.-]+\s+[\d.-]+\s+([\d.]+)\s+([\d.]+)\s*["']/i);
const width = Number(viewBox?.[1]);
const height = Number(viewBox?.[2]);
return {
aspectRatio: width > 0 && height > 0 ? width / height : null,
xml,
};
})
.catch(() => {
authenticatedSvgCache.delete(cacheKey);
return null;
});
authenticatedSvgCache.set(cacheKey, pending);
return pending;
};
const AuthenticatedResourceImage = ({
alt,
fitAspect = false,
href,
loadResourceBlob,
resizeMode = "cover",
session,
style,
}: {
alt: string;
fitAspect?: boolean;
href: string;
loadResourceBlob?: ((resourceUrl: string) => Promise<Blob>) | null;
resizeMode?: "center" | "contain" | "cover" | "repeat" | "stretch";
session: SessionLike;
style: StyleProp<ImageStyle>;
}) => {
const headerSource = useMemo(() => getAuthenticatedResourceSource(href, session), [href, session]);
const [displaySource, setDisplaySource] = useState<AuthenticatedImageSource>(headerSource);
const [aspectRatio, setAspectRatio] = useState(16 / 9);
const [svgXml, setSvgXml] = useState<string | null>(null);
const svgRequestStartedRef = useRef(false);
const svgSourceKeyRef = useRef("");
const svgSourceKey = getAuthenticatedSvgCacheKey(displaySource);
const imageStyle = fitAspect ? [style, { aspectRatio, height: undefined, width: "100%" as const }] : style;
useEffect(() => {
let cancelled = false;
setSvgXml(null);
setAspectRatio(16 / 9);
svgRequestStartedRef.current = false;
setDisplaySource(headerSource);
void loadProtectedResourceDataUrl(href, session, loadResourceBlob).then((dataUrl) => {
if (cancelled || !dataUrl) {
return;
}
setDisplaySource({ uri: dataUrl });
});
return () => {
cancelled = true;
};
}, [headerSource, href, loadResourceBlob, session]);
useEffect(() => {
svgSourceKeyRef.current = svgSourceKey;
const cached = authenticatedSvgCache.get(svgSourceKey);
if (cached) {
svgRequestStartedRef.current = true;
void cached.then((result) => {
if (!result || svgSourceKeyRef.current !== svgSourceKey) {
return;
}
if (result.aspectRatio) {
setAspectRatio(result.aspectRatio);
}
setSvgXml(result.xml);
});
}
return () => {
if (svgSourceKeyRef.current === svgSourceKey) {
svgSourceKeyRef.current = "";
}
};
}, [svgSourceKey]);
const loadSvgFallback = () => {
if (svgRequestStartedRef.current) {
return;
}
svgRequestStartedRef.current = true;
void loadAuthenticatedSvg(displaySource)
.then((result) => {
if (!result || svgSourceKeyRef.current !== svgSourceKey) {
return;
}
if (result.aspectRatio) {
setAspectRatio(result.aspectRatio);
}
setSvgXml(result.xml);
});
};
if (svgXml) {
return (
<View accessibilityLabel={alt || undefined} accessible={Boolean(alt)} style={imageStyle}>
<SvgXml height="100%" width="100%" xml={svgXml} />
</View>
);
}
return (
<RNImage
accessibilityLabel={alt || undefined}
accessible={Boolean(alt)}
fadeDuration={Platform.OS === "android" ? 0 : undefined}
onLoad={(event) => {
const { height, width } = event.nativeEvent.source;
if (height > 0 && width > 0) {
setAspectRatio(width / height);
}
}}
onError={loadSvgFallback}
resizeMethod={Platform.OS === "android" ? "resize" : "auto"}
resizeMode={resizeMode}
source={displaySource}
style={imageStyle}
/>
);
};
const DetailActionSheetItem = ({ danger = false, disabled = false, icon, label, onPress }: { danger?: boolean; disabled?: boolean; icon: ReactNode; label: string; onPress: () => void }) => (
<Pressable accessibilityRole="button" disabled={disabled} onPress={onPress} style={[styles.actionSheetItem, disabled && styles.buttonDisabled]}>
@@ -330,27 +82,16 @@ export const MemoDetailModal = ({
syncStatus: MobileSyncQueueItem["status"] | null;
visible: boolean;
}) => {
const { client, session } = useSession();
const { client } = useSession();
const { resolvedTheme } = useMobileTheme();
const { resolvedLocale } = useMobileLocale();
const safeAreaInsets = useSafeAreaInsets();
const [actionsOpen, setActionsOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchMatchCount, setSearchMatchCount] = useState(0);
const [activeMatchIndex, setActiveMatchIndex] = useState(0);
const [imagePreview, setImagePreview] = useState<{ alt: string; source: string } | null>(null);
const [resourceTarget, setResourceTarget] = useState<MobileResourceTarget | null>(null);
const [viewerReady, setViewerReady] = useState(false);
const viewerRef = useRef<LocalTiptapEditorRef>(null);
const resourceDataUrlCacheRef = useRef(new Map<string, Promise<string | null>>());
const baseUrl = session?.baseUrl.replace(/\/+$/, "") ?? "";
const viewerContent = useMemo<TiptapDoc>(
() => (memo ? resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown) : { type: "doc", content: [{ type: "paragraph" }] }),
[memo]
);
const [bodyReady, setBodyReady] = useState(false);
const downloadResource = useCallback(async (target: MobileResourceTarget) => {
if (!client) throw new Error(resolvedLocale === "en-US" ? "The resource client is unavailable." : "当前无法读取资源。");
try {
@@ -374,44 +115,31 @@ export const MemoDetailModal = ({
}
}, [client, resolvedLocale]);
const loadViewerResource = useCallback((source: string) => {
if (!client) {
return Promise.resolve(null);
// HTML in a JS-disabled WebView: full markdown (tables/bold/etc.) without Fabric
// ParagraphShadowNode / AttributedString measure crashes on iPadOS 26.5.
// Not Expo Dom TipTap — static HTML only + media capture denied.
const detailText = memo
? resolveMemoContentMarkdown(memo.contentJson, memo.contentMarkdown) || memo.contentText || "没有正文内容"
: "没有正文内容";
const detailHtml = useMemo(
() => buildMemoDetailHtml(detailText, resolvedTheme, {
notebookName,
tags: memo?.tags,
title: memo?.title?.trim() || DEFAULT_MEMO_TITLE,
}),
[detailText, memo?.tags, memo?.title, notebookName, resolvedTheme]
);
const searchMatches = useMemo(() => getTextSearchMatches(detailText, searchQuery), [detailText, searchQuery]);
// Unmount WebView as soon as the modal hides so teardown does not race Me tab layout.
const showBodyWebView = Boolean(visible && memo && !isLoading);
// Close search host when leaving the memo so no TextInput is measured during dismiss.
useEffect(() => {
if (!visible) {
setSearchOpen(false);
setSearchQuery("");
}
const path = normalizeProtectedResourcePath(source, session);
const cached = resourceDataUrlCacheRef.current.get(path);
if (cached) {
return cached;
}
const pending = client.getResourceBlob(path).then(blobToDataUrl).catch(() => null);
resourceDataUrlCacheRef.current.set(path, pending);
return pending;
}, [client, session]);
const onResourcePress = useCallback(async (targetJson: string) => {
const target = parseMobileResourceTargetJson(targetJson);
if (target) {
setResourceTarget(target);
}
}, []);
const onImagePreview = useCallback(async (payloadJson: string) => {
try {
const parsed = JSON.parse(payloadJson) as { alt?: unknown; source?: unknown };
if (typeof parsed.source === "string" && parsed.source) {
setImagePreview({
alt: typeof parsed.alt === "string" ? parsed.alt : "",
source: parsed.source,
});
}
} catch {
// Ignore malformed bridge payloads.
}
}, []);
const searchMatchLabel = searchQuery.trim()
? `${searchMatchCount > 0 ? activeMatchIndex + 1 : 0}/${searchMatchCount}`
: "0/0";
}, [visible]);
const searchMatchLabel = searchQuery.trim() ? `${searchMatches.length > 0 ? activeMatchIndex + 1 : 0}/${searchMatches.length}` : "0/0";
const syncStatusLabel = isSaving || syncStatus === "syncing"
? "保存中"
: syncStatus === "conflict"
@@ -427,28 +155,19 @@ export const MemoDetailModal = ({
) + 16;
useEffect(() => {
setViewerReady(false);
setSearchOpen(false);
setSearchQuery("");
setSearchMatchCount(0);
setActiveMatchIndex(0);
setImagePreview(null);
setResourceTarget(null);
resourceDataUrlCacheRef.current.clear();
}, [memo?.id]);
}, [detailText, searchQuery]);
useEffect(() => {
if (!viewerReady || !searchOpen) {
return;
}
viewerRef.current?.search(searchQuery, activeMatchIndex);
}, [activeMatchIndex, searchOpen, searchQuery, viewerReady]);
setBodyReady(false);
}, [memo?.id]);
const moveSearchMatch = (direction: 1 | -1) => {
if (searchMatchCount === 0) {
if (searchMatches.length === 0) {
return;
}
setActiveMatchIndex((current) => (current + direction + searchMatchCount) % searchMatchCount);
setActiveMatchIndex((current) => (current + direction + searchMatches.length) % searchMatches.length);
};
const closeActionsAndRun = (action: () => void) => {
@@ -556,106 +275,61 @@ export const MemoDetailModal = ({
<ActivityIndicator color="#0f172a" />
</View>
) : memo ? (
<View style={detailLayoutStyles.body}>
<View style={detailLayoutStyles.meta}>
<Text selectable style={styles.detailTitle}>{memo.title?.trim() || DEFAULT_MEMO_TITLE}</Text>
<View style={styles.detailMetaRow}>
<View style={styles.detailNotebookButton}>
<Text numberOfLines={1} selectable style={styles.detailNotebookName}>{notebookName}</Text>
<ChevronDown color="#94a3b8" size={14} />
<View style={detailBodyStyles.container}>
{searchOpen ? (
<View style={[styles.noteSearchPanel, detailBodyStyles.searchPanel]}>
<View style={styles.searchBox}>
<Search color="#64748b" size={18} />
<TextInput
accessibilityLabel="在当前笔记内搜索"
autoCapitalize="none"
autoCorrect={false}
onChangeText={setSearchQuery}
placeholder="在当前笔记内搜索"
placeholderTextColor="#94a3b8"
style={styles.searchInput}
value={searchQuery}
/>
<Text style={[styles.noteSearchCount, searchQuery.trim() && searchMatches.length === 0 && styles.noteSearchCountEmpty]}>{searchMatchLabel}</Text>
</View>
<View style={styles.detailTagsGroup}>
<Tag color="#64748b" size={16} />
<Text
numberOfLines={1}
selectable
style={[styles.detailTagsInline, memo.tags.length === 0 && styles.detailTagsPlaceholder]}
>
{memo.tags.length ? memo.tags.join(", ") : "添加标签,用逗号分隔"}
</Text>
<View style={styles.richEditorSearchActions}>
<DetailActionButton disabled={searchMatches.length === 0} label="上一个搜索结果" onPress={() => moveSearchMatch(-1)}>
<ChevronLeft color={searchMatches.length === 0 ? "#cbd5e1" : "#0f172a"} size={16} />
</DetailActionButton>
<DetailActionButton disabled={searchMatches.length === 0} label="下一个搜索结果" onPress={() => moveSearchMatch(1)}>
<ChevronRight color={searchMatches.length === 0 ? "#cbd5e1" : "#0f172a"} size={16} />
</DetailActionButton>
<DetailActionButton label="关闭搜索" onPress={() => {
setSearchOpen(false);
setSearchQuery("");
}}>
<X color="#0f172a" size={16} />
</DetailActionButton>
</View>
</View>
{searchOpen ? (
<View style={styles.noteSearchPanel}>
<View style={styles.searchBox}>
<Search color="#64748b" size={18} />
<TextInput
accessibilityLabel="在当前笔记内搜索"
autoCapitalize="none"
autoCorrect={false}
onChangeText={(value) => {
setSearchQuery(value);
setActiveMatchIndex(0);
}}
placeholder="在当前笔记内搜索"
placeholderTextColor="#94a3b8"
style={styles.searchInput}
value={searchQuery}
/>
<Text style={[styles.noteSearchCount, searchQuery.trim() && searchMatchCount === 0 && styles.noteSearchCountEmpty]}>{searchMatchLabel}</Text>
</View>
<View style={styles.richEditorSearchActions}>
<DetailActionButton disabled={searchMatchCount === 0} label="上一个搜索结果" onPress={() => moveSearchMatch(-1)}>
<ChevronLeft color={searchMatchCount === 0 ? "#cbd5e1" : "#0f172a"} size={16} />
</DetailActionButton>
<DetailActionButton disabled={searchMatchCount === 0} label="下一个搜索结果" onPress={() => moveSearchMatch(1)}>
<ChevronRight color={searchMatchCount === 0 ? "#cbd5e1" : "#0f172a"} size={16} />
</DetailActionButton>
<DetailActionButton label="关闭搜索" onPress={() => {
setSearchOpen(false);
setSearchQuery("");
setSearchMatchCount(0);
setActiveMatchIndex(0);
viewerRef.current?.search("", 0);
}}>
<X color="#0f172a" size={16} />
</DetailActionButton>
</View>
</View>
) : null}
<View style={styles.detailDivider} />
</View>
{baseUrl ? (
<LocalTiptapEditor
key={memo.id}
baseUrl={baseUrl}
content={viewerContent}
dom={{
...SAFE_DOM_WEBVIEW_PROPS,
bounces: true,
contentInsetAdjustmentBehavior: "never",
overScrollMode: "never",
scrollEnabled: false,
style: [
detailLayoutStyles.viewer,
resolvedTheme === "dark" ? detailLayoutStyles.viewerDark : null,
],
}}
locale={resolvedLocale}
mode="viewer"
onImagePreview={onImagePreview}
onLoadResource={loadViewerResource}
onReady={async () => {
setViewerReady(true);
}}
onResourcePress={onResourcePress}
onSearchResult={async (count, index) => {
setSearchMatchCount(count);
setActiveMatchIndex(count > 0 ? index : 0);
}}
ref={viewerRef}
theme={resolvedTheme}
/>
) : (
<View style={styles.centerState}>
<Text style={styles.errorText}>{resolvedLocale === "en-US" ? "Not signed in." : "未登录。"}</Text>
</View>
)}
{!viewerReady ? (
<View pointerEvents="none" style={detailLayoutStyles.viewerLoading}>
<ActivityIndicator color="#0f172a" />
</View>
) : null}
<View style={[detailBodyStyles.webHost, resolvedTheme === "dark" ? detailBodyStyles.webHostDark : null]}>
{showBodyWebView ? (
<WebView
key={memo.id}
{...SAFE_DOM_WEBVIEW_PROPS}
allowsLinkPreview={false}
// Static HTML only — no JS reduces hang/TCC surface vs Dom TipTap.
domStorageEnabled={false}
javaScriptEnabled={false}
onLoadEnd={() => setBodyReady(true)}
originWhitelist={["*"]}
setSupportMultipleWindows={false}
source={{ html: detailHtml }}
style={[detailBodyStyles.webView, !bodyReady && detailBodyStyles.webViewHidden]}
/>
) : null}
{bodyReady ? null : (
<View pointerEvents="none" style={detailBodyStyles.loadingOverlay}>
<ActivityIndicator color="#0f172a" />
</View>
)}
</View>
</View>
) : (
<View style={styles.centerState}>
@@ -692,28 +366,6 @@ export const MemoDetailModal = ({
</Pressable>
</Modal>
) : null}
<Modal animationType="fade" onRequestClose={() => setImagePreview(null)} transparent visible={Boolean(imagePreview)}>
<View style={resourceImageStyles.previewBackdrop}>
{imagePreview ? (
<AuthenticatedResourceImage
alt={imagePreview.alt}
href={imagePreview.source}
loadResourceBlob={client?.getResourceBlob}
resizeMode="contain"
session={session}
style={resourceImageStyles.previewImage}
/>
) : null}
<Pressable
accessibilityLabel={resolvedLocale === "en-US" ? "Close image preview" : "关闭图片预览"}
accessibilityRole="button"
onPress={() => setImagePreview(null)}
style={resourceImageStyles.previewClose}
>
<X color="#ffffff" size={24} />
</Pressable>
</View>
</Modal>
<MobileResourceActions
canMutate={Boolean(memo && !memo.isDeleted && !memo.id.startsWith("local:"))}
onClose={() => setResourceTarget(null)}
@@ -734,54 +386,35 @@ export const MemoDetailModal = ({
);
};
const detailLayoutStyles = StyleSheet.create({
body: {
const detailBodyStyles = StyleSheet.create({
container: {
flex: 1,
minHeight: 0,
},
meta: {
paddingBottom: 0,
paddingHorizontal: 16,
paddingTop: 16,
loadingOverlay: {
...StyleSheet.absoluteFill,
alignItems: "center",
backgroundColor: "rgba(255,255,255,0.72)",
justifyContent: "center",
},
viewer: {
searchPanel: {
marginHorizontal: 16,
marginTop: 8,
},
webHost: {
backgroundColor: "#ffffff",
flex: 1,
minHeight: 0,
},
viewerDark: {
webHostDark: {
backgroundColor: "#0f172a",
},
viewerLoading: {
...StyleSheet.absoluteFill,
alignItems: "center",
backgroundColor: "rgba(248,250,252,0.72)",
justifyContent: "center",
top: 120,
webView: {
backgroundColor: "transparent",
flex: 1,
},
webViewHidden: {
opacity: 0,
},
});
const resourceImageStyles = StyleSheet.create({
previewBackdrop: {
alignItems: "center",
backgroundColor: "rgba(2,6,23,0.96)",
flex: 1,
justifyContent: "center",
padding: 16,
},
previewClose: {
alignItems: "center",
backgroundColor: "rgba(15,23,42,0.78)",
borderRadius: 999,
height: 46,
justifyContent: "center",
position: "absolute",
right: 18,
top: 54,
width: 46,
},
previewImage: {
height: "100%",
width: "100%",
},
});
+58 -30
View File
@@ -1,9 +1,8 @@
import { memo, type ReactNode } from "react";
import { memo, useEffect, useState, type ReactNode } from "react";
import type { MemoFilterMode } from "@edgeever/client";
import type { MemoSummary, Notebook } from "@edgeever/shared";
import { MOBILE_UI_METRICS, toggleMobileMemoFilterMode } from "@edgeever/shared/mobile-ui";
import { ActivityIndicator, FlatList, Platform, RefreshControl, View } from "react-native";
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
import { Check, ChevronDown, ChevronLeft, MoreHorizontal, Plus, RotateCcw, Search, Sparkles, Tag, X } from "../components/icons";
import { Pressable, Text, TextInput } from "../components/LocalizedText";
import type { MobileBootstrapProgress } from "../lib/local-mirror";
@@ -41,6 +40,8 @@ export const NotesView = ({
onRetry,
onSearchTextChange,
onSetMemoView,
/** When false, unmount the real TextInput (detail modal / tab switch). */
searchInputEnabled = true,
searchText,
totalMemoCount,
selectedMemoIds,
@@ -70,6 +71,7 @@ export const NotesView = ({
onRetry: () => void;
onSearchTextChange: (value: string) => void;
onSetMemoView: (memoView: MemoView) => void;
searchInputEnabled?: boolean;
searchText: string;
totalMemoCount: number;
selectionMode: boolean;
@@ -77,7 +79,18 @@ export const NotesView = ({
}) => {
const { resolvedTheme } = useMobileTheme();
const { preference: localePreference, translate } = useMobileLocale();
// Fabric on iPadOS 26.5 SIGTRAPs in BaseTextInputShadowNode::measureContent when a
// TextInput stays mounted under a closing memo modal / WebView teardown. Only mount
// the real input while the user is actively searching.
const [searchFocused, setSearchFocused] = useState(false);
const searchHostActive = searchInputEnabled && !selectionMode;
useEffect(() => {
if (!searchHostActive) {
setSearchFocused(false);
}
}, [searchHostActive]);
const searchActive = searchText.trim().length > 0;
const showSearchInput = searchHostActive && (searchFocused || searchActive);
const filterActive = memoFilterMode !== "all";
const searchStatusLabel = translate("正在搜索");
const searchResultLabel = translate(`${totalMemoCount} 条结果`);
@@ -124,19 +137,45 @@ export const NotesView = ({
<View style={styles.mobileSearchRow}>
<View style={[styles.mobileSearchButton, searchActive && styles.mobileSearchButtonActive, searchActive && resolvedTheme === "dark" && styles.mobileSearchButtonActiveDark]}>
<Search color={searchActive && resolvedTheme === "dark" ? "rgb(5, 150, 105)" : searchActive ? "#059669" : "#64748b"} size={17} />
<TextInput
accessibilityLabel="搜索笔记"
autoCapitalize="none"
autoCorrect={false}
onChangeText={onSearchTextChange}
placeholder="搜索笔记"
placeholderTextColor="#94a3b8"
returnKeyType="search"
style={[styles.mobileSearchInput, searchActive && resolvedTheme === "dark" && styles.mobileSearchInputActiveDark]}
value={searchText}
/>
{showSearchInput ? (
<TextInput
accessibilityLabel="搜索笔记"
autoCapitalize="none"
autoCorrect={false}
autoFocus={searchFocused && !searchActive}
onBlur={() => {
if (!searchText.trim()) {
setSearchFocused(false);
}
}}
onChangeText={onSearchTextChange}
placeholder="搜索笔记"
placeholderTextColor="#94a3b8"
returnKeyType="search"
style={[styles.mobileSearchInput, searchActive && resolvedTheme === "dark" && styles.mobileSearchInputActiveDark]}
value={searchText}
/>
) : (
<Pressable
accessibilityLabel="搜索笔记"
accessibilityRole="search"
disabled={!searchHostActive}
onPress={() => setSearchFocused(true)}
style={[styles.mobileSearchInput, { justifyContent: "center" }]}
>
<Text numberOfLines={1} style={{ color: "#94a3b8" }}>搜索笔记</Text>
</Pressable>
)}
{searchText ? (
<Pressable accessibilityLabel="清空搜索" accessibilityRole="button" onPress={() => onSearchTextChange("")} style={styles.mobileSearchClearButton}>
<Pressable
accessibilityLabel="清空搜索"
accessibilityRole="button"
onPress={() => {
onSearchTextChange("");
setSearchFocused(false);
}}
style={styles.mobileSearchClearButton}
>
<X color={resolvedTheme === "dark" ? "rgb(100, 116, 139)" : "#64748b"} size={14} />
</Pressable>
) : null}
@@ -395,21 +434,16 @@ const MemoCard = memo(function MemoCard({
}) {
const localePreference = useMobileLocale().preference;
const memoTitle = memo.title?.trim() || DEFAULT_MEMO_TITLE;
const pressScale = useSharedValue(1);
const pressAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: pressScale.value }],
}));
// Avoid Reanimated layout/entering transitions on list cards: concurrent
// Fabric text measurement on iPad (compatibility mode / wide width) crashed
// App Review builds with SIGSEGV inside RCTTextLayoutManager.
// Keep memo cards free of Reanimated transforms: concurrent Fabric
// AttributedString/Paragraph measure + animated scale corrupted heap on
// iPadOS 26.5 (SIGTRAP/SIGSEGV in ParagraphShadowNode / RCTTextLayoutManager).
return (
<Animated.View
<View
style={[
styles.memoCard,
listDensity === "compact" && styles.memoCardCompact,
selected && styles.memoCardSelected,
pressAnimatedStyle,
]}
>
{selectionMode ? (
@@ -431,12 +465,6 @@ const MemoCard = memo(function MemoCard({
delayLongPress={520}
onLongPress={onLongPress}
onPress={onPress}
onPressIn={() => {
pressScale.value = withTiming(0.985, { duration: 100 });
}}
onPressOut={() => {
pressScale.value = withTiming(1, { duration: 160 });
}}
style={[styles.memoCardContent, listDensity === "compact" && styles.memoCardContentCompact, selectionMode && styles.memoCardContentWithSelection]}
>
<View style={styles.memoCardTop}>
@@ -461,7 +489,7 @@ const MemoCard = memo(function MemoCard({
))}
</View>
</Pressable>
</Animated.View>
</View>
);
}, (previous, next) =>
previous.memo === next.memo &&
+433 -142
View File
@@ -390,6 +390,29 @@ export const WorkspaceScreen = ({
}
}, [memosQuery.data, notebooksQuery.data]);
// Keep list TextInput unmounted while a note is open, while Me/settings is
// open or opening, while the rich editor is opening/open, and for a short
// settle window after close — Fabric TextInput measure races WebView teardown
// on iPadOS 26.5.
const [searchInputEnabled, setSearchInputEnabled] = useState(true);
const [richEditorOpening, setRichEditorOpening] = useState(false);
const [settingsOpening, setSettingsOpening] = useState(false);
const settingsOpenGenerationRef = useRef(0);
useEffect(() => {
if (
selectedMemoId
|| richEditingSession
|| richEditorOpening
|| settingsOpening
|| activeView === "settings"
) {
setSearchInputEnabled(false);
return;
}
const timer = setTimeout(() => setSearchInputEnabled(true), 500);
return () => clearTimeout(timer);
}, [activeView, richEditingSession, richEditorOpening, selectedMemoId, settingsOpening]);
const refresh = async () => {
if (client) {
await syncMobileLocalMirror(client, dataScope);
@@ -424,6 +447,7 @@ export const WorkspaceScreen = ({
};
const showAllNotes = () => {
setSelectedMemoId(null);
setActiveView("notes");
setMemoView("notebook");
setActiveNotebookId(ALL_NOTES_ID);
@@ -432,24 +456,57 @@ export const WorkspaceScreen = ({
};
const showTrash = () => {
setSelectedMemoId(null);
setMemoView("trash");
setActiveNotebookId(ALL_NOTES_ID);
setSearchText("");
clearSelection();
};
const openSettings = () => {
const openSettings = useCallback(async () => {
// Me crash (16:40): same-frame unmount of NotesView + detail WebView while
// mounting Settings destroyed ShadowNodeFamily under Hermes HadesGC
// (SIGSEGV in ~ShadowNodeFamily on concurrent GC). Sequence instead:
// 1) tear down detail + search TextInput, 2) settle, 3) present Me as Modal
// without unmounting the notes tree.
const generation = ++settingsOpenGenerationRef.current;
setSettingsOpening(true);
setSearchInputEnabled(false);
setSelectedMemoId(null);
setSearchText("");
setActiveView("settings");
};
setNotesActionsOpen(false);
setSelectionMode(false);
setSelectedMemoIds(new Set());
setSelectionMoveOpen(false);
setSelectionMoreOpen(false);
try {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
await new Promise((resolve) => setTimeout(resolve, 300));
if (generation !== settingsOpenGenerationRef.current) {
return;
}
setActiveView("settings");
} finally {
if (generation === settingsOpenGenerationRef.current) {
setSettingsOpening(false);
}
}
}, []);
const closeSettings = () => {
const closeSettings = useCallback(() => {
settingsOpenGenerationRef.current += 1;
setSettingsOpening(false);
setSelectedMemoId(null);
setActiveView("notes");
if (memoView === "trash") {
setMemoView("notebook");
setActiveNotebookId(ALL_NOTES_ID);
}
};
}, [memoView]);
const toggleVisibleSelection = () => {
const visibleMemoIds = visibleMemos.map((memo) => memo.id);
@@ -485,6 +542,8 @@ export const WorkspaceScreen = ({
const closeRichEditor = () => {
const memoId = richEditingSession?.memo.id ?? null;
// Keep list search TextInput off while the editor tree finishes leaving.
setSearchInputEnabled(false);
setRichEditingSession(null);
setSelectedMemoId(null);
if (memoId) {
@@ -505,24 +564,42 @@ export const WorkspaceScreen = ({
const openRichEditor = useCallback(async (memo: MemoDetail) => {
beginEditorStartup();
let editingMemo = memo;
const queuedItem = (await listMobileSyncQueueItems(syncQueueScope)).find((item) => item.memoId === memo.id);
// Tear down the detail HTML WebView and keep list TextInput unmounted before
// mounting the editor chrome / Dom TipTap. Simultaneous Fabric TextInput
// measure + WKWebView host creation SIGSEGVs on iPadOS 26.5.
setRichEditorOpening(true);
setSearchInputEnabled(false);
setSelectedMemoId(null);
try {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
// Wait long enough for detail Modal + static WebView to fully leave the tree.
await new Promise((resolve) => setTimeout(resolve, 280));
if (!queuedItem && client && !memo.id.startsWith("local:")) {
try {
const response = await client.getMemo(memo.id);
editingMemo = response.memo;
await upsertLocalMemo(dataScope, editingMemo);
queryClient.setQueryData(["mobile", "memo", "notebook", editingMemo.id], { memo: editingMemo });
queryClient.setQueryData(["mobile", "memo", "trash", editingMemo.id], { memo: editingMemo });
} catch {
// The local mirror remains editable while offline.
let editingMemo = memo;
const queuedItem = (await listMobileSyncQueueItems(syncQueueScope)).find((item) => item.memoId === memo.id);
if (!queuedItem && client && !memo.id.startsWith("local:")) {
try {
const response = await client.getMemo(memo.id);
editingMemo = response.memo;
await upsertLocalMemo(dataScope, editingMemo);
queryClient.setQueryData(["mobile", "memo", "notebook", editingMemo.id], { memo: editingMemo });
queryClient.setQueryData(["mobile", "memo", "trash", editingMemo.id], { memo: editingMemo });
} catch {
// The local mirror remains editable while offline.
}
}
}
const draft = await loadMemoDraft(editingMemo.id);
memoDraftPrefetchRef.current.delete(memo.id);
setRichEditingSession({ draft, memo: editingMemo });
const draft = await loadMemoDraft(editingMemo.id);
memoDraftPrefetchRef.current.delete(memo.id);
setRichEditingSession({ draft, memo: editingMemo });
} finally {
setRichEditorOpening(false);
}
}, [client, dataScope, loadMemoDraft, queryClient, syncQueueScope]);
const memos = useMemo(() => memosQuery.data?.pages.flatMap((page) => page.memos) ?? [], [memosQuery.data]);
@@ -533,7 +610,13 @@ export const WorkspaceScreen = ({
const selectedMemoSyncStatus = selectedMemo
? syncQueueItems.find((item) => item.memoId === selectedMemo.id)?.status ?? null
: null;
const isRefreshing = notebooksQuery.isFetching || memosQuery.isFetching || searchQuery.isFetching || memoDetailQuery.isFetching;
// Pull-to-refresh must only track list refetches. Using isFetching (or
// memoDetailQuery) starts UIRefreshControl while opening a note or paging,
// and ending that animation during navigation crashes on iPadOS 26.5
// (SIGSEGV in RCTPullToRefreshViewComponentView / createComponentView).
const isRefreshing = searchActive
? searchQuery.isRefetching
: notebooksQuery.isRefetching || memosQuery.isRefetching;
const selectedMemoIdList = Array.from(selectedMemoIds);
const selectedMemos = visibleMemos.filter((memo) => selectedMemoIds.has(memo.id));
const canToggleVisibleSelection = visibleMemos.length > 0;
@@ -549,8 +632,15 @@ export const WorkspaceScreen = ({
const canCreateMemo = memoView !== "trash" && Boolean(createMemoNotebookId);
const openCreateMemo = () => {
beginEditorStartup();
setSearchInputEnabled(false);
setSelectedMemoId(null);
setIncomingClipDraft(null);
setCreateOpen(true);
// Defer Modal open so list layout settles before Dom WKWebView mounts.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setTimeout(() => setCreateOpen(true), 120);
});
});
};
const openIncomingClipDraft = useCallback((draft: MobileWebClipDraft) => {
@@ -1031,50 +1121,56 @@ export const WorkspaceScreen = ({
return (
<SafeAreaView edges={["top", "left", "right"]} style={styles.safeArea}>
{activeView === "notes" ? (
<NotesView
activeNotebook={activeNotebook}
initialSyncProgress={initialMirrorSyncProgress}
isLoading={notebooksQuery.isLoading || (searchActive ? searchQuery.isLoading : memosQuery.isLoading) || (isInitialMirrorStatusPending && visibleMemos.length === 0)}
isLoadingMore={searchActive ? searchQuery.isFetchingNextPage : memosQuery.isFetchingNextPage}
isRefreshing={isRefreshing}
memoFilterMode={memoFilterMode}
memoListDensity={memoListDensity}
memoView={memoView}
memos={visibleMemos}
notebooks={notebooks}
onCreate={openCreateMemo}
onClearSelection={clearSelection}
onFilterModeChange={setMemoFilterMode}
onOpenActions={() => setNotesActionsOpen(true)}
onOpenNotebookPicker={() => setNotebookPickerOpen(true)}
onMemoPress={handleMemoPress}
onMemoLongPress={(memo) => selectSingleMemo(memo.id)}
onLoadMore={() => {
const query = searchActive ? searchQuery : memosQuery;
if (query.hasNextPage && !query.isFetchingNextPage) {
void query.fetchNextPage();
}
}}
onRefresh={refresh}
onSearchTextChange={(value) => {
setSearchText(value);
clearSelection();
}}
onSetMemoView={(nextMemoView) => nextMemoView === "trash" ? showTrash() : showAllNotes()}
searchText={searchText}
totalMemoCount={searchActive
? searchQuery.data?.pages[0]?.totalCount ?? searchResults.length
: memosQuery.data?.pages[0]?.totalCount ?? memos.length}
selectionMode={selectionMode}
selectedMemoIds={selectedMemoIds}
error={initialMirrorSyncError ?? notebooksQuery.error ?? (searchActive ? searchQuery.error : memosQuery.error)}
isError={Boolean(initialMirrorSyncError) || notebooksQuery.isError || (searchActive ? searchQuery.isError : memosQuery.isError)}
onRetry={retryInitialSync}
/>
) : null}
{/* Keep NotesView mounted under Me — swapping trees unmounts the whole list
ShadowNode graph in one commit and races Hermes HadesGC on iPadOS 26.5. */}
<NotesView
activeNotebook={activeNotebook}
initialSyncProgress={initialMirrorSyncProgress}
isLoading={notebooksQuery.isLoading || (searchActive ? searchQuery.isLoading : memosQuery.isLoading) || (isInitialMirrorStatusPending && visibleMemos.length === 0)}
isLoadingMore={searchActive ? searchQuery.isFetchingNextPage : memosQuery.isFetchingNextPage}
isRefreshing={isRefreshing}
memoFilterMode={memoFilterMode}
memoListDensity={memoListDensity}
memoView={memoView}
memos={visibleMemos}
notebooks={notebooks}
onCreate={openCreateMemo}
onClearSelection={clearSelection}
onFilterModeChange={setMemoFilterMode}
onOpenActions={() => setNotesActionsOpen(true)}
onOpenNotebookPicker={() => setNotebookPickerOpen(true)}
onMemoPress={handleMemoPress}
onMemoLongPress={(memo) => selectSingleMemo(memo.id)}
onLoadMore={() => {
const query = searchActive ? searchQuery : memosQuery;
if (query.hasNextPage && !query.isFetchingNextPage) {
void query.fetchNextPage();
}
}}
onRefresh={refresh}
onSearchTextChange={(value) => {
setSearchText(value);
clearSelection();
}}
onSetMemoView={(nextMemoView) => nextMemoView === "trash" ? showTrash() : showAllNotes()}
searchInputEnabled={searchInputEnabled && activeView === "notes" && !settingsOpening}
searchText={searchText}
totalMemoCount={searchActive
? searchQuery.data?.pages[0]?.totalCount ?? searchResults.length
: memosQuery.data?.pages[0]?.totalCount ?? memos.length}
selectionMode={selectionMode}
selectedMemoIds={selectedMemoIds}
error={initialMirrorSyncError ?? notebooksQuery.error ?? (searchActive ? searchQuery.error : memosQuery.error)}
isError={Boolean(initialMirrorSyncError) || notebooksQuery.isError || (searchActive ? searchQuery.isError : memosQuery.isError)}
onRetry={retryInitialSync}
/>
{activeView === "settings" ? (
<Modal
animationType="slide"
onRequestClose={closeSettings}
presentationStyle="fullScreen"
visible={activeView === "settings"}
>
<SettingsView
currentUser={session?.user ?? null}
onClose={closeSettings}
@@ -1084,7 +1180,7 @@ export const WorkspaceScreen = ({
onImageCompressionChange={handleImageCompressionChange}
onSignOut={signOut}
/>
) : null}
</Modal>
<MemoDetailModal
isDeleting={deleteMemoMutation.isPending}
@@ -1258,9 +1354,10 @@ export const WorkspaceScreen = ({
</Pressable>
<BottomNavItem
active={false}
icon={<UserRound color="#64748b" size={20} />}
disabled={settingsOpening}
icon={<UserRound color={settingsOpening ? "#cbd5e1" : "#64748b"} size={20} />}
label="我的"
onPress={openSettings}
onPress={() => void openSettings()}
/>
</View>
) : null}
@@ -1611,6 +1708,11 @@ const CreateMemoModal = ({
const [draftLoaded, setDraftLoaded] = useState(false);
const [dirty, setDirty] = useState(false);
const [editorReady, setEditorReady] = useState(false);
// Delay Dom TipTap WKWebView host — cold create on iPadOS 26.5 can SIGTRAP in
// WebProcessPool init when Dom mounts in the same transaction as the Modal.
const [editorHostReady, setEditorHostReady] = useState(false);
const [createTitleFocused, setCreateTitleFocused] = useState(false);
const [createTagsFocused, setCreateTagsFocused] = useState(false);
const [imageOperation, setImageOperation] = useState<"idle" | "creating" | "uploading">("idle");
const [resourceTarget, setResourceTarget] = useState<MobileResourceTarget | null>(null);
const targetNotebookId = notebookId || fallbackNotebookId;
@@ -1624,11 +1726,20 @@ const CreateMemoModal = ({
useEffect(() => {
if (!visible) {
setEditorHostReady(false);
return;
}
let active = true;
setDraftLoaded(false);
setEditorReady(false);
setEditorHostReady(false);
setCreateTitleFocused(false);
setCreateTagsFocused(false);
const hostTimer = setTimeout(() => {
if (active) {
setEditorHostReady(true);
}
}, 360);
if (initialDraft) {
const markdown = initialDraft.contentMarkdown;
contentMarkdownRef.current = markdown;
@@ -1642,6 +1753,7 @@ const CreateMemoModal = ({
setDraftLoaded(true);
return () => {
active = false;
clearTimeout(hostTimer);
};
}
void readMobileNewMemoDraft(dataScope).then((draft) => {
@@ -1664,6 +1776,7 @@ const CreateMemoModal = ({
});
return () => {
active = false;
clearTimeout(hostTimer);
};
}, [dataScope, fallbackNotebookId, initialDraft, visible]);
@@ -1889,6 +2002,17 @@ const CreateMemoModal = ({
return;
}
await flushEditor();
// Unmount Dom TipTap before the create Modal tears down (same ShadowNode race as edit).
setEditorHostReady(false);
setCreateTitleFocused(false);
setCreateTagsFocused(false);
setEditorReady(false);
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
await new Promise((resolve) => setTimeout(resolve, 280));
createMutation.mutate();
};
@@ -1938,9 +2062,9 @@ const CreateMemoModal = ({
if (target) setResourceTarget(target);
}, []);
const editorElement = useMemo(() => draftLoaded && baseUrl ? (
const editorElement = useMemo(() => draftLoaded && baseUrl && editorHostReady ? (
<LocalTiptapEditor
autoFocus
autoFocus={false}
baseUrl={baseUrl}
content={contentJsonRef.current}
dom={{
@@ -1974,7 +2098,7 @@ const CreateMemoModal = ({
locale={resolvedLocale}
theme={resolvedTheme}
/>
) : null, [baseUrl, draftLoaded, loadEditorResource, resolvedLocale, resolvedTheme, selectResource]);
) : null, [baseUrl, draftLoaded, editorHostReady, loadEditorResource, resolvedLocale, resolvedTheme, selectResource]);
return (
<Modal animationType="slide" onRequestClose={() => void requestClose()} presentationStyle="fullScreen" visible={visible}>
@@ -1999,36 +2123,75 @@ const CreateMemoModal = ({
</View>
<View style={styles.createMemoMain}>
<TextInput
autoCorrect
accessibilityLabel="笔记标题"
onChangeText={(value) => {
setTitle(value);
markDirty();
}}
placeholder={DEFAULT_MEMO_TITLE}
placeholderTextColor="#94a3b8"
style={styles.createMemoTitleInput}
value={title}
/>
{/* Lazy title/tags TextInputs — same iPadOS 26.5 Fabric measure crash as edit. */}
{createTitleFocused && editorReady ? (
<TextInput
autoCorrect
autoFocus
accessibilityLabel="笔记标题"
onBlur={() => setCreateTitleFocused(false)}
onChangeText={(value) => {
setTitle(value);
markDirty();
}}
placeholder={DEFAULT_MEMO_TITLE}
placeholderTextColor="#94a3b8"
style={styles.createMemoTitleInput}
value={title}
/>
) : (
<Pressable
accessibilityLabel="笔记标题"
accessibilityRole="button"
onPress={() => {
if (editorReady) {
setCreateTitleFocused(true);
}
}}
style={styles.createMemoTitleInput}
>
<Text numberOfLines={1} style={{ color: title ? "#0f172a" : "#94a3b8", fontSize: 22, fontWeight: "700" }}>
{title || DEFAULT_MEMO_TITLE}
</Text>
</Pressable>
)}
<View style={styles.createMemoMetaRow}>
<Pressable accessibilityLabel="所在笔记本" accessibilityRole="button" onPress={() => setNotebookPickerOpen(true)} style={styles.createMemoNotebookButton}>
<Text numberOfLines={1} style={styles.createMemoNotebookText}>{selectedNotebookName}</Text>
<ChevronDown color="#64748b" size={14} />
</Pressable>
<TextInput
accessibilityLabel="笔记标签"
autoCorrect
onChangeText={(value) => {
setTagsText(value);
markDirty();
}}
placeholder="添加标签,用逗号分隔"
placeholderTextColor="#94a3b8"
style={styles.createMemoTagsInput}
value={tagsText}
/>
{createTagsFocused && editorReady ? (
<TextInput
accessibilityLabel="笔记标签"
autoCorrect
autoFocus
onBlur={() => setCreateTagsFocused(false)}
onChangeText={(value) => {
setTagsText(value);
markDirty();
}}
placeholder="添加标签,用逗号分隔"
placeholderTextColor="#94a3b8"
style={styles.createMemoTagsInput}
value={tagsText}
/>
) : (
<Pressable
accessibilityLabel="添加标签,用逗号分隔"
accessibilityRole="button"
onPress={() => {
if (editorReady) {
setCreateTagsFocused(true);
}
}}
style={[styles.createMemoTagsInput, { justifyContent: "center" }]}
>
<Text numberOfLines={1} style={{ color: tagsText ? "#0f172a" : "#94a3b8" }}>
{tagsText || "添加标签,用逗号分隔"}
</Text>
</Pressable>
)}
</View>
<View style={styles.createMemoEditorFrame}>
@@ -2258,6 +2421,7 @@ const RichEditorModal = ({
const flushResolverRef = useRef<(() => void) | null>(null);
const savingRef = useRef(false);
const uploadingRef = useRef(false);
const closingRef = useRef(false);
const [title, setTitle] = useState(resolveEditableMemoTitle(restoredDraft?.title ?? memo?.title));
const [tagsText, setTagsText] = useState(restoredDraft?.tagsText ?? memo?.tags.join(", ") ?? "");
const [notebookId, setNotebookId] = useState(restoredDraft?.notebookId ?? memo?.notebookId ?? "");
@@ -2267,11 +2431,30 @@ const RichEditorModal = ({
const [dirty, setDirty] = useState(Boolean(restoredDraft));
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [closing, setClosing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [startupMs, setStartupMs] = useState<number | null>(null);
const [resourceTarget, setResourceTarget] = useState<MobileResourceTarget | null>(null);
// Stagger TextInput chrome and Dom TipTap so Fabric never measures title/tags
// TextInputs in the same commit as WKWebView host creation (iPadOS 26.5 SIGSEGV).
const [chromeReady, setChromeReady] = useState(false);
const [editorHostReady, setEditorHostReady] = useState(false);
const [titleFocused, setTitleFocused] = useState(false);
const [tagsFocused, setTagsFocused] = useState(false);
const notebookLabel = notebooks.find((notebook) => notebook.id === notebookId)?.name ?? "未分类";
const saveLabel = error ? "保存失败" : saving ? "保存中" : uploading ? "上传中" : dirty ? (draftRestored ? "本地草稿" : "未保存") : ready ? "已保存" : "加载中";
const saveLabel = closing
? "正在退出"
: error
? "保存失败"
: saving
? "保存中"
: uploading
? "上传中"
: dirty
? (draftRestored ? "本地草稿" : "未保存")
: ready
? "已保存"
: "加载中";
const titleRef = useRef(title);
const tagsTextRef = useRef(tagsText);
const notebookIdRef = useRef(notebookId);
@@ -2279,11 +2462,19 @@ const RichEditorModal = ({
tagsTextRef.current = tagsText;
notebookIdRef.current = notebookId;
useEffect(() => () => {
if (initialFocusTimerRef.current !== null) {
clearTimeout(initialFocusTimerRef.current);
initialFocusTimerRef.current = null;
}
useEffect(() => {
// Phase 1: header chrome only (Pressable labels, no TextInput).
// Phase 2: Dom TipTap host after layout has settled without native text fields.
const chromeTimer = setTimeout(() => setChromeReady(true), 120);
const editorTimer = setTimeout(() => setEditorHostReady(true), 360);
return () => {
clearTimeout(chromeTimer);
clearTimeout(editorTimer);
if (initialFocusTimerRef.current !== null) {
clearTimeout(initialFocusTimerRef.current);
initialFocusTimerRef.current = null;
}
};
}, []);
const persistDraft = async (contentJson: TiptapDoc) => {
@@ -2372,17 +2563,44 @@ const RichEditorModal = ({
};
const requestClose = async () => {
if (savingRef.current || uploadingRef.current) {
if (savingRef.current || uploadingRef.current || closingRef.current) {
return;
}
closingRef.current = true;
setClosing(true);
if (initialFocusTimerRef.current !== null) {
clearTimeout(initialFocusTimerRef.current);
initialFocusTimerRef.current = null;
}
await flushEditor();
const savedMemo = await save();
if (savedMemo) {
try {
await flushEditor();
const savedMemo = await save();
if (!savedMemo && dirtyRef.current) {
// Hard save failure: stay so the user can retry (draft is still local).
closingRef.current = false;
setClosing(false);
return;
}
// 17:06 crash: unmounting Dom TipTap + remounting NotesView in one commit
// double-frees ShadowNodeFamily under Fabric (SIGBUS on JS thread).
// Drop the editor host first, settle, then leave the screen.
setTitleFocused(false);
setTagsFocused(false);
setEditorHostReady(false);
setChromeReady(false);
setReady(false);
setNotebookPickerOpen(false);
setResourceTarget(null);
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
await new Promise((resolve) => setTimeout(resolve, 320));
onClose();
} catch {
closingRef.current = false;
setClosing(false);
}
};
@@ -2492,9 +2710,9 @@ const RichEditorModal = ({
}, []);
const editorElement = useMemo(
() => memo && baseUrl ? (
() => memo && baseUrl && editorHostReady ? (
<LocalTiptapEditor
autoFocus
autoFocus={false}
baseUrl={baseUrl}
content={contentJsonRef.current}
dom={{
@@ -2516,27 +2734,31 @@ const RichEditorModal = ({
if (initialFocusTimerRef.current !== null) {
clearTimeout(initialFocusTimerRef.current);
}
// Focus body only after TextInputs have settled; never autoFocus Dom on mount.
initialFocusTimerRef.current = setTimeout(() => {
initialFocusTimerRef.current = null;
editorRef.current?.focusEnd();
if (Platform.OS === "android") {
showEdgeEverKeyboard();
}
}, 180);
}, 280);
}}
ref={editorRef}
locale={resolvedLocale}
theme={resolvedTheme}
/>
) : null,
[baseUrl, loadEditorResource, memo?.id, resolvedLocale, resolvedTheme, selectResource]
[baseUrl, editorHostReady, loadEditorResource, memo?.id, resolvedLocale, resolvedTheme, selectResource]
);
useEffect(() => {
if (!memo || !dirty) {
if (!memo || !dirty || closingRef.current) {
return;
}
const timeout = setTimeout(() => {
if (closingRef.current) {
return;
}
void writeMobileMemoDraft({
memoId: memo.id,
expectedRevision: memo.revision,
@@ -2551,10 +2773,13 @@ const RichEditorModal = ({
}, [dirty, memo, notebookId, tagsText, title]);
useEffect(() => {
if (!memo || !dirty || !ready || savingRef.current || uploadingRef.current) {
if (!memo || !dirty || !ready || savingRef.current || uploadingRef.current || closingRef.current) {
return;
}
const timeout = setTimeout(() => {
if (closingRef.current) {
return;
}
void flushEditor().then(save);
}, 1200);
return () => clearTimeout(timeout);
@@ -2563,53 +2788,101 @@ const RichEditorModal = ({
return (
<SafeAreaView style={styles.richEditorSafeArea}>
<View style={styles.createMemoHeader}>
<Pressable accessibilityLabel="返回" accessibilityRole="button" disabled={saving || uploading} onPress={() => void requestClose()} style={styles.createMemoBackButton}>
<ChevronLeft color={saving || uploading ? "#cbd5e1" : "#0f172a"} size={30} />
<Pressable accessibilityLabel="返回" accessibilityRole="button" disabled={saving || uploading || closing} onPress={() => void requestClose()} style={styles.createMemoBackButton}>
<ChevronLeft color={saving || uploading || closing ? "#cbd5e1" : "#0f172a"} size={30} />
</Pressable>
<View style={styles.createMemoHeaderActions}>
<Text numberOfLines={1} style={[styles.createMemoStatus, styles.richEditorHeaderStatus, (saving || uploading || dirty) && styles.createMemoStatusActive, error && styles.richEditorStatusError]}>{saveLabel}</Text>
<Text numberOfLines={1} style={[styles.createMemoStatus, styles.richEditorHeaderStatus, (saving || uploading || dirty || closing) && styles.createMemoStatusActive, error && styles.richEditorStatusError]}>{saveLabel}</Text>
<Pressable
accessibilityLabel="完成编辑"
accessibilityRole="button"
disabled={saving || uploading || !ready}
disabled={saving || uploading || closing || !ready}
onPress={() => void requestClose()}
style={[styles.createMemoDoneButton, (saving || uploading || !ready) && styles.createMemoDoneButtonDisabled]}
style={[styles.createMemoDoneButton, (saving || uploading || closing || !ready) && styles.createMemoDoneButtonDisabled]}
>
{saving ? <ActivityIndicator color="#64748b" size="small" /> : <Text style={[styles.createMemoDoneText, (uploading || !ready) && styles.createMemoDoneTextDisabled]}>完成</Text>}
{saving || closing ? <ActivityIndicator color="#64748b" size="small" /> : <Text style={[styles.createMemoDoneText, (uploading || !ready) && styles.createMemoDoneTextDisabled]}>完成</Text>}
</Pressable>
</View>
</View>
{memo && baseUrl ? (
<View style={styles.richEditorContainer}>
<TextInput
onChangeText={(value) => {
setTitle(value);
dirtyRef.current = true;
setDirty(true);
}}
placeholder={DEFAULT_MEMO_TITLE}
placeholderTextColor="#94a3b8"
style={styles.createMemoTitleInput}
value={title}
/>
{chromeReady ? (
// Only mount title TextInput after editor is ready AND user taps —
// cold open of RichEditorModal must stay TextInput-free on iPadOS 26.5.
titleFocused && ready ? (
<TextInput
autoFocus
onBlur={() => setTitleFocused(false)}
onChangeText={(value) => {
setTitle(value);
dirtyRef.current = true;
setDirty(true);
}}
placeholder={DEFAULT_MEMO_TITLE}
placeholderTextColor="#94a3b8"
style={styles.createMemoTitleInput}
value={title}
/>
) : (
<Pressable
accessibilityLabel="笔记标题"
accessibilityRole="button"
onPress={() => {
if (ready) {
setTitleFocused(true);
}
}}
style={styles.createMemoTitleInput}
>
<Text numberOfLines={1} style={{ color: title ? "#0f172a" : "#94a3b8", fontSize: 22, fontWeight: "700" }}>
{title || DEFAULT_MEMO_TITLE}
</Text>
</Pressable>
)
) : (
<View style={styles.createMemoTitleInput} />
)}
<View style={[styles.createMemoMetaRow, styles.richStandaloneMetaRow]}>
<Pressable accessibilityLabel="所在笔记本" accessibilityRole="button" onPress={() => setNotebookPickerOpen(true)} style={styles.createMemoNotebookButton}>
<Text numberOfLines={1} style={styles.createMemoNotebookText}>{notebookLabel}</Text>
<ChevronDown color="#64748b" size={14} />
</Pressable>
<TextInput
autoCorrect
onChangeText={(value) => {
setTagsText(value);
dirtyRef.current = true;
setDirty(true);
}}
placeholder="添加标签,用逗号分隔"
placeholderTextColor="#94a3b8"
style={[styles.createMemoTagsInput, styles.richStandaloneTagsInput]}
value={tagsText}
/>
{chromeReady ? (
tagsFocused && ready ? (
<TextInput
autoCorrect
autoFocus
onBlur={() => setTagsFocused(false)}
onChangeText={(value) => {
setTagsText(value);
dirtyRef.current = true;
setDirty(true);
}}
placeholder="添加标签,用逗号分隔"
placeholderTextColor="#94a3b8"
style={[styles.createMemoTagsInput, styles.richStandaloneTagsInput]}
value={tagsText}
/>
) : (
<Pressable
accessibilityLabel="添加标签,用逗号分隔"
accessibilityRole="button"
onPress={() => {
if (ready) {
setTagsFocused(true);
}
}}
style={[styles.createMemoTagsInput, styles.richStandaloneTagsInput, { justifyContent: "center" }]}
>
<Text numberOfLines={1} style={{ color: tagsText ? "#0f172a" : "#94a3b8" }}>
{tagsText || "添加标签,用逗号分隔"}
</Text>
</Pressable>
)
) : (
<View style={[styles.createMemoTagsInput, styles.richStandaloneTagsInput]} />
)}
</View>
{draftRestored ? <Text style={styles.richEditorDraftNotice}>已恢复上次未完成的本地草稿</Text> : null}
<View style={styles.richEditorFrame}>
@@ -3037,8 +3310,26 @@ const ActionButton = ({
</Pressable>
);
const BottomNavItem = ({ active = false, icon, label, onPress }: { active?: boolean; icon: ReactNode; label: string; onPress: () => void }) => (
<Pressable accessibilityRole="button" accessibilityState={{ selected: active }} onPress={onPress} style={styles.bottomNavItem}>
const BottomNavItem = ({
active = false,
disabled = false,
icon,
label,
onPress,
}: {
active?: boolean;
disabled?: boolean;
icon: ReactNode;
label: string;
onPress: () => void;
}) => (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled, selected: active }}
disabled={disabled}
onPress={onPress}
style={[styles.bottomNavItem, disabled && styles.buttonDisabled]}
>
{icon}
<Text style={[styles.bottomNavText, active && styles.bottomNavTextActive]}>{label}</Text>
</Pressable>
+14 -6
View File
@@ -77,7 +77,7 @@
},
"apps/desktop": {
"name": "@edgeever/desktop",
"version": "1.11.1",
"version": "1.12.3",
"dependencies": {
"electron-updater": "^6.6.2",
},
@@ -129,6 +129,7 @@
"expo-sqlite": "~57.0.1",
"expo-status-bar": "latest",
"expo-updates": "~57.0.8",
"markdown-it": "10.0.0",
"mermaid": "^11.16.0",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -145,6 +146,7 @@
"semver": "^7.8.5",
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/react": "latest",
"@types/semver": "^7.7.1",
"babel-preset-expo": "latest",
@@ -727,7 +729,7 @@
"@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="],
"@expo/ui": ["@expo/ui@57.0.8", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@babel/core": "*", "expo": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-worklets": "*" }, "optionalPeers": ["@babel/core", "react-dom", "react-native-worklets"] }, "sha512-aR17CRM45k4JyFahX4Xn2b6ti3aiddC4SAzvsm3iUCVacQA6tGmClu012rYNeVXhJ96csX2o7EEymfzYqgJNxw=="],
"@expo/ui": ["@expo/ui@57.0.9", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@babel/core": "*", "expo": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-worklets": "*" }, "optionalPeers": ["@babel/core", "react-dom", "react-native-worklets"] }, "sha512-VIxvk5ncgylBj2vrIP1iLaMc3XmYucKbf0hIcg3qx9l2anB9JzaYnH7cvVgNU3RfwV8R9m/tA7lX9BP7D8uMQw=="],
"@expo/vector-icons": ["@expo/vector-icons@15.1.1", "", { "peerDependencies": { "expo-font": ">=14.0.4", "react": "*", "react-native": "*" } }, "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw=="],
@@ -1363,10 +1365,16 @@
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
"@types/marked": ["@types/marked@6.0.0", "", { "dependencies": { "marked": "*" } }, "sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"@types/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="],
"@types/minimatch": ["@types/minimatch@3.0.5", "", {}, "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="],
@@ -1477,7 +1485,7 @@
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
@@ -2115,7 +2123,7 @@
"expo-modules-jsi": ["expo-modules-jsi@57.0.3", "", { "peerDependencies": { "react-native": "*" } }, "sha512-B+iXJCC5OIXjFKkLLSY2DZ8BGS+hC3PBTrALpV43pHqhXqqZrRH3uTEylZgsoKQO8N8tjmjAua2ucTFHtr1o0w=="],
"expo-router": ["expo-router@57.0.9", "", { "dependencies": { "@expo/log-box": "^57.0.2", "@expo/metro-runtime": "^57.0.8", "@expo/schema-utils": "^57.0.2", "@expo/ui": "^57.0.8", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^57.0.1", "expo-server": "^57.0.1", "expo-symbols": "^57.0.1", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.26.0", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "standard-navigation": "^0.0.5", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^57.0.8", "expo-linking": "^57.0.4", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-jPBwHmKmBfzCLUuKgFf+I/UhFkxvS3J2WF5MQKcuupC8XzQPBAqdzbxmcPIDY3VIVabHw7WnhwzsROWA74t1LA=="],
"expo-router": ["expo-router@57.0.10", "", { "dependencies": { "@expo/log-box": "^57.0.2", "@expo/metro-runtime": "^57.0.8", "@expo/schema-utils": "^57.0.2", "@expo/ui": "^57.0.9", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^57.0.1", "expo-server": "^57.0.1", "expo-symbols": "^57.0.1", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.26.0", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "standard-navigation": "^0.0.5", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^57.0.9", "expo-linking": "^57.0.5", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-E6Cjudl4wQ86KdEHqLGhBmfOFRdtzXTtRzEEDmqOvqtkAc9C637u0us7CGKkkee9m+kwuHqfhn779ww85rn/Pg=="],
"expo-secure-store": ["expo-secure-store@57.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-tLa1VmSadOq19mA/dwkl99RbHyjLE0T1qqBYMY3/OsguZTI+rlrDy/DDJjupqlVtmr95hD7o1pYqx5aL+B4YMA=="],
@@ -4301,6 +4309,8 @@
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"jwa/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"jws/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -4309,8 +4319,6 @@
"log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
"markdown-it/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"markdown-it/entities": ["entities@2.0.3", "", {}, "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
+1 -1
View File
@@ -38,7 +38,7 @@ The App follows the mobile PWA for high-frequency layouts and interactions, but
| Memo list, filters, sort, pin, and batch operations | Implemented |
| Local-cache search | Implemented |
| Local TipTap create/edit and autosave flow | Implemented |
| Note detail body preview via read-only TipTap (same schema as edit) | Implemented |
| Note detail body preview via static HTML WebView markdown (TipTap reserved for edit; avoids iPadOS 26.5 Fabric text crashes) | Implemented |
| Attachments, image compression, and resource library | Implemented |
| Tag management | Implemented |
| Revision history and restore | Implemented |
+32 -6
View File
@@ -65,10 +65,16 @@ describe("mobile app scope", () => {
expect(memoDetailSource).toContain("onResolveSyncConflict");
});
test("renders note detail body with the shared read-only TipTap viewer", () => {
expect(memoDetailSource).toContain('mode="viewer"');
expect(memoDetailSource).toContain("LocalTiptapEditor");
expect(memoDetailSource).not.toContain("react-native-markdown-display");
test("renders note detail markdown via static HTML WebView (not Dom TipTap / Fabric Text)", () => {
// Full markdown (tables/headings/bold) via markdown-it → HTML WebView with JS off.
// Avoids Fabric AttributedString SIGTRAP and Dom TipTap media TCC on iPadOS 26.5.
expect(memoDetailSource).toContain("resolveMemoContentMarkdown");
expect(memoDetailSource).toContain("buildMemoDetailHtml");
expect(memoDetailSource).toContain("react-native-webview");
expect(memoDetailSource).toContain("javaScriptEnabled={false}");
expect(memoDetailSource).toContain("SAFE_DOM_WEBVIEW_PROPS");
expect(memoDetailSource).not.toContain('mode="viewer"');
expect(memoDetailSource).not.toContain("LocalTiptapEditor");
});
test("keeps memo list free of Reanimated layout transitions that crash Fabric text layout", () => {
@@ -77,20 +83,40 @@ describe("mobile app scope", () => {
expect(notesViewSource).not.toContain("LinearTransition");
expect(notesViewSource).not.toContain("entering=");
expect(notesViewSource).not.toContain("layout={LinearTransition");
expect(notesViewSource).not.toContain("useSharedValue");
expect(notesViewSource).not.toContain("useAnimatedStyle");
expect(notesViewSource).not.toContain("react-native-reanimated");
});
test("hardens DOM/WebView hosts against media capture probes during App Review", () => {
expect(mobileDomSource).toContain('mediaCapturePermissionGrantType: "deny"');
expect(mobileDomSource).toContain("mediaPlaybackRequiresUserAction: true");
// Detail body is native Markdown; Dom hosts remain on edit/create/mermaid paths.
expect(workspaceSource).toContain("SAFE_DOM_WEBVIEW_PROPS");
expect(memoDetailSource).toContain("SAFE_DOM_WEBVIEW_PROPS");
});
test("declares iOS privacy strings and full-screen phone-on-iPad presentation", () => {
const infoPlist = appJson.expo.ios?.infoPlist ?? {};
expect(appJson.expo.ios?.supportsTablet).toBe(false);
expect(appJson.expo.ios?.supportsTablet).toBe(true);
expect(infoPlist.UIRequiresFullScreen).toBe(true);
expect(String(infoPlist.NSMicrophoneUsageDescription ?? "")).toMatch(/microphone/i);
expect(String(infoPlist.NSCameraUsageDescription ?? "")).toMatch(/camera/i);
});
test("keeps New Architecture off to avoid iPadOS 26.5 Fabric text layout crashes", () => {
expect((appJson.expo as { newArchEnabled?: boolean }).newArchEnabled).toBe(false);
});
test("keeps list search TextInput unmounted while a note is open", () => {
expect(notesViewSource).toContain("searchInputEnabled");
expect(notesViewSource).toContain("showSearchInput");
expect(workspaceSource).toContain("searchInputEnabled={searchInputEnabled");
expect(workspaceSource).toContain("setSearchInputEnabled(false)");
});
test("keeps pull-to-refresh off memo-detail fetches that crash iPadOS RefreshControl", () => {
expect(workspaceSource).not.toMatch(/isRefreshing\s*=\s*[^;]*memoDetailQuery\.isFetching/);
expect(workspaceSource).toMatch(/isRefreshing\s*=\s*searchActive/);
expect(workspaceSource).toContain("isRefetching");
});
});