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:
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>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user