feat(mobile,ios): template create parity and DomWebView input stability
Add note-from-template on iOS with the same entry points as Android, harden Android create/edit so DomWebView keeps focus, and silence stale injectJavaScript rejections after editor unmount. Related to #187.
This commit is contained in:
1 parent
fc60daf89c
commit
ca218b3226
15 files changed
+1140
-234
No files matched your search
@@ -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 = "<group>"; };
|
||||
9B02E8D28111E1E732F3BF36 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9BA3D6859C4C6D9F65435671 /* TemplatePickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatePickerSheet.swift; sourceTree = "<group>"; };
|
||||
9D65FED94BCD836BDD5CB752 /* DeviceId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceId.swift; sourceTree = "<group>"; };
|
||||
A375D81573943A98C5732F81 /* MobileUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileUI.swift; sourceTree = "<group>"; };
|
||||
AB02424D0412FE36C4BCD116 /* index.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = index.html; sourceTree = "<group>"; };
|
||||
@@ -154,6 +158,7 @@
|
||||
C5126510AFB9AFBF7ABADB45 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||
C693439A39274372B39F10DE /* EdgeEver.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = EdgeEver.entitlements; sourceTree = "<group>"; };
|
||||
C6B15550738F6080C23DE15F /* ImageCompressor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCompressor.swift; sourceTree = "<group>"; };
|
||||
C8D1DC59EC9FA65240DCBC19 /* MemoTemplatesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoTemplatesTests.swift; sourceTree = "<group>"; };
|
||||
D045B340A984E17B3B0047BF /* ShareHandoffStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareHandoffStore.swift; sourceTree = "<group>"; };
|
||||
D06F83AE844C342D3F6EAD7F /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = "<group>"; };
|
||||
D2C2945A52C2957CCFC09FCF /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = "<group>"; };
|
||||
@@ -166,6 +171,7 @@
|
||||
EA9305413739F81EA39395F7 /* ResourceBlobStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceBlobStore.swift; sourceTree = "<group>"; };
|
||||
EFF10CDC8FB8C97DA0103AA0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
F080AD98FDF3AADDD90B9931 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
F1F36ACF8073C8A61AE397CD /* MemoTemplates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoTemplates.swift; sourceTree = "<group>"; };
|
||||
F50D91D8505FE6446ABD647C /* WorkspaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceView.swift; sourceTree = "<group>"; };
|
||||
F9182F742B06FB1713E27C2A /* ResourceActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceActions.swift; sourceTree = "<group>"; };
|
||||
FDEF3A661FA08F338EC96C74 /* LocalMirrorRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMirrorRepository.swift; sourceTree = "<group>"; };
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -17,17 +17,25 @@ import { styles } from "../screens/workspace-styles";
|
||||
|
||||
type MobileClient = ReturnType<typeof createEdgeEverClient>;
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<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>
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
<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 ? (
|
||||
const sheet = (
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={[
|
||||
styles.actionSheetBackdrop,
|
||||
presentation === "overlay" ? styles.templatePickerOverlayRoot : null,
|
||||
{ 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} keyboardShouldPersistTaps="handled">
|
||||
<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 ? (
|
||||
<View style={styles.templatePickerErrorBlock}>
|
||||
<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} />
|
||||
))}
|
||||
{savedLoadErrorMessage ? (
|
||||
<Text style={styles.templatePickerErrorDetail} numberOfLines={3}>
|
||||
{savedLoadErrorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => void savedTemplatesQuery.refetch()}
|
||||
style={styles.templatePickerRetryButton}
|
||||
>
|
||||
<Text style={styles.templatePickerRetryText}>{translate("重试")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : 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>
|
||||
<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>
|
||||
);
|
||||
|
||||
if (presentation === "overlay") {
|
||||
return sheet;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal animationType="fade" onRequestClose={onClose} transparent visible>
|
||||
{sheet}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -209,3 +259,4 @@ const TemplateRow = ({
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<unknown>).then === "function") {
|
||||
void (result as Promise<unknown>).catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// Synchronous bridge failures (rare) — ignore.
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
}}>
|
||||
<X color="#0f172a" size={16} />
|
||||
</DetailActionButton>
|
||||
|
||||
@@ -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 (
|
||||
<CreateMemoModal
|
||||
baseUrl={session?.baseUrl ?? ""}
|
||||
client={client}
|
||||
dataScope={dataScope}
|
||||
defaultNotebookId={createMemoNotebookId}
|
||||
imageCompressionEnabled={imageCompressionEnabled}
|
||||
initialDraft={incomingClipDraft ?? createSeed}
|
||||
notebooks={notebooks}
|
||||
onCreated={() => {
|
||||
setCreateOpen(false);
|
||||
setIncomingClipDraft(null);
|
||||
setCreateSeed(null);
|
||||
setActiveView("notes");
|
||||
setMemoView("notebook");
|
||||
setSelectedMemoId(null);
|
||||
}}
|
||||
onDismiss={() => {
|
||||
setCreateOpen(false);
|
||||
setIncomingClipDraft(null);
|
||||
setCreateSeed(null);
|
||||
}}
|
||||
onQueued={runForcedSync}
|
||||
syncQueueScope={syncQueueScope}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={["top", "left", "right"]} style={styles.safeArea}>
|
||||
|
||||
@@ -1270,27 +1310,6 @@ export const WorkspaceScreen = ({
|
||||
}}
|
||||
/> : null}
|
||||
|
||||
<CreateMemoModal
|
||||
baseUrl={session?.baseUrl ?? ""}
|
||||
client={client}
|
||||
dataScope={dataScope}
|
||||
defaultNotebookId={createMemoNotebookId}
|
||||
imageCompressionEnabled={imageCompressionEnabled}
|
||||
initialDraft={incomingClipDraft ?? createSeed}
|
||||
notebooks={notebooks}
|
||||
onCreated={() => {
|
||||
setCreateOpen(false);
|
||||
setIncomingClipDraft(null);
|
||||
setCreateSeed(null);
|
||||
setActiveView("notes");
|
||||
setMemoView("notebook");
|
||||
setSelectedMemoId(null);
|
||||
}}
|
||||
onQueued={runForcedSync}
|
||||
syncQueueScope={syncQueueScope}
|
||||
visible={createOpen}
|
||||
/>
|
||||
|
||||
<MobileCreateChoiceModal
|
||||
bottomOffset={58 + safeAreaInsets.bottom}
|
||||
canCreate={canCreateMemo}
|
||||
@@ -1734,9 +1753,9 @@ const CreateMemoModal = ({
|
||||
initialDraft,
|
||||
notebooks,
|
||||
onCreated,
|
||||
onDismiss,
|
||||
onQueued,
|
||||
syncQueueScope,
|
||||
visible,
|
||||
}: {
|
||||
baseUrl: string;
|
||||
client?: ReturnType<typeof useSession>["client"];
|
||||
@@ -1746,9 +1765,9 @@ const CreateMemoModal = ({
|
||||
initialDraft?: MobileCreateMemoSeed | MobileWebClipDraft | null;
|
||||
notebooks: Notebook[];
|
||||
onCreated: (memo: MemoDetail) => void;
|
||||
onDismiss: () => void;
|
||||
onQueued: () => void | Promise<void>;
|
||||
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<ReturnType<typeof setTimeout> | 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 ? (
|
||||
<LocalTiptapEditor
|
||||
key={`create-editor-${editorSeed}`}
|
||||
autoFocus
|
||||
baseUrl={baseUrl}
|
||||
content={contentJsonRef.current}
|
||||
@@ -2175,122 +2242,117 @@ const CreateMemoModal = ({
|
||||
onReady={async (elapsedMs) => {
|
||||
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 (
|
||||
<Modal animationType="slide" onRequestClose={() => void requestClose()} presentationStyle="fullScreen" visible={visible}>
|
||||
<SafeAreaView style={styles.createMemoSafeArea}>
|
||||
<View style={styles.createMemoHeader}>
|
||||
<Pressable accessibilityLabel="返回" accessibilityRole="button" disabled={createMutation.isPending || imageOperation !== "idle"} onPress={() => void requestClose()} style={styles.createMemoBackButton}>
|
||||
<ChevronLeft color={createMutation.isPending || imageOperation !== "idle" ? "#cbd5e1" : "#0f172a"} size={30} />
|
||||
<SafeAreaView edges={["top", "left", "right", "bottom"]} style={styles.createMemoSafeArea}>
|
||||
<View style={styles.createMemoHeader}>
|
||||
<Pressable accessibilityLabel="返回" accessibilityRole="button" disabled={createMutation.isPending || imageOperation !== "idle"} onPress={() => void requestClose()} style={styles.createMemoBackButton}>
|
||||
<ChevronLeft color={createMutation.isPending || imageOperation !== "idle" ? "#cbd5e1" : "#0f172a"} size={30} />
|
||||
</Pressable>
|
||||
<View style={styles.createMemoHeaderActions}>
|
||||
<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}
|
||||
onPress={() => void flushEditor().then(() => createMutation.mutate())}
|
||||
style={[styles.createMemoDoneButton, !canSubmitCreateMemo && styles.createMemoDoneButtonDisabled]}
|
||||
>
|
||||
{createMutation.isPending ? <ActivityIndicator color="#64748b" size="small" /> : <Text style={[styles.createMemoDoneText, !canSubmitCreateMemo && styles.createMemoDoneTextDisabled]}>完成</Text>}
|
||||
</Pressable>
|
||||
<View style={styles.createMemoHeaderActions}>
|
||||
<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}
|
||||
onPress={() => void flushEditor().then(() => createMutation.mutate())}
|
||||
style={[styles.createMemoDoneButton, !canSubmitCreateMemo && styles.createMemoDoneButtonDisabled]}
|
||||
>
|
||||
{createMutation.isPending ? <ActivityIndicator color="#64748b" size="small" /> : <Text style={[styles.createMemoDoneText, !canSubmitCreateMemo && styles.createMemoDoneTextDisabled]}>完成</Text>}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.createMemoMain}>
|
||||
<TextInput
|
||||
autoCorrect
|
||||
accessibilityLabel="笔记标题"
|
||||
onChangeText={(value) => {
|
||||
setTitle(value);
|
||||
markDirty();
|
||||
}}
|
||||
placeholder={DEFAULT_MEMO_TITLE}
|
||||
placeholderTextColor="#94a3b8"
|
||||
style={styles.createMemoTitleInput}
|
||||
value={title}
|
||||
/>
|
||||
|
||||
<View style={styles.createMemoMetaRow}>
|
||||
<Pressable accessibilityLabel="所在笔记本" accessibilityRole="button" onPress={() => setNotebookPickerOpen(true)} style={styles.createMemoNotebookButton}>
|
||||
<Text numberOfLines={1} style={styles.createMemoNotebookText}>{selectedNotebookName}</Text>
|
||||
<ChevronDown color="#64748b" size={14} />
|
||||
</Pressable>
|
||||
<TextInput
|
||||
accessibilityLabel="笔记标签"
|
||||
autoCorrect
|
||||
onChangeText={(value) => {
|
||||
setTagsText(value);
|
||||
markDirty();
|
||||
}}
|
||||
placeholder="添加标签,用逗号分隔"
|
||||
placeholderTextColor="#94a3b8"
|
||||
style={styles.createMemoTagsInput}
|
||||
value={tagsText}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.createMemoEditorFrame}>
|
||||
{!editorReady ? (
|
||||
<View style={styles.richEditorLoading}>
|
||||
<ActivityIndicator color="#0f172a" />
|
||||
<Text style={styles.mutedText}>正在启动本地编辑器</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{editorElement}
|
||||
</View>
|
||||
|
||||
{createMutation.error ? (
|
||||
<Text style={styles.errorText}>{createMutation.error instanceof Error ? createMutation.error.message : "创建失败"}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<NotebookPickerModal
|
||||
activeNotebookId={targetNotebookId}
|
||||
notebooks={notebooks}
|
||||
onClose={() => setNotebookPickerOpen(false)}
|
||||
onSelect={(nextNotebookId) => {
|
||||
setNotebookId(nextNotebookId);
|
||||
setNotebookPickerOpen(false);
|
||||
<View style={styles.createMemoMain}>
|
||||
<TextInput
|
||||
autoCorrect
|
||||
accessibilityLabel="笔记标题"
|
||||
onChangeText={(value) => {
|
||||
setTitle(value);
|
||||
markDirty();
|
||||
}}
|
||||
visible={notebookPickerOpen}
|
||||
placeholder={DEFAULT_MEMO_TITLE}
|
||||
placeholderTextColor="#94a3b8"
|
||||
style={styles.createMemoTitleInput}
|
||||
value={title}
|
||||
/>
|
||||
<MobileResourceActions
|
||||
canMutate={Boolean(materializedMemoRef.current)}
|
||||
onClose={() => setResourceTarget(null)}
|
||||
onDelete={deleteResource}
|
||||
onDownload={downloadResource}
|
||||
onRename={renameResource}
|
||||
onSaveAs={saveResourceAs}
|
||||
target={resourceTarget}
|
||||
/>
|
||||
<MobileTemplatePickerModal
|
||||
client={client}
|
||||
onClose={() => setTemplatePickerOpen(false)}
|
||||
onSelect={requestApplyTemplateSeed}
|
||||
visible={templatePickerOpen}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
|
||||
<View style={styles.createMemoMetaRow}>
|
||||
<Pressable accessibilityLabel="所在笔记本" accessibilityRole="button" onPress={() => setNotebookPickerOpen(true)} style={styles.createMemoNotebookButton}>
|
||||
<Text numberOfLines={1} style={styles.createMemoNotebookText}>{selectedNotebookName}</Text>
|
||||
<ChevronDown color="#64748b" size={14} />
|
||||
</Pressable>
|
||||
<TextInput
|
||||
accessibilityLabel="笔记标签"
|
||||
autoCorrect
|
||||
onChangeText={(value) => {
|
||||
setTagsText(value);
|
||||
markDirty();
|
||||
}}
|
||||
placeholder="添加标签,用逗号分隔"
|
||||
placeholderTextColor="#94a3b8"
|
||||
style={styles.createMemoTagsInput}
|
||||
value={tagsText}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.createMemoEditorFrame}>
|
||||
{editorElement}
|
||||
</View>
|
||||
|
||||
{createMutation.error ? (
|
||||
<Text style={styles.errorText}>{createMutation.error instanceof Error ? createMutation.error.message : "创建失败"}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<NotebookPickerModal
|
||||
activeNotebookId={targetNotebookId}
|
||||
notebooks={notebooks}
|
||||
onClose={() => setNotebookPickerOpen(false)}
|
||||
onSelect={(nextNotebookId) => {
|
||||
setNotebookId(nextNotebookId);
|
||||
setNotebookPickerOpen(false);
|
||||
markDirty();
|
||||
}}
|
||||
visible={notebookPickerOpen}
|
||||
/>
|
||||
<MobileResourceActions
|
||||
canMutate={Boolean(materializedMemoRef.current)}
|
||||
onClose={() => setResourceTarget(null)}
|
||||
onDelete={deleteResource}
|
||||
onDownload={downloadResource}
|
||||
onRename={renameResource}
|
||||
onSaveAs={saveResourceAs}
|
||||
target={resourceTarget}
|
||||
/>
|
||||
<MobileTemplatePickerModal
|
||||
client={client}
|
||||
onClose={() => {
|
||||
setTemplatePickerOpen(false);
|
||||
scheduleFocusEnd(80);
|
||||
}}
|
||||
onSelect={requestApplyTemplateSeed}
|
||||
presentation="overlay"
|
||||
visible={templatePickerOpen}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = ({
|
||||
</View>
|
||||
{draftRestored ? <Text style={styles.richEditorDraftNotice}>已恢复上次未完成的本地草稿</Text> : null}
|
||||
<View style={styles.richEditorFrame}>
|
||||
{!ready ? (
|
||||
<View style={styles.richEditorLoading}>
|
||||
<ActivityIndicator color="#0f172a" />
|
||||
<Text style={styles.mutedText}>正在启动本地编辑器</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{editorElement}
|
||||
</View>
|
||||
{error ? <Text style={styles.richEditorInlineError}>{error}</Text> : null}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user