1598 lines
70 KiB
Swift
1598 lines
70 KiB
Swift
import AVFoundation
|
||
import SwiftUI
|
||
|
||
private enum ImagePickerRoute: String, Identifiable {
|
||
case camera
|
||
case library
|
||
|
||
var id: String { rawValue }
|
||
}
|
||
|
||
enum MemoEditMode: Equatable {
|
||
/// `seed` pre-fills title/body/tags for an explicit template, clip, or share flow.
|
||
/// A regular create always starts blank.
|
||
case create(notebookId: String, seed: CreateMemoSeed? = nil)
|
||
case edit(memoId: String)
|
||
}
|
||
|
||
enum MemoEditInitialFocus: Hashable {
|
||
case body
|
||
case title
|
||
}
|
||
|
||
/// Android CreateMemoModal / rich-edit shell parity (createMemo* tokens).
|
||
struct MemoEditView: View {
|
||
@Environment(AppEnvironment.self) private var env
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
|
||
let mode: MemoEditMode
|
||
var initialFocus: MemoEditInitialFocus = .body
|
||
var initialSharedImages: [ShareHandoffStore.SharedImage] = []
|
||
/// When set (edit-from-detail), close by popping to the list under the cover first —
|
||
/// never `dismiss()` onto a still-pushed detail page.
|
||
var onLeaveToList: (() -> Void)? = nil
|
||
/// Create path: called after commit so the workspace can refresh its list.
|
||
var onCreateFinished: ((String) -> Void)? = nil
|
||
|
||
@State private var viewModel = MemoEditViewModel()
|
||
/// True after Back/Done commit starts — blocks late TipTap `change` from rewriting the
|
||
/// `new` draft so the next create opens empty instead of the previous note body.
|
||
/// False until `loadInitial` has filled title/body from mirror — prevents TipTap boot
|
||
/// with empty defaults from overwriting a non-empty note via autosave / flush.
|
||
/// Snapshot of body when edit opened (or last intentional load). Used to reject empty clobbers.
|
||
@State private var showNotebookPicker = false
|
||
@State private var showTagPicker = false
|
||
@State private var showImageSourcePicker = false
|
||
@State private var imagePickerRoute: ImagePickerRoute?
|
||
@State private var isImportingImageBatch = false
|
||
@State private var showCameraAccessAlert = false
|
||
@State private var cameraAccessCanOpenSettings = false
|
||
@State private var cameraAccessMessage = ""
|
||
@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?
|
||
@State private var aiSelection: AiEditorSelection?
|
||
@State private var showEmptyAiSelectionAlert = false
|
||
@State private var aiUndoToken: UUID?
|
||
@State private var didImportSharedImages = false
|
||
@State private var isSuggestingTags = false
|
||
@State private var smartTagsAdded = false
|
||
@State private var showSmartTagAlert = false
|
||
@State private var smartTagAlertTitle = ""
|
||
@State private var smartTagAlertMessage = ""
|
||
@State private var smartTagTask: Task<Void, Never>?
|
||
@State private var didApplyInitialTitleFocus = false
|
||
@FocusState private var titleFocused: Bool
|
||
|
||
private var title: String { get { viewModel.title } nonmutating set { viewModel.title = newValue } }
|
||
private var tagsText: String { get { viewModel.tagsText } nonmutating set { viewModel.tagsText = newValue } }
|
||
private var notebookId: String { get { viewModel.notebookId } nonmutating set { viewModel.notebookId = newValue } }
|
||
private var contentMarkdown: String { get { viewModel.contentMarkdown } nonmutating set { viewModel.contentMarkdown = newValue } }
|
||
private var contentJSON: String { get { viewModel.contentJSON } nonmutating set { viewModel.contentJSON = newValue } }
|
||
private var expectedRevision: Int? { get { viewModel.expectedRevision } nonmutating set { viewModel.expectedRevision = newValue } }
|
||
private var expectedContentHash: String? { get { viewModel.expectedContentHash } nonmutating set { viewModel.expectedContentHash = newValue } }
|
||
private var memoId: String? { get { viewModel.memoId } nonmutating set { viewModel.memoId = newValue } }
|
||
private var error: String? { get { viewModel.error } nonmutating set { viewModel.error = newValue } }
|
||
private var editGeneration: UInt64 { get { viewModel.editGeneration } nonmutating set { viewModel.editGeneration = newValue } }
|
||
private var isMaterializing: Bool { get { viewModel.isMaterializing } nonmutating set { viewModel.isMaterializing = newValue } }
|
||
private var isDirty: Bool { get { viewModel.isDirty } nonmutating set { viewModel.isDirty = newValue } }
|
||
private var isSaving: Bool { get { viewModel.isSaving } nonmutating set { viewModel.isSaving = newValue } }
|
||
private var isCreating: Bool { get { viewModel.isCreating } nonmutating set { viewModel.isCreating = newValue } }
|
||
private var isUploading: Bool { get { viewModel.isUploading || isImportingImageBatch } nonmutating set { viewModel.isUploading = newValue } }
|
||
private var editorReady: Bool { get { viewModel.editorReady } nonmutating set { viewModel.editorReady = newValue } }
|
||
private var suppressPersistence: Bool { get { viewModel.suppressPersistence } nonmutating set { viewModel.suppressPersistence = newValue } }
|
||
private var contentHydrated: Bool { get { viewModel.contentHydrated } nonmutating set { viewModel.contentHydrated = newValue } }
|
||
private var baselineMarkdown: String { get { viewModel.baselineMarkdown } nonmutating set { viewModel.baselineMarkdown = newValue } }
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
VStack(spacing: 0) {
|
||
createHeader
|
||
createMain
|
||
}
|
||
|
||
// Impossible-to-miss upload feedback (status chip alone was too easy to miss).
|
||
if isUploading {
|
||
Color.black.opacity(0.28)
|
||
.ignoresSafeArea()
|
||
.allowsHitTesting(true)
|
||
VStack(spacing: 12) {
|
||
ProgressView()
|
||
.controlSize(.large)
|
||
.tint(.white)
|
||
Text(env.preferences.t("正在上传图片…", en: "Uploading image…", pl: "Przesyłanie obrazu…"))
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
}
|
||
.padding(24)
|
||
.background(.ultraThinMaterial.opacity(0.9))
|
||
.background(Color.black.opacity(0.55))
|
||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
.accessibilityIdentifier("createMemoUploadOverlay")
|
||
}
|
||
|
||
if aiUndoToken != nil {
|
||
VStack {
|
||
Spacer()
|
||
HStack(spacing: 12) {
|
||
Text(env.preferences.t("AI 已更新选中内容。", en: "AI updated the selection.", pl: "AI zaktualizowało zaznaczenie."))
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
Spacer(minLength: 0)
|
||
Button(env.preferences.t("撤销", en: "Undo", pl: "Cofnij")) {
|
||
Task {
|
||
guard await SharedTipTapRuntime.editor.undoAiSelectionDraft() else {
|
||
aiUndoToken = nil
|
||
return
|
||
}
|
||
await pullEditorSnapshotIfPossible()
|
||
markDirtyAndScheduleSave()
|
||
aiUndoToken = nil
|
||
}
|
||
}
|
||
.font(.system(size: 13, weight: .bold))
|
||
.foregroundStyle(Color(red: 0.43, green: 0.91, blue: 0.72))
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.frame(minHeight: 48)
|
||
.background(Color(red: 0.06, green: 0.09, blue: 0.16))
|
||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||
.shadow(color: .black.opacity(0.22), radius: 14, y: 6)
|
||
.padding(.horizontal, 14)
|
||
.padding(.bottom, 12)
|
||
}
|
||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||
.allowsHitTesting(true)
|
||
}
|
||
}
|
||
.background(AppTheme.card.ignoresSafeArea())
|
||
.accessibilityIdentifier(CreateMemoChrome.root)
|
||
.sheet(isPresented: $showNotebookPicker) {
|
||
EditNotebookPickerSheet(
|
||
notebooks: availableNotebooks,
|
||
selectedId: notebookId
|
||
) { id in
|
||
notebookId = id
|
||
markDirtyAndScheduleSave()
|
||
showNotebookPicker = false
|
||
}
|
||
.presentationDetents([.medium, .large])
|
||
}
|
||
.sheet(isPresented: $showTagPicker) {
|
||
MemoTagPickerSheet(
|
||
selectedTags: viewModel.tags
|
||
) { tags in
|
||
tagsText = tags.joined(separator: ", ")
|
||
markDirtyAndScheduleSave()
|
||
}
|
||
.presentationDetents([.medium, .large])
|
||
}
|
||
.sheet(isPresented: $showTemplatePicker) {
|
||
TemplatePickerSheet { seed in
|
||
requestApplyTemplate(seed)
|
||
}
|
||
}
|
||
.sheet(item: $aiSelection) { selection in
|
||
AiAssistantSheet(
|
||
title: title,
|
||
selectedMarkdown: selection.markdown.isEmpty ? selection.text : selection.markdown
|
||
) { draft, mode in
|
||
let applied = await SharedTipTapRuntime.editor.applyAiSelectionDraft(
|
||
draft,
|
||
append: mode == .append
|
||
)
|
||
guard applied else {
|
||
throw NSError(
|
||
domain: "EdgeEverAiSelection",
|
||
code: 1,
|
||
userInfo: [NSLocalizedDescriptionKey: env.preferences.t(
|
||
"选区已失效,请重新选择文本后再试。",
|
||
en: "The selection expired. Select the text again and retry.",
|
||
pl: "Zaznaczenie wygasło. Zaznacz tekst ponownie i spróbuj jeszcze raz."
|
||
)]
|
||
)
|
||
}
|
||
await pullEditorSnapshotIfPossible()
|
||
markDirtyAndScheduleSave()
|
||
presentAiUndo()
|
||
}
|
||
.presentationDetents([.large])
|
||
}
|
||
.alert(
|
||
env.preferences.t("请先选择正文", en: "Select text first", pl: "Najpierw zaznacz tekst"),
|
||
isPresented: $showEmptyAiSelectionAlert
|
||
) {
|
||
Button(env.preferences.t("好的", en: "OK", pl: "OK"), role: .cancel) {}
|
||
} message: {
|
||
Text(env.preferences.t(
|
||
"在正文中选中一段文字,然后再点 AI。",
|
||
en: "Select some text in the note body, then tap AI again.",
|
||
pl: "Zaznacz fragment tekstu w treści notatki, a potem ponownie dotknij AI."
|
||
))
|
||
}
|
||
.alert(smartTagAlertTitle, isPresented: $showSmartTagAlert) {
|
||
Button(env.preferences.t("好的", en: "OK", pl: "OK"), role: .cancel) {}
|
||
} message: {
|
||
Text(smartTagAlertMessage)
|
||
}
|
||
.alert(
|
||
env.preferences.t("应用模板?", en: "Apply template?", pl: "Zastosować szablon?"),
|
||
isPresented: $showApplyTemplateConfirm
|
||
) {
|
||
Button(env.preferences.t("取消", en: "Cancel", pl: "Anuluj"), role: .cancel) {
|
||
pendingTemplateSeed = nil
|
||
}
|
||
Button(env.preferences.t("替换", en: "Replace", pl: "Zastąp"), 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.", pl: "Bieżąca treść zostanie zastąpiona szablonem."))
|
||
}
|
||
.sheet(item: $resourceTarget) { target in
|
||
ResourceActionSheet(
|
||
target: target,
|
||
canMutate: {
|
||
if case .edit(let id) = mode { return !id.hasPrefix("local:") }
|
||
return false
|
||
}(),
|
||
onContentChanged: {
|
||
Task { await reloadAfterResourceChange() }
|
||
}
|
||
)
|
||
.presentationDetents([.height(360), .medium])
|
||
.presentationDragIndicator(.hidden)
|
||
}
|
||
.confirmationDialog(
|
||
env.preferences.t("添加图片", en: "Add image", pl: "Dodaj obraz"),
|
||
isPresented: $showImageSourcePicker,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button(env.preferences.t("拍照", en: "Take photo", pl: "Zrób zdjęcie")) {
|
||
scheduleCameraCapture()
|
||
}
|
||
Button(env.preferences.t("从相册选择", en: "Choose from library", pl: "Wybierz z biblioteki")) {
|
||
scheduleImagePicker(.library)
|
||
}
|
||
Button(env.preferences.t("取消", en: "Cancel", pl: "Anuluj"), role: .cancel) {}
|
||
} message: {
|
||
Text(env.preferences.t("直接拍照或选择已有照片", en: "Take a new photo or choose an existing one", pl: "Zrób nowe zdjęcie lub wybierz istniejące"))
|
||
}
|
||
// fullScreenCover avoids nested-sheet bugs when MemoEditView itself is already a fullScreenCover.
|
||
// PHPicker + NSItemProvider (not SwiftUI PhotosPicker/Transferable) is the reliable path.
|
||
.fullScreenCover(item: $imagePickerRoute) { route in
|
||
Group {
|
||
switch route {
|
||
case .camera:
|
||
SystemCameraPicker(onFinish: handleImagePickerResult)
|
||
case .library:
|
||
SystemImagePicker(onFinish: handleImagePickerResult)
|
||
}
|
||
}
|
||
.ignoresSafeArea()
|
||
}
|
||
.alert(
|
||
env.preferences.t("无法使用相机", en: "Unable to use camera", pl: "Nie można użyć aparatu"),
|
||
isPresented: $showCameraAccessAlert
|
||
) {
|
||
Button(env.preferences.t("取消", en: "Cancel", pl: "Anuluj"), role: .cancel) {}
|
||
if cameraAccessCanOpenSettings {
|
||
Button(env.preferences.t("前往设置", en: "Open settings", pl: "Otwórz ustawienia")) {
|
||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||
UIApplication.shared.open(url)
|
||
}
|
||
}
|
||
} message: {
|
||
Text(cameraAccessMessage)
|
||
}
|
||
.alert(
|
||
env.preferences.t("图片上传失败", en: "Image upload failed", pl: "Nie udało się przesłać obrazu"),
|
||
isPresented: $showUploadError
|
||
) {
|
||
Button(env.preferences.t("好的", en: "OK", pl: "OK"), role: .cancel) {}
|
||
} message: {
|
||
Text(error ?? env.preferences.t("请重试", en: "Please try again", pl: "Spróbuj ponownie"))
|
||
}
|
||
.task {
|
||
await loadInitial()
|
||
contentHydrated = true
|
||
// Only TipTap's acknowledged content push may mark the body ready.
|
||
try? await Task.sleep(nanoseconds: 800_000_000)
|
||
if !Task.isCancelled, !editorReady {
|
||
if initialFocus == .title {
|
||
focusTitleOnce()
|
||
} else {
|
||
// One open-edit focus only (SharedTipTapRuntime also focuses once per document).
|
||
SharedTipTapRuntime.editor.focusEnd()
|
||
}
|
||
}
|
||
await importInitialSharedImagesIfNeeded()
|
||
}
|
||
.onDisappear {
|
||
smartTagTask?.cancel()
|
||
smartTagTask = nil
|
||
viewModel.cancelScheduledSave()
|
||
// Create commit is owned by Back / Done (Android `requestClose` = createMutation).
|
||
// Only flush edit sessions, or create-after-image-materialize if still dirty and
|
||
// the cover was dismissed without going through handleBack.
|
||
if suppressPersistence { return }
|
||
if isDirty, contentHydrated, (!isCreate || hasMaterializedServerMemo) {
|
||
Task { await flushPending() }
|
||
}
|
||
}
|
||
.preferredColorScheme(env.preferences.colorScheme)
|
||
}
|
||
|
||
// MARK: - Header (createMemoHeader)
|
||
|
||
private var createHeader: some View {
|
||
HStack(spacing: 8) {
|
||
Button {
|
||
Task { await handleBack() }
|
||
} label: {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 22, weight: .semibold))
|
||
.foregroundStyle(busyChrome ? AppTheme.muted : AppTheme.title)
|
||
.frame(width: 38, height: 38)
|
||
.contentShape(Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(busyChrome)
|
||
.accessibilityLabel(env.preferences.t("返回", en: "Back", pl: "Wstecz"))
|
||
.accessibilityIdentifier(CreateMemoChrome.back)
|
||
|
||
Spacer(minLength: 0)
|
||
|
||
HStack(spacing: 8) {
|
||
Text(statusLabel)
|
||
.font(.system(size: 12, weight: .bold))
|
||
.foregroundStyle(saveStatus.isActive ? AppTheme.accentStrong : AppTheme.secondary)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.background(saveStatus.isActive ? AppTheme.accentSoft : AppTheme.searchFill)
|
||
.clipShape(Capsule())
|
||
.lineLimit(1)
|
||
.accessibilityIdentifier(CreateMemoChrome.status)
|
||
|
||
if isCreate {
|
||
Button {
|
||
showTemplatePicker = true
|
||
} label: {
|
||
Text(env.preferences.t("模板", en: "Template", pl: "Szablon"))
|
||
.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", pl: "Szablon"))
|
||
.accessibilityIdentifier(CreateMemoChrome.template)
|
||
}
|
||
|
||
Button {
|
||
Task {
|
||
if let selection = await SharedTipTapRuntime.editor.captureAiSelection() {
|
||
aiSelection = selection
|
||
} else {
|
||
showEmptyAiSelectionAlert = true
|
||
}
|
||
}
|
||
} label: {
|
||
Image(systemName: "sparkles")
|
||
.font(.system(size: 15, weight: .bold))
|
||
.foregroundStyle(editorReady && !busyChrome ? AppTheme.accentStrong : AppTheme.muted)
|
||
.frame(width: 36, height: 36)
|
||
.background(AppTheme.accentSoft)
|
||
.clipShape(Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!editorReady || busyChrome)
|
||
.accessibilityLabel(env.preferences.t("用 AI 处理选中内容", en: "Use AI on selection", pl: "Użyj AI na zaznaczeniu"))
|
||
|
||
Button {
|
||
Task { await handleDone() }
|
||
} label: {
|
||
Group {
|
||
if isCreating || isSaving && isCreate {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
.tint(AppTheme.secondary)
|
||
} else {
|
||
Text(env.preferences.t("完成", en: "Done", pl: "Gotowe"))
|
||
.font(.system(size: 14, weight: .bold))
|
||
.foregroundStyle(canSubmitDone ? Color.white : AppTheme.secondary)
|
||
}
|
||
}
|
||
.frame(minWidth: 58, minHeight: 36)
|
||
.padding(.horizontal, 12)
|
||
.background(canSubmitDone ? AppTheme.title : AppTheme.disabledFill)
|
||
.clipShape(Capsule())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!canSubmitDone)
|
||
.accessibilityLabel(env.preferences.t("完成", en: "Done", pl: "Gotowe"))
|
||
.accessibilityIdentifier(CreateMemoChrome.done)
|
||
}
|
||
.accessibilityIdentifier(CreateMemoChrome.header)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 8)
|
||
.frame(minHeight: 52)
|
||
.background(AppTheme.card)
|
||
.overlay(alignment: .bottom) {
|
||
Rectangle().fill(AppTheme.cardBorder).frame(height: 1)
|
||
}
|
||
.accessibilityIdentifier(CreateMemoChrome.header)
|
||
}
|
||
|
||
// MARK: - Main (createMemoMain)
|
||
|
||
private var createMain: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
TextField(
|
||
env.preferences.t("无标题笔记", en: "Untitled note", pl: "Notatka bez tytułu"),
|
||
text: Binding(
|
||
get: { viewModel.title },
|
||
set: { viewModel.title = $0 }
|
||
)
|
||
)
|
||
.font(.system(size: 28, weight: .heavy))
|
||
.foregroundStyle(AppTheme.title)
|
||
.textFieldStyle(.plain)
|
||
.focused($titleFocused)
|
||
.padding(.top, 14)
|
||
.padding(.bottom, 8)
|
||
.onChange(of: title) { _, _ in markDirtyAndScheduleSave() }
|
||
.accessibilityLabel(env.preferences.t("笔记标题", en: "Note title", pl: "Tytuł notatki"))
|
||
.accessibilityIdentifier(CreateMemoChrome.title)
|
||
|
||
HStack(spacing: 10) {
|
||
Button {
|
||
showNotebookPicker = true
|
||
} label: {
|
||
HStack(spacing: 3) {
|
||
Text(selectedNotebookName)
|
||
.font(.system(size: 15, weight: .bold))
|
||
.foregroundStyle(AppTheme.secondary)
|
||
.lineLimit(1)
|
||
Image(systemName: "chevron.down")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(AppTheme.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.frame(maxWidth: 160, alignment: .leading)
|
||
.frame(minHeight: 30)
|
||
.layoutPriority(1)
|
||
.accessibilityLabel(env.preferences.t("所在笔记本", en: "Notebook", pl: "Notatnik"))
|
||
.accessibilityIdentifier(CreateMemoChrome.notebook)
|
||
|
||
Button {
|
||
Task {
|
||
await pullEditorSnapshotIfPossible()
|
||
showTagPicker = true
|
||
}
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
Text(viewModel.tags.isEmpty
|
||
? env.preferences.t("添加标签", en: "Add tags", pl: "Dodaj tagi")
|
||
: viewModel.tags.map { "#\($0)" }.joined(separator: ", "))
|
||
.lineLimit(1)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
Image(systemName: "chevron.down")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
}
|
||
.font(.system(size: 15))
|
||
.foregroundStyle(viewModel.tags.isEmpty ? AppTheme.muted : AppTheme.secondary)
|
||
.frame(minHeight: 36)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(env.preferences.t("笔记标签", en: "Tags", pl: "Tagi"))
|
||
.accessibilityIdentifier(CreateMemoChrome.tags)
|
||
|
||
Button {
|
||
generateAndApplySmartTags()
|
||
} label: {
|
||
Group {
|
||
if isSuggestingTags {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
.tint(AppTheme.accentStrong)
|
||
} else {
|
||
Image(systemName: smartTagsAdded ? "checkmark" : "tag.badge.plus")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(AppTheme.accentStrong)
|
||
}
|
||
}
|
||
.frame(width: 34, height: 34)
|
||
.background(smartTagsAdded ? AppTheme.accentSoft : Color.clear)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(isSuggestingTags || busyChrome || viewModel.tags.count >= 24
|
||
|| (title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
&& contentMarkdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
|
||
.opacity((busyChrome || viewModel.tags.count >= 24) ? 0.45 : 1)
|
||
.accessibilityLabel(env.preferences.t(
|
||
isSuggestingTags ? "正在生成智能标签" : smartTagsAdded ? "智能标签已添加" : "智能标签",
|
||
en: isSuggestingTags ? "Generating smart tags" : smartTagsAdded ? "Smart tags added" : "Smart tags",
|
||
pl: isSuggestingTags ? "Generowanie inteligentnych tagów" : smartTagsAdded ? "Dodano inteligentne tagi" : "Inteligentne tagi"
|
||
))
|
||
.accessibilityIdentifier(CreateMemoChrome.smartTags)
|
||
|
||
}
|
||
.frame(minHeight: 40)
|
||
.accessibilityIdentifier(CreateMemoChrome.metaRow)
|
||
|
||
ZStack {
|
||
// Mount TipTap only after local body is loaded — shared WebView must not
|
||
// receive empty defaults first (that used to autosave-wipe demo notes).
|
||
if contentHydrated {
|
||
TipTapWebView(
|
||
mode: .editor,
|
||
documentJSON: contentJSON,
|
||
markdown: contentMarkdown,
|
||
baseURL: env.session.session.map { URL(string: $0.baseUrl) } ?? nil,
|
||
token: env.session.session?.token,
|
||
locale: env.preferences.apiLocale,
|
||
theme: colorScheme == .dark ? "dark" : "light",
|
||
placeholder: env.preferences.t("开始输入…", en: "Start writing…", pl: "Zacznij pisać…"),
|
||
onChange: { md, json in
|
||
guard contentHydrated, !suppressPersistence else { return }
|
||
// Accept the JSON and Markdown emitted by the same TipTap transaction.
|
||
// Native code only falls back to its compatibility serializer when the
|
||
// editor cannot provide Markdown at all.
|
||
viewModel.applyEditorPayload(markdown: md, json: json)
|
||
if !isUploading {
|
||
markDirtyAndScheduleSave()
|
||
} else {
|
||
isDirty = true
|
||
}
|
||
},
|
||
onResourcePress: { target in
|
||
resourceTarget = target
|
||
},
|
||
onImagePreview: nil,
|
||
onPickImage: {
|
||
guard !isUploading else { return }
|
||
UIApplication.shared.sendAction(
|
||
#selector(UIResponder.resignFirstResponder),
|
||
to: nil,
|
||
from: nil,
|
||
for: nil
|
||
)
|
||
error = nil
|
||
showImageSourcePicker = true
|
||
},
|
||
onSearchResult: nil,
|
||
onBodyReady: {
|
||
// Do not focusEnd here — bodyReady also fires on typing re-binds.
|
||
// Open-edit focus is owned by SharedTipTapRuntime (once per document).
|
||
editorReady = true
|
||
if initialFocus == .title {
|
||
focusTitleOnce()
|
||
}
|
||
}
|
||
)
|
||
.opacity(1)
|
||
}
|
||
|
||
if !editorReady || !contentHydrated {
|
||
VStack(spacing: 10) {
|
||
ProgressView()
|
||
.tint(AppTheme.title)
|
||
Text(env.preferences.t("正在启动本地编辑器", en: "Starting local editor", pl: "Uruchamianie lokalnego edytora"))
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundStyle(AppTheme.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.background(AppTheme.card)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.background(AppTheme.card)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||
.stroke(AppTheme.border, lineWidth: 1)
|
||
)
|
||
.padding(.top, 4)
|
||
.padding(.horizontal, -4)
|
||
.accessibilityIdentifier(CreateMemoChrome.editorFrame)
|
||
|
||
if let error {
|
||
Text(error)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(AppTheme.danger)
|
||
.padding(.top, 8)
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.bottom, 8)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
}
|
||
|
||
// MARK: - Image source
|
||
|
||
private func scheduleImagePicker(_ route: ImagePickerRoute) {
|
||
Task { @MainActor in
|
||
// Let the confirmation dialog finish dismissing before presenting UIKit.
|
||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||
guard !Task.isCancelled else { return }
|
||
imagePickerRoute = route
|
||
}
|
||
}
|
||
|
||
private func scheduleCameraCapture() {
|
||
Task { @MainActor in
|
||
// Permission prompts and full-screen covers must not race the source dialog dismissal.
|
||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||
guard !Task.isCancelled else { return }
|
||
beginCameraCapture()
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func beginCameraCapture() {
|
||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||
switch CameraCaptureAccess.nextStep(
|
||
isCameraAvailable: SystemCameraPicker.isAvailable,
|
||
authorizationStatus: status
|
||
) {
|
||
case .openCamera:
|
||
imagePickerRoute = .camera
|
||
case .requestPermission:
|
||
Task {
|
||
let granted = await AVCaptureDevice.requestAccess(for: .video)
|
||
await MainActor.run {
|
||
if granted {
|
||
imagePickerRoute = .camera
|
||
} else {
|
||
showCameraSettingsAlert()
|
||
}
|
||
}
|
||
}
|
||
case .showSettings:
|
||
showCameraSettingsAlert()
|
||
case .unavailable:
|
||
cameraAccessCanOpenSettings = false
|
||
cameraAccessMessage = env.preferences.t(
|
||
"此设备没有可用相机。",
|
||
en: "This device does not have an available camera.",
|
||
pl: "To urządzenie nie ma dostępnego aparatu."
|
||
)
|
||
showCameraAccessAlert = true
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func showCameraSettingsAlert() {
|
||
cameraAccessCanOpenSettings = true
|
||
cameraAccessMessage = env.preferences.t(
|
||
"请前往系统设置,允许 EdgeEver 使用相机后再试。",
|
||
en: "Open system settings and allow EdgeEver to use the camera, then try again.",
|
||
pl: "Otwórz ustawienia systemu i zezwól EdgeEver na używanie aparatu, a następnie spróbuj ponownie."
|
||
)
|
||
showCameraAccessAlert = true
|
||
}
|
||
|
||
@MainActor
|
||
private func handleImagePickerResult(_ result: ImagePickerResult) {
|
||
imagePickerRoute = nil
|
||
switch result {
|
||
case .cancelled:
|
||
break
|
||
case .failed(let message):
|
||
error = message
|
||
showUploadError = true
|
||
case .picked(let data, let filename):
|
||
Task { _ = await insertImageData(data, filename: filename) }
|
||
case .pickedImages(let images):
|
||
Task { await insertImageBatch(images) }
|
||
}
|
||
}
|
||
|
||
// MARK: - Derived state
|
||
|
||
private var isCreate: Bool {
|
||
if case .create = mode { return true }
|
||
return false
|
||
}
|
||
|
||
private var busyChrome: Bool {
|
||
isCreating || isUploading
|
||
}
|
||
|
||
private var saveStatus: CreateSaveStatus {
|
||
CreateSaveStatus.derive(
|
||
editorReady: editorReady,
|
||
isDirty: isDirty,
|
||
isSaving: isSaving,
|
||
isCreating: isCreating,
|
||
isUploading: isUploading,
|
||
hasError: error != nil
|
||
)
|
||
}
|
||
|
||
private var statusLabel: String {
|
||
env.preferences.t(saveStatus.labelZH, en: saveStatus.labelEN, pl: saveStatus.labelPL)
|
||
}
|
||
|
||
private var canSubmitDone: Bool {
|
||
if isCreate {
|
||
return !notebookId.isEmpty && !isCreating && !isUploading
|
||
}
|
||
return !isSaving && !isUploading && editorReady
|
||
}
|
||
|
||
private var canUseTemplate: Bool {
|
||
isCreate && !isCreating && !isUploading
|
||
}
|
||
|
||
private var availableNotebooks: [Notebook] {
|
||
(try? env.mirror.listNotebooks(scope: env.session.dataScope ?? "")) ?? []
|
||
}
|
||
|
||
private var selectedNotebookName: String {
|
||
if let name = availableNotebooks.first(where: { $0.id == notebookId })?.name {
|
||
return name
|
||
}
|
||
return env.preferences.t("选择笔记本", en: "Choose notebook", pl: "Wybierz notatnik")
|
||
}
|
||
|
||
private var tags: [String] {
|
||
viewModel.tags
|
||
}
|
||
|
||
// MARK: - Actions
|
||
|
||
private func markDirtyAndScheduleSave() {
|
||
viewModel.markDirty()
|
||
guard !suppressPersistence, !isUploading else { return }
|
||
scheduleSave()
|
||
}
|
||
|
||
private func generateAndApplySmartTags() {
|
||
guard !isSuggestingTags, !busyChrome, viewModel.tags.count < 24 else { return }
|
||
smartTagTask?.cancel()
|
||
isSuggestingTags = true
|
||
smartTagsAdded = false
|
||
smartTagTask = Task { @MainActor in
|
||
await pullEditorSnapshotIfPossible()
|
||
let currentTags = viewModel.tags
|
||
let input = AiTagSuggestionsInput(
|
||
title: title,
|
||
contentMarkdown: contentMarkdown,
|
||
currentTags: currentTags,
|
||
locale: env.preferences.apiLocale
|
||
)
|
||
do {
|
||
let response = try await env.session.client.suggestAiTags(input)
|
||
try Task.checkCancellation()
|
||
let availableSlots = max(0, 24 - currentTags.count)
|
||
let additions = Array(response.suggestions
|
||
.map(\.name)
|
||
.filter { name in
|
||
!currentTags.contains { $0.caseInsensitiveCompare(name) == .orderedSame }
|
||
}
|
||
.prefix(availableSlots))
|
||
guard !additions.isEmpty else {
|
||
isSuggestingTags = false
|
||
smartTagAlertTitle = env.preferences.t("智能标签", en: "Smart tags", pl: "Inteligentne tagi")
|
||
smartTagAlertMessage = env.preferences.t(
|
||
"没有找到适合这篇笔记的新标签。",
|
||
en: "No useful new tags were found for this note.",
|
||
pl: "Nie znaleziono przydatnych nowych tagów dla tej notatki."
|
||
)
|
||
showSmartTagAlert = true
|
||
smartTagTask = nil
|
||
return
|
||
}
|
||
tagsText = (currentTags + additions).joined(separator: ", ")
|
||
markDirtyAndScheduleSave()
|
||
isSuggestingTags = false
|
||
smartTagsAdded = true
|
||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||
guard !Task.isCancelled else { return }
|
||
smartTagsAdded = false
|
||
smartTagTask = nil
|
||
} catch is CancellationError {
|
||
isSuggestingTags = false
|
||
} catch let apiError as APIError where apiError.code == "ai_not_configured" {
|
||
isSuggestingTags = false
|
||
smartTagAlertTitle = env.preferences.t("智能标签生成失败", en: "Couldn't generate smart tags", pl: "Nie udało się wygenerować inteligentnych tagów")
|
||
smartTagAlertMessage = env.preferences.t(
|
||
"请先在“模型与代理”中配置默认模型。",
|
||
en: "Configure a model in Models and agents first.",
|
||
pl: "Najpierw skonfiguruj model w sekcji „Modele i agenci”."
|
||
)
|
||
showSmartTagAlert = true
|
||
smartTagTask = nil
|
||
} catch {
|
||
isSuggestingTags = false
|
||
smartTagAlertTitle = env.preferences.t("智能标签生成失败", en: "Couldn't generate smart tags", pl: "Nie udało się wygenerować inteligentnych tagów")
|
||
smartTagAlertMessage = error.localizedDescription
|
||
showSmartTagAlert = true
|
||
smartTagTask = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
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 = MemoEditViewModel.emptyDocJSON
|
||
baselineMarkdown = seed.contentMarkdown
|
||
editorReady = false
|
||
markDirtyAndScheduleSave()
|
||
// Re-push body into the shared editor runtime.
|
||
Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||
SharedTipTapRuntime.editor.focusEnd()
|
||
}
|
||
}
|
||
|
||
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:
|
||
// 1) the note appears in the list, 2) the next "New note" opens empty.
|
||
if isCreate {
|
||
if busyChrome { return }
|
||
await pullEditorSnapshotIfPossible()
|
||
await commitCreate()
|
||
} else {
|
||
await flushPending()
|
||
leaveEditor()
|
||
}
|
||
}
|
||
|
||
private func handleDone() async {
|
||
await pullEditorSnapshotIfPossible()
|
||
if isCreate {
|
||
await commitCreate()
|
||
} else {
|
||
await flushPending()
|
||
leaveEditor()
|
||
}
|
||
}
|
||
|
||
/// Edit-from-detail: parent pops path then clears the cover so list is revealed.
|
||
/// Create / other hosts: standard environment dismiss.
|
||
private func leaveEditor() {
|
||
if let onLeaveToList {
|
||
onLeaveToList()
|
||
} else {
|
||
dismiss()
|
||
}
|
||
}
|
||
|
||
/// True once `materializeForImage` (or edit open) holds a real server memo id.
|
||
private var hasMaterializedServerMemo: Bool {
|
||
guard let memoId else { return false }
|
||
return !memoId.hasPrefix("local:")
|
||
}
|
||
|
||
/// Pull markdown/JSON from TipTap so saves don't race the async change bridge.
|
||
private func pullEditorSnapshotIfPossible() async {
|
||
guard let snap = await SharedTipTapRuntime.editor.snapshotContent() else { return }
|
||
viewModel.applyEditorPayload(markdown: snap.markdown, json: snap.json)
|
||
}
|
||
|
||
private func presentAiUndo() {
|
||
let token = UUID()
|
||
aiUndoToken = token
|
||
Task {
|
||
try? await Task.sleep(nanoseconds: 6_500_000_000)
|
||
guard !Task.isCancelled, aiUndoToken == token else { return }
|
||
aiUndoToken = nil
|
||
}
|
||
}
|
||
|
||
private func loadInitial() async {
|
||
guard let scope = env.session.dataScope else { return }
|
||
switch mode {
|
||
case .create(let nb, let seed):
|
||
// A regular create must never inherit state from the previous create
|
||
// session. Clear both in-memory fields and any legacy persisted draft.
|
||
title = ""
|
||
tagsText = ""
|
||
contentMarkdown = ""
|
||
contentJSON = MemoEditViewModel.emptyDocJSON
|
||
notebookId = nb
|
||
memoId = nil
|
||
expectedRevision = nil
|
||
expectedContentHash = nil
|
||
try? env.drafts.clear(scope: scope, key: DraftRepository.newKey)
|
||
if let seed {
|
||
// Only explicit template / clip / share input may prefill a create.
|
||
title = seed.title
|
||
tagsText = seed.tagsText
|
||
contentMarkdown = seed.contentMarkdown
|
||
contentJSON = MemoEditViewModel.emptyDocJSON
|
||
}
|
||
baselineMarkdown = contentMarkdown
|
||
case .edit(let id):
|
||
memoId = id
|
||
// Prefer mirror body over a stale empty draft that could wipe the note.
|
||
if let memo = try? env.mirror.resolveMemo(scope: scope, id: id) {
|
||
title = memo.title ?? ""
|
||
tagsText = memo.tags.joined(separator: ", ")
|
||
contentMarkdown = memo.contentMarkdown
|
||
contentJSON = (try? memo.contentJson.jsonString()) ?? contentJSON
|
||
notebookId = memo.notebookId
|
||
expectedRevision = memo.revision
|
||
expectedContentHash = memo.contentHash
|
||
memoId = memo.id
|
||
// Overlay draft only when it still has body (or memo was already empty).
|
||
if let draft = try? env.drafts.read(scope: scope, key: DraftRepository.memoKey(id)) {
|
||
let draftBody = draft.contentMarkdown.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let memoBody = memo.contentMarkdown.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !draftBody.isEmpty || memoBody.isEmpty {
|
||
title = draft.title
|
||
tagsText = draft.tagsText
|
||
contentMarkdown = draft.contentMarkdown
|
||
contentJSON = draft.contentJson ?? contentJSON
|
||
if !draft.notebookId.isEmpty { notebookId = draft.notebookId }
|
||
expectedRevision = draft.expectedRevision ?? expectedRevision
|
||
}
|
||
}
|
||
} else if let draft = try? env.drafts.read(scope: scope, key: DraftRepository.memoKey(id)) {
|
||
title = draft.title
|
||
tagsText = draft.tagsText
|
||
contentMarkdown = draft.contentMarkdown
|
||
contentJSON = draft.contentJson ?? contentJSON
|
||
notebookId = draft.notebookId
|
||
expectedRevision = draft.expectedRevision
|
||
}
|
||
baselineMarkdown = contentMarkdown
|
||
}
|
||
}
|
||
|
||
private func focusTitleOnce() {
|
||
guard !didApplyInitialTitleFocus else { return }
|
||
didApplyInitialTitleFocus = true
|
||
titleFocused = true
|
||
}
|
||
|
||
/// After server-side rename/delete, pull the latest memo body into the editor.
|
||
private func reloadAfterResourceChange() async {
|
||
guard case .edit(let id) = mode, let scope = env.session.dataScope else { return }
|
||
// Prefer live server copy when online.
|
||
if let remote = try? await env.session.client.getMemo(id: id) {
|
||
try? env.mirror.upsertMemo(scope: scope, memo: remote)
|
||
title = remote.title ?? ""
|
||
tagsText = remote.tags.joined(separator: ", ")
|
||
contentMarkdown = remote.contentMarkdown
|
||
contentJSON = (try? remote.contentJson.jsonString()) ?? contentJSON
|
||
expectedRevision = remote.revision
|
||
expectedContentHash = remote.contentHash
|
||
baselineMarkdown = contentMarkdown
|
||
return
|
||
}
|
||
if let memo = try? env.mirror.resolveMemo(scope: scope, id: id) {
|
||
title = memo.title ?? ""
|
||
tagsText = memo.tags.joined(separator: ", ")
|
||
contentMarkdown = memo.contentMarkdown
|
||
contentJSON = (try? memo.contentJson.jsonString()) ?? contentJSON
|
||
expectedRevision = memo.revision
|
||
expectedContentHash = memo.contentHash
|
||
baselineMarkdown = contentMarkdown
|
||
}
|
||
}
|
||
|
||
private func scheduleSave() {
|
||
viewModel.scheduleSave {
|
||
await persistDraftOrQueue()
|
||
}
|
||
}
|
||
|
||
private func persistDraftOrQueue() async {
|
||
guard let scope = env.session.dataScope else { return }
|
||
guard contentHydrated, !suppressPersistence else { return }
|
||
guard !isSaving else { return }
|
||
let generationAtStart = editGeneration
|
||
// Last chance: only backfill Markdown when the editor supplied none. Never replace
|
||
// valid TipTap Markdown with the intentionally limited native compatibility serializer.
|
||
viewModel.reconcileMarkdownWithJSON()
|
||
if viewModel.wouldClobberNonEmptyBody(isCreate: isCreate) {
|
||
NSLog(
|
||
"MemoEditView: skip persist — refusing body clobber baseText=%d nextText=%d",
|
||
EditorContentCodec.plainTextFromMarkdown(baselineMarkdown).count,
|
||
EditorContentCodec.plainText(markdown: contentMarkdown, json: contentJSON).count
|
||
)
|
||
isDirty = false
|
||
return
|
||
}
|
||
isSaving = true
|
||
defer {
|
||
isSaving = false
|
||
if generationAtStart == editGeneration {
|
||
isDirty = false
|
||
} else if !suppressPersistence {
|
||
scheduleSave()
|
||
}
|
||
}
|
||
let now = EdgeEverDate.nowString()
|
||
|
||
// A non-materialized create remains in memory until Done/Back commits it.
|
||
// Persisting it under the shared `new` key would leak this content into a
|
||
// later create session.
|
||
if isCreate, !hasMaterializedServerMemo {
|
||
return
|
||
}
|
||
|
||
guard let memoId, !memoId.hasPrefix("local:") else {
|
||
return
|
||
}
|
||
|
||
guard var memo = try? env.mirror.resolveMemo(scope: scope, id: memoId) else {
|
||
NSLog("MemoEditView persist: mirror miss for \(memoId)")
|
||
return
|
||
}
|
||
memo.title = title.isEmpty ? env.preferences.t("无标题笔记", en: "Untitled note", pl: "Notatka bez tytułu") : title
|
||
memo.contentMarkdown = contentMarkdown
|
||
memo.contentText = contentMarkdown
|
||
memo.tags = tags
|
||
memo.notebookId = notebookId
|
||
memo.updatedAt = now
|
||
memo.excerpt = String(contentMarkdown.prefix(160))
|
||
if let json = try? JSONValue.parse(contentJSON) {
|
||
memo.contentJson = json
|
||
}
|
||
// Capture server base BEFORE writing local content. Never prefer a stale
|
||
// in-memory expectedRevision that lagged behind a completed sync — that was
|
||
// causing endless "Note changed before the offline draft could sync" conflicts.
|
||
let rev = memo.revision
|
||
let hash = memo.contentHash
|
||
try? env.mirror.upsertMemo(scope: scope, memo: memo)
|
||
|
||
try? env.outbox.enqueueUpdate(
|
||
scope: scope,
|
||
payload: MemoUpdatePayload(
|
||
memoId: memo.id,
|
||
expectedRevision: rev,
|
||
expectedContentHash: hash,
|
||
title: memo.title ?? title,
|
||
contentMarkdown: contentMarkdown,
|
||
contentJson: contentJSON,
|
||
notebookId: notebookId,
|
||
tags: tags
|
||
)
|
||
)
|
||
expectedRevision = rev
|
||
expectedContentHash = hash
|
||
if !isCreate {
|
||
try? env.drafts.write(
|
||
scope: scope,
|
||
draft: viewModel.makeDraft(
|
||
key: DraftRepository.memoKey(memo.id),
|
||
expectedRevision: rev,
|
||
updatedAt: now
|
||
)
|
||
)
|
||
}
|
||
NSLog(
|
||
"MemoEditView persist update memo=%@ baseRev=%d mdLen=%d hasImg=%d jsonHasImg=%d",
|
||
memo.id,
|
||
rev,
|
||
contentMarkdown.count,
|
||
contentMarkdown.contains("/api/v1/resources/") ? 1 : 0,
|
||
contentJSON.contains("/api/v1/resources/") ? 1 : 0
|
||
)
|
||
await env.runSyncCycle()
|
||
// Refresh base from mirror after flush so the next keystroke doesn't reuse a dead revision.
|
||
if let refreshed = try? env.mirror.resolveMemo(scope: scope, id: memoId) {
|
||
expectedRevision = refreshed.revision
|
||
expectedContentHash = refreshed.contentHash
|
||
}
|
||
}
|
||
|
||
private func commitCreate() async {
|
||
guard let scope = env.session.dataScope else { return }
|
||
guard !notebookId.isEmpty else {
|
||
error = env.preferences.t("请选择笔记本", en: "Choose a notebook", pl: "Wybierz notatnik")
|
||
return
|
||
}
|
||
guard let finishedId = await viewModel.performCreateCommit(operation: {
|
||
// Android createMutation: if image materialize already created a server memo,
|
||
// Done/Back updates that memo — never mint a second local: create.
|
||
let outcome = try MemoCreateCommit.commit(
|
||
scope: scope,
|
||
memoId: memoId,
|
||
expectedRevision: expectedRevision,
|
||
expectedContentHash: expectedContentHash,
|
||
notebookId: notebookId,
|
||
title: title,
|
||
untitledTitle: env.preferences.t("无标题笔记", en: "Untitled note", pl: "Notatka bez tytułu"),
|
||
contentMarkdown: contentMarkdown,
|
||
contentJSON: contentJSON,
|
||
tags: tags,
|
||
mirror: env.mirror,
|
||
outbox: env.outbox,
|
||
drafts: env.drafts
|
||
)
|
||
switch outcome {
|
||
case .createdLocal(let id), .updatedMaterialized(let id):
|
||
memoId = id
|
||
}
|
||
// Prefer mirror id after sync (create may remap local: → server id).
|
||
var resolvedFinishedId = memoId!
|
||
// Belt-and-suspenders: clear create draft again after commit (race with in-flight write).
|
||
try? env.drafts.clear(scope: scope, key: DraftRepository.newKey)
|
||
await env.runSyncCycle()
|
||
if let id = memoId, let refreshed = try? env.mirror.resolveMemo(scope: scope, id: id) {
|
||
expectedRevision = refreshed.revision
|
||
expectedContentHash = refreshed.contentHash
|
||
memoId = refreshed.id
|
||
resolvedFinishedId = refreshed.id
|
||
}
|
||
// Clear again after sync — materialize/persist paths may have re-touched `new`.
|
||
try? env.drafts.clear(scope: scope, key: DraftRepository.newKey)
|
||
return resolvedFinishedId
|
||
}) else { return }
|
||
onCreateFinished?(finishedId)
|
||
// Create modal sits on the list already; WorkspaceView reloads on cover dismiss.
|
||
dismiss()
|
||
}
|
||
|
||
private func flushPending() async {
|
||
await drainPendingSave()
|
||
await env.runSyncCycle()
|
||
}
|
||
|
||
/// Wait for an in-flight autosave before forcing the latest editor generation to disk.
|
||
private func drainPendingSave() async {
|
||
await viewModel.drainScheduledSave {
|
||
await persistDraftOrQueue()
|
||
}
|
||
}
|
||
|
||
/// K24 materialize: ensure a server memo id before image upload.
|
||
private func materializeForImage() async throws -> String {
|
||
if let memoId, !memoId.hasPrefix("local:") {
|
||
return memoId
|
||
}
|
||
guard let scope = env.session.dataScope else {
|
||
throw APIError(status: 0, code: nil, message: "未登录")
|
||
}
|
||
if isMaterializing {
|
||
try await Task.sleep(nanoseconds: 300_000_000)
|
||
if let memoId, !memoId.hasPrefix("local:") { return memoId }
|
||
}
|
||
isMaterializing = true
|
||
defer { isMaterializing = false }
|
||
|
||
if let localId = memoId, localId.hasPrefix("local:"),
|
||
let pending = try env.outbox.pendingCreate(scope: scope, memoId: localId)
|
||
{
|
||
if pending.status == .syncing {
|
||
await env.runSyncCycle()
|
||
if let resolved = try env.mirror.resolveMemo(scope: scope, id: localId), !resolved.id.hasPrefix("local:") {
|
||
memoId = resolved.id
|
||
expectedRevision = resolved.revision
|
||
expectedContentHash = resolved.contentHash
|
||
return resolved.id
|
||
}
|
||
}
|
||
try env.outbox.cancelMemo(scope: scope, memoId: localId)
|
||
try env.mirror.deleteMemo(scope: scope, id: localId)
|
||
}
|
||
|
||
// Capture latest editor body before minting the server memo (avoid stale empty markdown).
|
||
await pullEditorSnapshotIfPossible()
|
||
viewModel.reconcileMarkdownWithJSON()
|
||
let memo = try await env.session.client.createMemo(
|
||
notebookId: notebookId.isEmpty ? (availableNotebooks.first?.id ?? "") : notebookId,
|
||
title: title.isEmpty ? env.preferences.t("无标题笔记", en: "Untitled note", pl: "Notatka bez tytułu") : title,
|
||
contentMarkdown: contentMarkdown,
|
||
tags: tags
|
||
)
|
||
try env.mirror.upsertMemo(scope: scope, memo: memo)
|
||
try env.drafts.clear(scope: scope, key: DraftRepository.newKey)
|
||
memoId = memo.id
|
||
expectedRevision = memo.revision
|
||
expectedContentHash = memo.contentHash
|
||
notebookId = memo.notebookId
|
||
return memo.id
|
||
}
|
||
|
||
/// Upload bytes from the system PHPicker and insert into TipTap.
|
||
private func insertImageBatch(_ images: [(data: Data, filename: String)]) async {
|
||
guard !isUploading, !images.isEmpty else { return }
|
||
isImportingImageBatch = true
|
||
var sources: [String] = []
|
||
for image in images {
|
||
let succeeded = await insertImageData(image.data, filename: image.filename) { sources.append($0) }
|
||
if !succeeded { break }
|
||
}
|
||
if !sources.isEmpty {
|
||
_ = await SharedTipTapRuntime.editor.groupImages(sources: sources)
|
||
await pullEditorSnapshotIfPossible()
|
||
}
|
||
isImportingImageBatch = false
|
||
if !sources.isEmpty {
|
||
editGeneration &+= 1
|
||
isDirty = true
|
||
await drainPendingSave()
|
||
}
|
||
}
|
||
|
||
private func insertImageData(_ data: Data, filename: String, onInserted: ((String) -> Void)? = nil) async -> Bool {
|
||
let succeeded = await viewModel.performUpload {
|
||
NSLog("MemoEditView insertImageData: start bytes=%d name=%@", data.count, filename)
|
||
let compress = env.preferences.useCompression
|
||
&& TipTapResourceLoader.sniffImageMime(data) != "image/svg+xml"
|
||
let prepared = compress
|
||
? ImageCompressor.compressIfNeeded(data) // Android parity: WebP @ 0.82, max edge 2560
|
||
: Self.preparedUpload(from: data, preferredName: filename)
|
||
NSLog(
|
||
"MemoEditView insertImageData: start=%d → prepared=%d mime=%@ file=%@ compress=%d",
|
||
data.count,
|
||
prepared.data.count,
|
||
prepared.mimeType,
|
||
prepared.filename,
|
||
compress ? 1 : 0
|
||
)
|
||
let serverId = try await materializeForImage()
|
||
NSLog("MemoEditView insertImageData: memoId=%@", serverId)
|
||
let resource = try await env.session.client.uploadMemoResource(
|
||
memoId: serverId,
|
||
filename: prepared.filename,
|
||
mimeType: prepared.mimeType,
|
||
data: prepared.data
|
||
)
|
||
NSLog("MemoEditView insertImageData: uploaded resourceId=%@ url=%@", resource.id, resource.url)
|
||
// Prefer protected relative path so hydrate + menus work offline/online.
|
||
let imageSrc: String = {
|
||
if resource.url.contains("/api/v1/resources/") {
|
||
return ResourceCache.normalizeProtectedResourcePath(
|
||
resource.url,
|
||
baseURL: env.session.session.flatMap { URL(string: $0.baseUrl) }
|
||
)
|
||
}
|
||
return "/api/v1/resources/\(resource.id)/blob"
|
||
}()
|
||
// TipTap is JSON-driven: must insert via JS (markdown-only never re-renders).
|
||
// Seed blob cache + hydrate so file:// WebView can paint the protected src.
|
||
let inserted = await SharedTipTapRuntime.editor.insertImage(
|
||
src: imageSrc,
|
||
alt: prepared.filename,
|
||
displayData: prepared.data,
|
||
mimeType: prepared.mimeType
|
||
)
|
||
if !inserted {
|
||
throw APIError(
|
||
status: 0,
|
||
code: nil,
|
||
message: env.preferences.t(
|
||
"图片已上传,但插入编辑器失败,请重试。",
|
||
en: "Upload succeeded but insert into editor failed. Please try again.",
|
||
pl: "Przesłano obraz, ale nie udało się wstawić go do edytora. Spróbuj ponownie."
|
||
)
|
||
)
|
||
}
|
||
onInserted?(imageSrc)
|
||
if !isImportingImageBatch {
|
||
_ = await SharedTipTapRuntime.editor.groupImages(sources: [imageSrc])
|
||
}
|
||
// Snapshot TipTap JSON (order is authoritative). Only inject if the resource
|
||
// is truly missing — never append a second image node at document end.
|
||
await pullEditorSnapshotIfPossible()
|
||
if !EditorContentCodec.jsonContainsResource(contentJSON, src: imageSrc) {
|
||
viewModel.ensureImageInContent(imageSrc: imageSrc, alt: prepared.filename)
|
||
} else {
|
||
viewModel.reconcileMarkdownWithJSON()
|
||
}
|
||
editGeneration &+= 1
|
||
isDirty = true
|
||
// Persist immediately so leaving the editor cannot orphan the resource.
|
||
await drainPendingSave()
|
||
NSLog(
|
||
"MemoEditView insertImageData: done src=%@ mdHas=%d jsonHas=%d textLen=%d",
|
||
imageSrc,
|
||
contentMarkdown.contains(imageSrc) ? 1 : 0,
|
||
EditorContentCodec.jsonContainsResource(contentJSON, src: imageSrc) ? 1 : 0,
|
||
EditorContentCodec.plainText(markdown: contentMarkdown, json: contentJSON).count
|
||
)
|
||
}
|
||
if !succeeded {
|
||
showUploadError = true
|
||
NSLog("MemoEditView insertImageData failed: %@", error ?? "unknown")
|
||
}
|
||
return succeeded
|
||
}
|
||
|
||
private func importInitialSharedImagesIfNeeded() async {
|
||
guard !didImportSharedImages, !initialSharedImages.isEmpty else { return }
|
||
didImportSharedImages = true
|
||
for image in initialSharedImages {
|
||
do {
|
||
let data = try Data(contentsOf: image.fileURL)
|
||
let succeeded = await insertImageData(data, filename: image.filename)
|
||
env.shareHandoff.removeImage(image)
|
||
guard succeeded else { return }
|
||
} catch {
|
||
env.shareHandoff.removeImage(image)
|
||
self.error = error.localizedDescription
|
||
showUploadError = true
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
/// When compression is off, still normalize HEIC → JPEG for reliable upload mime.
|
||
private static func preparedUpload(
|
||
from data: Data,
|
||
preferredName: String
|
||
) -> (data: Data, mimeType: String, filename: String) {
|
||
let normalized = ImagePickerData.normalize(data)
|
||
let mime = TipTapResourceLoader.sniffImageMime(normalized)
|
||
let resolvedMime = mime == "application/octet-stream" ? "image/jpeg" : mime
|
||
let ext: String
|
||
switch resolvedMime {
|
||
case "image/png": ext = "png"
|
||
case "image/gif": ext = "gif"
|
||
case "image/webp": ext = "webp"
|
||
case "image/svg+xml": ext = "svg"
|
||
default: ext = "jpg"
|
||
}
|
||
let base = (preferredName as NSString).deletingPathExtension
|
||
let name = base.isEmpty ? "image.\(ext)" : "\(base).\(ext)"
|
||
return (normalized, resolvedMime, name)
|
||
}
|
||
}
|
||
|
||
struct MemoTagPickerSheet: View {
|
||
@Environment(AppEnvironment.self) private var env
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var selection: [String]
|
||
@State private var query = ""
|
||
@State private var availableTags: [TagSummary] = []
|
||
@State private var error: String?
|
||
let allowCreate: Bool
|
||
let maxSelections: Int
|
||
let closeOnSelection: Bool
|
||
let title: String
|
||
let titleEN: String
|
||
let titlePL: String
|
||
let onChange: ([String]) -> Void
|
||
|
||
init(
|
||
selectedTags: [String],
|
||
allowCreate: Bool = true,
|
||
maxSelections: Int = 24,
|
||
closeOnSelection: Bool = false,
|
||
title: String = "选择标签",
|
||
titleEN: String = "Choose tags",
|
||
titlePL: String = "Wybierz tagi",
|
||
onChange: @escaping ([String]) -> Void
|
||
) {
|
||
_selection = State(initialValue: selectedTags)
|
||
self.allowCreate = allowCreate
|
||
self.maxSelections = maxSelections
|
||
self.closeOnSelection = closeOnSelection
|
||
self.title = title
|
||
self.titleEN = titleEN
|
||
self.titlePL = titlePL
|
||
self.onChange = onChange
|
||
}
|
||
|
||
private var normalizedQuery: String {
|
||
query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.replacingOccurrences(of: "^#", with: "", options: .regularExpression)
|
||
}
|
||
|
||
private var visibleTags: [TagSummary] {
|
||
guard !normalizedQuery.isEmpty else { return availableTags }
|
||
return availableTags.filter { $0.name.localizedCaseInsensitiveContains(normalizedQuery) }
|
||
}
|
||
|
||
private var hasExactMatch: Bool {
|
||
availableTags.contains { $0.name.caseInsensitiveCompare(normalizedQuery) == .orderedSame }
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
VStack(spacing: 12) {
|
||
if !selection.isEmpty {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
ForEach(selection, id: \.self) { tag in
|
||
Button {
|
||
toggle(tag)
|
||
} label: {
|
||
HStack(spacing: 4) {
|
||
Text("#\(tag)")
|
||
Image(systemName: "xmark")
|
||
}
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(AppTheme.accent)
|
||
.padding(.horizontal, 11)
|
||
.frame(minHeight: 32)
|
||
.background(AppTheme.accentSoft)
|
||
.clipShape(Capsule())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(env.preferences.t("移除标签 \(tag)", en: "Remove tag \(tag)", pl: "Usuń tag \(tag)"))
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
}
|
||
}
|
||
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "magnifyingglass").foregroundStyle(AppTheme.muted)
|
||
TextField(
|
||
allowCreate
|
||
? env.preferences.t("搜索或输入新标签", en: "Search or enter a new tag", pl: "Wyszukaj lub wpisz nowy tag")
|
||
: env.preferences.t("搜索标签", en: "Search tags", pl: "Szukaj tagów"),
|
||
text: $query
|
||
)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.onSubmit {
|
||
if allowCreate { createTag() }
|
||
}
|
||
if allowCreate && !normalizedQuery.isEmpty && !hasExactMatch && selection.count < maxSelections {
|
||
Button(env.preferences.t("新建", en: "Create", pl: "Utwórz"), action: createTag)
|
||
.font(.system(size: 13, weight: .bold))
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.frame(minHeight: 42)
|
||
.background(AppTheme.card)
|
||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||
.overlay(RoundedRectangle(cornerRadius: 9).stroke(AppTheme.border, lineWidth: 1))
|
||
.padding(.horizontal, 16)
|
||
|
||
if let error {
|
||
Text(error).font(.system(size: 13)).foregroundStyle(AppTheme.danger)
|
||
}
|
||
|
||
List(visibleTags, id: \.name) { tag in
|
||
Button {
|
||
toggle(tag.name)
|
||
} label: {
|
||
HStack {
|
||
Image(systemName: selection.contains(tag.name) ? "checkmark.square.fill" : "square")
|
||
.foregroundStyle(selection.contains(tag.name) ? AppTheme.accent : AppTheme.muted)
|
||
Text("#\(tag.name)").foregroundStyle(AppTheme.title)
|
||
Spacer()
|
||
Text(env.preferences.t("\(tag.memoCount) 条笔记", en: "\(tag.memoCount) notes", pl: "Notatki: \(tag.memoCount)"))
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(AppTheme.muted)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.listStyle(.plain)
|
||
.overlay {
|
||
if visibleTags.isEmpty {
|
||
ContentUnavailableView(
|
||
env.preferences.t("暂无匹配标签", en: "No matching tags", pl: "Brak pasujących tagów"),
|
||
systemImage: "tag",
|
||
description: allowCreate
|
||
? Text(env.preferences.t("可以输入名称创建新标签。", en: "Enter a name to create a new tag.", pl: "Wpisz nazwę, aby utworzyć nowy tag."))
|
||
: Text(env.preferences.t("换个关键词再试。", en: "Try another keyword.", pl: "Spróbuj innego słowa kluczowego."))
|
||
)
|
||
}
|
||
}
|
||
}
|
||
.padding(.top, 12)
|
||
.navigationTitle(env.preferences.t(title, en: titleEN, pl: titlePL))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button(env.preferences.t("完成", en: "Done", pl: "Gotowe")) { dismiss() }
|
||
}
|
||
}
|
||
.task { loadTags() }
|
||
}
|
||
}
|
||
|
||
private func loadTags() {
|
||
guard let scope = env.session.dataScope else { return }
|
||
do {
|
||
availableTags = try env.mirror.listTags(scope: scope)
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func toggle(_ tag: String) {
|
||
selection = MobileUI.toggleTagSelection(current: selection, tag: tag, maxSelections: maxSelections)
|
||
onChange(selection)
|
||
if closeOnSelection {
|
||
dismiss()
|
||
}
|
||
}
|
||
|
||
private func createTag() {
|
||
let additions = normalizedQuery
|
||
.split(whereSeparator: { $0 == "," || $0 == "," || $0.isNewline })
|
||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||
.filter { !$0.isEmpty }
|
||
for tag in additions where selection.count < maxSelections && !selection.contains(tag) {
|
||
selection.append(tag)
|
||
}
|
||
query = ""
|
||
onChange(selection)
|
||
}
|
||
|
||
}
|
||
|
||
|
||
// MARK: - Compact notebook picker for create/edit
|
||
|
||
struct EditNotebookPickerSheet: View {
|
||
@Environment(AppEnvironment.self) private var env
|
||
@Environment(\.dismiss) private var dismiss
|
||
let notebooks: [Notebook]
|
||
let selectedId: String
|
||
var onSelect: (String) -> Void
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
HStack {
|
||
Text(env.preferences.t("选择笔记本", en: "Choose notebook", pl: "Wybierz notatnik"))
|
||
.font(.system(size: 16, weight: .bold))
|
||
.foregroundStyle(AppTheme.title)
|
||
Spacer()
|
||
Button(env.preferences.t("关闭", en: "Close", pl: "Zamknij")) { dismiss() }
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.foregroundStyle(AppTheme.slate)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 12)
|
||
.background(AppTheme.card)
|
||
.overlay(alignment: .bottom) {
|
||
Rectangle().fill(AppTheme.border).frame(height: 1)
|
||
}
|
||
|
||
List {
|
||
ForEach(notebooks, id: \.id) { nb in
|
||
Button {
|
||
onSelect(nb.id)
|
||
dismiss()
|
||
} label: {
|
||
HStack {
|
||
Text(nb.name)
|
||
.foregroundStyle(AppTheme.title)
|
||
Spacer()
|
||
if nb.id == selectedId {
|
||
Image(systemName: "checkmark")
|
||
.foregroundStyle(AppTheme.accent)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
}
|
||
.background(AppTheme.card)
|
||
}
|
||
}
|