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:
1 parent
e46e66b46e
commit
494cce5e84
10 files changed
+850
-665
No files matched your search
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
/** 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>`;
|
||||
};
|
||||
@@ -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%",
|
||||
},
|
||||
});
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user