From fc60daf89cd0e1c9e31a77ebca22cf80693104cc Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sat, 8 Aug 2026 11:16:31 +0800 Subject: [PATCH] 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. --- .../src/components/MobileTemplatePicker.tsx | 211 ++++++++++++++++++ apps/mobile/src/components/icons.tsx | 1 + apps/mobile/src/lib/mobile-locale.tsx | 19 ++ apps/mobile/src/lib/mobile-templates.test.ts | 56 +++++ apps/mobile/src/lib/mobile-templates.ts | 96 ++++++++ .../mobile/src/screens/WorkspaceNotesView.tsx | 43 +++- apps/mobile/src/screens/WorkspaceScreen.tsx | 127 +++++++++-- apps/mobile/src/screens/workspace-styles.ts | 152 +++++++++++++ packages/client/src/index.ts | 17 ++ 9 files changed, 700 insertions(+), 22 deletions(-) create mode 100644 apps/mobile/src/components/MobileTemplatePicker.tsx create mode 100644 apps/mobile/src/lib/mobile-templates.test.ts create mode 100644 apps/mobile/src/lib/mobile-templates.ts diff --git a/apps/mobile/src/components/MobileTemplatePicker.tsx b/apps/mobile/src/components/MobileTemplatePicker.tsx new file mode 100644 index 00000000..9e83c76c --- /dev/null +++ b/apps/mobile/src/components/MobileTemplatePicker.tsx @@ -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; + +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 ( + + + event.stopPropagation()}> + + + + {translate("从模板新建")} + + {translate("选择预设结构快速开始,也可使用网页端保存的自定义模板。")} + + + + + + + + + {translate("我的自定义模板")} + {isLoadingSaved ? ( + + + {translate("正在加载模板")} + + ) : null} + {!isLoadingSaved && savedTemplatesQuery.isError ? ( + + {translate("自定义模板暂时无法加载,仍可使用下方内置模板。")} + + ) : null} + {!isLoadingSaved && !savedTemplatesQuery.isError && savedTemplates.length === 0 ? ( + + {translate("暂无自定义模板。可在网页端将常用笔记另存为模板。")} + + ) : null} + {savedTemplates.map((template) => ( + handleSelect(template)} template={template} /> + ))} + + + {translate("内置推荐模板")} + {builtInTemplates.map((template) => ( + handleSelect(template)} template={template} /> + ))} + + + + + ); +}; + +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 ( + + + event.stopPropagation()}> + + + + {translate("新建笔记")} + {translate("选择创建方式")} + + + + + + + { + onClose(); + onBlank(); + }} + style={[styles.templateChoiceRow, !canCreate && styles.templateChoiceRowDisabled]} + > + + + + + {translate("空白笔记")} + {translate("从空白页开始记录")} + + + { + onClose(); + onTemplate(); + }} + style={[styles.templateChoiceRow, !canCreate && styles.templateChoiceRowDisabled]} + > + + + + + {translate("从模板新建")} + {translate("使用会议纪要、周报等预设结构")} + + + + + + + ); +}; + +const TemplateRow = ({ + onPress, + template, +}: { + onPress: () => void; + template: MobileSelectableTemplate; +}) => { + const { translate } = useMobileLocale(); + return ( + + + + + + + {template.name} + + + {template.source === "saved" ? translate("自定义") : translate("内置")} + + + + {template.description ? ( + {template.description} + ) : null} + + + ); +}; diff --git a/apps/mobile/src/components/icons.tsx b/apps/mobile/src/components/icons.tsx index dd05c56c..02096659 100644 --- a/apps/mobile/src/components/icons.tsx +++ b/apps/mobile/src/components/icons.tsx @@ -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"); diff --git a/apps/mobile/src/lib/mobile-locale.tsx b/apps/mobile/src/lib/mobile-locale.tsx index 98c099bf..fd3e45f4 100644 --- a/apps/mobile/src/lib/mobile-locale.tsx +++ b/apps/mobile/src/lib/mobile-locale.tsx @@ -119,6 +119,25 @@ const mobileOnlyTranslations = new Map([ ["{{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()) => { diff --git a/apps/mobile/src/lib/mobile-templates.test.ts b/apps/mobile/src/lib/mobile-templates.test.ts new file mode 100644 index 00000000..5fbf13e5 --- /dev/null +++ b/apps/mobile/src/lib/mobile-templates.test.ts @@ -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); + }); +}); diff --git a/apps/mobile/src/lib/mobile-templates.ts b/apps/mobile/src/lib/mobile-templates.ts new file mode 100644 index 00000000..9e921896 --- /dev/null +++ b/apps/mobile/src/lib/mobile-templates.ts @@ -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) => + Boolean(seed.title.trim() || seed.contentMarkdown.trim() || seed.tagsText.trim()); diff --git a/apps/mobile/src/screens/WorkspaceNotesView.tsx b/apps/mobile/src/screens/WorkspaceNotesView.tsx index 6fd5adfc..62c961c1 100644 --- a/apps/mobile/src/screens/WorkspaceNotesView.tsx +++ b/apps/mobile/src/screens/WorkspaceNotesView.tsx @@ -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 = ({ 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 = ({ {emptyTitle} {emptyDescription} - {emptyAction ? ( - - - {emptyAction.label} - + {emptyActions && emptyActions.length > 0 ? ( + + {emptyActions.map((action) => { + const isSecondary = action.variant === "secondary"; + return ( + + {isSecondary + ? + : } + + {action.label} + + + ); + })} + ) : null} } diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index ea4abfc5..03cf7083 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -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(null); const [searchText, setSearchText] = useState(""); const [createOpen, setCreateOpen] = useState(false); + const [createSeed, setCreateSeed] = useState(null); + const [createChoiceOpen, setCreateChoiceOpen] = useState(false); + const [templatePickerOpen, setTemplatePickerOpen] = useState(false); const [incomingClipDraft, setIncomingClipDraft] = useState(null); const [incomingClipCaptureUrl, setIncomingClipCaptureUrl] = useState(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 = ({ { setCreateOpen(false); setIncomingClipDraft(null); + setCreateSeed(null); setActiveView("notes"); setMemoView("notebook"); setSelectedMemoId(null); @@ -1274,6 +1291,23 @@ export const WorkspaceScreen = ({ visible={createOpen} /> + openCreateMemo()} + onClose={() => setCreateChoiceOpen(false)} + onTemplate={openCreateFromTemplate} + visible={createChoiceOpen} + /> + + setTemplatePickerOpen(false)} + onSelect={(seed) => openCreateMemo(seed)} + visible={templatePickerOpen} + /> + @@ -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]} > @@ -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["client"]; dataScope: string; defaultNotebookId: string; imageCompressionEnabled: boolean; - initialDraft?: MobileWebClipDraft | null; + initialDraft?: MobileCreateMemoSeed | MobileWebClipDraft | null; notebooks: Notebook[]; onCreated: (memo: MemoDetail) => void; onQueued: () => void | Promise; 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(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 ? ( - ) : null, [baseUrl, draftLoaded, loadEditorResource, resolvedLocale, resolvedTheme, selectResource]); + ) : null, [baseUrl, draftLoaded, editorSeed, loadEditorResource, resolvedLocale, resolvedTheme, selectResource]); return ( void requestClose()} presentationStyle="fullScreen" visible={visible}> @@ -2110,6 +2196,15 @@ const CreateMemoModal = ({ {imageOperation === "creating" ? "正在创建" : imageOperation === "uploading" ? "正在上传" : createMutation.isPending || dirty ? "保存中" : editorReady ? "已保存" : "正在启动"} + setTemplatePickerOpen(true)} + style={[styles.createMemoTemplateButton, !canUseTemplate && styles.createMemoDoneButtonDisabled]} + > + {translate("模板")} + + setTemplatePickerOpen(false)} + onSelect={requestApplyTemplateSeed} + visible={templatePickerOpen} + /> ); diff --git a/apps/mobile/src/screens/workspace-styles.ts b/apps/mobile/src/screens/workspace-styles.ts index ef15081f..c22dbfd0 100644 --- a/apps/mobile/src/screens/workspace-styles.ts +++ b/apps/mobile/src/screens/workspace-styles.ts @@ -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", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index de0ee63b..37662df6 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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("/api/v1/templates"), + + useTemplate: (templateId: string, notebookId: string) => + request(`/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",