feat(mobile): create notes from templates on Android

Add template picker entry points for mobile note creation (create header,
empty list, long-press +) using built-in presets and saved cloud templates.
Related to #187.
This commit is contained in:
tianma-if committed 2026-08-08 11:16:31 +08:00
1 parent 12e1d9d872
commit fc60daf89c
9 files changed
+700 -22

No files matched your search

@@ -0,0 +1,211 @@
import { useQuery } from "@tanstack/react-query";
import type { createEdgeEverClient } from "@edgeever/client";
import { useMemo } from "react";
import { ActivityIndicator, Modal, Pressable, ScrollView, View } from "react-native";
import { FileText, LayoutTemplate, X } from "./icons";
import { Text } from "./LocalizedText";
import {
getMobileBuiltInTemplates,
mobileTemplateToCreateSeed,
toMobileSelectableTemplate,
type MobileCreateMemoSeed,
type MobileSelectableTemplate,
type MobileSupportedLocale,
} from "../lib/mobile-templates";
import { useMobileLocale } from "../lib/mobile-locale";
import { styles } from "../screens/workspace-styles";
type MobileClient = ReturnType<typeof createEdgeEverClient>;
export const MobileTemplatePickerModal = ({
bottomOffset = 0,
client,
onClose,
onSelect,
visible,
}: {
bottomOffset?: number;
client: MobileClient | null;
onClose: () => void;
onSelect: (seed: MobileCreateMemoSeed) => void;
visible: boolean;
}) => {
const { resolvedLocale, translate } = useMobileLocale();
const builtInTemplates = useMemo(
() => getMobileBuiltInTemplates(resolvedLocale as MobileSupportedLocale).map((template) => toMobileSelectableTemplate(template, "builtin")),
[resolvedLocale]
);
const savedTemplatesQuery = useQuery({
queryKey: ["mobile", "templates"],
enabled: visible && Boolean(client),
queryFn: async () => {
if (!client) {
return [] as MobileSelectableTemplate[];
}
const response = await client.listTemplates();
return response.templates.map((template) => toMobileSelectableTemplate(template, "saved"));
},
staleTime: 30_000,
});
const savedTemplates = savedTemplatesQuery.data ?? [];
const isLoadingSaved = savedTemplatesQuery.isLoading || savedTemplatesQuery.isFetching;
const handleSelect = (template: MobileSelectableTemplate) => {
onSelect(mobileTemplateToCreateSeed(template));
onClose();
};
return (
<Modal animationType="fade" onRequestClose={onClose} transparent visible={visible}>
<Pressable onPress={onClose} style={[styles.actionSheetBackdrop, { paddingBottom: bottomOffset }]}>
<Pressable style={[styles.listActionSheet, styles.templatePickerSheet]} onPress={(event) => event.stopPropagation()}>
<View style={styles.actionSheetHandle} />
<View style={styles.listActionSheetHeader}>
<View style={styles.listActionSheetHeaderText}>
<Text numberOfLines={1} style={styles.actionSheetTitle}>{translate("从模板新建")}</Text>
<Text numberOfLines={2} style={styles.actionSheetSubtitle}>
{translate("选择预设结构快速开始,也可使用网页端保存的自定义模板。")}
</Text>
</View>
<Pressable accessibilityLabel={translate("关闭")} accessibilityRole="button" onPress={onClose} style={styles.sheetCloseButton}>
<X color="#0f172a" size={18} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.listActionSheetContent} style={styles.listActionSheetScroll}>
<Text style={styles.actionSheetSectionTitle}>{translate("我的自定义模板")}</Text>
{isLoadingSaved ? (
<View style={styles.templatePickerLoading}>
<ActivityIndicator color="#0f172a" size="small" />
<Text style={styles.mutedText}>{translate("正在加载模板")}</Text>
</View>
) : null}
{!isLoadingSaved && savedTemplatesQuery.isError ? (
<Text style={styles.templatePickerHint}>
{translate("自定义模板暂时无法加载,仍可使用下方内置模板。")}
</Text>
) : null}
{!isLoadingSaved && !savedTemplatesQuery.isError && savedTemplates.length === 0 ? (
<Text style={styles.templatePickerHint}>
{translate("暂无自定义模板。可在网页端将常用笔记另存为模板。")}
</Text>
) : null}
{savedTemplates.map((template) => (
<TemplateRow key={`saved-${template.id}`} onPress={() => handleSelect(template)} template={template} />
))}
<View style={styles.listActionDivider} />
<Text style={styles.actionSheetSectionTitle}>{translate("内置推荐模板")}</Text>
{builtInTemplates.map((template) => (
<TemplateRow key={`builtin-${template.id}`} onPress={() => handleSelect(template)} template={template} />
))}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
);
};
export const MobileCreateChoiceModal = ({
bottomOffset = 0,
canCreate,
onBlank,
onClose,
onTemplate,
visible,
}: {
bottomOffset?: number;
canCreate: boolean;
onBlank: () => void;
onClose: () => void;
onTemplate: () => void;
visible: boolean;
}) => {
const { translate } = useMobileLocale();
return (
<Modal animationType="fade" onRequestClose={onClose} transparent visible={visible}>
<Pressable onPress={onClose} style={[styles.actionSheetBackdrop, { paddingBottom: bottomOffset }]}>
<Pressable style={styles.listActionSheet} onPress={(event) => event.stopPropagation()}>
<View style={styles.actionSheetHandle} />
<View style={styles.listActionSheetHeader}>
<View style={styles.listActionSheetHeaderText}>
<Text numberOfLines={1} style={styles.actionSheetTitle}>{translate("新建笔记")}</Text>
<Text numberOfLines={1} style={styles.actionSheetSubtitle}>{translate("选择创建方式")}</Text>
</View>
<Pressable accessibilityLabel={translate("关闭")} accessibilityRole="button" onPress={onClose} style={styles.sheetCloseButton}>
<X color="#0f172a" size={18} />
</Pressable>
</View>
<View style={styles.listActionSheetContent}>
<Pressable
accessibilityRole="button"
disabled={!canCreate}
onPress={() => {
onClose();
onBlank();
}}
style={[styles.templateChoiceRow, !canCreate && styles.templateChoiceRowDisabled]}
>
<View style={styles.templateChoiceIcon}>
<FileText color="#0f172a" size={18} />
</View>
<View style={styles.templateChoiceText}>
<Text style={styles.templateChoiceTitle}>{translate("空白笔记")}</Text>
<Text style={styles.templateChoiceDescription}>{translate("从空白页开始记录")}</Text>
</View>
</Pressable>
<Pressable
accessibilityRole="button"
disabled={!canCreate}
onPress={() => {
onClose();
onTemplate();
}}
style={[styles.templateChoiceRow, !canCreate && styles.templateChoiceRowDisabled]}
>
<View style={styles.templateChoiceIcon}>
<LayoutTemplate color="#0f172a" size={18} />
</View>
<View style={styles.templateChoiceText}>
<Text style={styles.templateChoiceTitle}>{translate("从模板新建")}</Text>
<Text style={styles.templateChoiceDescription}>{translate("使用会议纪要、周报等预设结构")}</Text>
</View>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
);
};
const TemplateRow = ({
onPress,
template,
}: {
onPress: () => void;
template: MobileSelectableTemplate;
}) => {
const { translate } = useMobileLocale();
return (
<Pressable accessibilityRole="button" onPress={onPress} style={styles.templateRow}>
<View style={styles.templateRowIcon}>
<LayoutTemplate color="#047857" size={16} />
</View>
<View style={styles.templateRowText}>
<View style={styles.templateRowTitleRow}>
<Text numberOfLines={1} style={styles.templateRowTitle}>{template.name}</Text>
<View style={[styles.templateBadge, template.source === "saved" ? styles.templateBadgeCustom : styles.templateBadgeBuiltIn]}>
<Text style={[styles.templateBadgeText, template.source === "saved" ? styles.templateBadgeTextCustom : styles.templateBadgeTextBuiltIn]}>
{template.source === "saved" ? translate("自定义") : translate("内置")}
</Text>
</View>
</View>
{template.description ? (
<Text numberOfLines={2} style={styles.templateRowDescription}>{template.description}</Text>
) : null}
</View>
</Pressable>
);
};
+1
View File
@@ -43,6 +43,7 @@ export const ImagePlus = createIcon("plus-square");
export const Info = createIcon("info");
export const Italic = createIcon("italic");
export const KeyRound = createIcon("key");
export const LayoutTemplate = createIcon("layout");
export const Link = createIcon("link");
export const List = createIcon("list");
export const LockKeyhole = createIcon("lock");
+19
View File
@@ -119,6 +119,25 @@ const mobileOnlyTranslations = new Map<string, string>([
["{{count}} 条结果", "Results: {{count}}"],
["筛选:{{filter}} · {{count}} 条", "Filter: {{filter}} · {{count}} notes"],
["已加载 {{loaded}} / {{total}} 条笔记", "Loaded {{loaded}} of {{total}} notes"],
["从模板新建", "New from template"],
["选择预设结构快速开始,也可使用网页端保存的自定义模板。", "Start from a preset structure, or use custom templates saved on the web."],
["我的自定义模板", "My custom templates"],
["内置推荐模板", "Recommended templates"],
["正在加载模板", "Loading templates"],
["自定义模板暂时无法加载,仍可使用下方内置模板。", "Custom templates could not load. Built-in templates are still available."],
["暂无自定义模板。可在网页端将常用笔记另存为模板。", "No custom templates yet. Save notes as templates on the web."],
["新建笔记", "New note"],
["选择创建方式", "Choose how to create"],
["空白笔记", "Blank note"],
["从空白页开始记录", "Start with an empty page"],
["使用会议纪要、周报等预设结构", "Use meeting notes, weekly reviews, and more"],
["自定义", "Custom"],
["内置", "Built-in"],
["模板", "Template"],
["应用模板?", "Apply template?"],
["当前内容将被模板内容替换。", "The current content will be replaced by the template."],
["替换", "Replace"],
["关闭", "Close"],
]);
const flattenStrings = (value: unknown, prefix = "", output = new Map<string, string>()) => {
@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test";
import {
createMemoSeedHasContent,
getMobileBuiltInTemplates,
mobileTemplateToCreateSeed,
toMobileSelectableTemplate,
} from "./mobile-templates";
describe("mobile-templates", () => {
test("returns built-in templates for both locales", () => {
const zh = getMobileBuiltInTemplates("zh-CN");
const en = getMobileBuiltInTemplates("en-US");
expect(zh.length).toBe(6);
expect(en.length).toBe(6);
expect(zh[0]?.title).toBe("灵感速记");
expect(en[0]?.title).toBe("Quick Spark");
expect(zh[1]?.contentMarkdown).toContain("会议纪要");
expect(en[1]?.contentMarkdown).toContain("Meeting Minutes");
});
test("maps built-in and saved templates to selectable rows", () => {
const builtIn = getMobileBuiltInTemplates("zh-CN")[0]!;
const selectableBuiltIn = toMobileSelectableTemplate(builtIn, "builtin");
expect(selectableBuiltIn.source).toBe("builtin");
expect(selectableBuiltIn.name).toBe(builtIn.title);
const selectableSaved = toMobileSelectableTemplate(
{
id: "tpl_1",
name: "我的周报",
description: "团队周报",
title: "【周报】",
contentJson: { type: "doc", content: [] },
contentMarkdown: "## 本周",
tags: ["work", "weekly"],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
"saved"
);
expect(selectableSaved.source).toBe("saved");
expect(selectableSaved.title).toBe("【周报】");
expect(mobileTemplateToCreateSeed(selectableSaved)).toEqual({
title: "【周报】",
contentMarkdown: "## 本周",
tagsText: "work, weekly",
});
});
test("detects whether a seed has user content", () => {
expect(createMemoSeedHasContent({ title: "", contentMarkdown: "", tagsText: "" })).toBe(false);
expect(createMemoSeedHasContent({ title: "a", contentMarkdown: "", tagsText: "" })).toBe(true);
expect(createMemoSeedHasContent({ title: "", contentMarkdown: "x", tagsText: "" })).toBe(true);
expect(createMemoSeedHasContent({ title: "", contentMarkdown: "", tagsText: "tag" })).toBe(true);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { enUS, zhCN } from "@edgeever/shared/i18n";
import type { MemoTemplate } from "@edgeever/shared";
export type MobileSupportedLocale = "zh-CN" | "en-US";
export type MobileCreateMemoSeed = {
title: string;
contentMarkdown: string;
tagsText: string;
};
export type MobileBuiltInTemplate = {
id: string;
title: string;
description: string;
contentMarkdown: string;
tags: string[];
};
export type MobileSelectableTemplate = {
id: string;
name: string;
description: string;
title: string;
contentMarkdown: string;
tags: string[];
source: "builtin" | "saved";
};
const BUILTIN_TEMPLATE_DEFS = [
{ id: "quick-note", key: "quickNote", tag: "quick-note" },
{ id: "meeting", key: "meeting", tag: "meeting" },
{ id: "weekly-review", key: "weeklyReview", tag: "weekly-review" },
{ id: "reading", key: "reading", tag: "reading" },
{ id: "okr", key: "okr", tag: "okr" },
{ id: "post-mortem", key: "postMortem", tag: "post-mortem" },
] as const;
type BuiltInItemKey = (typeof BUILTIN_TEMPLATE_DEFS)[number]["key"];
const getTemplateCatalog = (locale: MobileSupportedLocale) =>
(locale === "en-US" ? enUS.templates.items : zhCN.templates.items) as Record<
BuiltInItemKey,
{ title: string; description: string; contentMarkdown: string }
>;
export const getMobileBuiltInTemplates = (locale: MobileSupportedLocale): MobileBuiltInTemplate[] => {
const catalog = getTemplateCatalog(locale);
return BUILTIN_TEMPLATE_DEFS.map((item) => {
const content = catalog[item.key];
return {
id: item.id,
title: content.title,
description: content.description,
contentMarkdown: content.contentMarkdown,
tags: ["template", item.tag],
};
});
};
export const toMobileSelectableTemplate = (
template: MobileBuiltInTemplate | MemoTemplate,
source: "builtin" | "saved"
): MobileSelectableTemplate => {
if (source === "builtin") {
const builtIn = template as MobileBuiltInTemplate;
return {
id: builtIn.id,
name: builtIn.title,
description: builtIn.description,
title: builtIn.title,
contentMarkdown: builtIn.contentMarkdown,
tags: builtIn.tags,
source,
};
}
const saved = template as MemoTemplate;
return {
id: saved.id,
name: saved.name,
description: saved.description ?? "",
title: saved.title ?? saved.name,
contentMarkdown: saved.contentMarkdown,
tags: saved.tags,
source,
};
};
export const mobileTemplateToCreateSeed = (template: MobileSelectableTemplate): MobileCreateMemoSeed => ({
title: template.title,
contentMarkdown: template.contentMarkdown,
tagsText: template.tags.join(", "),
});
export const createMemoSeedHasContent = (seed: Pick<MobileCreateMemoSeed, "title" | "contentMarkdown" | "tagsText">) =>
Boolean(seed.title.trim() || seed.contentMarkdown.trim() || seed.tagsText.trim());
+34 -9
View File
@@ -4,7 +4,7 @@ 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 { Check, ChevronDown, ChevronLeft, LayoutTemplate, MoreHorizontal, Plus, RotateCcw, Search, Sparkles, Tag, X } from "../components/icons";
import { Pressable, Text, TextInput } from "../components/LocalizedText";
import type { MobileBootstrapProgress } from "../lib/local-mirror";
import { useMobileLocale } from "../lib/mobile-locale";
@@ -30,6 +30,7 @@ export const NotesView = ({
memos,
notebooks,
onCreate,
onCreateFromTemplate,
onClearSelection,
onFilterModeChange,
onOpenActions,
@@ -59,6 +60,7 @@ export const NotesView = ({
memos: MemoSummary[];
notebooks: Notebook[];
onCreate: () => void;
onCreateFromTemplate?: () => void;
onClearSelection: () => void;
onFilterModeChange: (filterMode: MemoFilterMode) => void;
onOpenActions: () => void;
@@ -186,7 +188,14 @@ export const NotesView = ({
</View>
<MemoList
emptyAction={memoView === "notebook" && notebooks.length > 0 ? { label: "新建笔记", onPress: onCreate } : undefined}
emptyActions={memoView === "notebook" && notebooks.length > 0 && !searchActive && memoFilterMode === "all"
? [
{ label: "新建笔记", onPress: onCreate, variant: "primary" as const },
...(onCreateFromTemplate
? [{ label: "从模板新建", onPress: onCreateFromTemplate, variant: "secondary" as const }]
: []),
]
: undefined}
emptyDescription={searchActive ? "换个关键词再试" : memoFilterMode !== "all" ? "试试切换筛选条件,或调整搜索关键词。" : memoView === "trash" ? "删除的笔记会显示在这里。" : "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。"}
emptyTitle={searchActive ? "没有找到匹配笔记" : memoFilterMode !== "all" ? "没有符合筛选的笔记" : memoView === "trash" ? "回收站为空" : "暂无笔记"}
error={error}
@@ -211,7 +220,7 @@ export const NotesView = ({
const MemoList = ({
emptyAction,
emptyActions,
emptyDescription,
emptyTitle,
error,
@@ -230,7 +239,7 @@ const MemoList = ({
selectionMode = false,
selectedMemoIds = new Set(),
}: {
emptyAction?: { label: string; onPress: () => void };
emptyActions?: Array<{ label: string; onPress: () => void; variant?: "primary" | "secondary" }>;
emptyDescription: string;
emptyTitle: string;
error?: unknown;
@@ -323,11 +332,27 @@ const MemoList = ({
<View style={styles.memoListEmptyCard}>
<Text style={styles.emptyTitle}>{emptyTitle}</Text>
<Text style={styles.mutedText}>{emptyDescription}</Text>
{emptyAction ? (
<Pressable accessibilityRole="button" onPress={emptyAction.onPress} style={styles.emptyActionButton}>
<Plus color="#ffffff" size={18} />
<Text style={styles.emptyActionButtonText}>{emptyAction.label}</Text>
</Pressable>
{emptyActions && emptyActions.length > 0 ? (
<View style={styles.emptyActionRow}>
{emptyActions.map((action) => {
const isSecondary = action.variant === "secondary";
return (
<Pressable
accessibilityRole="button"
key={action.label}
onPress={action.onPress}
style={isSecondary ? styles.emptyActionSecondaryButton : styles.emptyActionButton}
>
{isSecondary
? <LayoutTemplate color="#0f172a" size={16} />
: <Plus color="#ffffff" size={18} />}
<Text style={isSecondary ? styles.emptyActionSecondaryButtonText : styles.emptyActionButtonText}>
{action.label}
</Text>
</Pressable>
);
})}
</View>
) : null}
</View>
}
+114 -13
View File
@@ -116,8 +116,10 @@ import { showEdgeEverKeyboard } from "../../modules/edgeever-keyboard";
import LocalTiptapEditor, { type LocalTiptapEditorRef } from "../components/LocalTiptapEditor";
import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom";
import { MobileResourceActions } from "../components/MobileResourceActions";
import { MobileCreateChoiceModal, MobileTemplatePickerModal } from "../components/MobileTemplatePicker";
import { resolveMobileThemeStyles, useMobileTheme } from "../lib/mobile-theme";
import { useMobileUpdate } from "../lib/mobile-update";
import { createMemoSeedHasContent, type MobileCreateMemoSeed } from "../lib/mobile-templates";
import { MobileMermaidDiagram, MobileMermaidProvider } from "../components/MobileMermaid";
import { getMobileMarkdownFenceLanguage, trimMobileMarkdownFenceContent } from "../lib/mobile-mermaid";
import {
@@ -236,6 +238,9 @@ export const WorkspaceScreen = ({
const [selectedMemoId, setSelectedMemoId] = useState<string | null>(null);
const [searchText, setSearchText] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [createSeed, setCreateSeed] = useState<MobileCreateMemoSeed | null>(null);
const [createChoiceOpen, setCreateChoiceOpen] = useState(false);
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const [incomingClipDraft, setIncomingClipDraft] = useState<MobileWebClipDraft | null>(null);
const [incomingClipCaptureUrl, setIncomingClipCaptureUrl] = useState<string | null>(null);
const [isImportingShare, setIsImportingShare] = useState(false);
@@ -593,14 +598,23 @@ export const WorkspaceScreen = ({
? activeNotebookId
: defaultMemoNotebookId;
const canCreateMemo = memoView !== "trash" && Boolean(createMemoNotebookId);
const openCreateMemo = () => {
const openCreateMemo = useCallback((seed: MobileCreateMemoSeed | null = null) => {
beginEditorStartup();
setIncomingClipDraft(null);
setCreateSeed(seed);
setCreateOpen(true);
};
}, []);
const openCreateFromTemplate = useCallback(() => {
if (!canCreateMemo) {
return;
}
setTemplatePickerOpen(true);
}, [canCreateMemo]);
const openIncomingClipDraft = useCallback((draft: MobileWebClipDraft) => {
beginEditorStartup();
setCreateSeed(null);
setIncomingClipDraft(draft);
setActiveView("notes");
setMemoView("notebook");
@@ -1155,7 +1169,8 @@ export const WorkspaceScreen = ({
memoView={memoView}
memos={visibleMemos}
notebooks={notebooks}
onCreate={openCreateMemo}
onCreate={() => openCreateMemo()}
onCreateFromTemplate={canCreateMemo ? openCreateFromTemplate : undefined}
onClearSelection={clearSelection}
onFilterModeChange={setMemoFilterMode}
onOpenActions={() => setNotesActionsOpen(true)}
@@ -1257,14 +1272,16 @@ export const WorkspaceScreen = ({
<CreateMemoModal
baseUrl={session?.baseUrl ?? ""}
client={client}
dataScope={dataScope}
defaultNotebookId={createMemoNotebookId}
imageCompressionEnabled={imageCompressionEnabled}
initialDraft={incomingClipDraft}
initialDraft={incomingClipDraft ?? createSeed}
notebooks={notebooks}
onCreated={() => {
setCreateOpen(false);
setIncomingClipDraft(null);
setCreateSeed(null);
setActiveView("notes");
setMemoView("notebook");
setSelectedMemoId(null);
@@ -1274,6 +1291,23 @@ export const WorkspaceScreen = ({
visible={createOpen}
/>
<MobileCreateChoiceModal
bottomOffset={58 + safeAreaInsets.bottom}
canCreate={canCreateMemo}
onBlank={() => openCreateMemo()}
onClose={() => setCreateChoiceOpen(false)}
onTemplate={openCreateFromTemplate}
visible={createChoiceOpen}
/>
<MobileTemplatePickerModal
bottomOffset={58 + safeAreaInsets.bottom}
client={client}
onClose={() => setTemplatePickerOpen(false)}
onSelect={(seed) => openCreateMemo(seed)}
visible={templatePickerOpen}
/>
<Modal animationType="fade" statusBarTranslucent transparent visible={isImportingShare}>
<View style={styles.shareImportBackdrop}>
<View style={styles.shareImportCard}>
@@ -1367,7 +1401,14 @@ export const WorkspaceScreen = ({
accessibilityLabel="新建笔记"
accessibilityRole="button"
disabled={!canCreateMemo}
onPress={openCreateMemo}
onLongPress={() => {
if (!canCreateMemo) {
return;
}
Vibration.vibrate(8);
setCreateChoiceOpen(true);
}}
onPress={() => openCreateMemo()}
style={[styles.bottomCreateButton, !canCreateMemo && styles.bottomCreateButtonDisabled]}
>
<Plus color={canCreateMemo ? "#ffffff" : "#e2e8f0"} size={28} />
@@ -1686,6 +1727,7 @@ const ActionSheetItem = ({ compact = false, danger = false, disabled = false, ic
const CreateMemoModal = ({
baseUrl,
client: clientProp,
dataScope,
defaultNotebookId,
imageCompressionEnabled,
@@ -1697,19 +1739,22 @@ const CreateMemoModal = ({
visible,
}: {
baseUrl: string;
client?: ReturnType<typeof useSession>["client"];
dataScope: string;
defaultNotebookId: string;
imageCompressionEnabled: boolean;
initialDraft?: MobileWebClipDraft | null;
initialDraft?: MobileCreateMemoSeed | MobileWebClipDraft | null;
notebooks: Notebook[];
onCreated: (memo: MemoDetail) => void;
onQueued: () => void | Promise<void>;
syncQueueScope: string;
visible: boolean;
}) => {
const { client, session } = useSession();
const sessionState = useSession();
const client = clientProp ?? sessionState.client;
const session = sessionState.session;
const queryClient = useQueryClient();
const { resolvedLocale } = useMobileLocale();
const { resolvedLocale, translate } = useMobileLocale();
const { resolvedTheme } = useMobileTheme();
const fallbackNotebookId = defaultNotebookId;
const editorRef = useRef<LocalTiptapEditorRef>(null);
@@ -1731,6 +1776,8 @@ const CreateMemoModal = ({
const [tagsText, setTagsText] = useState("");
const [contentMarkdown, setContentMarkdown] = useState("");
const [notebookPickerOpen, setNotebookPickerOpen] = useState(false);
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const [editorSeed, setEditorSeed] = useState(0);
const [draftLoaded, setDraftLoaded] = useState(false);
const [dirty, setDirty] = useState(false);
const [editorReady, setEditorReady] = useState(false);
@@ -1747,11 +1794,13 @@ const CreateMemoModal = ({
useEffect(() => {
if (!visible) {
setTemplatePickerOpen(false);
return;
}
let active = true;
setDraftLoaded(false);
setEditorReady(false);
setTemplatePickerOpen(false);
if (initialDraft) {
const markdown = initialDraft.contentMarkdown;
contentMarkdownRef.current = markdown;
@@ -1762,6 +1811,7 @@ const CreateMemoModal = ({
setContentMarkdown(markdown);
setNotebookId(fallbackNotebookId);
setDirty(false);
setEditorSeed((current) => current + 1);
setDraftLoaded(true);
return () => {
active = false;
@@ -1783,6 +1833,7 @@ const CreateMemoModal = ({
setContentMarkdown(markdown);
setNotebookId(restoredNotebookId);
setDirty(false);
setEditorSeed((current) => current + 1);
setDraftLoaded(true);
});
return () => {
@@ -1790,8 +1841,41 @@ const CreateMemoModal = ({
};
}, [dataScope, fallbackNotebookId, initialDraft, visible]);
const isWebClipDraft = Boolean(
initialDraft && "sourceUrl" in initialDraft && typeof initialDraft.sourceUrl === "string" && initialDraft.sourceUrl.length > 0
);
const applyTemplateSeed = useCallback((seed: MobileCreateMemoSeed) => {
const markdown = seed.contentMarkdown;
contentMarkdownRef.current = markdown;
contentJsonRef.current = markdownToDoc(markdown);
setTitle(seed.title);
setTagsText(seed.tagsText);
setContentMarkdown(markdown);
setEditorReady(false);
setEditorSeed((current) => current + 1);
draftVersionRef.current += 1;
setDirty(true);
}, []);
const requestApplyTemplateSeed = useCallback((seed: MobileCreateMemoSeed) => {
const current = {
title: titleRef.current,
contentMarkdown: contentMarkdownRef.current,
tagsText: tagsTextRef.current,
};
if (createMemoSeedHasContent(current)) {
Alert.alert(translate("应用模板?"), translate("当前内容将被模板内容替换。"), [
{ text: translate("取消"), style: "cancel" },
{ text: translate("替换"), style: "destructive", onPress: () => applyTemplateSeed(seed) },
]);
return;
}
applyTemplateSeed(seed);
}, [applyTemplateSeed, translate]);
useEffect(() => {
if (!visible || !draftLoaded || !dirty || initialDraft) {
if (!visible || !draftLoaded || !dirty || isWebClipDraft) {
return;
}
const draftVersion = draftVersionRef.current;
@@ -1821,7 +1905,7 @@ const CreateMemoModal = ({
});
}, 350);
return () => clearTimeout(timeout);
}, [dataScope, dirty, draftLoaded, initialDraft, tagsText, targetNotebookId, title, visible, contentMarkdown]);
}, [contentMarkdown, dataScope, dirty, draftLoaded, isWebClipDraft, tagsText, targetNotebookId, title, visible]);
const createMutation = useMutation({
mutationFn: async () => {
@@ -1900,7 +1984,7 @@ const CreateMemoModal = ({
materializedMemoRef.current = null;
draftVersionRef.current += 1;
setDirty(false);
if (!initialDraft) {
if (!isWebClipDraft) {
await clearMobileNewMemoDraft(dataScope);
}
if (materializedMemoId) {
@@ -1911,6 +1995,7 @@ const CreateMemoModal = ({
},
});
const canSubmitCreateMemo = Boolean(targetNotebookId) && !createMutation.isPending && imageOperation === "idle";
const canUseTemplate = imageOperation === "idle" && !createMutation.isPending;
const materializeMemoForImage = async () => {
if (materializedMemoRef.current) {
@@ -1928,7 +2013,7 @@ const CreateMemoModal = ({
});
materializedMemoRef.current = response.memo;
await upsertLocalMemo(dataScope, response.memo);
if (!initialDraft) {
if (!isWebClipDraft) {
await clearMobileNewMemoDraft(dataScope);
}
await Promise.all([
@@ -2063,6 +2148,7 @@ const CreateMemoModal = ({
const editorElement = useMemo(() => draftLoaded && baseUrl ? (
<LocalTiptapEditor
key={`create-editor-${editorSeed}`}
autoFocus
baseUrl={baseUrl}
content={contentJsonRef.current}
@@ -2097,7 +2183,7 @@ const CreateMemoModal = ({
locale={resolvedLocale}
theme={resolvedTheme}
/>
) : null, [baseUrl, draftLoaded, loadEditorResource, resolvedLocale, resolvedTheme, selectResource]);
) : null, [baseUrl, draftLoaded, editorSeed, loadEditorResource, resolvedLocale, resolvedTheme, selectResource]);
return (
<Modal animationType="slide" onRequestClose={() => void requestClose()} presentationStyle="fullScreen" visible={visible}>
@@ -2110,6 +2196,15 @@ const CreateMemoModal = ({
<Text style={[styles.createMemoStatus, createMutation.isPending && styles.createMemoStatusActive]}>
{imageOperation === "creating" ? "正在创建" : imageOperation === "uploading" ? "正在上传" : createMutation.isPending || dirty ? "保存中" : editorReady ? "已保存" : "正在启动"}
</Text>
<Pressable
accessibilityLabel={translate("模板")}
accessibilityRole="button"
disabled={!canUseTemplate}
onPress={() => setTemplatePickerOpen(true)}
style={[styles.createMemoTemplateButton, !canUseTemplate && styles.createMemoDoneButtonDisabled]}
>
<Text style={[styles.createMemoTemplateButtonText, !canUseTemplate && styles.createMemoDoneTextDisabled]}>{translate("模板")}</Text>
</Pressable>
<Pressable
accessibilityLabel="完成新建笔记"
disabled={!canSubmitCreateMemo}
@@ -2188,6 +2283,12 @@ const CreateMemoModal = ({
onSaveAs={saveResourceAs}
target={resourceTarget}
/>
<MobileTemplatePickerModal
client={client}
onClose={() => setTemplatePickerOpen(false)}
onSelect={requestApplyTemplateSeed}
visible={templatePickerOpen}
/>
</SafeAreaView>
</Modal>
);
+152
View File
@@ -876,6 +876,158 @@ const baseWorkspaceStyles = StyleSheet.create({
fontSize: 13,
fontWeight: "800",
},
emptyActionRow: {
alignItems: "center",
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
justifyContent: "center",
marginTop: 8,
},
emptyActionSecondaryButton: {
alignItems: "center",
backgroundColor: "#ffffff",
borderColor: "#cbd5e1",
borderRadius: 8,
borderWidth: 1,
flexDirection: "row",
gap: 8,
minHeight: 38,
paddingHorizontal: 14,
},
emptyActionSecondaryButtonText: {
color: "#0f172a",
fontSize: 13,
fontWeight: "800",
},
createMemoTemplateButton: {
alignItems: "center",
borderColor: "#e2e8f0",
borderRadius: 999,
borderWidth: 1,
justifyContent: "center",
minHeight: 36,
paddingHorizontal: 12,
paddingVertical: 7,
},
createMemoTemplateButtonText: {
color: "#0f172a",
fontSize: 13,
fontWeight: "700",
},
templatePickerSheet: {
maxHeight: "84%",
},
templatePickerLoading: {
alignItems: "center",
flexDirection: "row",
gap: 8,
paddingHorizontal: 8,
paddingVertical: 10,
},
templatePickerHint: {
color: "#64748b",
fontSize: 12,
lineHeight: 18,
paddingHorizontal: 8,
paddingVertical: 8,
},
templateRow: {
alignItems: "flex-start",
borderRadius: 12,
flexDirection: "row",
gap: 10,
paddingHorizontal: 10,
paddingVertical: 12,
},
templateRowIcon: {
alignItems: "center",
backgroundColor: "#ecfdf5",
borderRadius: 10,
height: 32,
justifyContent: "center",
marginTop: 1,
width: 32,
},
templateRowText: {
flex: 1,
gap: 4,
minWidth: 0,
},
templateRowTitleRow: {
alignItems: "center",
flexDirection: "row",
gap: 8,
},
templateRowTitle: {
color: "#0f172a",
flex: 1,
fontSize: 14,
fontWeight: "700",
},
templateRowDescription: {
color: "#64748b",
fontSize: 12,
lineHeight: 17,
},
templateBadge: {
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 7,
paddingVertical: 2,
},
templateBadgeBuiltIn: {
backgroundColor: "#f8fafc",
borderColor: "#e2e8f0",
},
templateBadgeCustom: {
backgroundColor: "#ecfdf5",
borderColor: "#a7f3d0",
},
templateBadgeText: {
fontSize: 10,
fontWeight: "700",
},
templateBadgeTextBuiltIn: {
color: "#64748b",
},
templateBadgeTextCustom: {
color: "#047857",
},
templateChoiceRow: {
alignItems: "center",
borderRadius: 12,
flexDirection: "row",
gap: 12,
paddingHorizontal: 10,
paddingVertical: 14,
},
templateChoiceRowDisabled: {
opacity: 0.45,
},
templateChoiceIcon: {
alignItems: "center",
backgroundColor: "#f1f5f9",
borderRadius: 12,
height: 40,
justifyContent: "center",
width: 40,
},
templateChoiceText: {
flex: 1,
gap: 2,
minWidth: 0,
},
templateChoiceTitle: {
color: "#0f172a",
fontSize: 15,
fontWeight: "700",
},
templateChoiceDescription: {
color: "#64748b",
fontSize: 12,
lineHeight: 17,
},
panelRow: {
backgroundColor: "#ffffff",
borderColor: "#e2e8f0",
+17
View File
@@ -13,6 +13,7 @@ import type {
MemoRevision,
MemoSummary,
MemoShare,
MemoTemplate,
Notebook,
Resource,
ResourceListItem,
@@ -76,6 +77,14 @@ export type MemoResponse = {
memo: MemoDetail;
};
export type ListTemplatesResponse = {
templates: MemoTemplate[];
};
export type TemplateResponse = {
template: MemoTemplate;
};
export type MemoShareResponse = {
share: MemoShare | null;
};
@@ -343,6 +352,14 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
body: JSON.stringify(payload),
}),
listTemplates: () => request<ListTemplatesResponse>("/api/v1/templates"),
useTemplate: (templateId: string, notebookId: string) =>
request<MemoResponse>(`/api/v1/templates/${templateId}/use`, {
method: "POST",
body: JSON.stringify({ notebookId }),
}),
moveMemos: (payload: { memoIds: string[]; notebookId: string }) =>
request<{ ok: true; moved: number }>("/api/v1/memos/batch/move", {
method: "POST",