feat(mobile): align standalone editor and protected images

This commit is contained in:
tianma-if committed 2026-07-19 07:06:18 +08:00
1 parent f94cd87e12
commit a6d3cc50fd
2 files changed
+256 -310

No files matched your search

@@ -6,7 +6,7 @@ import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import type { TiptapDoc } from "@edgeever/shared";
import { useDOMImperativeHandle, type DOMImperativeFactory, type DOMProps } from "expo/dom";
import { useCallback, useEffect, useRef, type ReactNode, type Ref } from "react";
import { useCallback, useEffect, useMemo, useRef, type ReactNode, type Ref } from "react";
type EditorDoc = TiptapDoc;
@@ -17,10 +17,7 @@ type PickedImage = {
export interface LocalTiptapEditorRef extends DOMImperativeFactory {
flush: () => void;
focus: () => void;
focusEnd: () => void;
replaceAll: DOMImperativeFactory[string];
search: DOMImperativeFactory[string];
}
type LocalTiptapEditorProps = {
@@ -28,9 +25,9 @@ type LocalTiptapEditorProps = {
content: EditorDoc;
dom?: DOMProps;
onChange: (content: EditorDoc) => Promise<void>;
onLoadResource: (source: string) => Promise<string | null>;
onPickImage: () => Promise<PickedImage | null>;
onReady: (startupMs: number) => Promise<void>;
onSearchState?: (count: number, activeIndex: number) => Promise<void>;
ref: Ref<LocalTiptapEditorRef>;
locale: "zh-CN" | "en-US";
theme: "light" | "dark";
@@ -42,22 +39,23 @@ export default function LocalTiptapEditor(props: LocalTiptapEditorProps) {
const startedAtRef = useRef(performance.now());
const changeTimerRef = useRef<number | null>(null);
const onChangeRef = useRef(props.onChange);
const onLoadResourceRef = useRef(props.onLoadResource);
const onPickImageRef = useRef(props.onPickImage);
const onReadyRef = useRef(props.onReady);
const onSearchStateRef = useRef(props.onSearchState);
onChangeRef.current = props.onChange;
onLoadResourceRef.current = props.onLoadResource;
onPickImageRef.current = props.onPickImage;
onReadyRef.current = props.onReady;
onSearchStateRef.current = props.onSearchState;
const protectedImageExtension = useMemo(
() => createProtectedImageExtension(props.baseUrl, (source) => onLoadResourceRef.current(source)),
[props.baseUrl]
);
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({
allowBase64: false,
inline: false,
}),
protectedImageExtension,
Placeholder.configure({
placeholder: props.locale === "en-US" ? "Start writing..." : "开始记录...",
}),
@@ -95,80 +93,13 @@ export default function LocalTiptapEditor(props: LocalTiptapEditorProps) {
void onChangeRef.current(normalizeImageSources(editor.getJSON() as EditorDoc, props.baseUrl));
}, [editor, props.baseUrl]);
const findSearchMatches = useCallback((query: string) => {
const needle = query.trim().toLocaleLowerCase();
if (!editor || editor.isDestroyed || needle.length === 0) {
return [];
}
const characters: Array<{ char: string; pos: number }> = [];
let previousTextEnd: number | null = null;
editor.state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) {
return;
}
if (previousTextEnd !== null && pos > previousTextEnd) {
characters.push({ char: "\u0000", pos: -1 });
}
for (let index = 0; index < node.text.length; index += 1) {
characters.push({ char: node.text[index] ?? "", pos: pos + index });
}
previousTextEnd = pos + node.text.length;
});
const haystack = characters.map((item) => item.char).join("").toLocaleLowerCase();
const matches: Array<{ from: number; to: number }> = [];
let index = haystack.indexOf(needle);
while (index !== -1) {
const start = characters[index];
const end = characters[index + needle.length - 1];
if (start && end && start.pos >= 0 && end.pos >= 0) {
matches.push({ from: start.pos, to: end.pos + 1 });
}
index = haystack.indexOf(needle, index + needle.length);
}
return matches;
}, [editor]);
const search = useCallback((query: string, requestedIndex: number) => {
const matches = findSearchMatches(query);
const activeIndex = matches.length > 0
? ((Math.trunc(requestedIndex) % matches.length) + matches.length) % matches.length
: 0;
const match = matches[activeIndex];
if (match && editor && !editor.isDestroyed) {
editor.chain().setTextSelection(match).scrollIntoView().run();
}
void onSearchStateRef.current?.(matches.length, activeIndex);
}, [editor, findSearchMatches]);
const replaceAll = useCallback((query: string, replacement: string) => {
const matches = findSearchMatches(query);
if (!editor || editor.isDestroyed || matches.length === 0) {
void onSearchStateRef.current?.(0, 0);
return;
}
editor.chain().command(({ tr, dispatch }) => {
for (const match of [...matches].reverse()) {
tr.insertText(replacement, match.from, match.to);
}
dispatch?.(tr);
return true;
}).run();
window.requestAnimationFrame(() => search(query, 0));
}, [editor, findSearchMatches, search]);
useDOMImperativeHandle(
props.ref,
() => ({
flush,
focus: () => editor?.commands.focus(),
focusEnd: () => editor?.commands.focus("end"),
replaceAll: (...args: Parameters<DOMImperativeFactory[string]>) => replaceAll(String(args[0] ?? ""), String(args[1] ?? "")),
search: (...args: Parameters<DOMImperativeFactory[string]>) => search(String(args[0] ?? ""), Number(args[1] ?? 0)),
}),
[editor, flush, replaceAll, search]
[editor, flush]
);
useEffect(() => {
@@ -313,6 +244,12 @@ const normalizeImageSources = (doc: EditorDoc, baseUrl: string) => {
return mapImageSources(doc, (source) => source.startsWith(`${normalizedBaseUrl}/`) ? source.slice(normalizedBaseUrl.length) : source);
};
const normalizeProtectedResourceSource = (source: string, baseUrl: string) => {
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
const relativeSource = source.startsWith(`${normalizedBaseUrl}/`) ? source.slice(normalizedBaseUrl.length) : source;
return relativeSource.startsWith("/api/v1/resources/") ? relativeSource : null;
};
const resolveUrl = (source: string, baseUrl: string) => {
if (!source.startsWith("/")) {
return source;
@@ -320,6 +257,70 @@ const resolveUrl = (source: string, baseUrl: string) => {
return `${baseUrl.replace(/\/+$/, "")}${source}`;
};
const createProtectedImageExtension = (baseUrl: string, loadResource: (source: string) => Promise<string | null>) => Image.extend({
addNodeView() {
return ({ node }) => {
const image = document.createElement("img");
const imageType = node.type;
let requestId = 0;
const clearRequest = () => {
requestId += 1;
};
const renderNode = (attributes: Record<string, unknown>) => {
clearRequest();
const source = String(attributes.src ?? "");
const alt = String(attributes.alt ?? "");
const title = String(attributes.title ?? "");
image.alt = alt;
if (title) {
image.title = title;
} else {
image.removeAttribute("title");
}
const protectedSource = normalizeProtectedResourceSource(source, baseUrl);
if (!protectedSource) {
image.src = resolveUrl(source, baseUrl);
return;
}
image.removeAttribute("src");
const activeRequestId = requestId;
void loadResource(protectedSource)
.then((dataUrl) => {
if (activeRequestId === requestId) {
image.src = dataUrl ?? resolveUrl(source, baseUrl);
}
})
.catch(() => {
if (activeRequestId === requestId) {
image.src = resolveUrl(source, baseUrl);
}
});
};
renderNode(node.attrs);
return {
dom: image,
update: (updatedNode) => {
if (updatedNode.type !== imageType) {
return false;
}
renderNode(updatedNode.attrs);
return true;
},
destroy: clearRequest,
};
};
},
}).configure({
allowBase64: false,
inline: false,
});
const getEditorStyles = (theme: "light" | "dark") => `
:root { color-scheme: ${theme}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
* { box-sizing: border-box; }
+176 -231
View File
@@ -65,6 +65,7 @@ import {
type AppStateStatus,
FlatList,
Image as RNImage,
type ImageStyle,
InteractionManager,
Linking,
Modal,
@@ -72,12 +73,14 @@ import {
RefreshControl,
ScrollView,
StyleSheet,
type StyleProp,
Switch,
View,
} from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Alert, Pressable, Text, TextInput } from "../components/LocalizedText";
import Markdown from "react-native-markdown-display";
import Markdown, { type RenderRules } from "react-native-markdown-display";
import { SvgXml } from "react-native-svg";
import { buildRevisionDiffRows, createExcerpt, docToMarkdown, docToText, getNotebookDescendantIds, markdownToDoc, type ApiToken, type AuthUser, type MemoDetail, type MemoRevision, type MemoSummary, type Notebook, type ResourceListItem, type RevisionDiffRow, type TagSummary, type TiptapDoc } from "@edgeever/shared";
import { clearMobileMemoDraft, clearMobileNewMemoDraft, readMobileMemoDraft, readMobileNewMemoDraft, writeMobileMemoDraft, writeMobileNewMemoDraft } from "../lib/mobile-drafts";
import {
@@ -1231,15 +1234,9 @@ export const WorkspaceScreen = () => {
{richEditingMemo ? <RichEditorModal
baseUrl={session?.baseUrl ?? ""}
imageCompressionEnabled={imageCompressionEnabled}
isDeleting={deleteMemoMutation.isPending}
memo={richEditingMemo}
notebooks={notebooks}
onClose={closeRichEditor}
onDelete={handleDeleteMemo}
onRestored={(memo) => {
setRichEditingMemo(null);
setSelectedMemoId(memo.id);
}}
updateMutation={localUpdateMemoMutation}
/> : null}
{editorRuntimeWarm ? (
@@ -2274,6 +2271,7 @@ const CreateMemoModal = ({
const { resolvedTheme } = useMobileTheme();
const fallbackNotebookId = activeNotebookId !== ALL_NOTES_ID ? activeNotebookId : notebooks[0]?.id ?? "";
const editorRef = useRef<LocalTiptapEditorRef>(null);
const resourceDataUrlCacheRef = useRef(new Map<string, Promise<string | null>>());
const contentJsonRef = useRef<TiptapDoc>(markdownToDoc(""));
const contentMarkdownRef = useRef("");
const dirtyRef = useRef(false);
@@ -2565,6 +2563,19 @@ const CreateMemoModal = ({
]);
};
const loadEditorResource = useCallback((source: string) => {
if (!client) {
return Promise.resolve(null);
}
const cached = resourceDataUrlCacheRef.current.get(source);
if (cached) {
return cached;
}
const pending = client.getResourceBlob(source).then(blobToDataUrl).catch(() => null);
resourceDataUrlCacheRef.current.set(source, pending);
return pending;
}, [client]);
const editorElement = useMemo(() => draftLoaded && baseUrl ? (
<LocalTiptapEditor
baseUrl={baseUrl}
@@ -2585,6 +2596,7 @@ const CreateMemoModal = ({
flushResolverRef.current?.();
flushResolverRef.current = null;
}}
onLoadResource={loadEditorResource}
onPickImage={pickAndUploadImage}
onReady={async (elapsedMs) => {
setEditorReady(true);
@@ -2594,7 +2606,7 @@ const CreateMemoModal = ({
locale={resolvedLocale}
theme={resolvedTheme}
/>
) : null, [baseUrl, draftLoaded, resolvedLocale, resolvedTheme]);
) : null, [baseUrl, draftLoaded, loadEditorResource, resolvedLocale, resolvedTheme]);
return (
<Modal animationType="none" onRequestClose={() => void requestClose()} presentationStyle="fullScreen" visible={visible}>
@@ -4071,6 +4083,7 @@ const ResourceCard = ({
onPreview: () => void;
resource: ResourceListItem;
}) => {
const { session } = useSession();
const source = resource.memoDeleted ? "已删除笔记" : resource.memoTitle || resource.memoExcerpt || resource.memoId;
const isImage = resource.kind === "image";
const localePreference = useMobileLocalePreference();
@@ -4079,7 +4092,11 @@ const ResourceCard = ({
<Pressable onPress={isImage ? onPreview : onOpen} style={layout === "grid" ? styles.resourceGridCard : styles.resourceCard}>
<View style={layout === "grid" ? styles.resourceGridThumb : styles.resourceThumb}>
{isImage ? (
<RNImage source={{ uri: resource.url }} style={styles.resourceImage} />
<AuthenticatedResourceImage
alt={resource.filename || "图片资源"}
source={getAuthenticatedResourceSource(resource.url, session)}
style={styles.resourceImage}
/>
) : (
<View style={styles.resourceFileIcon}>{getResourceIcon(resource)}</View>
)}
@@ -4134,8 +4151,10 @@ const ImagePreviewModal = ({
resource: ResourceListItem | null;
resourceCount: number;
resourceIndex: number;
}) => (
<Modal animationType="fade" transparent visible={Boolean(resource)} onRequestClose={onClose}>
}) => {
const { session } = useSession();
return <Modal animationType="fade" transparent visible={Boolean(resource)} onRequestClose={onClose}>
<View style={styles.previewBackdrop}>
<View style={styles.previewHeader}>
<Text numberOfLines={1} style={styles.previewTitle}>
@@ -4150,7 +4169,14 @@ const ImagePreviewModal = ({
<X color="#0f172a" size={20} />
</IconButton>
</View>
{resource ? <RNImage resizeMode="contain" source={{ uri: resource.url }} style={styles.previewImage} /> : null}
{resource ? (
<AuthenticatedResourceImage
alt={resource.filename || "图片预览"}
resizeMode="contain"
source={getAuthenticatedResourceSource(resource.url, session)}
style={styles.previewImage}
/>
) : null}
{resourceCount > 1 ? (
<View style={styles.previewNavRow}>
<Pressable accessibilityLabel="上一张" accessibilityRole="button" onPress={onPrevious} style={styles.previewNavButton}>
@@ -4168,8 +4194,8 @@ const ImagePreviewModal = ({
</Pressable>
) : null}
</View>
</Modal>
);
</Modal>;
};
const RevisionHistoryModal = ({
memo,
@@ -4349,6 +4375,96 @@ const RevisionDiffCell = ({ row, tone }: { row: RevisionDiffRow; tone: "history"
</View>
);
const getAuthenticatedResourceSource = (
source: string,
session: { baseUrl: string; token: string } | null
) => {
const baseUrl = session?.baseUrl.replace(/\/+$/, "") ?? "";
const uri = source.startsWith("/") && baseUrl ? `${baseUrl}${source}` : source;
const isProtectedResource = source.startsWith("/api/v1/resources/") || Boolean(baseUrl && uri.startsWith(`${baseUrl}/api/v1/resources/`));
return {
uri,
...(session?.token && isProtectedResource ? { headers: { Authorization: `Bearer ${session.token}` } } : {}),
};
};
const AuthenticatedResourceImage = ({
alt,
fitAspect = false,
resizeMode = "cover",
source,
style,
}: {
alt: string;
fitAspect?: boolean;
resizeMode?: "center" | "contain" | "cover" | "repeat" | "stretch";
source: ReturnType<typeof getAuthenticatedResourceSource>;
style: StyleProp<ImageStyle>;
}) => {
const [aspectRatio, setAspectRatio] = useState(16 / 9);
const [svgXml, setSvgXml] = useState<string | null>(null);
const svgRequestStartedRef = useRef(false);
const imageStyle = fitAspect ? [style, { aspectRatio, height: undefined, width: "100%" as const }] : style;
useEffect(() => {
setSvgXml(null);
svgRequestStartedRef.current = false;
}, [source.uri]);
const loadSvgFallback = () => {
if (svgRequestStartedRef.current) {
return;
}
svgRequestStartedRef.current = true;
void fetch(source.uri, { headers: source.headers })
.then(async (response) => {
if (!response.ok || !response.headers.get("Content-Type")?.toLowerCase().includes("svg")) {
return null;
}
return response.text();
})
.then((xml) => {
if (!xml) {
return;
}
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]);
if (width > 0 && height > 0) {
setAspectRatio(width / height);
}
setSvgXml(xml);
})
.catch(() => undefined);
};
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)}
onLoad={(event) => {
const { height, width } = event.nativeEvent.source;
if (height > 0 && width > 0) {
setAspectRatio(width / height);
}
}}
onError={loadSvgFallback}
resizeMode={resizeMode}
source={source}
style={imageStyle}
/>
);
};
const MemoDetailModal = ({
isDeleting,
isLoading,
@@ -4382,6 +4498,7 @@ const MemoDetailModal = ({
onTogglePin: (memo: MemoDetail) => void;
visible: boolean;
}) => {
const { session } = useSession();
const { resolvedTheme, toggleTheme } = useMobileTheme();
const [actionsOpen, setActionsOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
@@ -4392,6 +4509,18 @@ const MemoDetailModal = ({
() => resolveMobileThemeStyles(detailMarkdownStyles, resolvedTheme),
[resolvedTheme]
);
const detailMarkdownRules = useMemo<RenderRules>(() => ({
image: (node, _children, _parents, markdownStyles) => (
<AuthenticatedResourceImage
alt={String(node.attributes.alt ?? "")}
fitAspect
key={node.key}
resizeMode="contain"
source={getAuthenticatedResourceSource(String(node.attributes.src ?? ""), session)}
style={markdownStyles._VIEW_SAFE_image}
/>
),
}), [session]);
const detailText = memo?.contentMarkdown || memo?.contentText || "没有正文内容";
const searchMatches = useMemo(() => getTextSearchMatches(detailText, searchQuery), [detailText, searchQuery]);
const searchMatchLabel = searchQuery.trim() ? `${searchMatches.length > 0 ? activeMatchIndex + 1 : 0}/${searchMatches.length}` : "0/0";
@@ -4479,7 +4608,7 @@ const MemoDetailModal = ({
{searchOpen && searchQuery.trim() ? (
<HighlightedDetailText activeIndex={activeMatchIndex} matches={searchMatches} text={detailText} />
) : (
<Markdown style={themedDetailMarkdownStyles}>{detailText}</Markdown>
<Markdown rules={detailMarkdownRules} style={themedDetailMarkdownStyles}>{detailText}</Markdown>
)}
</ScrollView>
) : (
@@ -4566,29 +4695,23 @@ const HighlightedDetailText = ({
const RichEditorModal = ({
baseUrl,
imageCompressionEnabled,
isDeleting,
memo,
notebooks,
onClose,
onDelete,
onRestored,
updateMutation,
}: {
baseUrl: string;
imageCompressionEnabled: boolean;
isDeleting: boolean;
memo: MemoDetail | null;
notebooks: Notebook[];
onClose: () => void;
onDelete: (memo: MemoDetail) => void;
onRestored: (memo: MemoDetail) => void;
updateMutation: MobileMemoUpdateMutation;
}) => {
const { client } = useSession();
const { resolvedLocale } = useMobileLocale();
const { resolvedTheme, toggleTheme } = useMobileTheme();
const insets = useSafeAreaInsets();
const { resolvedTheme } = useMobileTheme();
const editorRef = useRef<LocalTiptapEditorRef>(null);
const resourceDataUrlCacheRef = useRef(new Map<string, Promise<string | null>>());
const initialFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const contentJsonRef = useRef<TiptapDoc>(memo?.contentJson ?? markdownToDoc(memo?.contentMarkdown ?? ""));
const contentMarkdownRef = useRef(memo?.contentMarkdown ?? "");
@@ -4601,14 +4724,6 @@ const RichEditorModal = ({
const [tagsText, setTagsText] = useState(memo?.tags.join(", ") ?? "");
const [notebookId, setNotebookId] = useState(memo?.notebookId ?? "");
const [notebookPickerOpen, setNotebookPickerOpen] = useState(false);
const [actionsOpen, setActionsOpen] = useState(false);
const [historyMemo, setHistoryMemo] = useState<MemoDetail | null>(null);
const [noteSearchOpen, setNoteSearchOpen] = useState(false);
const [noteSearchReplaceOpen, setNoteSearchReplaceOpen] = useState(false);
const [noteSearchQuery, setNoteSearchQuery] = useState("");
const [noteSearchReplacement, setNoteSearchReplacement] = useState("");
const [noteSearchCount, setNoteSearchCount] = useState(0);
const [noteSearchIndex, setNoteSearchIndex] = useState(0);
const [draftLoaded, setDraftLoaded] = useState(false);
const [draftRestored, setDraftRestored] = useState(false);
const [ready, setReady] = useState(false);
@@ -4618,7 +4733,7 @@ const RichEditorModal = ({
const [error, setError] = useState<string | null>(null);
const [startupMs, setStartupMs] = useState<number | null>(null);
const notebookLabel = notebooks.find((notebook) => notebook.id === notebookId)?.name ?? "未分类";
const saveLabel = error ? "保存失败" : saving ? "正在保存" : uploading ? "正在上传" : dirty ? "本地草稿" : ready ? "已保存" : "正在启动";
const saveLabel = error ? "保存失败" : saving ? "保存中" : uploading ? "上传中" : dirty ? (draftRestored ? "本地草稿" : "未保存") : ready ? "已保存" : "加载中";
const titleRef = useRef(title);
const tagsTextRef = useRef(tagsText);
const notebookIdRef = useRef(notebookId);
@@ -4640,14 +4755,6 @@ const RichEditorModal = ({
setReady(false);
setSaving(false);
setUploading(false);
setActionsOpen(false);
setHistoryMemo(null);
setNoteSearchOpen(false);
setNoteSearchReplaceOpen(false);
setNoteSearchQuery("");
setNoteSearchReplacement("");
setNoteSearchCount(0);
setNoteSearchIndex(0);
uploadingRef.current = false;
setError(null);
setStartupMs(null);
@@ -4734,6 +4841,7 @@ const RichEditorModal = ({
await clearMobileMemoDraft(memo.id);
dirtyRef.current = false;
setDirty(false);
setDraftRestored(false);
return savedMemo;
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "保存失败");
@@ -4764,62 +4872,6 @@ const RichEditorModal = ({
});
};
const handleEditorSearchState = useCallback(async (count: number, activeIndex: number) => {
setNoteSearchCount(count);
setNoteSearchIndex(activeIndex);
}, []);
const openNoteSearch = (showReplace = false) => {
setActionsOpen(false);
setNoteSearchOpen(true);
setNoteSearchReplaceOpen(showReplace);
};
const closeNoteSearch = () => {
setNoteSearchOpen(false);
setNoteSearchReplaceOpen(false);
setNoteSearchCount(0);
setNoteSearchIndex(0);
editorRef.current?.focus();
};
const moveNoteSearchMatch = (direction: 1 | -1) => {
if (noteSearchCount === 0) {
return;
}
const nextIndex = (noteSearchIndex + direction + noteSearchCount) % noteSearchCount;
setNoteSearchIndex(nextIndex);
editorRef.current?.search(noteSearchQuery, nextIndex);
};
const replaceAllNoteSearchMatches = () => {
if (!noteSearchQuery.trim() || noteSearchCount === 0) {
return;
}
editorRef.current?.replaceAll(noteSearchQuery, noteSearchReplacement);
};
const openRevisionHistory = async () => {
setActionsOpen(false);
await flushEditor();
if (!memo) {
return;
}
setHistoryMemo({
...memo,
contentJson: contentJsonRef.current,
contentMarkdown: contentMarkdownRef.current,
title: titleRef.current.trim() || DEFAULT_MEMO_TITLE,
});
};
useEffect(() => {
if (!noteSearchOpen || !ready) {
return;
}
editorRef.current?.search(noteSearchQuery, 0);
}, [noteSearchOpen, noteSearchQuery, ready]);
const requestClose = async () => {
if (savingRef.current || uploadingRef.current) {
return;
@@ -4875,6 +4927,19 @@ const RichEditorModal = ({
}
};
const loadEditorResource = useCallback((source: string) => {
if (!client) {
return Promise.resolve(null);
}
const cached = resourceDataUrlCacheRef.current.get(source);
if (cached) {
return cached;
}
const pending = client.getResourceBlob(source).then(blobToDataUrl).catch(() => null);
resourceDataUrlCacheRef.current.set(source, pending);
return pending;
}, [client]);
const editorElement = useMemo(
() => memo && baseUrl && draftLoaded ? (
<LocalTiptapEditor
@@ -4888,6 +4953,7 @@ const RichEditorModal = ({
style: styles.richEditorWebView,
}}
onChange={persistDraft}
onLoadResource={loadEditorResource}
onPickImage={pickAndUploadImage}
onReady={async (elapsedMs) => {
setStartupMs(elapsedMs);
@@ -4901,13 +4967,12 @@ const RichEditorModal = ({
editorRef.current?.focusEnd();
}, 160);
}}
onSearchState={handleEditorSearchState}
ref={editorRef}
locale={resolvedLocale}
theme={resolvedTheme}
/>
) : null,
[baseUrl, draftLoaded, handleEditorSearchState, memo?.id, resolvedLocale, resolvedTheme]
[baseUrl, draftLoaded, loadEditorResource, memo?.id, resolvedLocale, resolvedTheme]
);
useEffect(() => {
@@ -4956,12 +5021,6 @@ const RichEditorModal = ({
>
{saving ? <ActivityIndicator color="#64748b" size="small" /> : <Text style={[styles.createMemoDoneText, (uploading || !ready) && styles.createMemoDoneTextDisabled]}>完成</Text>}
</Pressable>
<Pressable accessibilityLabel={resolvedTheme === "dark" ? "切换到浅色模式" : "切换到深色模式"} accessibilityRole="button" onPress={toggleTheme} style={styles.richEditorHeaderIconButton}>
{resolvedTheme === "dark" ? <Sun color="#475569" size={19} /> : <Moon color="#475569" size={19} />}
</Pressable>
<Pressable accessibilityLabel="笔记更多操作" accessibilityRole="button" onPress={() => setActionsOpen(true)} style={styles.richEditorHeaderIconButton}>
<MoreHorizontal color="#475569" size={20} />
</Pressable>
</View>
</View>
@@ -4996,54 +5055,6 @@ const RichEditorModal = ({
value={tagsText}
/>
</View>
{noteSearchOpen ? (
<View style={styles.richEditorSearchPanel}>
<View style={styles.searchBox}>
<Search color="#64748b" size={17} />
<TextInput
autoCapitalize="none"
autoCorrect={false}
autoFocus
onChangeText={setNoteSearchQuery}
placeholder="在当前笔记内搜索"
placeholderTextColor="#94a3b8"
style={styles.searchInput}
value={noteSearchQuery}
/>
<Text style={[styles.noteSearchCount, noteSearchQuery.trim() && noteSearchCount === 0 && styles.noteSearchCountEmpty]}>
{`${noteSearchCount > 0 ? noteSearchIndex + 1 : 0}/${noteSearchCount}`}
</Text>
<Pressable accessibilityLabel="关闭搜索" accessibilityRole="button" onPress={closeNoteSearch} style={styles.richEditorSearchIconButton}>
<X color="#64748b" size={17} />
</Pressable>
</View>
{noteSearchReplaceOpen ? (
<View style={styles.searchBox}>
<RefreshCw color="#64748b" size={17} />
<TextInput
onChangeText={setNoteSearchReplacement}
placeholder="替换为"
placeholderTextColor="#94a3b8"
style={styles.searchInput}
value={noteSearchReplacement}
/>
</View>
) : null}
<View style={styles.richEditorSearchActions}>
<ActionButton disabled={noteSearchCount === 0} label="上一个搜索结果" onPress={() => moveNoteSearchMatch(-1)}>
<ChevronLeft color={noteSearchCount === 0 ? "#cbd5e1" : "#0f172a"} size={16} />
</ActionButton>
<ActionButton disabled={noteSearchCount === 0} label="下一个搜索结果" onPress={() => moveNoteSearchMatch(1)}>
<ChevronRight color={noteSearchCount === 0 ? "#cbd5e1" : "#0f172a"} size={16} />
</ActionButton>
{noteSearchReplaceOpen ? (
<ActionButton disabled={noteSearchCount === 0} label="全部替换" onPress={replaceAllNoteSearchMatches}>
<RefreshCw color={noteSearchCount === 0 ? "#cbd5e1" : "#0f172a"} size={16} />
</ActionButton>
) : null}
</View>
</View>
) : null}
{draftRestored ? <Text style={styles.richEditorDraftNotice}>已恢复上次未完成的本地草稿</Text> : null}
<View style={styles.richEditorFrame}>
{!ready || !draftLoaded ? (
@@ -5075,39 +5086,6 @@ const RichEditorModal = ({
}}
visible={notebookPickerOpen}
/>
<Modal animationType="fade" onRequestClose={() => setActionsOpen(false)} transparent visible={actionsOpen}>
<Pressable onPress={() => setActionsOpen(false)} style={styles.richEditorMoreBackdrop}>
<Pressable style={[styles.richEditorMoreMenu, { top: insets.top + 54 }]}>
<ActionSheetItem icon={<Search color="#0f172a" size={18} />} label="搜索当前笔记" onPress={() => openNoteSearch(false)} />
<ActionSheetItem icon={<RefreshCw color="#0f172a" size={18} />} label="替换当前笔记" onPress={() => openNoteSearch(true)} />
<ActionSheetItem icon={<History color="#0f172a" size={18} />} label="版本历史" onPress={() => void openRevisionHistory()} />
<View style={styles.listActionDivider} />
<ActionSheetItem
danger
disabled={isDeleting}
icon={<Trash2 color="#b91c1c" size={18} />}
label={isDeleting ? "删除中" : "删除"}
onPress={() => {
setActionsOpen(false);
if (memo) {
onDelete(memo);
}
}}
/>
</Pressable>
</Pressable>
</Modal>
{historyMemo ? (
<RevisionHistoryModal
memo={historyMemo}
onClose={() => setHistoryMemo(null)}
onRestored={async (restoredMemo) => {
await clearMobileMemoDraft(restoredMemo.id);
setHistoryMemo(null);
onRestored(restoredMemo);
}}
/>
) : null}
</SafeAreaView>
</Modal>
);
@@ -6602,6 +6580,19 @@ const openResource = (resource: ResourceListItem) => {
});
};
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 appendResourceMarkdown = (
currentMarkdown: string,
resource: {
@@ -8150,13 +8141,6 @@ const baseWorkspaceStyles = StyleSheet.create({
richEditorHeaderStatus: {
maxWidth: 76,
},
richEditorHeaderIconButton: {
alignItems: "center",
borderRadius: 999,
height: 36,
justifyContent: "center",
width: 36,
},
richEditorContainer: {
backgroundColor: "#ffffff",
flex: 1,
@@ -8240,45 +8224,6 @@ const baseWorkspaceStyles = StyleSheet.create({
richStandaloneTagsInput: {
minHeight: 30,
},
richEditorSearchPanel: {
backgroundColor: "#f8fafc",
borderBottomColor: "#e2e8f0",
borderBottomWidth: 1,
gap: 8,
marginHorizontal: -12,
paddingHorizontal: 12,
paddingVertical: 10,
},
richEditorSearchActions: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
richEditorSearchIconButton: {
alignItems: "center",
borderRadius: 999,
height: 32,
justifyContent: "center",
width: 32,
},
richEditorMoreBackdrop: {
flex: 1,
},
richEditorMoreMenu: {
backgroundColor: "#ffffff",
borderColor: "#e2e8f0",
borderRadius: 9,
borderWidth: 1,
elevation: 8,
padding: 6,
position: "absolute",
right: 12,
shadowColor: "#0f172a",
shadowOffset: { width: 0, height: 5 },
shadowOpacity: 0.18,
shadowRadius: 12,
width: 228,
},
richEditorLoading: {
alignItems: "center",
backgroundColor: "#ffffff",