1630 lines
75 KiB
Swift
1630 lines
75 KiB
Swift
import SwiftUI
|
||
import Pow
|
||
import UIKit
|
||
|
||
/// Android WorkspaceMemoDetail shell parity (detailHeader*, detailMeta*, detailEditFab).
|
||
struct MemoDetailView: View {
|
||
@Environment(AppEnvironment.self) private var env
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
let memoId: String
|
||
var initialShareImage = false
|
||
/// Present editor from the parent `WorkspaceView` (more reliable than cover on a pushed page).
|
||
var onEdit: (String, MemoEditInitialFocus) -> Void = { _, _ in }
|
||
|
||
@State private var memo: MemoDetail?
|
||
@State private var showRevisions = false
|
||
@State private var memoSharePayload: MemoSharePayload?
|
||
@State private var imageExportSharePayload: MemoImageExportSharePayload?
|
||
@State private var imageExportPreviewPayload: MemoImageExportPreviewPayload?
|
||
@State private var imageExportDocumentPayload: MemoImageExportDocumentPayload?
|
||
@State private var imageExportMessage: MemoImageExportMessage?
|
||
@State private var imageExporting = false
|
||
@State private var imageFontStatus = "ready"
|
||
@State private var imageFontRecoveryId: String?
|
||
@State private var imageFontsRecovered = false
|
||
@State private var retryImageFonts: (() -> Void)?
|
||
@State private var imageExportRequestId: String?
|
||
@State private var imageExportBuffer = MemoImageExportBuffer()
|
||
@State private var imageShareOptionsOpen = false
|
||
@State private var imageExportIntent: MemoImageExportIntent = .share
|
||
@State private var error: String?
|
||
@State private var conflictItem: OutboxItem?
|
||
@State private var outboxStatus: OutboxStatus?
|
||
@State private var lastOutboxError: String?
|
||
@State private var pinPulse = false
|
||
@State private var searchOpen = false
|
||
@State private var titleCollapsed = false
|
||
@State private var searchQuery = ""
|
||
@State private var searchMatchCount = 0
|
||
@State private var searchMatchIndex = 0
|
||
@State private var showDeleteConfirm = false
|
||
@State private var showMoreMenu = false
|
||
@State private var showNoteIdCopied = false
|
||
@State private var showAiAssistant = false
|
||
@State private var showNotebookPicker = false
|
||
@State private var resourceTarget: ResourceTarget?
|
||
@State private var imagePreview: (source: String, alt: String)?
|
||
/// TipTap EditorBundle is ~4MB; keep native text visible until first setContent finishes.
|
||
@State private var bodyReady = false
|
||
@State private var initialShareImageHandled = false
|
||
|
||
var body: some View {
|
||
@Bindable var preferences = env.preferences
|
||
return VStack(spacing: 0) {
|
||
detailHeader
|
||
if syncStatus == .conflict, memo != nil {
|
||
conflictBanner
|
||
} else if syncStatus == .error || syncStatus == .pending, memo != nil {
|
||
syncBanner
|
||
}
|
||
if let memo {
|
||
detailBody(memo)
|
||
} else if let error {
|
||
ContentUnavailableView(
|
||
env.preferences.t("加载失败", en: "Failed to load", pl: "Nie udało się wczytać"),
|
||
systemImage: "exclamationmark.triangle",
|
||
description: Text(error)
|
||
)
|
||
} else {
|
||
// Should almost never flash: load() runs onAppear before next frame when mirror is warm.
|
||
ProgressView()
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
.background(AppTheme.card)
|
||
// UIKit FAB in overlay — SwiftUI Button over WKWebView often receives zero taps.
|
||
.overlay(alignment: .bottomTrailing) {
|
||
if let memo, !memo.isDeleted, !blocksRichTextEdit(memo) {
|
||
EditFabButton(
|
||
accessibilityLabel: env.preferences.t("编辑笔记", en: "Edit note", pl: "Edytuj notatkę")
|
||
) {
|
||
onEdit(memo.id, .body)
|
||
}
|
||
.frame(width: 56, height: 56)
|
||
.padding(.trailing, 12)
|
||
.padding(.bottom, 12)
|
||
}
|
||
}
|
||
.background(AppTheme.card.ignoresSafeArea())
|
||
.toolbar(.hidden, for: .navigationBar)
|
||
.accessibilityIdentifier(DetailMemoChrome.root)
|
||
.sheet(isPresented: $showRevisions) {
|
||
if let memo {
|
||
RevisionsView(
|
||
memoId: memo.id,
|
||
memoTitle: memo.title,
|
||
isDeleted: memo.isDeleted
|
||
) {
|
||
showRevisions = false
|
||
load()
|
||
}
|
||
}
|
||
}
|
||
.sheet(item: $conflictItem) { item in
|
||
ConflictResolutionView(item: item) {
|
||
conflictItem = nil
|
||
load()
|
||
refreshSyncStatus()
|
||
}
|
||
}
|
||
.sheet(item: $resourceTarget) { target in
|
||
ResourceActionSheet(
|
||
target: target,
|
||
canMutate: memo.map { !$0.isDeleted && !$0.id.hasPrefix("local:") } ?? false,
|
||
onContentChanged: { load() }
|
||
)
|
||
.presentationDetents([.height(360), .medium])
|
||
.presentationDragIndicator(.hidden)
|
||
}
|
||
.sheet(item: $memoSharePayload) { payload in
|
||
ActivityShareView(items: [payload.message, payload.url]) { _, _, error in
|
||
if let error { self.error = error.localizedDescription }
|
||
memoSharePayload = nil
|
||
}
|
||
}
|
||
.sheet(item: $imageExportSharePayload) { payload in
|
||
ActivityShareView(items: [payload.url]) { _, _, shareError in
|
||
if let shareError {
|
||
imageExportMessage = MemoImageExportMessage(
|
||
title: env.preferences.t("导出失败", en: "Export failed", pl: "Eksport nie powiódł się"),
|
||
message: shareError.localizedDescription
|
||
)
|
||
}
|
||
imageExportSharePayload = nil
|
||
}
|
||
}
|
||
.sheet(item: $imageExportDocumentPayload) { payload in
|
||
MemoImageDocumentExportView(fileURL: payload.url) { result in
|
||
if case let .failure(exportError) = result {
|
||
imageExportMessage = MemoImageExportMessage(
|
||
title: env.preferences.t("保存失败", en: "Save failed", pl: "Nie udało się zapisać"),
|
||
message: exportError.localizedDescription
|
||
)
|
||
}
|
||
imageExportDocumentPayload = nil
|
||
}
|
||
}
|
||
.sheet(item: $imageExportPreviewPayload, onDismiss: {
|
||
imageFontRecoveryId = nil
|
||
retryImageFonts = nil
|
||
if let requestId = imageExportRequestId { imageExportBuffer.cancel(requestId: requestId) }
|
||
imageExportRequestId = nil
|
||
imageExporting = false
|
||
}) { payload in
|
||
let currentPayload = imageExportPreviewPayload ?? payload
|
||
MemoImageExportPreviewView(
|
||
payload: currentPayload,
|
||
isLoadingFonts: imageExporting,
|
||
onRetryFonts: { retryImageFonts?() },
|
||
isEnglish: env.preferences.isEnglish,
|
||
isPolish: env.preferences.uiLanguage == .polish,
|
||
onCopy: {
|
||
guard let image = UIImage(contentsOfFile: currentPayload.url.path) else {
|
||
imageExportMessage = MemoImageExportMessage(
|
||
title: env.preferences.t("复制失败", en: "Copy failed", pl: "Nie udało się skopiować"),
|
||
message: env.preferences.t("无法读取生成的图片。", en: "The generated image could not be read.", pl: "Nie udało się odczytać wygenerowanego obrazu.")
|
||
)
|
||
return
|
||
}
|
||
UIPasteboard.general.image = image
|
||
imageExportMessage = MemoImageExportMessage(
|
||
title: env.preferences.t("复制成功", en: "Copied", pl: "Skopiowano"),
|
||
message: env.preferences.t("图片已复制到剪贴板。", en: "The image is on your clipboard.", pl: "Obraz jest w schowku.")
|
||
)
|
||
},
|
||
onSave: {
|
||
imageFontRecoveryId = nil
|
||
imageExportPreviewPayload = nil
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||
imageExportDocumentPayload = MemoImageExportDocumentPayload(url: currentPayload.url)
|
||
}
|
||
},
|
||
onShare: {
|
||
imageFontRecoveryId = nil
|
||
imageExportPreviewPayload = nil
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||
imageExportSharePayload = MemoImageExportSharePayload(url: currentPayload.url)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
.sheet(isPresented: $imageShareOptionsOpen) {
|
||
NavigationStack {
|
||
Form {
|
||
Section(env.preferences.t("主题风格", en: "Theme", pl: "Motyw")) {
|
||
Picker(env.preferences.t("主题风格", en: "Theme", pl: "Motyw"), selection: Binding(
|
||
get: { preferences.noteImageShare.theme },
|
||
set: { theme in
|
||
var share = preferences.noteImageShare
|
||
share.theme = theme
|
||
share.fontStyle = "auto"
|
||
preferences.noteImageShare = share
|
||
}
|
||
)) {
|
||
Text(env.preferences.t("透明", en: "Glass", pl: "Szkło")).tag("glass")
|
||
Text(env.preferences.t("金句", en: "Quote", pl: "Cytat")).tag("quote")
|
||
Text(env.preferences.t("书摘", en: "Book excerpt", pl: "Fragment książki")).tag("book")
|
||
Text(env.preferences.t("备忘录", en: "Memo", pl: "Notatka")).tag("memo")
|
||
Text(env.preferences.t("便当", en: "Bento", pl: "Bento")).tag("bento")
|
||
Text(env.preferences.t("黑日", en: "Black Sun", pl: "Czarne słońce")).tag("black-sun")
|
||
Text(env.preferences.t("框界", en: "Frame", pl: "Ramka")).tag("frame")
|
||
Text(env.preferences.t("手写", en: "Handwritten", pl: "Pismo odręczne")).tag("handwritten")
|
||
Text(env.preferences.t("代码", en: "Code", pl: "Kod")).tag("code")
|
||
}
|
||
.pickerStyle(.menu)
|
||
}
|
||
Section(env.preferences.t("字体风格", en: "Typography", pl: "Typografia")) {
|
||
NavigationLink {
|
||
NoteImageFontPickerView()
|
||
} label: {
|
||
HStack {
|
||
Text(env.preferences.t("字体风格", en: "Typography", pl: "Typografia"))
|
||
Spacer()
|
||
Text(NoteImageFontPickerOptions.label(preferences))
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.trailing)
|
||
}
|
||
}
|
||
Text(env.preferences.t(
|
||
"下载型字体首次使用需联网并缓存;普惠体等推荐字体使用完整文件,首次下载较大。",
|
||
en: "Downloadable fonts need a connection on first use and are cached. PuHuiTi and other recommended fonts use larger, complete original files.",
|
||
pl: "Czcionki są pobierane przy pierwszym użyciu i zapisywane lokalnie. PuHuiTi i inne polecane czcionki używają większych, pełnych plików."
|
||
))
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Section(env.preferences.t("字号大小", en: "Font size", pl: "Rozmiar czcionki")) {
|
||
Picker(env.preferences.t("字号大小", en: "Font size", pl: "Rozmiar czcionki"), selection: $preferences.noteImageShare.fontSize) {
|
||
Text(env.preferences.t("紧凑", en: "Compact", pl: "Kompaktowy")).tag("sm")
|
||
Text(env.preferences.t("标准", en: "Standard", pl: "Standardowy")).tag("md")
|
||
Text(env.preferences.t("舒适", en: "Comfortable", pl: "Wygodny")).tag("lg")
|
||
}
|
||
.pickerStyle(.segmented)
|
||
}
|
||
Section(env.preferences.t("卡片宽度", en: "Card width", pl: "Szerokość karty")) {
|
||
Picker(env.preferences.t("卡片宽度", en: "Card width", pl: "Szerokość karty"), selection: $preferences.noteImageShare.cardWidth) {
|
||
Text(env.preferences.t("紧凑", en: "Compact", pl: "Kompaktowy")).tag("compact")
|
||
Text(env.preferences.t("标准", en: "Standard", pl: "Standardowy")).tag("standard")
|
||
Text(env.preferences.t("宽屏", en: "Wide", pl: "Szeroka")).tag("wide")
|
||
}
|
||
.pickerStyle(.segmented)
|
||
}
|
||
Section(env.preferences.t("显示内容", en: "Content elements", pl: "Elementy treści")) {
|
||
Toggle(env.preferences.t("笔记标题", en: "Note title", pl: "Tytuł notatki"), isOn: $preferences.noteImageShare.showTitle)
|
||
Toggle(env.preferences.t("笔记本", en: "Notebook", pl: "Notatnik"), isOn: $preferences.noteImageShare.showNotebook)
|
||
Toggle(env.preferences.t("标签", en: "Tags", pl: "Tagi"), isOn: $preferences.noteImageShare.showTags)
|
||
Toggle(env.preferences.t("更新时间", en: "Updated time", pl: "Czas aktualizacji"), isOn: $preferences.noteImageShare.showUpdatedAt)
|
||
Toggle(env.preferences.t("EdgeEver 品牌标识", en: "EdgeEver branding", pl: "Oznaczenie EdgeEver"), isOn: $preferences.noteImageShare.showBranding)
|
||
}
|
||
Section(env.preferences.t("图片格式", en: "Image format", pl: "Format obrazu")) {
|
||
Picker(env.preferences.t("图片格式", en: "Image format", pl: "Format obrazu"), selection: $preferences.noteImageShare.format) {
|
||
Text(env.preferences.t("PNG · 超清无损", en: "PNG · Best for text", pl: "PNG · Najlepszy do tekstu")).tag("png")
|
||
Text(env.preferences.t("JPEG · 体积小", en: "JPEG · Smaller file", pl: "JPEG · Mniejszy plik")).tag("jpeg")
|
||
}
|
||
.pickerStyle(.segmented)
|
||
}
|
||
}
|
||
.navigationTitle(env.preferences.t("分享为图片", en: "Share as image", pl: "Udostępnij jako obraz"))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button(env.preferences.t("取消", en: "Cancel", pl: "Anuluj")) { imageShareOptionsOpen = false }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button(env.preferences.t("生成预览", en: "Generate Preview", pl: "Generuj podgląd")) {
|
||
guard let memo else { return }
|
||
imageShareOptionsOpen = false
|
||
exportMemoImage(
|
||
memo,
|
||
format: env.preferences.noteImageShare.format,
|
||
theme: env.preferences.noteImageShare.theme,
|
||
fontStyle: env.preferences.noteImageShare.fontStyle,
|
||
fontSize: env.preferences.noteImageShare.fontSize,
|
||
cardWidth: env.preferences.noteImageShare.cardWidth,
|
||
showTitle: env.preferences.noteImageShare.showTitle,
|
||
showNotebook: env.preferences.noteImageShare.showNotebook,
|
||
showTags: env.preferences.noteImageShare.showTags,
|
||
showUpdatedAt: env.preferences.noteImageShare.showUpdatedAt,
|
||
showBranding: env.preferences.noteImageShare.showBranding,
|
||
intent: .preview
|
||
)
|
||
}
|
||
.disabled(imageExporting || !bodyReady || memo == nil)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.overlay(alignment: .bottom) {
|
||
if imageExporting && imageExportPreviewPayload == nil {
|
||
ProgressView(imageFontStatus == "loading"
|
||
? env.preferences.t("字体加载中…", en: "Loading font…", ja: "フォントを読み込み中…", pl: "Ładowanie czcionki…")
|
||
: env.preferences.t("正在生成图片…", en: "Generating image…", ja: "画像を生成中…", pl: "Generowanie obrazu…"))
|
||
.padding().background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)).padding()
|
||
}
|
||
}
|
||
.alert(item: $imageExportMessage) { message in
|
||
Alert(
|
||
title: Text(message.title),
|
||
message: Text(message.message),
|
||
dismissButton: .default(Text(env.preferences.t("确定", en: "OK", pl: "OK")))
|
||
)
|
||
}
|
||
.sheet(isPresented: $showAiAssistant) {
|
||
if let memo {
|
||
AiAssistantSheet(memo: memo) { draft, mode in
|
||
try await applyAiDraft(draft, mode: mode, to: memo)
|
||
}
|
||
}
|
||
}
|
||
.sheet(isPresented: $showNotebookPicker) {
|
||
EditNotebookPickerSheet(
|
||
notebooks: availableNotebooks,
|
||
selectedId: memo?.notebookId ?? ""
|
||
) { notebookId in
|
||
showNotebookPicker = false
|
||
guard let memo else { return }
|
||
Task { await moveMemoToNotebook(memo, notebookId: notebookId) }
|
||
}
|
||
.presentationDetents([.medium, .large])
|
||
}
|
||
.fullScreenCover(isPresented: Binding(
|
||
get: { imagePreview != nil },
|
||
set: { if !$0 { imagePreview = nil } }
|
||
)) {
|
||
if let imagePreview {
|
||
ResourceImagePreviewHost(
|
||
source: imagePreview.source,
|
||
alt: imagePreview.alt,
|
||
baseURL: env.session.session.flatMap { URL(string: $0.baseUrl) },
|
||
token: env.session.session?.token,
|
||
onClose: {
|
||
self.imagePreview = nil
|
||
},
|
||
canMutate: memo.map { !$0.isDeleted && !$0.id.hasPrefix("local:") } ?? false,
|
||
onContentChanged: {
|
||
load()
|
||
// Rename/delete may invalidate the blob; close preview after mutation.
|
||
self.imagePreview = nil
|
||
}
|
||
)
|
||
}
|
||
}
|
||
.confirmationDialog(
|
||
env.preferences.t("笔记操作", en: "Note actions", pl: "Działania na notatce"),
|
||
isPresented: $showMoreMenu,
|
||
titleVisibility: .visible
|
||
) {
|
||
if let memo {
|
||
if !blocksRichTextEdit(memo) {
|
||
Button(env.preferences.t("编辑", en: "Edit", pl: "Edytuj")) { onEdit(memo.id, .body) }
|
||
}
|
||
if !memo.isDeleted && !isTemporaryMemoId(memo.id) && !blocksRichTextEdit(memo) {
|
||
Button(env.preferences.t("AI 笔记助手", en: "AI note assistant", pl: "Asystent AI notatek")) {
|
||
showAiAssistant = true
|
||
}
|
||
}
|
||
Button(
|
||
memo.isPinned
|
||
? env.preferences.t("取消置顶", en: "Unpin", pl: "Odepnij")
|
||
: env.preferences.t("置顶", en: "Pin", pl: "Przypnij")
|
||
) {
|
||
Task {
|
||
await togglePin(memo)
|
||
pinPulse.toggle()
|
||
}
|
||
}
|
||
Button(env.preferences.t("分享链接", en: "Share link", pl: "Udostępnij link")) {
|
||
Task { await shareMemo(memo) }
|
||
}
|
||
Button(
|
||
imageExporting
|
||
? env.preferences.t("正在导出图片…", en: "Exporting image…", pl: "Eksportowanie obrazu…")
|
||
: env.preferences.t("分享为图片", en: "Share as image", pl: "Udostępnij jako obraz")
|
||
) {
|
||
imageShareOptionsOpen = true
|
||
}
|
||
.disabled(imageExporting || !bodyReady)
|
||
Button(env.preferences.t("高级导出 PNG", en: "Advanced export PNG", pl: "Zaawansowany eksport PNG")) {
|
||
exportMemoImage(memo, format: "png")
|
||
}
|
||
.disabled(imageExporting || !bodyReady)
|
||
Button(env.preferences.t("导出 JPEG", en: "Export JPEG", pl: "Eksportuj JPEG")) {
|
||
exportMemoImage(memo, format: "jpeg")
|
||
}
|
||
.disabled(imageExporting || !bodyReady)
|
||
Button(
|
||
isTemporaryMemoId(memo.id)
|
||
? env.preferences.t("同步后可复制笔记 ID", en: "Copy note ID after sync", pl: "Identyfikator notatki można skopiować po synchronizacji")
|
||
: env.preferences.t("复制笔记 ID", en: "Copy note ID", pl: "Kopiuj identyfikator notatki")
|
||
) {
|
||
UIPasteboard.general.string = memo.id
|
||
showNoteIdCopied = true
|
||
}
|
||
.disabled(isTemporaryMemoId(memo.id))
|
||
Button(env.preferences.t("修订历史", en: "Revisions", pl: "Wersje")) { showRevisions = true }
|
||
Button(env.preferences.t("删除", en: "Delete", pl: "Usuń"), role: .destructive) {
|
||
showDeleteConfirm = true
|
||
}
|
||
}
|
||
}
|
||
.alert(env.preferences.t("删除笔记", en: "Delete note", pl: "Usuń notatkę"), isPresented: $showDeleteConfirm) {
|
||
Button(env.preferences.t("删除", en: "Delete", pl: "Usuń"), role: .destructive) {
|
||
if let memo { Task { await deleteMemo(memo) } }
|
||
}
|
||
Button(env.preferences.t("取消", en: "Cancel", pl: "Anuluj"), role: .cancel) {}
|
||
} message: {
|
||
Text(env.preferences.t("笔记将移入回收站。", en: "The note will move to trash.", pl: "Notatka zostanie przeniesiona do kosza."))
|
||
}
|
||
.alert(
|
||
env.preferences.t("笔记 ID 已复制", en: "Note ID copied", pl: "Skopiowano identyfikator notatki"),
|
||
isPresented: $showNoteIdCopied
|
||
) {
|
||
Button(env.preferences.t("好", en: "OK", pl: "OK"), role: .cancel) {}
|
||
} message: {
|
||
Text(memo?.id ?? memoId)
|
||
}
|
||
// Local SQLite mirror is sync and cheap — load before the first blank ProgressView frame.
|
||
.onAppear {
|
||
if memo == nil {
|
||
load()
|
||
}
|
||
refreshSyncStatus()
|
||
TipTapWarmPool.warmIfNeeded()
|
||
SharedTipTapRuntime.viewer.resetReaderScroll()
|
||
openInitialShareImageIfReady()
|
||
}
|
||
.onDisappear {
|
||
if let requestId = imageExportRequestId {
|
||
imageExportBuffer.cancel(requestId: requestId)
|
||
}
|
||
imageExportRequestId = nil
|
||
imageFontRecoveryId = nil
|
||
retryImageFonts = nil
|
||
imageExporting = false
|
||
}
|
||
.onChange(of: bodyReady) { _, _ in
|
||
openInitialShareImageIfReady()
|
||
}
|
||
.task(id: memoId) {
|
||
// Re-load if mirror was empty on first paint (rare race during bootstrap).
|
||
if memo == nil {
|
||
load()
|
||
}
|
||
refreshSyncStatus()
|
||
}
|
||
.onChange(of: env.isSyncing) { _, _ in
|
||
refreshSyncStatus()
|
||
}
|
||
.onChange(of: memoId) { _, _ in
|
||
bodyReady = false
|
||
titleCollapsed = false
|
||
SharedTipTapRuntime.viewer.resetReaderScroll()
|
||
searchQuery = ""
|
||
searchMatchCount = 0
|
||
searchMatchIndex = 0
|
||
showNotebookPicker = false
|
||
load()
|
||
refreshSyncStatus()
|
||
}
|
||
.preferredColorScheme(env.preferences.colorScheme)
|
||
}
|
||
|
||
// MARK: - Header
|
||
|
||
private var detailHeader: some View {
|
||
HStack(spacing: 0) {
|
||
Button {
|
||
dismiss()
|
||
} label: {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundStyle(AppTheme.slate)
|
||
.frame(width: 32, height: 32)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(env.preferences.t("返回列表", en: "Back to list", pl: "Wróć do listy"))
|
||
.accessibilityIdentifier(DetailMemoChrome.back)
|
||
|
||
if titleCollapsed && !searchOpen, let memo {
|
||
collapsedHeaderTitle(memo)
|
||
.padding(.leading, 8)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
} else {
|
||
Spacer(minLength: 8)
|
||
}
|
||
|
||
HStack(spacing: 2) {
|
||
Button {
|
||
handleSyncStatusPress()
|
||
} label: {
|
||
Text(syncLabel)
|
||
.font(.system(size: 11, weight: syncStatus == .conflict || syncStatus == .error ? .bold : .medium))
|
||
.foregroundStyle(syncStatus.foreground)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
.background(syncStatus.background)
|
||
.clipShape(Capsule())
|
||
.lineLimit(1)
|
||
.frame(maxWidth: 88)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!syncStatus.isInteractive || memo == nil)
|
||
.accessibilityLabel(syncLabel)
|
||
.accessibilityIdentifier(DetailMemoChrome.syncStatus)
|
||
|
||
if let memo, !memo.isDeleted {
|
||
headerIconButton(
|
||
systemImage: "square.and.arrow.up",
|
||
label: env.preferences.t("分享笔记", en: "Share note", pl: "Udostępnij notatkę"),
|
||
id: DetailMemoChrome.share
|
||
) {
|
||
Task { await shareMemo(memo) }
|
||
}
|
||
headerIconButton(
|
||
systemImage: "clock.arrow.circlepath",
|
||
label: env.preferences.t("版本历史", en: "Version history", pl: "Historia wersji"),
|
||
id: DetailMemoChrome.history
|
||
) {
|
||
showRevisions = true
|
||
}
|
||
headerIconButton(
|
||
systemImage: "magnifyingglass",
|
||
label: env.preferences.t("搜索当前笔记", en: "Search in note", pl: "Szukaj w notatce"),
|
||
id: DetailMemoChrome.search
|
||
) {
|
||
withAnimation(Motion.chip) {
|
||
if searchOpen {
|
||
closeSearch()
|
||
} else {
|
||
searchOpen = true
|
||
}
|
||
}
|
||
}
|
||
headerIconButton(
|
||
systemImage: "ellipsis",
|
||
label: env.preferences.t("笔记操作", en: "Note actions", pl: "Działania na notatce"),
|
||
id: DetailMemoChrome.more
|
||
) {
|
||
showMoreMenu = true
|
||
}
|
||
}
|
||
}
|
||
.accessibilityIdentifier(DetailMemoChrome.header)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.frame(minHeight: 48)
|
||
.background(AppTheme.card)
|
||
.overlay(alignment: .bottom) {
|
||
Rectangle().fill(AppTheme.cardBorder).frame(height: 1)
|
||
}
|
||
.accessibilityIdentifier(DetailMemoChrome.header)
|
||
}
|
||
|
||
private func headerIconButton(
|
||
systemImage: String,
|
||
label: String,
|
||
id: String,
|
||
action: @escaping () -> Void
|
||
) -> some View {
|
||
Button(action: action) {
|
||
Image(systemName: systemImage)
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundStyle(AppTheme.slate)
|
||
.frame(width: 32, height: 32)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(label)
|
||
.accessibilityIdentifier(id)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func collapsedHeaderTitle(_ memo: MemoDetail) -> some View {
|
||
if memo.isDeleted || blocksRichTextEdit(memo) {
|
||
Text(localizedTitle(for: memo))
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(AppTheme.title)
|
||
.lineLimit(1)
|
||
} else {
|
||
Button {
|
||
onEdit(memo.id, .title)
|
||
} label: {
|
||
Text(localizedTitle(for: memo))
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(AppTheme.title)
|
||
.lineLimit(1)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(env.preferences.t("编辑笔记标题", en: "Edit note title", pl: "Edytuj tytuł notatki"))
|
||
.accessibilityHint(env.preferences.t("进入编辑并聚焦标题", en: "Opens editing with the title focused", pl: "Otwiera edycję z kursorem w tytule"))
|
||
.accessibilityIdentifier(DetailMemoChrome.title)
|
||
}
|
||
}
|
||
|
||
// MARK: - Banners
|
||
|
||
private var conflictBanner: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text(env.preferences.t(
|
||
"云端笔记已在其他标签页、设备,或离线期间被更新。可先复制本地草稿,再采用云端版本后继续编辑。",
|
||
en: "This note changed on another device or while offline. Copy the local draft, then adopt the cloud version to continue.",
|
||
pl: "Ta notatka zmieniła się na innym urządzeniu lub w trybie offline. Skopiuj lokalny szkic, a następnie przyjmij wersję z chmury, aby kontynuować."
|
||
))
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(AppTheme.dangerStrong)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
if let lastOutboxError, !lastOutboxError.isEmpty {
|
||
Text(lastOutboxError)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(AppTheme.dangerStrong)
|
||
}
|
||
|
||
HStack(spacing: 8) {
|
||
Button {
|
||
if let item = conflictItem {
|
||
// Open full conflict resolution (Android "更多")
|
||
conflictItem = item
|
||
} else {
|
||
detectConflict()
|
||
}
|
||
} label: {
|
||
Text(env.preferences.t("处理冲突", en: "Resolve", pl: "Rozwiąż"))
|
||
.font(.system(size: 12, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 8)
|
||
.background(AppTheme.dangerAction)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Button {
|
||
Task { await copyLocalDraft() }
|
||
} label: {
|
||
Text(env.preferences.t("复制本地草稿", en: "Copy local draft", pl: "Kopiuj lokalny szkic"))
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(AppTheme.dangerStrong)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 8)
|
||
.background(AppTheme.card)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(AppTheme.dangerBorder, lineWidth: 1)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(AppTheme.dangerSurface)
|
||
.overlay(alignment: .bottom) {
|
||
Rectangle().fill(AppTheme.dangerBorder).frame(height: 0.5)
|
||
}
|
||
}
|
||
|
||
private var syncBanner: some View {
|
||
let isError = syncStatus == .error
|
||
return VStack(alignment: .leading, spacing: 10) {
|
||
Text(
|
||
lastOutboxError?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||
?? (isError
|
||
? env.preferences.t("本地改动未能上传到云端。内容仍保存在本机,可立即重试。", en: "Local changes could not upload. Content is still on device; retry anytime.", pl: "Nie udało się przesłać lokalnych zmian. Treść nadal jest na urządzeniu – możesz spróbować ponownie w dowolnym momencie.")
|
||
: env.preferences.t("本地改动待上传。下拉刷新或点此可立即同步。", en: "Local changes pending upload. Pull to refresh or tap to sync now.", pl: "Lokalne zmiany czekają na przesłanie. Przeciągnij w dół, aby odświeżyć, lub dotknij, aby zsynchronizować teraz."))
|
||
)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(isError ? AppTheme.dangerStrong : AppTheme.infoText)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
HStack(spacing: 8) {
|
||
Button {
|
||
Task {
|
||
// Force: ignore outbox backoff so "Memo not found" retries immediately
|
||
// and can recover via recreate-on-404 in OutboxFlusher.
|
||
await env.runSyncCycle(force: true)
|
||
refreshSyncStatus()
|
||
load()
|
||
}
|
||
} label: {
|
||
Text(env.preferences.t("立即同步", en: "Sync now", pl: "Synchronizuj teraz"))
|
||
.font(.system(size: 12, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 8)
|
||
.background(isError ? AppTheme.dangerAction : AppTheme.infoAction)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
if isError {
|
||
Button {
|
||
Task { await copyLocalDraft() }
|
||
} label: {
|
||
Text(env.preferences.t("复制本地草稿", en: "Copy local draft", pl: "Kopiuj lokalny szkic"))
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(AppTheme.dangerStrong)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 8)
|
||
.background(AppTheme.card)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(AppTheme.dangerBorder, lineWidth: 1)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(isError ? AppTheme.dangerSurface : AppTheme.infoSurface)
|
||
.overlay(alignment: .bottom) {
|
||
Rectangle()
|
||
.fill(isError ? AppTheme.dangerBorder : AppTheme.infoText.opacity(0.55))
|
||
.frame(height: 0.5)
|
||
}
|
||
}
|
||
|
||
// MARK: - Body
|
||
|
||
private func detailBody(_ memo: MemoDetail) -> some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
if !titleCollapsed || searchOpen {
|
||
detailTitleRow(memo)
|
||
.padding(.top, 8)
|
||
.edgeEverSuccessShine(trigger: pinPulse)
|
||
|
||
HStack(spacing: 8) {
|
||
notebookAffiliationControl(memo)
|
||
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "tag")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(AppTheme.secondary)
|
||
Text(
|
||
memo.tags.isEmpty
|
||
? env.preferences.t("添加标签,用逗号分隔", en: "Add tags, comma separated", pl: "Dodaj tagi, oddzielone przecinkami")
|
||
: memo.tags.joined(separator: ", ")
|
||
)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(memo.tags.isEmpty ? AppTheme.muted : AppTheme.secondary)
|
||
.lineLimit(1)
|
||
.textSelection(.enabled)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.accessibilityIdentifier(DetailMemoChrome.tags)
|
||
}
|
||
.frame(minHeight: 32)
|
||
.padding(.top, 6)
|
||
.accessibilityIdentifier(DetailMemoChrome.metaRow)
|
||
|
||
Text(
|
||
"\(env.preferences.t("创建于", en: "Created", pl: "Utworzono")) \(MemoDetailDate.format(memo.createdAt, locale: env.preferences.resolvedLocale))"
|
||
+ " · "
|
||
+ "\(env.preferences.t("更新于", en: "Updated", pl: "Zaktualizowano")) \(MemoDetailDate.format(memo.updatedAt, locale: env.preferences.resolvedLocale))"
|
||
)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(AppTheme.muted)
|
||
.padding(.top, 4)
|
||
.textSelection(.enabled)
|
||
}
|
||
|
||
if searchOpen {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "magnifyingglass")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(AppTheme.secondary)
|
||
TextField(
|
||
env.preferences.t("在当前笔记内搜索", en: "Search in this note", pl: "Szukaj w tej notatce"),
|
||
text: $searchQuery
|
||
)
|
||
.font(.system(size: 14))
|
||
.textFieldStyle(.plain)
|
||
.onChange(of: searchQuery) { _, query in
|
||
searchMatchIndex = 0
|
||
SharedTipTapRuntime.viewer.search(query, index: 0)
|
||
}
|
||
Text(searchMatchCount == 0 ? "0/0" : "\(searchMatchIndex + 1)/\(searchMatchCount)")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(AppTheme.secondary)
|
||
.monospacedDigit()
|
||
Button {
|
||
guard searchMatchCount > 0 else { return }
|
||
let next = (searchMatchIndex - 1 + searchMatchCount) % searchMatchCount
|
||
SharedTipTapRuntime.viewer.search(searchQuery, index: next)
|
||
} label: {
|
||
Image(systemName: "chevron.up")
|
||
.font(.system(size: 12, weight: .bold))
|
||
.frame(width: 28, height: 28)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(searchMatchCount == 0)
|
||
.accessibilityLabel(env.preferences.t("上一个匹配项", en: "Previous match", pl: "Poprzednie dopasowanie"))
|
||
Button {
|
||
guard searchMatchCount > 0 else { return }
|
||
let next = (searchMatchIndex + 1) % searchMatchCount
|
||
SharedTipTapRuntime.viewer.search(searchQuery, index: next)
|
||
} label: {
|
||
Image(systemName: "chevron.down")
|
||
.font(.system(size: 12, weight: .bold))
|
||
.frame(width: 28, height: 28)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(searchMatchCount == 0)
|
||
.accessibilityLabel(env.preferences.t("下一个匹配项", en: "Next match", pl: "Następne dopasowanie"))
|
||
Button {
|
||
closeSearch()
|
||
} label: {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 12, weight: .bold))
|
||
.foregroundStyle(AppTheme.title)
|
||
.frame(width: 28, height: 28)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(10)
|
||
.background(AppTheme.background)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(AppTheme.border, lineWidth: 1)
|
||
)
|
||
.padding(.top, 14)
|
||
}
|
||
|
||
Rectangle()
|
||
.fill(AppTheme.border)
|
||
.frame(height: 1)
|
||
.padding(.top, titleCollapsed && !searchOpen ? 0 : 16)
|
||
.padding(.bottom, 8)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
|
||
// Always show TipTap at full opacity. A plain contentText overlay looked like a
|
||
// "broken layout" (one wall of text) when bodyReady failed to flip.
|
||
ZStack {
|
||
TipTapWebView(
|
||
mode: .viewer,
|
||
documentJSON: (try? memo.contentJson.jsonString())
|
||
?? "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}",
|
||
markdown: memo.contentMarkdown,
|
||
baseURL: env.session.session.flatMap { URL(string: $0.baseUrl) },
|
||
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: nil,
|
||
onResourcePress: { target in
|
||
resourceTarget = target
|
||
},
|
||
onImagePreview: { source, alt in
|
||
imagePreview = (source, alt)
|
||
},
|
||
onDoubleTap: blocksRichTextEdit(memo) ? nil : {
|
||
onEdit(memo.id, .body)
|
||
},
|
||
onPickImage: nil,
|
||
onSearchResult: { count, index in
|
||
searchMatchCount = count
|
||
searchMatchIndex = index
|
||
},
|
||
onReaderScroll: { collapsed in
|
||
titleCollapsed = collapsed
|
||
},
|
||
onImageExportEvent: { event in
|
||
handleImageExportEvent(event)
|
||
},
|
||
onBodyReady: {
|
||
bodyReady = true
|
||
}
|
||
)
|
||
|
||
if !bodyReady {
|
||
ProgressView()
|
||
.tint(AppTheme.title)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.background(AppTheme.card.opacity(0.92))
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.accessibilityIdentifier(DetailMemoChrome.body)
|
||
.task(id: memo.id) {
|
||
// Safety net: never leave the spinner forever if a ready callback is missed.
|
||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||
if !bodyReady {
|
||
bodyReady = true
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
}
|
||
|
||
// MARK: - Sync helpers
|
||
|
||
private var syncStatus: DetailSyncStatus {
|
||
DetailSyncStatus.derive(outboxStatus: outboxStatus, isGlobalSyncing: env.isSyncing)
|
||
}
|
||
|
||
private var syncLabel: String {
|
||
env.preferences.t(syncStatus.labelZH, en: syncStatus.labelEN, pl: syncStatus.labelPL)
|
||
}
|
||
|
||
private var availableNotebooks: [Notebook] {
|
||
(try? env.mirror.listNotebooks(scope: env.session.dataScope ?? "")) ?? []
|
||
}
|
||
|
||
private func notebookName(for memo: MemoDetail) -> String {
|
||
availableNotebooks.first(where: { $0.id == memo.notebookId })?.name
|
||
?? env.preferences.t("笔记本", en: "Notebook", pl: "Notatnik")
|
||
}
|
||
|
||
private func notebookAffiliationLabel(_ memo: MemoDetail) -> some View {
|
||
HStack(spacing: 4) {
|
||
Text(notebookName(for: memo))
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(AppTheme.secondary)
|
||
.lineLimit(1)
|
||
Image(systemName: "chevron.down")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(AppTheme.muted)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.layoutPriority(0)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func notebookAffiliationControl(_ memo: MemoDetail) -> some View {
|
||
if memo.isDeleted {
|
||
notebookAffiliationLabel(memo)
|
||
.accessibilityIdentifier(DetailMemoChrome.notebook)
|
||
} else {
|
||
Button {
|
||
showNotebookPicker = true
|
||
} label: {
|
||
notebookAffiliationLabel(memo)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(env.preferences.t("所在笔记本", en: "Notebook", pl: "Notatnik"))
|
||
.accessibilityHint(env.preferences.t("更改笔记所属笔记本", en: "Change the notebook for this note", pl: "Zmień notatnik tej notatki"))
|
||
.accessibilityIdentifier(DetailMemoChrome.notebook)
|
||
}
|
||
}
|
||
|
||
private func handleSyncStatusPress() {
|
||
switch syncStatus {
|
||
case .conflict:
|
||
detectConflict()
|
||
case .error, .pending:
|
||
Task {
|
||
await env.runSyncCycle(force: true)
|
||
refreshSyncStatus()
|
||
load()
|
||
}
|
||
case .synced, .syncing:
|
||
break
|
||
}
|
||
}
|
||
|
||
private func detailTitleRow(_ memo: MemoDetail) -> some View {
|
||
HStack(spacing: 6) {
|
||
if memo.isPinned {
|
||
Text("★")
|
||
.font(.system(size: 16))
|
||
.foregroundStyle(AppTheme.secondary)
|
||
}
|
||
Button {
|
||
onEdit(memo.id, .title)
|
||
} label: {
|
||
Text(localizedTitle(for: memo))
|
||
.font(.system(size: 18, weight: .bold))
|
||
.foregroundStyle(AppTheme.title)
|
||
.lineLimit(4)
|
||
.multilineTextAlignment(.leading)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(memo.isDeleted || blocksRichTextEdit(memo))
|
||
.accessibilityLabel(isInfographic(memo)
|
||
? env.preferences.t("信息图标题", en: "Infographic title", pl: "Tytuł infografiki")
|
||
: isStructuredTable(memo)
|
||
? env.preferences.t("表格标题", en: "Database title", pl: "Tytuł bazy danych")
|
||
: isVisualDiagram(memo)
|
||
? env.preferences.t("图表标题", en: "Diagram title", pl: "Tytuł diagramu")
|
||
: env.preferences.t("编辑笔记标题", en: "Edit note title", pl: "Edytuj tytuł notatki"))
|
||
.accessibilityHint(memo.contentMarkdown.contains("<!-- edgeever-poster-v1:")
|
||
? env.preferences.t("海报请在 Web 或桌面端编辑", en: "Edit posters on Web or desktop", pl: "Plakaty edytuj w wersji webowej lub desktopowej")
|
||
: isInfographic(memo)
|
||
? env.preferences.t("信息图请在 Web 或桌面端编辑", en: "Edit infographics on Web or desktop", pl: "Infografiki edytuj w wersji webowej lub desktopowej")
|
||
: isStructuredTable(memo)
|
||
? env.preferences.t("多维表格请在 Web 或桌面端编辑", en: "Edit databases on Web or desktop", pl: "Bazy danych edytuj w wersji webowej lub desktopowej")
|
||
: isVisualDiagram(memo)
|
||
? env.preferences.t("可视化图表请在 Web 或桌面端编辑", en: "Edit visual diagrams on Web or desktop", pl: "Diagramy wizualne edytuj w wersji webowej lub desktopowej")
|
||
: env.preferences.t("进入编辑并聚焦标题", en: "Opens editing with the title focused", pl: "Otwiera edycję z kursorem w tytule"))
|
||
.accessibilityIdentifier(DetailMemoChrome.title)
|
||
}
|
||
}
|
||
|
||
private func load() {
|
||
guard let scope = env.session.dataScope else { return }
|
||
do {
|
||
memo = try env.mirror.resolveMemo(scope: scope, id: memoId)
|
||
if memo == nil {
|
||
error = env.preferences.t("本地未找到该笔记,请先同步。", en: "Note not in local cache. Sync first.", pl: "Notatki nie ma w lokalnej pamięci podręcznej. Najpierw zsynchronizuj.")
|
||
}
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func isVisualDiagram(_ memo: MemoDetail) -> Bool {
|
||
memo.contentMarkdown.contains("<!-- edgeever-diagram-v1:")
|
||
}
|
||
|
||
private func isStructuredTable(_ memo: MemoDetail) -> Bool {
|
||
memo.contentMarkdown.contains("<!-- edgeever-table-v1:")
|
||
}
|
||
|
||
private func isInfographic(_ memo: MemoDetail) -> Bool {
|
||
memo.contentMarkdown.contains("<!-- edgeever-infographic-v1:")
|
||
}
|
||
|
||
private func blocksRichTextEdit(_ memo: MemoDetail) -> Bool {
|
||
isVisualDiagram(memo) || isStructuredTable(memo) || isInfographic(memo) || memo.contentMarkdown.contains("<!-- edgeever-poster-v1:")
|
||
}
|
||
|
||
private func refreshSyncStatus() {
|
||
guard let scope = env.session.dataScope else {
|
||
outboxStatus = nil
|
||
lastOutboxError = nil
|
||
return
|
||
}
|
||
let items = (try? env.outbox.listItems(scope: scope)) ?? []
|
||
if let item = items.first(where: { $0.memoId == memoId }) {
|
||
outboxStatus = item.status
|
||
lastOutboxError = item.lastError
|
||
if item.status == .conflict {
|
||
conflictItem = item
|
||
}
|
||
} else {
|
||
outboxStatus = nil
|
||
lastOutboxError = nil
|
||
}
|
||
}
|
||
|
||
private func detectConflict() {
|
||
guard let scope = env.session.dataScope else { return }
|
||
let items = (try? env.outbox.listItems(scope: scope)) ?? []
|
||
conflictItem = items.first { $0.memoId == memoId && $0.status == .conflict }
|
||
refreshSyncStatus()
|
||
}
|
||
|
||
private func copyLocalDraft() async {
|
||
guard let memo else { return }
|
||
let text = [localizedTitle(for: memo), memo.contentMarkdown].filter { !$0.isEmpty }.joined(separator: "\n\n")
|
||
UIPasteboard.general.string = text
|
||
}
|
||
|
||
private func isTemporaryMemoId(_ id: String) -> Bool {
|
||
id.hasPrefix("local:") || id.hasPrefix("local_")
|
||
}
|
||
|
||
private func closeSearch() {
|
||
searchOpen = false
|
||
searchQuery = ""
|
||
searchMatchCount = 0
|
||
searchMatchIndex = 0
|
||
SharedTipTapRuntime.viewer.search("", index: 0)
|
||
}
|
||
|
||
private func localizedTitle(for memo: MemoDetail) -> String {
|
||
let title = memo.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
return title.isEmpty ? env.preferences.t("无标题笔记", en: "Untitled note", pl: "Notatka bez tytułu") : title
|
||
}
|
||
|
||
private func moveMemoToNotebook(_ memo: MemoDetail, notebookId: String) async {
|
||
guard !memo.isDeleted else { return }
|
||
guard !notebookId.isEmpty, notebookId != memo.notebookId else { return }
|
||
guard let scope = env.session.dataScope else { return }
|
||
let revision = memo.revision
|
||
let contentHash = memo.contentHash
|
||
var updated = memo
|
||
updated.notebookId = notebookId
|
||
updated.updatedAt = EdgeEverDate.nowString()
|
||
do {
|
||
try env.mirror.upsertMemo(scope: scope, memo: updated)
|
||
self.memo = updated
|
||
try env.outbox.enqueueUpdate(
|
||
scope: scope,
|
||
payload: MemoUpdatePayload(
|
||
memoId: updated.id,
|
||
expectedRevision: revision,
|
||
expectedContentHash: contentHash,
|
||
title: updated.title ?? "",
|
||
contentMarkdown: updated.contentMarkdown,
|
||
contentJson: try? updated.contentJson.jsonString(),
|
||
notebookId: notebookId,
|
||
tags: updated.tags
|
||
)
|
||
)
|
||
await env.runSyncCycle()
|
||
load()
|
||
refreshSyncStatus()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
load()
|
||
}
|
||
}
|
||
|
||
private func togglePin(_ memo: MemoDetail) async {
|
||
guard let scope = env.session.dataScope else { return }
|
||
do {
|
||
let updated = try await env.session.client.updateMemo(
|
||
id: memo.id,
|
||
expectedRevision: nil,
|
||
expectedContentHash: nil,
|
||
editSessionId: nil,
|
||
notebookId: nil,
|
||
title: nil,
|
||
isPinned: !memo.isPinned,
|
||
contentMarkdown: nil,
|
||
tags: nil
|
||
)
|
||
try env.mirror.upsertMemo(scope: scope, memo: updated)
|
||
self.memo = updated
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func applyAiDraft(
|
||
_ draft: String,
|
||
mode: AiDraftApplyMode,
|
||
to sourceMemo: MemoDetail
|
||
) async throws {
|
||
guard let scope = env.session.dataScope else {
|
||
throw APIError(
|
||
status: -1,
|
||
code: "session_unavailable",
|
||
message: env.preferences.t("登录状态已失效,请重新登录。", en: "Your session has expired. Sign in again.", pl: "Sesja wygasła. Zaloguj się ponownie.")
|
||
)
|
||
}
|
||
let normalizedDraft = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !normalizedDraft.isEmpty else { return }
|
||
let currentContent = sourceMemo.contentMarkdown.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let contentMarkdown = mode == .append && !currentContent.isEmpty
|
||
? "\(currentContent)\n\n\(normalizedDraft)"
|
||
: normalizedDraft
|
||
|
||
let editSession = try await env.session.client.createMemoEditSession(memoId: sourceMemo.id)
|
||
guard
|
||
editSession.baseRevision == sourceMemo.revision,
|
||
editSession.baseContentHash == sourceMemo.contentHash
|
||
else {
|
||
throw APIError(
|
||
status: 409,
|
||
code: "revision_conflict",
|
||
message: env.preferences.t(
|
||
"笔记已在其他设备更新,请刷新后重新生成。",
|
||
en: "This note changed on another device. Refresh it and generate again.",
|
||
pl: "Ta notatka zmieniła się na innym urządzeniu. Odśwież ją i wygeneruj ponownie."
|
||
)
|
||
)
|
||
}
|
||
|
||
let updated = try await env.session.client.updateMemo(
|
||
id: sourceMemo.id,
|
||
expectedRevision: sourceMemo.revision,
|
||
expectedContentHash: sourceMemo.contentHash,
|
||
editSessionId: editSession.id,
|
||
notebookId: nil,
|
||
title: nil,
|
||
isPinned: nil,
|
||
contentMarkdown: contentMarkdown,
|
||
contentJson: nil,
|
||
tags: nil
|
||
)
|
||
try env.mirror.upsertMemo(scope: scope, memo: updated)
|
||
memo = updated
|
||
refreshSyncStatus()
|
||
}
|
||
|
||
private func openInitialShareImageIfReady() {
|
||
guard initialShareImage, bodyReady, memo?.isDeleted == false, !initialShareImageHandled else { return }
|
||
initialShareImageHandled = true
|
||
imageShareOptionsOpen = true
|
||
}
|
||
|
||
private func shareMemo(_ memo: MemoDetail) async {
|
||
do {
|
||
let share = try await env.session.client.createMemoShare(memoId: memo.id)
|
||
let base = env.session.session?.baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
||
guard let url = URL(string: "\(base)/share/\(share.token)") else { return }
|
||
let title = memo.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let displayTitle = title?.isEmpty == false
|
||
? title!
|
||
: env.preferences.t("无标题笔记", en: "Untitled note", pl: "Notatka bez tytułu")
|
||
memoSharePayload = MemoSharePayload(message: "\(displayTitle)\n\(url.absoluteString)", url: url)
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func deleteMemo(_ memo: MemoDetail) async {
|
||
guard let scope = env.session.dataScope else { return }
|
||
do {
|
||
if memo.id.hasPrefix("local:") {
|
||
try env.outbox.cancelMemo(scope: scope, memoId: memo.id)
|
||
try env.mirror.deleteMemo(scope: scope, id: memo.id)
|
||
} else {
|
||
_ = try env.mirror.softDeleteMemo(scope: scope, id: memo.id)
|
||
try await env.session.client.deleteMemo(id: memo.id, permanent: false)
|
||
}
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func exportMemoImage(
|
||
_ memo: MemoDetail,
|
||
format: String,
|
||
theme: String = "mint",
|
||
fontStyle: String = "auto",
|
||
fontSize: String = "lg",
|
||
cardWidth: String = "wide",
|
||
showTitle: Bool = false,
|
||
showNotebook: Bool = false,
|
||
showTags: Bool = false,
|
||
showUpdatedAt: Bool = true,
|
||
showBranding: Bool = true,
|
||
intent: MemoImageExportIntent = .share
|
||
) {
|
||
guard !imageExporting, bodyReady else { return }
|
||
let requestId = UUID().uuidString
|
||
imageExportBuffer.start(requestId: requestId)
|
||
imageExportRequestId = requestId
|
||
imageFontRecoveryId = requestId
|
||
imageFontsRecovered = false
|
||
imageFontStatus = "loading"
|
||
retryImageFonts = {
|
||
exportMemoImage(memo, format: format, theme: theme, fontStyle: fontStyle, fontSize: fontSize,
|
||
cardWidth: cardWidth, showTitle: showTitle, showNotebook: showNotebook, showTags: showTags,
|
||
showUpdatedAt: showUpdatedAt, showBranding: showBranding, intent: .preview)
|
||
}
|
||
imageExportIntent = intent
|
||
imageExporting = true
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 60) {
|
||
guard imageExportRequestId == requestId else { return }
|
||
imageExportBuffer.cancel(requestId: requestId)
|
||
imageExportRequestId = nil
|
||
imageExporting = false
|
||
imageExportMessage = MemoImageExportMessage(
|
||
title: env.preferences.t("导出失败", en: "Export failed", pl: "Eksport nie powiódł się"),
|
||
message: env.preferences.t(
|
||
"生成图片超时。请调小导出字号,或尝试较短的笔记。",
|
||
en: "Image generation timed out. Try a smaller font size or a shorter note.",
|
||
ja: "画像の生成がタイムアウトしました。文字を小さくするか、短いノートでお試しください。",
|
||
pl: "Generowanie obrazu przekroczyło limit czasu. Spróbuj mniejszej czcionki lub krótszej notatki."
|
||
)
|
||
)
|
||
}
|
||
let title = memo.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
SharedTipTapRuntime.viewer.exportNoteImage(request: [
|
||
"requestId": requestId,
|
||
"format": format,
|
||
"title": title?.isEmpty == false ? title! : env.preferences.t("无标题笔记", en: "Untitled note", pl: "Notatka bez tytułu"),
|
||
"fallbackTitle": env.preferences.t("无标题笔记", en: "Untitled note", pl: "Notatka bez tytułu"),
|
||
"notebook": showNotebook ? notebookName(for: memo) : "",
|
||
"tags": showTags ? memo.tags : [],
|
||
"updatedAt": showUpdatedAt ? formattedImageExportDate(memo.updatedAt) : "",
|
||
"theme": theme,
|
||
"fontStyle": fontStyle,
|
||
"fontSize": fontSize,
|
||
"cardWidth": cardWidth,
|
||
"showTitle": showTitle,
|
||
"showNotebook": showNotebook,
|
||
"showTags": showTags,
|
||
"showUpdatedAt": showUpdatedAt,
|
||
"branding": showBranding,
|
||
])
|
||
}
|
||
|
||
private func formattedImageExportDate(_ rawValue: String) -> String {
|
||
let parsers = [ISO8601DateFormatter.edgeEver, ISO8601DateFormatter.edgeEverFallback]
|
||
guard let date = parsers.lazy.compactMap({ $0.date(from: rawValue) }).first else { return rawValue }
|
||
let formatter = DateFormatter()
|
||
formatter.locale = env.preferences.uiLanguage == .polish
|
||
? Locale(identifier: "pl_PL")
|
||
: (env.preferences.isEnglish ? Locale(identifier: "en_US") : Locale(identifier: "zh_CN"))
|
||
formatter.dateStyle = .medium
|
||
formatter.timeStyle = .short
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
private func handleImageExportEvent(_ event: [String: Any]) {
|
||
if event["type"] as? String == "imageExportFontsReady" {
|
||
guard event["requestId"] as? String == imageFontRecoveryId else { return }
|
||
imageFontsRecovered = true
|
||
if !imageExporting, imageExportPreviewPayload?.fontFallback == true { retryImageFonts?() }
|
||
return
|
||
}
|
||
if event["type"] as? String == "imageExportFontStatus" {
|
||
guard event["requestId"] as? String == imageExportRequestId else { return }
|
||
imageFontStatus = event["status"] as? String ?? "ready"
|
||
return
|
||
}
|
||
switch imageExportBuffer.accept(event) {
|
||
case .none:
|
||
break
|
||
case let .failure(message):
|
||
imageExportRequestId = nil
|
||
imageExporting = false
|
||
let explanation: String
|
||
switch message {
|
||
case "NOTE_IMAGE_TOO_LONG":
|
||
explanation = env.preferences.t(
|
||
"笔记太长,无法清晰地导出为单张图片。",
|
||
en: "This note is too long to export clearly as one image.",
|
||
ja: "ノートが長すぎるため、1枚の画像として鮮明に書き出せません。",
|
||
pl: "Notatka jest zbyt długa, aby wyeksportować ją czytelnie jako jeden obraz."
|
||
)
|
||
case "NOTE_IMAGE_RENDER_FAILED":
|
||
explanation = env.preferences.t(
|
||
"图片生成失败,请稍后重试。",
|
||
en: "The image could not be generated. Please try again.",
|
||
ja: "画像を生成できませんでした。もう一度お試しください。",
|
||
pl: "Nie udało się wygenerować obrazu. Spróbuj ponownie."
|
||
)
|
||
default:
|
||
explanation = message
|
||
}
|
||
imageExportMessage = MemoImageExportMessage(
|
||
title: env.preferences.t("导出失败", en: "Export failed", pl: "Eksport nie powiódł się"),
|
||
message: explanation
|
||
)
|
||
case let .complete(data, filename, metadata):
|
||
imageExportRequestId = nil
|
||
imageExporting = false
|
||
do {
|
||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("EdgeEverNoteExports", isDirectory: true)
|
||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||
let safeFilename = (filename as NSString).lastPathComponent
|
||
let url = directory.appendingPathComponent(safeFilename)
|
||
try data.write(to: url, options: .atomic)
|
||
if imageExportIntent == .preview || metadata.fontFallback {
|
||
imageExportPreviewPayload = MemoImageExportPreviewPayload(
|
||
id: imageExportPreviewPayload?.id ?? UUID(),
|
||
fontFallback: metadata.fontFallback,
|
||
url: url,
|
||
width: metadata.width,
|
||
height: metadata.height,
|
||
totalImages: metadata.totalImages,
|
||
failedImages: metadata.failedImages
|
||
)
|
||
if metadata.fontFallback, imageFontsRecovered {
|
||
let recoveryId = imageFontRecoveryId
|
||
DispatchQueue.main.async {
|
||
if imageFontRecoveryId == recoveryId, imageExportPreviewPayload != nil { retryImageFonts?() }
|
||
}
|
||
}
|
||
} else {
|
||
imageExportSharePayload = MemoImageExportSharePayload(url: url)
|
||
}
|
||
} catch {
|
||
imageExportMessage = MemoImageExportMessage(
|
||
title: env.preferences.t("导出失败", en: "Export failed", pl: "Eksport nie powiódł się"),
|
||
message: error.localizedDescription
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct MemoSharePayload: Identifiable {
|
||
let id = UUID()
|
||
let message: String
|
||
let url: URL
|
||
}
|
||
|
||
private struct MemoImageExportSharePayload: Identifiable {
|
||
let id = UUID()
|
||
let url: URL
|
||
}
|
||
|
||
private struct MemoImageExportPreviewPayload: Identifiable {
|
||
let id: UUID
|
||
let fontFallback: Bool
|
||
let url: URL
|
||
let width: Int
|
||
let height: Int
|
||
let totalImages: Int
|
||
let failedImages: Int
|
||
}
|
||
|
||
private struct MemoImageExportDocumentPayload: Identifiable {
|
||
let id = UUID()
|
||
let url: URL
|
||
}
|
||
|
||
private enum MemoImageExportIntent {
|
||
case preview
|
||
case share
|
||
}
|
||
|
||
private struct MemoImageExportMessage: Identifiable {
|
||
let id = UUID()
|
||
let title: String
|
||
let message: String
|
||
}
|
||
|
||
private struct MemoImageExportPreviewView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
let payload: MemoImageExportPreviewPayload
|
||
let isLoadingFonts: Bool
|
||
let onRetryFonts: () -> Void
|
||
let isEnglish: Bool
|
||
var isPolish: Bool = false
|
||
let onCopy: () -> Void
|
||
let onSave: () -> Void
|
||
let onShare: () -> Void
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
VStack(spacing: 0) {
|
||
ScrollView {
|
||
VStack(spacing: 12) {
|
||
if let image = UIImage(contentsOfFile: payload.url.path) {
|
||
Image(uiImage: image)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||
.shadow(color: .black.opacity(0.16), radius: 12, y: 5)
|
||
} else {
|
||
ContentUnavailableView(
|
||
isPolish ? "Podgląd niedostępny" : (isEnglish ? "Preview unavailable" : "无法预览"),
|
||
systemImage: "photo.badge.exclamationmark"
|
||
)
|
||
}
|
||
if payload.fontFallback || isLoadingFonts {
|
||
if isLoadingFonts {
|
||
ProgressView(isPolish ? "Ładowanie czcionki…" : (isEnglish ? "Loading font…" : "字体加载中…"))
|
||
} else {
|
||
warning(isPolish
|
||
? "Tymczasowo używane są czcionki zastępcze. Po załadowaniu oryginalne czcionki zostaną przywrócone automatycznie."
|
||
: (isEnglish ? "Temporarily using substitute fonts. The original fonts will be restored automatically when loaded." : "暂时使用替代字体,加载成功后将自动恢复"))
|
||
Button(isPolish ? "Ponów próbę" : (isEnglish ? "Retry" : "重试"), action: onRetryFonts)
|
||
}
|
||
}
|
||
if payload.failedImages > 0 {
|
||
warning(
|
||
isPolish
|
||
? "Nie udało się dołączyć obrazów z notatki: \(payload.failedImages) z \(payload.totalImages)."
|
||
: isEnglish
|
||
? "\(payload.failedImages) of \(payload.totalImages) note image(s) could not be included."
|
||
: "笔记中的 \(payload.totalImages) 张图片有 \(payload.failedImages) 张未能包含。"
|
||
)
|
||
}
|
||
if payload.height > 12_000 {
|
||
warning(
|
||
isPolish
|
||
? "To długi obraz. Niektóre aplikacje społecznościowe mogą obniżyć jego jakość – zachowaj zapisany oryginał."
|
||
: isEnglish
|
||
? "This is a long image. Some social apps may reduce its quality; keep the saved original."
|
||
: "图片较长,部分社交平台可能会压缩画质;建议保留保存的原图。"
|
||
)
|
||
}
|
||
}
|
||
.padding(16)
|
||
}
|
||
Divider()
|
||
HStack(spacing: 8) {
|
||
previewButton(isPolish ? "Kopiuj" : (isEnglish ? "Copy" : "复制图片"), systemImage: "doc.on.doc", action: onCopy)
|
||
previewButton(isPolish ? "Zapisz" : (isEnglish ? "Save" : "保存图片"), systemImage: "square.and.arrow.down", action: onSave)
|
||
Button(action: onShare) {
|
||
Label(isPolish ? "Udostępnij" : (isEnglish ? "Share" : "系统分享"), systemImage: "square.and.arrow.up")
|
||
.frame(maxWidth: .infinity, minHeight: 44)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.tint(Color(red: 22 / 255, green: 160 / 255, blue: 110 / 255))
|
||
}
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.padding(12)
|
||
}
|
||
.background(Color(.systemGroupedBackground))
|
||
.navigationTitle(isPolish ? "Podgląd obrazu" : (isEnglish ? "Image Preview" : "图片预览"))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button(isPolish ? "Zamknij" : (isEnglish ? "Close" : "关闭")) { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func warning(_ text: String) -> some View {
|
||
Text(text)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(Color.orange)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(10)
|
||
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
|
||
private func previewButton(_ title: String, systemImage: String, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
Label(title, systemImage: systemImage)
|
||
.frame(maxWidth: .infinity, minHeight: 44)
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
|
||
private struct MemoImageDocumentExportView: UIViewControllerRepresentable {
|
||
let fileURL: URL
|
||
let onFinish: (Result<Bool, Error>) -> Void
|
||
|
||
func makeCoordinator() -> Coordinator { Coordinator(onFinish: onFinish) }
|
||
|
||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||
let picker = UIDocumentPickerViewController(forExporting: [fileURL], asCopy: true)
|
||
picker.delegate = context.coordinator
|
||
picker.shouldShowFileExtensions = true
|
||
return picker
|
||
}
|
||
|
||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||
|
||
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||
let onFinish: (Result<Bool, Error>) -> Void
|
||
init(onFinish: @escaping (Result<Bool, Error>) -> Void) { self.onFinish = onFinish }
|
||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||
onFinish(.success(!urls.isEmpty))
|
||
}
|
||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||
onFinish(.success(false))
|
||
}
|
||
}
|
||
}
|
||
|
||
private final class MemoImageExportBuffer {
|
||
struct Metadata {
|
||
let fontFallback: Bool
|
||
let width: Int
|
||
let height: Int
|
||
let totalImages: Int
|
||
let failedImages: Int
|
||
}
|
||
|
||
enum Result {
|
||
case none
|
||
case complete(Data, filename: String, metadata: Metadata)
|
||
case failure(String)
|
||
}
|
||
|
||
private var requestId: String?
|
||
private var chunks: [String] = []
|
||
|
||
func start(requestId: String) {
|
||
self.requestId = requestId
|
||
chunks.removeAll(keepingCapacity: true)
|
||
}
|
||
|
||
func cancel(requestId: String) {
|
||
guard self.requestId == requestId else { return }
|
||
self.requestId = nil
|
||
chunks.removeAll(keepingCapacity: false)
|
||
}
|
||
|
||
func accept(_ event: [String: Any]) -> Result {
|
||
guard let currentRequestId = requestId,
|
||
event["requestId"] as? String == currentRequestId,
|
||
let type = event["type"] as? String
|
||
else { return .none }
|
||
|
||
if type == "imageExportChunk" {
|
||
if let chunk = event["chunk"] as? String { chunks.append(chunk) }
|
||
return .none
|
||
}
|
||
|
||
defer {
|
||
requestId = nil
|
||
chunks.removeAll(keepingCapacity: false)
|
||
}
|
||
if type == "imageExportError" {
|
||
return .failure(event["message"] as? String ?? "Image export failed")
|
||
}
|
||
guard type == "imageExportComplete",
|
||
let filename = event["filename"] as? String,
|
||
let data = Data(base64Encoded: chunks.joined())
|
||
else { return .failure("Image export returned invalid data") }
|
||
let metadata = Metadata(
|
||
fontFallback: event["fontFallback"] as? Bool ?? false,
|
||
width: (event["width"] as? NSNumber)?.intValue ?? 0,
|
||
height: (event["height"] as? NSNumber)?.intValue ?? 0,
|
||
totalImages: (event["totalImages"] as? NSNumber)?.intValue ?? 0,
|
||
failedImages: (event["failedImages"] as? NSNumber)?.intValue ?? 0
|
||
)
|
||
return .complete(data, filename: filename, metadata: metadata)
|
||
}
|
||
}
|
||
|
||
// MARK: - String helper
|
||
|
||
private extension String {
|
||
var nilIfEmpty: String? {
|
||
let t = trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return t.isEmpty ? nil : t
|
||
}
|
||
}
|
||
|
||
// MARK: - Version history (Android RevisionHistoryModal parity)
|
||
|
||
/// Android `RevisionHistoryModal`: header + selected summary/restore + timeline pills + markdown preview.
|