diff --git a/apps/ios/EdgeEver.xcodeproj/project.pbxproj b/apps/ios/EdgeEver.xcodeproj/project.pbxproj index 4f3e693d..edee39f4 100644 --- a/apps/ios/EdgeEver.xcodeproj/project.pbxproj +++ b/apps/ios/EdgeEver.xcodeproj/project.pbxproj @@ -33,12 +33,15 @@ 4A771542C5AD2E6A907187AD /* OutboxFlusher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8720E0D6F00D1509B76A374B /* OutboxFlusher.swift */; }; 4BA97CD264D0929B20ED19DB /* TipTapWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12DCA98C14CC6F28C22C41A1 /* TipTapWebView.swift */; }; 4EC901852EDA4BC001717747 /* TipTapContentSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A536119D29812EF11DAEAA8 /* TipTapContentSourceTests.swift */; }; + 4F3977342A76C352E95E14E7 /* MemoTemplates.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F36ACF8073C8A61AE397CD /* MemoTemplates.swift */; }; 506702F1751945424AC4CB98 /* MobileUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A375D81573943A98C5732F81 /* MobileUI.swift */; }; 50800DF91A760DE7FB68DFB4 /* Motion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C065FED3C78404D4D8DD19 /* Motion.swift */; }; 511A8DCB204AFCB2880AE51E /* MemoCreateCommitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E057D0906932A00DF62EBB /* MemoCreateCommitTests.swift */; }; 541297DC463B8FC593ECB5F6 /* ResourceBlobStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9305413739F81EA39395F7 /* ResourceBlobStore.swift */; }; + 588019CB46131EDF8DCB2F53 /* MemoTemplatesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D1DC59EC9FA65240DCBC19 /* MemoTemplatesTests.swift */; }; 5EEFDEEDC372725C7C7F22ED /* EdgeEverApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9CEA2902CDE0D7C4137D173 /* EdgeEverApp.swift */; }; 5F5EC9565A02FAFDD5D7B54C /* SyncProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12236E3A2F318CDEB17B80CB /* SyncProtocol.swift */; }; + 63D4B562B4EF6ED7291155F6 /* TemplatePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BA3D6859C4C6D9F65435671 /* TemplatePickerSheet.swift */; }; 6A671BB702DA77A0548E6E29 /* URL+Normalize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8662720DE42695E29F5741 /* URL+Normalize.swift */; }; 7BDAD91E69B7B62D7D390CB5 /* MemoEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3674473015F8EB6863B69B0F /* MemoEditView.swift */; }; 8430B1EDE6CA87A0C922E484 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D94BB61519CA2A345D99C1 /* SettingsView.swift */; }; @@ -141,6 +144,7 @@ 94DF0091B1FC2783AEC2D9F6 /* EdgeEverTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = EdgeEverTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9799115E572F9AF0B59A5AD4 /* WorkspaceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStore.swift; sourceTree = ""; }; 9B02E8D28111E1E732F3BF36 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9BA3D6859C4C6D9F65435671 /* TemplatePickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatePickerSheet.swift; sourceTree = ""; }; 9D65FED94BCD836BDD5CB752 /* DeviceId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceId.swift; sourceTree = ""; }; A375D81573943A98C5732F81 /* MobileUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileUI.swift; sourceTree = ""; }; AB02424D0412FE36C4BCD116 /* index.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = index.html; sourceTree = ""; }; @@ -154,6 +158,7 @@ C5126510AFB9AFBF7ABADB45 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; C693439A39274372B39F10DE /* EdgeEver.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = EdgeEver.entitlements; sourceTree = ""; }; C6B15550738F6080C23DE15F /* ImageCompressor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCompressor.swift; sourceTree = ""; }; + C8D1DC59EC9FA65240DCBC19 /* MemoTemplatesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoTemplatesTests.swift; sourceTree = ""; }; D045B340A984E17B3B0047BF /* ShareHandoffStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareHandoffStore.swift; sourceTree = ""; }; D06F83AE844C342D3F6EAD7F /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; D2C2945A52C2957CCFC09FCF /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; @@ -166,6 +171,7 @@ EA9305413739F81EA39395F7 /* ResourceBlobStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceBlobStore.swift; sourceTree = ""; }; EFF10CDC8FB8C97DA0103AA0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; F080AD98FDF3AADDD90B9931 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + F1F36ACF8073C8A61AE397CD /* MemoTemplates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoTemplates.swift; sourceTree = ""; }; F50D91D8505FE6446ABD647C /* WorkspaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceView.swift; sourceTree = ""; }; F9182F742B06FB1713E27C2A /* ResourceActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceActions.swift; sourceTree = ""; }; FDEF3A661FA08F338EC96C74 /* LocalMirrorRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMirrorRepository.swift; sourceTree = ""; }; @@ -379,6 +385,7 @@ E3328D871910187ED827B6CE /* NotebookPickerSheet.swift */, B30C95F4EABCD08555D97C6B /* NotesListView.swift */, B1D94BB61519CA2A345D99C1 /* SettingsView.swift */, + 9BA3D6859C4C6D9F65435671 /* TemplatePickerSheet.swift */, 9799115E572F9AF0B59A5AD4 /* WorkspaceStore.swift */, F50D91D8505FE6446ABD647C /* WorkspaceView.swift */, ); @@ -398,6 +405,7 @@ B905FCD37DFCE6B112511261 /* Models */ = { isa = PBXGroup; children = ( + F1F36ACF8073C8A61AE397CD /* MemoTemplates.swift */, C5126510AFB9AFBF7ABADB45 /* Models.swift */, ); path = Models; @@ -440,6 +448,7 @@ 57312B99FFBD9A2186A8C0D0 /* LiveLocalAPITests.swift */, 2035E878FD92B74D889D6047 /* LocalMirrorTests.swift */, 82E057D0906932A00DF62EBB /* MemoCreateCommitTests.swift */, + C8D1DC59EC9FA65240DCBC19 /* MemoTemplatesTests.swift */, 1606EF8ADEA4921421EB0690 /* MobileUIParityTests.swift */, E6159DBF27CC86920005E25D /* SyncProtocolTests.swift */, 8A536119D29812EF11DAEAA8 /* TipTapContentSourceTests.swift */, @@ -590,6 +599,7 @@ F51F6C5ED7C2B0E68DF5A55D /* LiveLocalAPITests.swift in Sources */, 8785D0133655387F399C3C58 /* LocalMirrorTests.swift in Sources */, 511A8DCB204AFCB2880AE51E /* MemoCreateCommitTests.swift in Sources */, + 588019CB46131EDF8DCB2F53 /* MemoTemplatesTests.swift in Sources */, A63E64622A06DF3842908A66 /* MobileUIParityTests.swift in Sources */, 1F8E6268C9DA330CA236B36F /* SyncProtocolTests.swift in Sources */, 4EC901852EDA4BC001717747 /* TipTapContentSourceTests.swift in Sources */, @@ -620,6 +630,7 @@ 08A3D4F0E80ACE44E36AD87D /* MemoCreateCommit.swift in Sources */, CA033464E43E9CF7C86585E6 /* MemoDetailView.swift in Sources */, 7BDAD91E69B7B62D7D390CB5 /* MemoEditView.swift in Sources */, + 4F3977342A76C352E95E14E7 /* MemoTemplates.swift in Sources */, 506702F1751945424AC4CB98 /* MobileUI.swift in Sources */, 90FFFFD00B2D19F96EA299C8 /* Models.swift in Sources */, 50800DF91A760DE7FB68DFB4 /* Motion.swift in Sources */, @@ -637,6 +648,7 @@ 35AAF7A2B1B4D5EE38F8518C /* SyncEngine.swift in Sources */, 2E185C3C27DA3A729F2734B4 /* SyncOutboxRepository.swift in Sources */, 5F5EC9565A02FAFDD5D7B54C /* SyncProtocol.swift in Sources */, + 63D4B562B4EF6ED7291155F6 /* TemplatePickerSheet.swift in Sources */, 4A2DD0673B81D32FA9E5F9DB /* Theme.swift in Sources */, 265DB48114144779A7C807F7 /* TipTapContentSource.swift in Sources */, 1186BE3109942FCBAB59E6B4 /* TipTapWarmPool.swift in Sources */, diff --git a/apps/ios/EdgeEver/Data/Models/MemoTemplates.swift b/apps/ios/EdgeEver/Data/Models/MemoTemplates.swift new file mode 100644 index 00000000..9e52e40c --- /dev/null +++ b/apps/ios/EdgeEver/Data/Models/MemoTemplates.swift @@ -0,0 +1,161 @@ +import Foundation + +// MARK: - Server template + +struct MemoTemplate: Codable, Equatable, Sendable, Identifiable { + var id: String + var name: String + var description: String? + var title: String? + var contentMarkdown: String + var tags: [String] + var createdAt: String + var updatedAt: String +} + +struct TemplatesResponse: Codable, Sendable { + var templates: [MemoTemplate] +} + +// MARK: - Create seed (Android MobileCreateMemoSeed) + +struct CreateMemoSeed: Equatable, Sendable { + var title: String + var contentMarkdown: String + var tagsText: String + + var hasContent: Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !contentMarkdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !tagsText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +// MARK: - Selectable row (built-in + saved) + +enum MemoTemplateSource: String, Equatable, Sendable { + case builtin + case saved +} + +struct SelectableMemoTemplate: Identifiable, Equatable, Sendable { + var id: String + var name: String + var description: String + var title: String + var contentMarkdown: String + var tags: [String] + var source: MemoTemplateSource + + var createSeed: CreateMemoSeed { + CreateMemoSeed( + title: title, + contentMarkdown: contentMarkdown, + tagsText: tags.joined(separator: ", ") + ) + } +} + +// MARK: - Built-in catalog (parity with apps/mobile + web i18n) + +enum BuiltInMemoTemplates { + private struct Item { + var id: String + var tag: String + var titleZH: String + var titleEN: String + var descriptionZH: String + var descriptionEN: String + var contentMarkdownZH: String + var contentMarkdownEN: String + } + + private static let items: [Item] = [ + Item( + id: "quick-note", + tag: "quick-note", + titleZH: "灵感速记", + titleEN: "Quick Spark", + descriptionZH: "快速捕捉闪念、临时灵感、资料链接与即刻行动。", + descriptionEN: "Capture fleeting thoughts, ideas, links, and immediate action items.", + contentMarkdownZH: "## 💡 闪念记录\n\n- \n\n## 📌 背景与补充说明\n\n\n\n## 🚀 下一步动作\n\n- [ ] ", + contentMarkdownEN: "## 💡 Fleeting Thoughts\n\n- \n\n## 📌 Context & Notes\n\n\n\n## 🚀 Next Actions\n\n- [ ] " + ), + Item( + id: "meeting", + tag: "meeting", + titleZH: "会议纪要", + titleEN: "Meeting Minutes", + descriptionZH: "结构化记录议题背景、核心结论与带负责人的待办事项。", + descriptionEN: "Structured log for agenda, key decisions, and action items with owners.", + contentMarkdownZH: "# 📝 会议纪要\n\n- **时间**:\n- **主持人/记录人**:\n- **参会人**:\n\n---\n\n## 🎯 会议目标\n\n- \n\n## 💬 核心讨论与决策\n\n1. **[议题 1]**\n - 讨论要点:\n - ✅ **决议**:\n\n2. **[议题 2]**\n - 讨论要点:\n - ✅ **决议**:\n\n## 📋 待办事项 (Action Items)\n\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n", + contentMarkdownEN: "# 📝 Meeting Minutes\n\n- **Time**:\n- **Host/Recorder**:\n- **Attendees**:\n\n---\n\n## 🎯 Goal\n\n- \n\n## 💬 Discussion & Decisions\n\n1. **[Topic 1]**\n - Points:\n - ✅ **Decision**:\n\n2. **[Topic 2]**\n - Points:\n - ✅ **Decision**:\n\n## 📋 Action Items\n\n- [ ] **[Owner]** Task description (Due: MM-DD)\n- [ ] **[Owner]** Task description (Due: MM-DD)\n" + ), + Item( + id: "weekly-review", + tag: "weekly-review", + titleZH: "周报与进展复盘", + titleEN: "Weekly Review & Status", + descriptionZH: "梳理本周核心产出、风险卡点与下周关键优先级。", + descriptionEN: "Summarize weekly highlights, blockers, and next week's key priorities.", + contentMarkdownZH: "# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n1. [ ] \n2. [ ] \n3. [ ] \n\n## 💡 总结与思考\n\n- \n", + contentMarkdownEN: "# 🗓️ Weekly Status Report\n\n## 🌟 Highlights\n\n- [x] **[Project/Feature]** Accomplishment details\n- [x] **[Project/Feature]** Accomplishment details\n\n## 🚧 Blockers & Risks\n\n- ⚠️ **Blocker**: Reason and required support\n\n## 🎯 Next Week Priorities\n\n1. [ ] \n2. [ ] \n3. [ ] \n\n## 💡 Reflection & Insights\n\n- \n" + ), + Item( + id: "reading", + tag: "reading", + titleZH: "深度阅读卡片", + titleEN: "Reading Note Card", + descriptionZH: "提炼核心观点、精妙摘录、个人理解与关联知识卡片。", + descriptionEN: "Extract key takeaways, quotes, reflections, and connected concepts.", + contentMarkdownZH: "# 📖 深度阅读卡片\n\n- **书名/文章**:\n- **作者/来源**:\n- **推荐指数**:⭐⭐⭐⭐⭐\n\n---\n\n## 💡 一句话总结 (Key Takeaway)\n\n> \n\n## ✍️ 核心观点与金句摘录\n\n> [摘录内容]\n> —— *原书/原文*\n\n## 🧠 我的理解与延伸思考\n\n- \n\n## 🔗 关联知识与行动\n\n- [ ] **落地实践**:\n", + contentMarkdownEN: "# 📖 Reading Note Card\n\n- **Book/Article**:\n- **Author/Source**:\n- **Rating**: ⭐⭐⭐⭐⭐\n\n---\n\n## 💡 Key Takeaway\n\n> \n\n## ✍️ Highlights & Quotes\n\n> [Quote content]\n> —— *Original Source*\n\n## 🧠 Personal Reflections\n\n- \n\n## 🔗 Action & Practice\n\n- [ ] **Action Plan**:\n" + ), + Item( + id: "okr", + tag: "okr", + titleZH: "目标与任务拆解", + titleEN: "Goal & Task Breakdown", + descriptionZH: "明确 OKR 目标、关键结果、里程碑与具体执行清单。", + descriptionEN: "Define OKRs, Key Results, milestones, and task checklists.", + contentMarkdownZH: "# 🎯 目标拆解\n\n- **周期**:\n- **负责人**:\n\n---\n\n## 📌 目标 (Objective)\n\n> \n\n## 📈 关键结果 (Key Results)\n\n- **KR 1**:期望指标 -> 当前进度\n- **KR 2**:期望指标 -> 当前进度\n\n## 🗓️ 里程碑节点 (Milestones)\n\n- [ ] **阶段一 (日期)**:完成标的\n- [ ] **阶段二 (日期)**:完成标的\n\n## 📋 执行任务清单\n\n- [ ] \n- [ ] \n", + contentMarkdownEN: "# 🎯 Goal Breakdown\n\n- **Period**:\n- **Owner**:\n\n---\n\n## 📌 Objective\n\n> \n\n## 📈 Key Results\n\n- **KR 1**: Target metric -> Current progress\n- **KR 2**: Target metric -> Current progress\n\n## 🗓️ Milestones\n\n- [ ] **Phase 1 (Date)**: Target\n- [ ] **Phase 2 (Date)**: Target\n\n## 📋 Execution Checklist\n\n- [ ] \n- [ ] \n" + ), + Item( + id: "post-mortem", + tag: "post-mortem", + titleZH: "问题排查与复盘", + titleEN: "Problem & Post-mortem", + descriptionZH: "记录故障现象、根因分析 (5 Whys) 与防范机制。", + descriptionEN: "Document incident symptoms, 5-Whys root cause, and preventive measures.", + contentMarkdownZH: "# 🔍 问题排查与复盘 (Post-mortem)\n\n- **发生时间**:\n- **影响范围**:\n- **处理状态**:已解决 / 处理中\n\n---\n\n## 🚨 故障现象与影响\n\n\n\n## 🛠️ 排查过程与解决方案\n\n1. \n2. \n\n## 🔬 根因分析 (Root Cause / 5 Whys)\n\n- **根本原因**:\n\n## 🛡️ 预防措施 (Action Items)\n\n- [ ] **[短期规避]** \n- [ ] **[长期优化]** \n", + contentMarkdownEN: "# 🔍 Post-mortem & Problem Investigation\n\n- **Occurred At**:\n- **Impact Scope**:\n- **Status**: Resolved / In-Progress\n\n---\n\n## 🚨 Symptoms & Impact\n\n\n\n## 🛠️ Investigation & Fix Steps\n\n1. \n2. \n\n## 🔬 Root Cause Analysis (5 Whys)\n\n- **Root Cause**:\n\n## 🛡️ Action Items & Prevention\n\n- [ ] **[Short-term]** \n- [ ] **[Long-term]** \n" + ), + ] + + static func all(isEnglish: Bool) -> [SelectableMemoTemplate] { + items.map { item in + SelectableMemoTemplate( + id: item.id, + name: isEnglish ? item.titleEN : item.titleZH, + description: isEnglish ? item.descriptionEN : item.descriptionZH, + title: isEnglish ? item.titleEN : item.titleZH, + contentMarkdown: isEnglish ? item.contentMarkdownEN : item.contentMarkdownZH, + tags: ["template", item.tag], + source: .builtin + ) + } + } + + static func fromSaved(_ template: MemoTemplate) -> SelectableMemoTemplate { + SelectableMemoTemplate( + id: template.id, + name: template.name, + description: template.description ?? "", + title: template.title ?? template.name, + contentMarkdown: template.contentMarkdown, + tags: template.tags, + source: .saved + ) + } +} diff --git a/apps/ios/EdgeEver/Data/Network/APIClient.swift b/apps/ios/EdgeEver/Data/Network/APIClient.swift index 0970949b..473b55b1 100644 --- a/apps/ios/EdgeEver/Data/Network/APIClient.swift +++ b/apps/ios/EdgeEver/Data/Network/APIClient.swift @@ -185,6 +185,25 @@ actor APIClient { return response.editSession } + // MARK: - Templates + + func listTemplates() async throws -> [MemoTemplate] { + let response: TemplatesResponse = try await request(path: "/api/v1/templates") + return response.templates + } + + func useTemplate(templateId: String, notebookId: String) async throws -> MemoDetail { + struct Body: Encodable { + var notebookId: String + } + let response: MemoResponse = try await request( + path: "/api/v1/templates/\(templateId)/use", + method: "POST", + body: Body(notebookId: notebookId) + ) + return response.memo + } + // MARK: - Resources / tokens / tags func uploadMemoResource(memoId: String, filename: String, mimeType: String, data: Data) async throws -> Resource { diff --git a/apps/ios/EdgeEver/DesignSystem/ChromeParity.swift b/apps/ios/EdgeEver/DesignSystem/ChromeParity.swift index 0df645b3..769549f4 100644 --- a/apps/ios/EdgeEver/DesignSystem/ChromeParity.swift +++ b/apps/ios/EdgeEver/DesignSystem/ChromeParity.swift @@ -8,6 +8,7 @@ enum CreateMemoChrome { static let header = "createMemoHeader" static let back = "createMemoBackButton" static let status = "createMemoStatus" + static let template = "createMemoTemplateButton" static let done = "createMemoDoneButton" static let title = "createMemoTitleInput" static let metaRow = "createMemoMetaRow" diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift index af3ae914..4c8d97b7 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift @@ -4,7 +4,8 @@ import UIKit import UniformTypeIdentifiers enum MemoEditMode: Equatable { - case create(notebookId: String) + /// `seed` pre-fills title/body/tags (template / clip-style create). When nil, restores local new-note draft. + case create(notebookId: String, seed: CreateMemoSeed? = nil) case edit(memoId: String) } @@ -47,7 +48,11 @@ struct MemoEditView: View { @State private var showNotebookPicker = false @State private var showImagePicker = false @State private var showUploadError = false + @State private var showTemplatePicker = false + @State private var showApplyTemplateConfirm = false + @State private var pendingTemplateSeed: CreateMemoSeed? @State private var resourceTarget: ResourceTarget? + private let emptyDocJSON = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}" var body: some View { ZStack { @@ -89,6 +94,27 @@ struct MemoEditView: View { } .presentationDetents([.medium, .large]) } + .sheet(isPresented: $showTemplatePicker) { + TemplatePickerSheet { seed in + requestApplyTemplate(seed) + } + } + .alert( + env.preferences.t("应用模板?", en: "Apply template?"), + isPresented: $showApplyTemplateConfirm + ) { + Button(env.preferences.t("取消", en: "Cancel"), role: .cancel) { + pendingTemplateSeed = nil + } + Button(env.preferences.t("替换", en: "Replace"), role: .destructive) { + if let seed = pendingTemplateSeed { + applyTemplateSeed(seed) + } + pendingTemplateSeed = nil + } + } message: { + Text(env.preferences.t("当前内容将被模板内容替换。", en: "The current content will be replaced by the template.")) + } .sheet(item: $resourceTarget) { target in ResourceActionSheet( target: target, @@ -183,6 +209,25 @@ struct MemoEditView: View { .lineLimit(1) .accessibilityIdentifier(CreateMemoChrome.status) + if isCreate { + Button { + showTemplatePicker = true + } label: { + Text(env.preferences.t("模板", en: "Template")) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(canUseTemplate ? AppTheme.title : AppTheme.secondary) + .frame(minHeight: 36) + .padding(.horizontal, 12) + .overlay( + Capsule().stroke(AppTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(!canUseTemplate) + .accessibilityLabel(env.preferences.t("模板", en: "Template")) + .accessibilityIdentifier(CreateMemoChrome.template) + } + Button { Task { await handleDone() } } label: { @@ -398,6 +443,10 @@ struct MemoEditView: View { return !isSaving && !isUploading && editorReady } + private var canUseTemplate: Bool { + isCreate && !isCreating && !isUploading + } + private var availableNotebooks: [Notebook] { (try? env.mirror.listNotebooks(scope: env.session.dataScope ?? "")) ?? [] } @@ -429,6 +478,34 @@ struct MemoEditView: View { scheduleSave() } + private func requestApplyTemplate(_ seed: CreateMemoSeed) { + let current = CreateMemoSeed(title: title, contentMarkdown: contentMarkdown, tagsText: tagsText) + if current.hasContent { + pendingTemplateSeed = seed + showApplyTemplateConfirm = true + return + } + applyTemplateSeed(seed) + } + + private func applyTemplateSeed(_ seed: CreateMemoSeed) { + title = seed.title + tagsText = seed.tagsText + contentMarkdown = seed.contentMarkdown + // Empty stub JSON forces TipTapContentSource to open from markdown structure. + contentJSON = emptyDocJSON + baselineMarkdown = seed.contentMarkdown + editorReady = false + isDirty = true + scheduleSave() + // Re-push body into the shared editor runtime. + Task { @MainActor in + try? await Task.sleep(nanoseconds: 50_000_000) + SharedTipTapRuntime.editor.focusEnd() + editorReady = true + } + } + private func handleBack() async { // Android CreateMemoModal `requestClose`: flush editor then always run createMutation. // Text-only create must mint a local:/server memo + clear the new-note draft so: @@ -522,9 +599,15 @@ struct MemoEditView: View { private func loadInitial() async { guard let scope = env.session.dataScope else { return } switch mode { - case .create(let nb): + case .create(let nb, let seed): notebookId = nb - if let draft = try? env.drafts.read(scope: scope, key: DraftRepository.newKey) { + if let seed { + // Template / explicit seed wins over local new-note draft (Android initialDraft). + title = seed.title + tagsText = seed.tagsText + contentMarkdown = seed.contentMarkdown + contentJSON = emptyDocJSON + } else if let draft = try? env.drafts.read(scope: scope, key: DraftRepository.newKey) { title = draft.title tagsText = draft.tagsText contentMarkdown = draft.contentMarkdown diff --git a/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift b/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift index a676ed71..d59b461d 100644 --- a/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift @@ -5,6 +5,8 @@ struct NotesListView: View { @Environment(AppEnvironment.self) private var env @Bindable var store: WorkspaceStore @Binding var path: NavigationPath + var onCreateNote: (() -> Void)? = nil + var onCreateFromTemplate: (() -> Void)? = nil /// Whole-list settle + Pow jump once when data first becomes available this session. @State private var listEntranceSettled = false @@ -410,7 +412,7 @@ struct NotesListView: View { ) } - /// Android `memoListEmptyCard` + `emptyTitle` / `mutedText`. + /// Android `memoListEmptyCard` + `emptyTitle` / `mutedText` + dual create actions. private func emptyCard(title: String, description: String, showCreate: Bool) -> some View { VStack(spacing: 10) { Text(title) @@ -420,8 +422,48 @@ struct NotesListView: View { .font(.system(size: 13)) .foregroundStyle(AppTheme.secondary) .multilineTextAlignment(.center) - if showCreate { - // Create is primarily the bottom-nav center button (Android parity). + if showCreate, !store.notebooks.isEmpty { + HStack(spacing: 8) { + if let onCreateNote { + Button(action: onCreateNote) { + HStack(spacing: 8) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .bold)) + Text(env.preferences.t("新建笔记", en: "New note")) + .font(.system(size: 13, weight: .heavy)) + } + .foregroundStyle(Color.white) + .padding(.horizontal, 14) + .frame(minHeight: 38) + .background(AppTheme.title) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("emptyCreateNote") + } + if let onCreateFromTemplate { + Button(action: onCreateFromTemplate) { + HStack(spacing: 8) { + Image(systemName: "square.grid.2x2") + .font(.system(size: 13, weight: .semibold)) + Text(env.preferences.t("从模板新建", en: "New from template")) + .font(.system(size: 13, weight: .heavy)) + } + .foregroundStyle(AppTheme.title) + .padding(.horizontal, 14) + .frame(minHeight: 38) + .background(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(hex: 0xCBD5E1), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("emptyCreateFromTemplate") + } + } + .padding(.top, 0) } } .frame(maxWidth: .infinity) diff --git a/apps/ios/EdgeEver/Features/Workspace/TemplatePickerSheet.swift b/apps/ios/EdgeEver/Features/Workspace/TemplatePickerSheet.swift new file mode 100644 index 00000000..2a2d211e --- /dev/null +++ b/apps/ios/EdgeEver/Features/Workspace/TemplatePickerSheet.swift @@ -0,0 +1,309 @@ +import SwiftUI + +/// Android `MobileCreateChoiceModal` — blank vs template. +struct CreateChoiceSheet: View { + @Environment(AppEnvironment.self) private var env + @Environment(\.dismiss) private var dismiss + + var canCreate: Bool + var onBlank: () -> Void + var onTemplate: () -> Void + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Color(hex: 0xCBD5E1)) + .frame(width: 42, height: 4) + .padding(.top, 10) + .padding(.bottom, 8) + + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + Text(env.preferences.t("新建笔记", en: "New note")) + .font(.system(size: 15, weight: .heavy)) + .foregroundStyle(AppTheme.title) + Text(env.preferences.t("选择创建方式", en: "Choose how to create")) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AppTheme.title) + .frame(width: 38, height: 38) + } + .buttonStyle(.plain) + .accessibilityLabel(env.preferences.t("关闭", en: "Close")) + } + .padding(.horizontal, 12) + .frame(minHeight: 48) + .overlay(alignment: .bottom) { + Rectangle().fill(AppTheme.border).frame(height: 1) + } + + VStack(spacing: 0) { + choiceRow( + systemImage: "doc.text", + title: env.preferences.t("空白笔记", en: "Blank note"), + description: env.preferences.t("从空白页开始记录", en: "Start with an empty page") + ) { + dismiss() + onBlank() + } + choiceRow( + systemImage: "square.grid.2x2", + title: env.preferences.t("从模板新建", en: "New from template"), + description: env.preferences.t( + "使用会议纪要、周报等预设结构", + en: "Use meeting notes, weekly reviews, and more" + ) + ) { + dismiss() + onTemplate() + } + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + .padding(.bottom, 12) + } + .background(Color.white) + .presentationDetents([.height(280)]) + .presentationDragIndicator(.hidden) + .accessibilityIdentifier("createChoiceSheet") + } + + private func choiceRow( + systemImage: String, + title: String, + description: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(AppTheme.title) + .frame(width: 40, height: 40) + .background(AppTheme.searchFill) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(AppTheme.title) + Text(description) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + .multilineTextAlignment(.leading) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 14) + .contentShape(Rectangle()) + .opacity(canCreate ? 1 : 0.45) + } + .buttonStyle(.plain) + .disabled(!canCreate) + } +} + +/// Android `MobileTemplatePickerModal` — saved + built-in templates. +struct TemplatePickerSheet: View { + @Environment(AppEnvironment.self) private var env + @Environment(\.dismiss) private var dismiss + + var onSelect: (CreateMemoSeed) -> Void + + @State private var saved: [SelectableMemoTemplate] = [] + @State private var isLoadingSaved = false + @State private var savedLoadFailed = false + + private var builtIn: [SelectableMemoTemplate] { + BuiltInMemoTemplates.all(isEnglish: env.preferences.isEnglish) + } + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Color(hex: 0xCBD5E1)) + .frame(width: 42, height: 4) + .padding(.top, 10) + .padding(.bottom, 8) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(env.preferences.t("从模板新建", en: "New from template")) + .font(.system(size: 15, weight: .heavy)) + .foregroundStyle(AppTheme.title) + Text(env.preferences.t( + "选择预设结构快速开始,也可使用网页端保存的自定义模板。", + en: "Start from a preset structure, or use custom templates saved on the web." + )) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AppTheme.title) + .frame(width: 38, height: 38) + } + .buttonStyle(.plain) + .accessibilityLabel(env.preferences.t("关闭", en: "Close")) + } + .padding(.horizontal, 12) + .padding(.bottom, 10) + .overlay(alignment: .bottom) { + Rectangle().fill(AppTheme.border).frame(height: 1) + } + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + sectionTitle(env.preferences.t("我的自定义模板", en: "My custom templates")) + + if isLoadingSaved { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(env.preferences.t("正在加载模板", en: "Loading templates")) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 10) + } else if savedLoadFailed { + hint(env.preferences.t( + "自定义模板暂时无法加载,仍可使用下方内置模板。", + en: "Custom templates could not load. Built-in templates are still available." + )) + } else if saved.isEmpty { + hint(env.preferences.t( + "暂无自定义模板。可在网页端将常用笔记另存为模板。", + en: "No custom templates yet. Save notes as templates on the web." + )) + } + + ForEach(saved) { template in + templateRow(template) + } + + divider + + sectionTitle(env.preferences.t("内置推荐模板", en: "Recommended templates")) + ForEach(builtIn) { template in + templateRow(template) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + .padding(.bottom, 20) + } + } + .background(Color.white) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + .accessibilityIdentifier("templatePickerSheet") + .task { await loadSaved() } + } + + private func loadSaved() async { + isLoadingSaved = true + savedLoadFailed = false + defer { isLoadingSaved = false } + do { + let list = try await env.session.client.listTemplates() + saved = list.map(BuiltInMemoTemplates.fromSaved) + } catch { + savedLoadFailed = true + saved = [] + } + } + + private func sectionTitle(_ text: String) -> some View { + Text(text) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppTheme.secondary) + .padding(.horizontal, 8) + .padding(.top, 8) + .padding(.bottom, 4) + } + + private func hint(_ text: String) -> some View { + Text(text) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 8) + .padding(.vertical, 8) + } + + private var divider: some View { + Rectangle() + .fill(AppTheme.searchFill) + .frame(height: 1) + .padding(.vertical, 8) + } + + private func templateRow(_ template: SelectableMemoTemplate) -> some View { + Button { + onSelect(template.createSeed) + dismiss() + } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "square.grid.2x2") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppTheme.accentStrong) + .frame(width: 32, height: 32) + .background(AppTheme.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(template.name) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppTheme.title) + .lineLimit(1) + Spacer(minLength: 0) + badge(for: template.source) + } + if !template.description.isEmpty { + Text(template.description) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("templateRow-\(template.id)") + } + + private func badge(for source: MemoTemplateSource) -> some View { + let isCustom = source == .saved + return Text(isCustom + ? env.preferences.t("自定义", en: "Custom") + : env.preferences.t("内置", en: "Built-in")) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(isCustom ? AppTheme.accentStrong : AppTheme.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(isCustom ? AppTheme.accentSoft : AppTheme.searchFill) + .overlay( + Capsule().stroke(isCustom ? Color(hex: 0xA7F3D0) : AppTheme.border, lineWidth: 1) + ) + .clipShape(Capsule()) + } +} diff --git a/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift b/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift index 0a2e6485..64e6ec4e 100644 --- a/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit import Pow /// Wrapper so `fullScreenCover(item:)` can present edit for a memo id. @@ -12,6 +13,10 @@ struct WorkspaceView: View { @State private var path = NavigationPath() @State private var showSettings = false @State private var showNewNote = false + @State private var createSeed: CreateMemoSeed? + @State private var showCreateChoice = false + @State private var showTemplatePicker = false + @State private var createLongPressConsumed = false @State private var showMoveSheet = false @State private var conflictItem: OutboxItem? @State private var createTapCount = 0 @@ -39,7 +44,12 @@ struct WorkspaceView: View { VStack(spacing: 0) { syncBanner listHeader - NotesListView(store: store, path: $path) + NotesListView( + store: store, + path: $path, + onCreateNote: { openCreateNote() }, + onCreateFromTemplate: { openCreateFromTemplate() } + ) .frame(maxWidth: .infinity, maxHeight: .infinity) } .background(AppTheme.background) @@ -75,7 +85,10 @@ struct WorkspaceView: View { // Android CreateMemoModal is fullScreen — not a half sheet / Form. .fullScreenCover(isPresented: $showNewNote) { MemoEditView( - mode: .create(notebookId: store.selectedNotebookId ?? store.notebooks.first?.id ?? ""), + mode: .create( + notebookId: store.selectedNotebookId ?? store.notebooks.first?.id ?? "", + seed: createSeed + ), onCreateFinished: { memoId in // Prime list + bounce **before** dismiss so settle runs under/with the cover, // not half a second after the list is already static. @@ -85,10 +98,23 @@ struct WorkspaceView: View { } ) .onDisappear { + createSeed = nil // Safety refresh if create finished without callback (e.g. swipe-dismiss empty). store.reload(env: env) } } + .sheet(isPresented: $showCreateChoice) { + CreateChoiceSheet( + canCreate: !store.notebooks.isEmpty, + onBlank: { openCreateNote() }, + onTemplate: { openCreateFromTemplate() } + ) + } + .sheet(isPresented: $showTemplatePicker) { + TemplatePickerSheet { seed in + openCreateNote(seed: seed) + } + } // Edit cover on workspace root; underlay is list (detail popped once cover is presented). .fullScreenCover(item: $editingMemo) { route in MemoEditView( @@ -437,8 +463,12 @@ struct WorkspaceView: View { } Button { + if createLongPressConsumed { + createLongPressConsumed = false + return + } createTapCount += 1 - showNewNote = true + openCreateNote() } label: { Image(systemName: "plus") .font(.system(size: 22, weight: .semibold)) @@ -455,9 +485,19 @@ struct WorkspaceView: View { .contentShape(Circle()) } .buttonStyle(CreateButtonPressStyle()) + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.45) + .onEnded { _ in + guard canCreate else { return } + createLongPressConsumed = true + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + showCreateChoice = true + } + ) .edgeEverCreatePing(count: createTapCount) .disabled(!canCreate) .accessibilityLabel(env.preferences.t("新建笔记", en: "New note")) + .accessibilityHint(env.preferences.t("长按可从模板新建", en: "Long-press to create from a template")) .accessibilityIdentifier("bottomCreateButton") .frame(maxWidth: .infinity) @@ -568,6 +608,17 @@ struct WorkspaceView: View { .animation(Motion.search, value: env.bootstrapProgress != nil) } + private func openCreateNote(seed: CreateMemoSeed? = nil) { + guard !store.notebooks.isEmpty else { return } + createSeed = seed + showNewNote = true + } + + private func openCreateFromTemplate() { + guard !store.notebooks.isEmpty else { return } + showTemplatePicker = true + } + private func consumeShare() { let payloads = env.shareHandoff.consumePending() guard let first = payloads.first, let scope = env.session.dataScope else { return } @@ -589,7 +640,7 @@ struct WorkspaceView: View { updatedAt: EdgeEverDate.nowString() ) ) - showNewNote = true + openCreateNote() } private func detectConflicts() { diff --git a/apps/ios/Tests/EdgeEverTests/MemoTemplatesTests.swift b/apps/ios/Tests/EdgeEverTests/MemoTemplatesTests.swift new file mode 100644 index 00000000..91d6ad7d --- /dev/null +++ b/apps/ios/Tests/EdgeEverTests/MemoTemplatesTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import EdgeEver + +final class MemoTemplatesTests: XCTestCase { + func testBuiltInCatalogHasSixItemsBothLocales() { + let zh = BuiltInMemoTemplates.all(isEnglish: false) + let en = BuiltInMemoTemplates.all(isEnglish: true) + XCTAssertEqual(zh.count, 6) + XCTAssertEqual(en.count, 6) + XCTAssertEqual(zh[0].name, "灵感速记") + XCTAssertEqual(en[0].name, "Quick Spark") + XCTAssertTrue(zh[1].contentMarkdown.contains("会议纪要")) + XCTAssertTrue(en[1].contentMarkdown.contains("Meeting Minutes")) + XCTAssertEqual(zh[0].source, .builtin) + XCTAssertEqual(zh[0].tags, ["template", "quick-note"]) + } + + func testCreateSeedFromSelectableTemplate() { + let template = SelectableMemoTemplate( + id: "tpl", + name: "我的周报", + description: "desc", + title: "【周报】", + contentMarkdown: "## 本周", + tags: ["work", "weekly"], + source: .saved + ) + XCTAssertEqual(template.createSeed.title, "【周报】") + XCTAssertEqual(template.createSeed.contentMarkdown, "## 本周") + XCTAssertEqual(template.createSeed.tagsText, "work, weekly") + XCTAssertTrue(template.createSeed.hasContent) + XCTAssertFalse(CreateMemoSeed(title: "", contentMarkdown: "", tagsText: "").hasContent) + XCTAssertTrue(CreateMemoSeed(title: "a", contentMarkdown: "", tagsText: "").hasContent) + } + + func testFromSavedMapsServerTemplate() { + let saved = MemoTemplate( + id: "tpl_1", + name: "自定义", + description: "说明", + title: nil, + contentMarkdown: "# Hi", + tags: ["x"], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + ) + let row = BuiltInMemoTemplates.fromSaved(saved) + XCTAssertEqual(row.source, .saved) + XCTAssertEqual(row.title, "自定义") + XCTAssertEqual(row.contentMarkdown, "# Hi") + } +} diff --git a/apps/mobile/src/components/LocalTiptapEditor.tsx b/apps/mobile/src/components/LocalTiptapEditor.tsx index 9089d6fd..409916f9 100644 --- a/apps/mobile/src/components/LocalTiptapEditor.tsx +++ b/apps/mobile/src/components/LocalTiptapEditor.tsx @@ -47,6 +47,8 @@ export interface LocalTiptapEditorRef extends DOMImperativeFactory { appendAttachment: (attachmentUrl: DOMValue, filename: DOMValue) => void; removeResource: (targetJson: DOMValue) => void; renameResource: (targetJson: DOMValue, filename: DOMValue) => void; + /** Replace body without remounting the DomWebView (JSON string of TipTap doc). */ + setContent: (contentJsonSerialized: DOMValue) => void; flush: () => void; focusEnd: () => void; replaceAll: (query: DOMValue, replacement: DOMValue) => void; @@ -457,6 +459,22 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { void onChangeRef.current(getPersistableEditorDoc(editor.getJSON() as EditorDoc, props.baseUrl)); }, [editor, isViewer, props.baseUrl]); + const setContent = useCallback((contentJsonSerialized: DOMValue) => { + if (!editor || editor.isDestroyed || typeof contentJsonSerialized !== "string") { + return; + } + try { + const parsed = JSON.parse(contentJsonSerialized) as EditorDoc; + const next = resolveImageSources(parsed, props.baseUrl); + editor.commands.setContent(next, { emitUpdate: !isViewer }); + if (!isViewer) { + editor.commands.focus("end"); + } + } catch { + // Ignore malformed payloads from the native bridge. + } + }, [editor, isViewer, props.baseUrl]); + const search = useCallback((query: DOMValue, requestedIndex: DOMValue) => { const matches = getEditorSearchMatches(editor, typeof query === "string" ? query : ""); const requestedMatchIndex = typeof requestedIndex === "number" ? requestedIndex : 0; @@ -607,6 +625,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { cancelImageUpload, completeImageUpload, appendAttachment, + setContent, flush, focusEnd: () => { if (!isViewer) editor?.commands.focus("end"); @@ -616,7 +635,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { replaceAll, search, }), - [appendAttachment, beginImageUpload, cancelImageUpload, completeImageUpload, editor, flush, isViewer, removeResource, renameResource, replaceAll, search] + [appendAttachment, beginImageUpload, cancelImageUpload, completeImageUpload, editor, flush, isViewer, removeResource, renameResource, replaceAll, search, setContent] ); useEffect(() => { diff --git a/apps/mobile/src/components/MobileTemplatePicker.tsx b/apps/mobile/src/components/MobileTemplatePicker.tsx index 9e83c76c..a4cb1eb1 100644 --- a/apps/mobile/src/components/MobileTemplatePicker.tsx +++ b/apps/mobile/src/components/MobileTemplatePicker.tsx @@ -17,17 +17,25 @@ import { styles } from "../screens/workspace-styles"; type MobileClient = ReturnType; +/** + * Template picker. + * - `modal` (default): system Modal — use from the workspace root only. + * - `overlay`: absolute fill overlay — use inside CreateMemoModal so we do NOT nest + * RN Modals over DomWebView (Android keyboard / focus breaks after nested modals). + */ export const MobileTemplatePickerModal = ({ bottomOffset = 0, client, onClose, onSelect, + presentation = "modal", visible, }: { bottomOffset?: number; client: MobileClient | null; onClose: () => void; onSelect: (seed: MobileCreateMemoSeed) => void; + presentation?: "modal" | "overlay"; visible: boolean; }) => { const { resolvedLocale, translate } = useMobileLocale(); @@ -43,67 +51,109 @@ export const MobileTemplatePickerModal = ({ if (!client) { return [] as MobileSelectableTemplate[]; } + if (typeof client.listTemplates !== "function") { + throw new Error("Client is missing listTemplates; reload the app to pick up the latest bundle."); + } const response = await client.listTemplates(); return response.templates.map((template) => toMobileSelectableTemplate(template, "saved")); }, - staleTime: 30_000, + staleTime: 15_000, + retry: 1, }); const savedTemplates = savedTemplatesQuery.data ?? []; const isLoadingSaved = savedTemplatesQuery.isLoading || savedTemplatesQuery.isFetching; + const savedLoadErrorMessage = savedTemplatesQuery.error instanceof Error + ? savedTemplatesQuery.error.message + : savedTemplatesQuery.isError + ? String(savedTemplatesQuery.error ?? "") + : ""; const handleSelect = (template: MobileSelectableTemplate) => { onSelect(mobileTemplateToCreateSeed(template)); onClose(); }; - return ( - - - event.stopPropagation()}> - - - - {translate("从模板新建")} - - {translate("选择预设结构快速开始,也可使用网页端保存的自定义模板。")} - - - - - - + if (!visible) { + return null; + } - - {translate("我的自定义模板")} - {isLoadingSaved ? ( - - - {translate("正在加载模板")} - - ) : null} - {!isLoadingSaved && savedTemplatesQuery.isError ? ( + const sheet = ( + + 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} /> - ))} + {savedLoadErrorMessage ? ( + + {savedLoadErrorMessage} + + ) : null} + void savedTemplatesQuery.refetch()} + style={styles.templatePickerRetryButton} + > + {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} /> - ))} - - + + {translate("内置推荐模板")} + {builtInTemplates.map((template) => ( + handleSelect(template)} template={template} /> + ))} + + + ); + + if (presentation === "overlay") { + return sheet; + } + + return ( + + {sheet} ); }; @@ -209,3 +259,4 @@ const TemplateRow = ({ ); }; + diff --git a/apps/mobile/src/lib/safe-dom-call.ts b/apps/mobile/src/lib/safe-dom-call.ts new file mode 100644 index 00000000..dfa23d07 --- /dev/null +++ b/apps/mobile/src/lib/safe-dom-call.ts @@ -0,0 +1,19 @@ +/** + * Expo DOM imperative methods (`focusEnd`, `setContent`, `flush`, …) bridge through + * `DomWebView.injectJavaScript`. After the native view unmounts, those calls reject with: + * + * Unable to find the class expo.modules.webview.DomWebView view with tag N + * + * That rejection surfaces as the red "Uncaught (in promise)" toast in dev. Swallow it — + * the host screen has already left the editor. + */ +export const safeDomCall = (invoke: () => unknown): void => { + try { + const result = invoke(); + if (result != null && typeof (result as Promise).then === "function") { + void (result as Promise).catch(() => undefined); + } + } catch { + // Synchronous bridge failures (rare) — ignore. + } +}; diff --git a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx index 8fa84871..d6b46fd1 100644 --- a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx +++ b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx @@ -9,6 +9,7 @@ import { Alert, Pressable, Text, TextInput } from "../components/LocalizedText"; import LocalTiptapEditor, { type LocalTiptapEditorRef } from "../components/LocalTiptapEditor"; import { MobileResourceActions } from "../components/MobileResourceActions"; import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom"; +import { safeDomCall } from "../lib/safe-dom-call"; import { getMobileImageTarget, openMobileResource, @@ -476,7 +477,7 @@ export const MemoDetailModal = ({ if (!viewerReady || !searchOpen) { return; } - viewerRef.current?.search(searchQuery, activeMatchIndex); + safeDomCall(() => viewerRef.current?.search(searchQuery, activeMatchIndex)); }, [activeMatchIndex, searchOpen, searchQuery, viewerReady]); const moveSearchMatch = (direction: 1 | -1) => { @@ -683,7 +684,7 @@ export const MemoDetailModal = ({ setSearchQuery(""); setSearchMatchCount(0); setActiveMatchIndex(0); - viewerRef.current?.search("", 0); + safeDomCall(() => viewerRef.current?.search("", 0)); }}> diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index 03cf7083..7eb80839 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -112,9 +112,9 @@ import { AccountSecurityPanel } from "./AccountSecurityModal"; import { beginEditorStartup, markStartup, recordEditorStartup } from "../lib/startup-performance"; import { prepareUploadAsset } from "../lib/mobile-image-upload"; import MobileWebClipCapture from "../components/MobileWebClipCapture"; -import { showEdgeEverKeyboard } from "../../modules/edgeever-keyboard"; import LocalTiptapEditor, { type LocalTiptapEditorRef } from "../components/LocalTiptapEditor"; import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom"; +import { safeDomCall } from "../lib/safe-dom-call"; import { MobileResourceActions } from "../components/MobileResourceActions"; import { MobileCreateChoiceModal, MobileTemplatePickerModal } from "../components/MobileTemplatePicker"; import { resolveMobileThemeStyles, useMobileTheme } from "../lib/mobile-theme"; @@ -553,6 +553,7 @@ export const WorkspaceScreen = ({ }, []); const openRichEditor = useCallback(async (memo: MemoDetail) => { + // Unmount detail DomWebView before the editable instance mounts (Android IME). beginEditorStartup(); let editingMemo = memo; const queuedItem = (await listMobileSyncQueueItems(syncQueueScope)).find((item) => item.memoId === memo.id); @@ -571,6 +572,7 @@ export const WorkspaceScreen = ({ const draft = await loadMemoDraft(editingMemo.id); memoDraftPrefetchRef.current.delete(memo.id); + setSelectedMemoId(null); setRichEditingSession({ draft, memo: editingMemo }); }, [client, dataScope, loadMemoDraft, queryClient, syncQueueScope]); @@ -600,6 +602,11 @@ export const WorkspaceScreen = ({ const canCreateMemo = memoView !== "trash" && Boolean(createMemoNotebookId); const openCreateMemo = useCallback((seed: MobileCreateMemoSeed | null = null) => { beginEditorStartup(); + // Drop detail / selection so no other DomWebView stays mounted under the editor. + // Multiple WebViews make Android IME attach to the wrong (often read-only) view. + setSelectedMemoId(null); + setSelectionMode(false); + setSelectedMemoIds(new Set()); setIncomingClipDraft(null); setCreateSeed(seed); setCreateOpen(true); @@ -609,11 +616,13 @@ export const WorkspaceScreen = ({ if (!canCreateMemo) { return; } + setSelectedMemoId(null); setTemplatePickerOpen(true); }, [canCreateMemo]); const openIncomingClipDraft = useCallback((draft: MobileWebClipDraft) => { beginEditorStartup(); + setSelectedMemoId(null); setCreateSeed(null); setIncomingClipDraft(draft); setActiveView("notes"); @@ -1154,6 +1163,37 @@ export const WorkspaceScreen = ({ />; } + // Full-tree create (same as rich edit) — never stack DomWebView inside RN Modal over + // list/detail WebViews; that breaks Android soft-input attachment. + if (createOpen) { + return ( + { + setCreateOpen(false); + setIncomingClipDraft(null); + setCreateSeed(null); + setActiveView("notes"); + setMemoView("notebook"); + setSelectedMemoId(null); + }} + onDismiss={() => { + setCreateOpen(false); + setIncomingClipDraft(null); + setCreateSeed(null); + }} + onQueued={runForcedSync} + syncQueueScope={syncQueueScope} + /> + ); + } + return ( @@ -1270,27 +1310,6 @@ export const WorkspaceScreen = ({ }} /> : null} - { - setCreateOpen(false); - setIncomingClipDraft(null); - setCreateSeed(null); - setActiveView("notes"); - setMemoView("notebook"); - setSelectedMemoId(null); - }} - onQueued={runForcedSync} - syncQueueScope={syncQueueScope} - visible={createOpen} - /> - ["client"]; @@ -1746,9 +1765,9 @@ const CreateMemoModal = ({ initialDraft?: MobileCreateMemoSeed | MobileWebClipDraft | null; notebooks: Notebook[]; onCreated: (memo: MemoDetail) => void; + onDismiss: () => void; onQueued: () => void | Promise; syncQueueScope: string; - visible: boolean; }) => { const sessionState = useSession(); const client = clientProp ?? sessionState.client; @@ -1777,7 +1796,6 @@ const CreateMemoModal = ({ 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); @@ -1788,58 +1806,89 @@ const CreateMemoModal = ({ const titleRef = useRef(title); const tagsTextRef = useRef(tagsText); const targetNotebookIdRef = useRef(targetNotebookId); + const userEditedSinceOpenRef = useRef(false); + const notebooksRef = useRef(notebooks); + notebooksRef.current = notebooks; titleRef.current = title; tagsTextRef.current = tagsText; targetNotebookIdRef.current = targetNotebookId; - useEffect(() => { - if (!visible) { - setTemplatePickerOpen(false); - return; + const focusTimerRef = useRef | null>(null); + + const pushBodyToEditor = useCallback((doc: TiptapDoc) => { + safeDomCall(() => editorRef.current?.setContent(JSON.stringify(doc))); + }, []); + + const scheduleFocusEnd = useCallback((delayMs = 60) => { + if (focusTimerRef.current !== null) { + clearTimeout(focusTimerRef.current); } + focusTimerRef.current = setTimeout(() => { + focusTimerRef.current = null; + safeDomCall(() => editorRef.current?.focusEnd()); + }, delayMs); + }, []); + + // Component is only mounted while create is open — init once on mount. + useEffect(() => { let active = true; - setDraftLoaded(false); setEditorReady(false); setTemplatePickerOpen(false); + userEditedSinceOpenRef.current = false; + draftVersionRef.current = 0; + + // Mount DomWebView immediately — do not wait on AsyncStorage before cold start. if (initialDraft) { const markdown = initialDraft.contentMarkdown; contentMarkdownRef.current = markdown; contentJsonRef.current = markdownToDoc(markdown); - draftVersionRef.current = 0; setTitle(initialDraft.title); setTagsText(initialDraft.tagsText); setContentMarkdown(markdown); setNotebookId(fallbackNotebookId); setDirty(false); - setEditorSeed((current) => current + 1); setDraftLoaded(true); return () => { active = false; }; } + + contentMarkdownRef.current = ""; + contentJsonRef.current = markdownToDoc(""); + setTitle(""); + setTagsText(""); + setContentMarkdown(""); + setNotebookId(fallbackNotebookId); + setDirty(false); + setDraftLoaded(true); + void readMobileNewMemoDraft(dataScope).then((draft) => { - if (!active) { + if (!active || !draft) { return; } - const restoredNotebookId = draft && notebooks.some((notebook) => notebook.id === draft.notebookId) + // Don't clobber if the user already started typing. + if (userEditedSinceOpenRef.current) { + return; + } + const restoredNotebookId = notebooksRef.current.some((notebook) => notebook.id === draft.notebookId) ? draft.notebookId : fallbackNotebookId; - const markdown = draft?.contentMarkdown ?? ""; + const markdown = draft.contentMarkdown ?? ""; + const doc = markdownToDoc(markdown); contentMarkdownRef.current = markdown; - contentJsonRef.current = markdownToDoc(markdown); - draftVersionRef.current = 0; - setTitle(draft?.title ?? ""); - setTagsText(draft?.tagsText ?? ""); + contentJsonRef.current = doc; + setTitle(draft.title ?? ""); + setTagsText(draft.tagsText ?? ""); setContentMarkdown(markdown); setNotebookId(restoredNotebookId); setDirty(false); - setEditorSeed((current) => current + 1); - setDraftLoaded(true); + pushBodyToEditor(doc); }); return () => { active = false; }; - }, [dataScope, fallbackNotebookId, initialDraft, visible]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only for create screen lifetime + }, []); const isWebClipDraft = Boolean( initialDraft && "sourceUrl" in initialDraft && typeof initialDraft.sourceUrl === "string" && initialDraft.sourceUrl.length > 0 @@ -1847,16 +1896,19 @@ const CreateMemoModal = ({ const applyTemplateSeed = useCallback((seed: MobileCreateMemoSeed) => { const markdown = seed.contentMarkdown; + const doc = markdownToDoc(markdown); contentMarkdownRef.current = markdown; - contentJsonRef.current = markdownToDoc(markdown); + contentJsonRef.current = doc; setTitle(seed.title); setTagsText(seed.tagsText); setContentMarkdown(markdown); - setEditorReady(false); - setEditorSeed((current) => current + 1); draftVersionRef.current += 1; + userEditedSinceOpenRef.current = true; setDirty(true); - }, []); + // In-place body replace — never remount DomWebView (remount costs ~1s and breaks Android IME). + pushBodyToEditor(doc); + scheduleFocusEnd(80); + }, [pushBodyToEditor, scheduleFocusEnd]); const requestApplyTemplateSeed = useCallback((seed: MobileCreateMemoSeed) => { const current = { @@ -1875,7 +1927,7 @@ const CreateMemoModal = ({ }, [applyTemplateSeed, translate]); useEffect(() => { - if (!visible || !draftLoaded || !dirty || isWebClipDraft) { + if (!draftLoaded || !dirty || isWebClipDraft) { return; } const draftVersion = draftVersionRef.current; @@ -1905,7 +1957,7 @@ const CreateMemoModal = ({ }); }, 350); return () => clearTimeout(timeout); - }, [contentMarkdown, dataScope, dirty, draftLoaded, isWebClipDraft, tagsText, targetNotebookId, title, visible]); + }, [contentMarkdown, dataScope, dirty, draftLoaded, isWebClipDraft, tagsText, targetNotebookId, title]); const createMutation = useMutation({ mutationFn: async () => { @@ -2040,7 +2092,7 @@ const CreateMemoModal = ({ if (isImage) { uploadId = createMobileImageUploadId(); const previewDataUrl = await createLocalImagePreviewDataUrl(asset); - editorRef.current?.beginImageUpload(uploadId, previewDataUrl); + safeDomCall(() => editorRef.current?.beginImageUpload(uploadId, previewDataUrl)); } const memo = await materializeMemoForImage(); setImageOperation("uploading"); @@ -2049,17 +2101,17 @@ const CreateMemoModal = ({ form.append("file", new ExpoFile(uploadAsset.uri)); const { resource } = await client!.uploadMemoResource(memo.id, form); if (resource.kind === "image" && uploadId) { - editorRef.current?.completeImageUpload( + safeDomCall(() => editorRef.current?.completeImageUpload( uploadId, resource.url, resource.filename || uploadAsset.name || "图片" - ); + )); } else { - editorRef.current?.appendAttachment(resource.url, resource.filename || uploadAsset.name || "附件"); + safeDomCall(() => editorRef.current?.appendAttachment(resource.url, resource.filename || uploadAsset.name || "附件")); } } catch (error) { if (uploadId) { - editorRef.current?.cancelImageUpload(uploadId); + safeDomCall(() => editorRef.current?.cancelImageUpload(uploadId)); } Alert.alert("附件上传失败", error instanceof Error ? error.message : "请检查网络连接后重试"); } finally { @@ -2068,6 +2120,7 @@ const CreateMemoModal = ({ }; const markDirty = () => { + userEditedSinceOpenRef.current = true; draftVersionRef.current += 1; setDirty(true); }; @@ -2087,7 +2140,7 @@ const CreateMemoModal = ({ resolve(); }; flushResolverRef.current = finish; - editorRef.current?.flush(); + safeDomCall(() => editorRef.current?.flush()); setTimeout(finish, 1000); }); }; @@ -2097,9 +2150,24 @@ const CreateMemoModal = ({ return; } await flushEditor(); + // Match prior Android create behavior: back commits the note (even if empty draft). createMutation.mutate(); }; + useEffect(() => { + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + void requestClose(); + return true; + }); + return () => { + subscription.remove(); + if (focusTimerRef.current !== null) { + clearTimeout(focusTimerRef.current); + focusTimerRef.current = null; + } + }; + }, []); + const loadEditorResource = useCallback((source: string) => { if (!client) { return Promise.resolve(null); @@ -2132,13 +2200,13 @@ const CreateMemoModal = ({ const renameResource = useCallback(async (target: MobileResourceTarget, filename: string) => { if (!client || !materializedMemoRef.current) throw new Error(resolvedLocale === "en-US" ? "Wait for this note to sync first." : "请等待笔记同步完成。"); const { resource } = await client.renameResource(target.resourceId, filename); - editorRef.current?.renameResource(JSON.stringify(target), resource.filename || filename); + safeDomCall(() => editorRef.current?.renameResource(JSON.stringify(target), resource.filename || filename)); }, [client, resolvedLocale]); const deleteResource = useCallback(async (target: MobileResourceTarget) => { if (!client || !materializedMemoRef.current) throw new Error(resolvedLocale === "en-US" ? "Wait for this note to sync first." : "请等待笔记同步完成。"); await client.deleteResource(target.resourceId); - editorRef.current?.removeResource(JSON.stringify(target)); + safeDomCall(() => editorRef.current?.removeResource(JSON.stringify(target))); }, [client, resolvedLocale]); const selectResource = useCallback(async (targetJson: string) => { @@ -2148,7 +2216,6 @@ const CreateMemoModal = ({ const editorElement = useMemo(() => draftLoaded && baseUrl ? ( { setEditorReady(true); recordEditorStartup(elapsedMs); - if (Platform.OS === "android") { - setTimeout(showEdgeEverKeyboard, 180); - } + // TipTap autofocus owns caret; avoid EdgeEverKeyboard (wrong WebView / stale inject). + scheduleFocusEnd(60); }} ref={editorRef} locale={resolvedLocale} theme={resolvedTheme} /> - ) : null, [baseUrl, draftLoaded, editorSeed, loadEditorResource, resolvedLocale, resolvedTheme, selectResource]); + ) : null, [baseUrl, draftLoaded, loadEditorResource, resolvedLocale, resolvedTheme, selectResource]); return ( - void requestClose()} presentationStyle="fullScreen" visible={visible}> - - - void requestClose()} style={styles.createMemoBackButton}> - + + + void requestClose()} style={styles.createMemoBackButton}> + + + + + {imageOperation === "creating" ? "正在创建" : imageOperation === "uploading" ? "正在上传" : createMutation.isPending || dirty ? "保存中" : editorReady ? "已保存" : "正在启动"} + + setTemplatePickerOpen(true)} + style={[styles.createMemoTemplateButton, !canUseTemplate && styles.createMemoDoneButtonDisabled]} + > + {translate("模板")} + + void flushEditor().then(() => createMutation.mutate())} + style={[styles.createMemoDoneButton, !canSubmitCreateMemo && styles.createMemoDoneButtonDisabled]} + > + {createMutation.isPending ? : 完成} - - - {imageOperation === "creating" ? "正在创建" : imageOperation === "uploading" ? "正在上传" : createMutation.isPending || dirty ? "保存中" : editorReady ? "已保存" : "正在启动"} - - setTemplatePickerOpen(true)} - style={[styles.createMemoTemplateButton, !canUseTemplate && styles.createMemoDoneButtonDisabled]} - > - {translate("模板")} - - void flushEditor().then(() => createMutation.mutate())} - style={[styles.createMemoDoneButton, !canSubmitCreateMemo && styles.createMemoDoneButtonDisabled]} - > - {createMutation.isPending ? : 完成} - - + - - { - setTitle(value); - markDirty(); - }} - placeholder={DEFAULT_MEMO_TITLE} - placeholderTextColor="#94a3b8" - style={styles.createMemoTitleInput} - value={title} - /> - - - setNotebookPickerOpen(true)} style={styles.createMemoNotebookButton}> - {selectedNotebookName} - - - { - setTagsText(value); - markDirty(); - }} - placeholder="添加标签,用逗号分隔" - placeholderTextColor="#94a3b8" - style={styles.createMemoTagsInput} - value={tagsText} - /> - - - - {!editorReady ? ( - - - 正在启动本地编辑器 - - ) : null} - {editorElement} - - - {createMutation.error ? ( - {createMutation.error instanceof Error ? createMutation.error.message : "创建失败"} - ) : null} - - setNotebookPickerOpen(false)} - onSelect={(nextNotebookId) => { - setNotebookId(nextNotebookId); - setNotebookPickerOpen(false); + + { + setTitle(value); markDirty(); }} - visible={notebookPickerOpen} + placeholder={DEFAULT_MEMO_TITLE} + placeholderTextColor="#94a3b8" + style={styles.createMemoTitleInput} + value={title} /> - setResourceTarget(null)} - onDelete={deleteResource} - onDownload={downloadResource} - onRename={renameResource} - onSaveAs={saveResourceAs} - target={resourceTarget} - /> - setTemplatePickerOpen(false)} - onSelect={requestApplyTemplateSeed} - visible={templatePickerOpen} - /> - - + + + setNotebookPickerOpen(true)} style={styles.createMemoNotebookButton}> + {selectedNotebookName} + + + { + setTagsText(value); + markDirty(); + }} + placeholder="添加标签,用逗号分隔" + placeholderTextColor="#94a3b8" + style={styles.createMemoTagsInput} + value={tagsText} + /> + + + + {editorElement} + + + {createMutation.error ? ( + {createMutation.error instanceof Error ? createMutation.error.message : "创建失败"} + ) : null} + + setNotebookPickerOpen(false)} + onSelect={(nextNotebookId) => { + setNotebookId(nextNotebookId); + setNotebookPickerOpen(false); + markDirty(); + }} + visible={notebookPickerOpen} + /> + setResourceTarget(null)} + onDelete={deleteResource} + onDownload={downloadResource} + onRename={renameResource} + onSaveAs={saveResourceAs} + target={resourceTarget} + /> + { + setTemplatePickerOpen(false); + scheduleFocusEnd(80); + }} + onSelect={requestApplyTemplateSeed} + presentation="overlay" + visible={templatePickerOpen} + /> + ); }; @@ -2610,7 +2672,7 @@ const RichEditorModal = ({ resolve(); }; flushResolverRef.current = finish; - editorRef.current?.flush(); + safeDomCall(() => editorRef.current?.flush()); setTimeout(finish, 1000); }); }; @@ -2665,23 +2727,23 @@ const RichEditorModal = ({ try { if (isImage && uploadId) { const previewDataUrl = await createLocalImagePreviewDataUrl(asset); - editorRef.current?.beginImageUpload(uploadId, previewDataUrl); + safeDomCall(() => editorRef.current?.beginImageUpload(uploadId, previewDataUrl)); } const uploadAsset = await prepareUploadAsset(asset, imageCompressionEnabled); const form = new FormData(); form.append("file", new ExpoFile(uploadAsset.uri)); const { resource } = await client.uploadMemoResource(memo.id, form); if (resource.kind === "image" && uploadId) { - editorRef.current?.completeImageUpload( + safeDomCall(() => editorRef.current?.completeImageUpload( uploadId, resource.url, resource.filename || uploadAsset.name || "图片" - ); + )); } else { - editorRef.current?.appendAttachment(resource.url, resource.filename || uploadAsset.name || "附件"); + safeDomCall(() => editorRef.current?.appendAttachment(resource.url, resource.filename || uploadAsset.name || "附件")); } } catch (uploadError) { - editorRef.current?.cancelImageUpload(uploadId); + safeDomCall(() => editorRef.current?.cancelImageUpload(uploadId)); setError(uploadError instanceof Error ? uploadError.message : "附件上传失败"); } finally { uploadingRef.current = false; @@ -2721,13 +2783,13 @@ const RichEditorModal = ({ const renameResource = useCallback(async (target: MobileResourceTarget, filename: string) => { if (!client || !memo || memo.id.startsWith("local:")) throw new Error(resolvedLocale === "en-US" ? "Wait for this note to sync first." : "请等待笔记同步完成。"); const { resource } = await client.renameResource(target.resourceId, filename); - editorRef.current?.renameResource(JSON.stringify(target), resource.filename || filename); + safeDomCall(() => editorRef.current?.renameResource(JSON.stringify(target), resource.filename || filename)); }, [client, memo, resolvedLocale]); const deleteResource = useCallback(async (target: MobileResourceTarget) => { if (!client || !memo || memo.id.startsWith("local:")) throw new Error(resolvedLocale === "en-US" ? "Wait for this note to sync first." : "请等待笔记同步完成。"); await client.deleteResource(target.resourceId); - editorRef.current?.removeResource(JSON.stringify(target)); + safeDomCall(() => editorRef.current?.removeResource(JSON.stringify(target))); }, [client, memo, resolvedLocale]); const selectResource = useCallback(async (targetJson: string) => { @@ -2760,13 +2822,11 @@ const RichEditorModal = ({ if (initialFocusTimerRef.current !== null) { clearTimeout(initialFocusTimerRef.current); } + // TipTap autofocus only — EdgeEverKeyboard.show() often attaches IME to the wrong WebView. initialFocusTimerRef.current = setTimeout(() => { initialFocusTimerRef.current = null; - editorRef.current?.focusEnd(); - if (Platform.OS === "android") { - showEdgeEverKeyboard(); - } - }, 180); + safeDomCall(() => editorRef.current?.focusEnd()); + }, 60); }} ref={editorRef} locale={resolvedLocale} @@ -2858,12 +2918,6 @@ const RichEditorModal = ({ {draftRestored ? 已恢复上次未完成的本地草稿 : null} - {!ready ? ( - - - 正在启动本地编辑器 - - ) : null} {editorElement} {error ? {error} : null} diff --git a/apps/mobile/src/screens/workspace-styles.ts b/apps/mobile/src/screens/workspace-styles.ts index c22dbfd0..b04cc688 100644 --- a/apps/mobile/src/screens/workspace-styles.ts +++ b/apps/mobile/src/screens/workspace-styles.ts @@ -918,6 +918,15 @@ const baseWorkspaceStyles = StyleSheet.create({ templatePickerSheet: { maxHeight: "84%", }, + /** Absolute fill when nested inside CreateMemoModal (avoid second RN Modal). */ + templatePickerOverlayRoot: { + bottom: 0, + left: 0, + position: "absolute", + right: 0, + top: 0, + zIndex: 40, + }, templatePickerLoading: { alignItems: "center", flexDirection: "row", @@ -932,6 +941,29 @@ const baseWorkspaceStyles = StyleSheet.create({ paddingHorizontal: 8, paddingVertical: 8, }, + templatePickerErrorBlock: { + gap: 8, + paddingBottom: 4, + }, + templatePickerErrorDetail: { + color: "#b91c1c", + fontSize: 11, + lineHeight: 16, + paddingHorizontal: 8, + }, + templatePickerRetryButton: { + alignSelf: "flex-start", + backgroundColor: "#f1f5f9", + borderRadius: 8, + marginHorizontal: 8, + paddingHorizontal: 12, + paddingVertical: 8, + }, + templatePickerRetryText: { + color: "#0f172a", + fontSize: 12, + fontWeight: "700", + }, templateRow: { alignItems: "flex-start", borderRadius: 12,