import SwiftUI import UIKit /// Wrapper so `fullScreenCover(item:)` can present edit for a memo id. struct EditingMemoRoute: Identifiable, Hashable { let id: String let initialFocus: MemoEditInitialFocus } private struct ShareImageMemoRoute: Hashable { let memoId: String } private struct SelectionSharePayload: Identifiable { let id = UUID() let message: String let url: URL } struct WorkspaceView: View { @Environment(AppEnvironment.self) private var env @State private var store = WorkspaceStore() @State private var path = NavigationPath() @State private var showSettings = false @State private var showNewNote = false @State private var createSeed: CreateMemoSeed? @State private var createSharedImages: [ShareHandoffStore.SharedImage] = [] @State private var showCreateChoice = false @State private var showTemplatePicker = false @State private var createLongPressConsumed = false @State private var showMoveSheet = false @State private var showSelectionMore = false @State private var pendingMemoShare: MemoSummary? @State private var pendingImageShareMemoId: String? @State private var selectionSharePayload: SelectionSharePayload? @State private var conflictItem: OutboxItem? /// Edit is presented from the workspace root — more reliable than cover on a pushed detail page. @State private var editingMemo: EditingMemoRoute? @State private var incomingClipURL: URL? @State private var isImportingShare = false @State private var shareImportAlert: ShareImportAlert? /// Android SafeAreaView edges=[top,left,right] — bottom chrome owns home indicator. private var showsBottomChrome: Bool { store.selectionMode || path.isEmpty } /// Solid chrome height = nav band + home-indicator. Create button sits *inside* the band /// (below the top separator), so list padding must not invent a second empty strip. private var bottomChromeHeight: CGFloat { MobileUIMetrics.bottomChromeHeight } var body: some View { NavigationStack(path: $path) { // List pads the solid white chrome height; create button lives inside the tab bar under the separator. ZStack(alignment: .bottom) { VStack(spacing: 0) { if showSettings { SettingsView(onClose: { showSettings = false }) } else { syncBanner listHeader NotesListView( store: store, path: $path, onCreateNote: { openCreateNote() }, onCreateFromTemplate: { openCreateFromTemplate() }, onCreateNotebook: { store.startCreatingNotebook = true store.showNotebookPicker = true } ) .frame(maxWidth: .infinity, maxHeight: .infinity) } } .background(AppTheme.background) .padding(.bottom, showsBottomChrome ? bottomChromeHeight : 0) if store.selectionMode { selectionBar } else if path.isEmpty { bottomNav } } .ignoresSafeArea(.container, edges: .bottom) .navigationBarHidden(true) .navigationDestination(for: String.self) { memoId in memoDetail(for: memoId) } .navigationDestination(for: ShareImageMemoRoute.self) { route in memoDetail(for: route.memoId, shareImage: true) } // Android CreateMemoModal is fullScreen — not a half sheet / Form. .fullScreenCover(isPresented: $showNewNote) { MemoEditView( mode: .create( notebookId: store.selectedNotebookId ?? store.notebooks.first?.id ?? "", seed: createSeed ), initialSharedImages: createSharedImages, onCreateFinished: { _ in store.reload(env: env) } ) .onDisappear { createSeed = nil createSharedImages = [] // Safety refresh if create finished without callback (e.g. swipe-dismiss empty). store.reload(env: env) } } .sheet(isPresented: $showCreateChoice) { CreateChoiceSheet( canCreate: !store.notebooks.isEmpty, onBlank: { openCreateNote() }, onTemplate: { openCreateFromTemplate() } ) } .sheet(isPresented: $showTemplatePicker) { TemplatePickerSheet { seed in openCreateNote(seed: seed) } } // Edit cover on workspace root; underlay is list (detail popped once cover is presented). .fullScreenCover(item: $editingMemo) { route in MemoEditView( mode: .edit(memoId: route.id), initialFocus: route.initialFocus, onLeaveToList: { // Pop detail first (no animation) while cover still covers the stack, // then dismiss the cover so the user only ever sees the list. store.reload(env: env) var t = Transaction() t.disablesAnimations = true withTransaction(t) { path = NavigationPath() } editingMemo = nil } ) } .sheet(isPresented: $store.showNotebookPicker) { NotebookPickerSheet(store: store) } .sheet(isPresented: $store.showActions) { ListActionsSheet(store: store) } .sheet(isPresented: $store.showTagFilterPicker) { MemoTagPickerSheet( selectedTags: store.selectedTag.map { [$0] } ?? [], allowCreate: false, maxSelections: 1, closeOnSelection: true, title: "按标签筛选", titleEN: "Filter by tag", titlePL: "Filtruj według tagu" ) { tags in store.selectTag(tags.first) store.reload(env: env) } .presentationDetents([.medium, .large]) } .sheet(isPresented: $showMoveSheet) { MoveNotebookSheet(notebooks: store.notebooks) { notebookId in Task { await store.moveSelection(env: env, notebookId: notebookId) showMoveSheet = false } } } .sheet(isPresented: $showSelectionMore, onDismiss: { if let memo = pendingMemoShare { pendingMemoShare = nil Task { await shareSelectedMemo(memo) } return } guard let memoId = pendingImageShareMemoId else { return } pendingImageShareMemoId = nil store.clearSelection() path.append(ShareImageMemoRoute(memoId: memoId)) }) { SelectionMoreSheet( store: store, onShare: { memo in pendingMemoShare = memo }, onShareImage: { memo in pendingImageShareMemoId = memo.id } ) .presentationDetents([.height(390), .medium]) .presentationDragIndicator(.hidden) } .sheet(item: $selectionSharePayload) { payload in ActivityShareView(items: [payload.message, payload.url]) { _, _, error in if let error { shareImportAlert = ShareImportAlert( title: env.preferences.t("分享失败", en: "Share failed", pl: "Udostępnianie nie powiodło się"), message: error.localizedDescription ) } selectionSharePayload = nil } } .sheet(item: $conflictItem) { item in ConflictResolutionView(item: item) { conflictItem = nil store.reload(env: env) } } .onAppear { store.reload(env: env) consumeShare() detectConflicts() } .onReceive(NotificationCenter.default.publisher(for: .edgeEverShareReceived)) { _ in consumeShare() } .onChange(of: env.session.session?.token) { _, _ in store.reload(env: env) } .onChange(of: env.isSyncing) { wasSyncing, isSyncing in // Refresh list when a background/bootstrap sync finishes. if wasSyncing && !isSyncing { store.reload(env: env) } } // Android invalidates the memo list on each bootstrap batch so the UI // can show progressive counts / partial notes during first login. .onChange(of: env.bootstrapProgress?.loadedCount) { _, _ in store.reload(env: env) } .onChange(of: env.bootstrapProgress?.totalCount) { _, _ in store.reload(env: env) } .onChange(of: env.preferences.showDescendantNotes) { _, _ in store.reload(env: env) } .refreshable { await env.runSyncCycle(force: true) store.reload(env: env) detectConflicts() } .overlay { if isImportingShare { ZStack { Color.black.opacity(0.34).ignoresSafeArea() VStack(spacing: 12) { ProgressView() .controlSize(.large) .tint(AppTheme.accent) Text(env.preferences.t("正在剪藏文章", en: "Clipping article", ja: "記事を取り込んでいます", pl: "Przechwytywanie artykułu")) .font(.system(size: 17, weight: .bold)) .foregroundStyle(AppTheme.title) Text(env.preferences.t("正在提取标题、正文和图片链接…", en: "Extracting the title, body, and image links…", ja: "タイトル、本文、画像リンクを抽出しています…", pl: "Wyodrębnianie tytułu, treści i linków do obrazów…")) .font(.system(size: 13)) .foregroundStyle(AppTheme.secondary) .multilineTextAlignment(.center) } .padding(24) .background(AppTheme.card) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) if let incomingClipURL { WebClipCaptureView( url: incomingClipURL, onCaptured: { page in finishRenderedClip(page, sourceURL: incomingClipURL) }, onFailed: { message in failRenderedClip(message, sourceURL: incomingClipURL) } ) .frame(width: 1, height: 1) .opacity(0.01) .allowsHitTesting(false) } } .accessibilityIdentifier("shareImportOverlay") } } .alert(item: $shareImportAlert) { alert in Alert( title: Text(alert.title), message: Text(alert.message), dismissButton: .default(Text(env.preferences.t("好的", en: "OK", pl: "OK"))) { if let draft = alert.draft { finishClip(draft) } } ) } } .preferredColorScheme(env.preferences.colorScheme) } // MARK: - Header (Android NotesView parity) private var listHeader: some View { let searchActive = !store.searchText.trimmingCharacters(in: .whitespaces).isEmpty return VStack(alignment: .leading, spacing: 0) { if store.selectionMode { HStack(spacing: 0) { Button { store.clearSelection() } label: { Image(systemName: "xmark") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(AppTheme.slate) .frame(width: 38, height: 38) } Text(store.selectedMemoIds.isEmpty ? env.preferences.t("选择笔记", en: "Select notes", pl: "Wybierz notatki") : env.preferences.t("已选择 \(store.selectedMemoIds.count) 条", en: "\(store.selectedMemoIds.count) selected", pl: "Zaznaczone: \(store.selectedMemoIds.count)")) .font(.system(size: 17, weight: .heavy)) .foregroundStyle(AppTheme.title) .padding(.horizontal, 8) Spacer(minLength: 0) } .frame(minHeight: 44) } HStack(alignment: .center) { Button { if store.selectedTag != nil { clearTagFilter() } else { store.showNotebookPicker = true } } label: { HStack(spacing: 4) { if store.selectedTag != nil { Image(systemName: "chevron.left") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(AppTheme.secondary) } Text(store.selectedTag.map { "#\($0)" } ?? store.activeNotebook?.name ?? env.preferences.t("全部笔记", en: "All notes", pl: "Wszystkie notatki")) .font(AppTheme.notebookTitleFont) .foregroundStyle(AppTheme.title) .lineLimit(1) if store.selectedTag == nil { Image(systemName: "chevron.down") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(AppTheme.secondary) } } .frame(minHeight: MobileUIMetrics.compactControlHeight) } .buttonStyle(.plain) .accessibilityLabel( store.selectedTag == nil ? env.preferences.t("切换笔记本", en: "Switch notebook", pl: "Przełącz notatnik") : env.preferences.t("返回笔记列表", en: "Back to notes", pl: "Wróć do notatek") ) Spacer(minLength: 8) Button { store.showActions = true } label: { Image(systemName: "ellipsis") .font(.system(size: 18, weight: .semibold)) .foregroundStyle(AppTheme.slate) .frame(width: MobileUIMetrics.compactControlHeight, height: MobileUIMetrics.compactControlHeight) .contentShape(Circle()) } .buttonStyle(.plain) .accessibilityLabel(env.preferences.t("列表选项", en: "List options", pl: "Opcje listy")) } .padding(.bottom, 12) HStack(spacing: 8) { HStack(spacing: 8) { Image(systemName: "magnifyingglass") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(searchActive ? AppTheme.accent : AppTheme.secondary) TextField(env.preferences.t("搜索笔记", en: "Search notes", pl: "Szukaj notatek"), text: $store.searchText) .font(AppTheme.searchFont) .foregroundStyle(AppTheme.title) .textInputAutocapitalization(.never) .autocorrectionDisabled() if searchActive { Button { withAnimation(Motion.search) { store.searchText = "" } store.reload(env: env) } label: { Image(systemName: "xmark") .font(.system(size: 11, weight: .bold)) .foregroundStyle(AppTheme.secondary) .frame(width: 28, height: 28) } .buttonStyle(.plain) .transition(.scale.combined(with: .opacity)) } } .padding(.horizontal, 12) .frame(height: MobileUIMetrics.compactControlHeight) .background(searchActive ? AppTheme.searchActiveFill : AppTheme.searchFill) .clipShape(Capsule()) .overlay( Capsule().stroke(searchActive ? AppTheme.accentBright : Color.clear, lineWidth: 1) ) .animation(Motion.search, value: searchActive) filterChip( active: store.filter == .pinned, // Match Android pin filter glyph (star outline / filled when active) systemImage: store.filter == .pinned ? "star.fill" : "star", label: env.preferences.t("置顶", en: "Pinned", pl: "Przypięte") ) { withAnimation(Motion.chip) { store.toggleFilter(.pinned) } store.reload(env: env) } filterChip( active: store.selectedTag != nil, systemImage: store.selectedTag == nil ? "tag" : "tag.fill", label: store.selectedTag.map { "#\($0)" } ?? env.preferences.t("按标签筛选", en: "Filter by tag", pl: "Filtruj według tagu") ) { store.showTagFilterPicker = true } } .edgeEverSelectionFeedback(store.filter) .animation(Motion.chip, value: store.selectedTag) .onChange(of: store.searchText) { _, _ in store.scheduleSearch(env: env) } if searchActive || store.filter != .all || store.selectedTag != nil { constraintBar .padding(.top, 12) .transition(.move(edge: .top).combined(with: .opacity)) } } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 8) .background(AppTheme.background) .overlay(alignment: .bottom) { Rectangle().fill(AppTheme.border).frame(height: 1) } .animation(Motion.search, value: searchActive || store.filter != .all || store.selectedTag != nil) } private var constraintBar: some View { let searchActive = !store.searchText.trimmingCharacters(in: .whitespaces).isEmpty return HStack(spacing: 8) { if searchActive { HStack(spacing: 4) { Image(systemName: "magnifyingglass") .font(.system(size: 10, weight: .bold)) Text(env.preferences.t("正在搜索", en: "Searching", pl: "Wyszukiwanie")) .font(.system(size: 12, weight: .bold)) } .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(AppTheme.accent) .clipShape(Capsule()) Text(env.preferences.t("\(store.totalCount) 条结果", en: "\(store.totalCount) results", pl: "Wyniki: \(store.totalCount)")) .font(.system(size: 12, weight: .bold)) .foregroundStyle(AppTheme.accentText) .frame(maxWidth: .infinity, alignment: .leading) Button(env.preferences.t("退出搜索", en: "Exit", pl: "Zakończ")) { store.searchText = "" store.reload(env: env) } .font(.system(size: 12, weight: .bold)) .foregroundStyle(AppTheme.accentText) } else { Text(env.preferences.t("筛选:\(filterLabel) · \(store.totalCount) 条", en: "Filter: \(filterLabelEN) · \(store.totalCount)", pl: "Filtr: \(filterLabelPL) · \(store.totalCount)")) .font(.system(size: 12, weight: .bold)) .foregroundStyle(AppTheme.secondary) .frame(maxWidth: .infinity, alignment: .leading) Button(env.preferences.t("重置", en: "Reset", pl: "Resetuj")) { if store.selectedTag != nil { clearTagFilter() } else { store.filter = .all store.reload(env: env) } } .font(.system(size: 12, weight: .bold)) .foregroundStyle(AppTheme.slate) } } .padding(.horizontal, 10) .padding(.vertical, 6) .frame(minHeight: 32) .background(searchActive ? AppTheme.searchActiveFill : AppTheme.card) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(searchActive ? AppTheme.accentBorder : AppTheme.border, lineWidth: 1) ) .overlay(alignment: .leading) { if searchActive { RoundedRectangle(cornerRadius: 1.5) .fill(AppTheme.accentBright) .frame(width: 3) .padding(.vertical, 1) } } .clipShape(RoundedRectangle(cornerRadius: 6)) } private var filterLabel: String { if let selectedTag = store.selectedTag { return "#\(selectedTag)" } switch store.filter { case .pinned: return "置顶" case .tagged: return "有标签" case .untagged: return "无标签" case .all: return "全部" } } private var filterLabelEN: String { if let selectedTag = store.selectedTag { return "#\(selectedTag)" } switch store.filter { case .pinned: return "Pinned" case .tagged: return "Tagged" case .untagged: return "Untagged" case .all: return "All" } } private var filterLabelPL: String { if let selectedTag = store.selectedTag { return "#\(selectedTag)" } switch store.filter { case .pinned: return "Przypięte" case .tagged: return "Z tagami" case .untagged: return "Bez tagów" case .all: return "Wszystkie" } } private func clearTagFilter() { store.clearTagFilter() store.reload(env: env) } private func filterChip(active: Bool, systemImage: String, label: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: systemImage) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(active ? .white : AppTheme.slate) .frame(width: MobileUIMetrics.compactControlHeight, height: MobileUIMetrics.compactControlHeight) .background(active ? AppTheme.filterActive : AppTheme.card) .clipShape(Circle()) .overlay(Circle().stroke(active ? AppTheme.filterActive : AppTheme.border, lineWidth: 1)) .accessibilityLabel(label) } .buttonStyle(.plain) } /// Bottom tab chrome: /// 1. top separator /// 2. Home | Create | Me — all *below* the separator (create must not sit on the line) /// 3. home-indicator slab /// Create is slightly smaller than the 52pt Android float so it fits cleanly under the line. private var bottomNav: some View { let canCreate = !store.notebooks.isEmpty let bottomInset = MobileUIMetrics.bottomSafeInset let createSize = MobileUIMetrics.bottomCreateButtonSize return VStack(spacing: 0) { // Separator first — tab content is strictly below this line. Rectangle() .fill(AppTheme.border) .frame(height: 1) .frame(maxWidth: .infinity) HStack(spacing: 0) { bottomNavItem( systemImage: "house.fill", label: env.preferences.t("首页", en: "Home", pl: "Start"), active: !showSettings ) { showSettings = false withAnimation(Motion.chip) { path = NavigationPath() } if store.selectedTag != nil { clearTagFilter() } } Button { if createLongPressConsumed { createLongPressConsumed = false return } openCreateNote() } label: { Image(systemName: "plus") .font(.system(size: 22, weight: .semibold)) .foregroundStyle(canCreate ? .white : AppTheme.disabledText) .frame(width: createSize, height: createSize) .background(canCreate ? AppTheme.accentAction : AppTheme.disabledFill) .clipShape(Circle()) .overlay(Circle().stroke(Color.white, lineWidth: 3)) .shadow( color: AppTheme.fabShadow.opacity(canCreate ? 0.28 : 0), radius: 6, y: 3 ) .contentShape(Circle()) } .buttonStyle(.plain) .simultaneousGesture( LongPressGesture(minimumDuration: 0.45) .onEnded { _ in guard canCreate else { return } createLongPressConsumed = true UIImpactFeedbackGenerator(style: .medium).impactOccurred() showCreateChoice = true } ) .disabled(!canCreate) .accessibilityLabel(env.preferences.t("新建笔记", en: "New note", pl: "Nowa notatka")) .accessibilityHint(env.preferences.t("长按可从模板新建", en: "Long-press to create from a template", pl: "Przytrzymaj, aby utworzyć z szablonu")) .accessibilityIdentifier("bottomCreateButton") .frame(maxWidth: .infinity) bottomNavItem( systemImage: "person", label: env.preferences.t("我的", en: "Me", pl: "Profil"), active: showSettings ) { showSettings = true } } .padding(.horizontal, 28) .frame(height: MobileUIMetrics.bottomNavigationHeight) .frame(maxWidth: .infinity) // Home indicator — same white surface, continuous with the 52pt band. AppTheme.card .frame(height: bottomInset) .frame(maxWidth: .infinity) } .background(AppTheme.card) .accessibilityIdentifier("bottomNav") } private func bottomNavItem( systemImage: String, label: String, active: Bool, action: @escaping () -> Void ) -> some View { Button(action: action) { VStack(spacing: 4) { Image(systemName: systemImage) .font(.system(size: 20, weight: active ? .semibold : .regular)) .foregroundStyle(active ? AppTheme.accentAction : AppTheme.secondary) Text(label) .font(AppTheme.bottomNavFont) .foregroundStyle(active ? AppTheme.title : AppTheme.secondary) } .frame(width: 80, height: 48) .overlay(alignment: .top) { if active { Capsule() .fill(AppTheme.accentAction) .frame(width: 20, height: 3) } } } .buttonStyle(.plain) .frame(maxWidth: .infinity) } private func memoDetail(for memoId: String, shareImage: Bool = false) -> some View { MemoDetailView(memoId: memoId, initialShareImage: shareImage) { editId, initialFocus in editingMemo = EditingMemoRoute(id: editId, initialFocus: initialFocus) // Keep the list under the editor so dismissing it does not flash the detail page. DispatchQueue.main.async { var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { path = NavigationPath() } } } } private func shareSelectedMemo(_ memo: MemoSummary) 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") selectionSharePayload = SelectionSharePayload(message: "\(displayTitle)\n\(url.absoluteString)", url: url) } catch { shareImportAlert = ShareImportAlert( title: env.preferences.t("分享失败", en: "Share failed", pl: "Udostępnianie nie powiodło się"), message: error.localizedDescription ) } } private var selectionBar: some View { let bottomInset = MobileUIMetrics.bottomSafeInset return VStack(spacing: 0) { HStack(spacing: 0) { Button { showMoveSheet = true } label: { VStack(spacing: 4) { Image(systemName: "folder") Text(env.preferences.t("移动", en: "Move", pl: "Przenieś")).font(.caption2.weight(.bold)) } } .frame(maxWidth: .infinity) .disabled(store.selectedMemoIds.isEmpty) Button(role: .destructive) { Task { await store.softDeleteSelection(env: env) } } label: { VStack(spacing: 4) { Image(systemName: "trash") Text(env.preferences.t("删除", en: "Delete", pl: "Usuń")).font(.caption2.weight(.bold)) } } .frame(maxWidth: .infinity) .disabled(store.selectedMemoIds.isEmpty) Button { showSelectionMore = true } label: { VStack(spacing: 4) { Image(systemName: "ellipsis") Text(env.preferences.t("更多", en: "More", pl: "Więcej")).font(.caption2.weight(.bold)) } } .frame(maxWidth: .infinity) } .padding(.horizontal, 20) .padding(.top, 4) .frame(height: MobileUIMetrics.bottomNavigationHeight, alignment: .top) .frame(maxWidth: .infinity) AppTheme.card .frame(height: bottomInset) .frame(maxWidth: .infinity) } .background(AppTheme.card) .overlay(alignment: .top) { Rectangle().fill(AppTheme.border).frame(height: 1) } .accessibilityIdentifier("selectionBar") } /// Quiet indicator for incremental sync only. /// First-login progress + sync-error banners live in `NotesListView` (Android MemoList parity). private var syncBanner: some View { Group { if env.isSyncing && env.bootstrapProgress == nil { HStack(spacing: 8) { ProgressView() .controlSize(.small) .tint(AppTheme.accent) Text(env.preferences.t("正在同步笔记", en: "Syncing notes", pl: "Synchronizowanie notatek")) .font(.caption2) .foregroundStyle(AppTheme.secondary) Spacer(minLength: 0) } .padding(.horizontal, 12) .padding(.vertical, 6) .background(AppTheme.card) .transition(.move(edge: .top).combined(with: .opacity)) } } .animation(Motion.search, value: env.isSyncing) .animation(Motion.search, value: env.bootstrapProgress != nil) } private func openCreateNote( seed: CreateMemoSeed? = nil, sharedImages: [ShareHandoffStore.SharedImage] = [] ) { guard !store.notebooks.isEmpty else { return } createSeed = seed createSharedImages = sharedImages showNewNote = true } private func openCreateFromTemplate() { guard !store.notebooks.isEmpty else { return } showTemplatePicker = true } private func consumeShare() { let payloads = env.shareHandoff.consumePending() guard !payloads.isEmpty else { return } let sharedImages = env.shareHandoff.sharedImages(from: payloads) if !sharedImages.isEmpty { guard !store.notebooks.isEmpty else { sharedImages.forEach(env.shareHandoff.removeImage) shareImportAlert = ShareImportAlert( title: env.preferences.t("无法保存图片", en: "Unable to save images", pl: "Nie można zapisać obrazów"), message: env.preferences.t("请先在 EdgeEver 中创建一个笔记本。", en: "Create a notebook in EdgeEver first.", pl: "Najpierw utwórz notatnik w EdgeEver.") ) return } openCreateNote( seed: CreateMemoSeed( title: sharedImages.count == 1 ? env.preferences.t("分享的图片", en: "Shared image", pl: "Udostępniony obraz") : env.preferences.t("分享的图片(\(sharedImages.count) 张)", en: "Shared images (\(sharedImages.count))", pl: "Udostępnione obrazy (\(sharedImages.count))"), contentMarkdown: "", tagsText: "" ), sharedImages: sharedImages ) return } guard let sourceURL = WebClipper.sharedWebURL(from: payloads) else { shareImportAlert = ShareImportAlert( title: env.preferences.t("无法剪藏", en: "Unable to clip", pl: "Nie można przechwycić strony"), message: env.preferences.t("分享内容里没有可识别的网页链接。", en: "The shared content does not contain a recognizable web link.", pl: "Udostępniona treść nie zawiera rozpoznawalnego linku do strony.") ) return } guard !store.notebooks.isEmpty else { shareImportAlert = ShareImportAlert( title: env.preferences.t("无法保存剪藏", en: "Unable to save clip", pl: "Nie można zapisać wycinka"), message: env.preferences.t("请先在 EdgeEver 中创建一个笔记本。", en: "Create a notebook in EdgeEver first.", pl: "Najpierw utwórz notatnik w EdgeEver.") ) return } isImportingShare = true if WebClipper.isWeChatArticle(sourceURL) { incomingClipURL = sourceURL } else { Task { let draft = await WebClipper.build(sourceURL, labels: webClipLabels) finishClip(draft) } } } private func finishRenderedClip(_ page: RenderedWebPage, sourceURL: URL) { finishClip(WebClipper.buildRendered(sourceURL, page: page, labels: webClipLabels)) } private var webClipLabels: WebClipLabels { WebClipLabels( sourceLabel: env.preferences.t("来源", en: "Source", ja: "出典", pl: "Źródło"), capturedAtLabel: env.preferences.t("剪藏时间", en: "Captured at", ja: "取り込み日時", pl: "Przechwycono"), unavailableBody: env.preferences.t( "正文暂时无法抓取,来源链接已保留,可稍后重试。", en: "Could not extract the page text. The source link is kept so you can retry later.", ja: "本文を取り込めませんでした。出典リンクは残してあるので、あとからやり直せます。", pl: "Nie udało się wyodrębnić tekstu strony. Link źródłowy został zachowany, więc możesz spróbować ponownie później." ), fallbackTitle: env.preferences.t("网页剪藏", en: "Web clip", ja: "ウェブクリップ", pl: "Wycinek strony"), imageAlt: env.preferences.t("图片", en: "Image", ja: "画像", pl: "Obraz"), usesFullwidthColon: env.preferences.uiLanguage == .chinese ) } private func localizedCaptureFailure(_ message: String) -> String { switch message { case "微信文章加载超时。": return env.preferences.t( "微信文章加载超时。", en: "The WeChat article took too long to load.", ja: "WeChat 記事の読み込みがタイムアウトしました。", pl: "Wczytywanie artykułu WeChat trwało zbyt długo." ) case "页面加载完成,但没有找到可剪藏的正文。": return env.preferences.t( "页面加载完成,但没有找到可剪藏的正文。", en: "The page finished loading, but there was no article text to clip.", ja: "ページの読み込みは終わりましたが、取り込める本文がありません。", pl: "Strona została wczytana, ale nie zawiera tekstu artykułu do przechwycenia." ) case "没有找到可剪藏的正文。": return env.preferences.t( "没有找到可剪藏的正文。", en: "No article text was found to clip.", ja: "取り込める本文が見つかりません。", pl: "Nie znaleziono tekstu artykułu do przechwycenia." ) default: return message } } private func failRenderedClip(_ message: String, sourceURL: URL) { incomingClipURL = nil Task { let draft = await WebClipper.build(sourceURL, labels: webClipLabels) isImportingShare = false shareImportAlert = ShareImportAlert( title: env.preferences.t("正文剪藏失败", en: "Article extraction failed", ja: "本文を取り込めませんでした", pl: "Nie udało się wyodrębnić artykułu"), message: localizedCaptureFailure(message) + " " + env.preferences.t( "已保留文章链接,你可以稍后重新分享重试。", en: "The article link was kept. Share it again later to retry.", ja: "記事リンクは残してあります。あとから再共有してやり直せます。", pl: "Link do artykułu został zachowany. Udostępnij go ponownie później, aby spróbować jeszcze raz." ), draft: draft ) } } private func finishClip(_ draft: WebClipDraft) { incomingClipURL = nil isImportingShare = false openCreateNote(seed: draft.createSeed) } private func detectConflicts() { guard let scope = env.session.dataScope else { return } let items = (try? env.outbox.listItems(scope: scope)) ?? [] conflictItem = items.first { $0.status == .conflict } } } private struct ShareImportAlert: Identifiable { let id = UUID() let title: String let message: String var draft: WebClipDraft? = nil } // NotebookPickerSheet / ListActionsSheet live in their own files for Android parity. struct SelectionMoreSheet: View { @Environment(AppEnvironment.self) private var env @Environment(\.dismiss) private var dismiss @Bindable var store: WorkspaceStore let onShare: (MemoSummary) -> Void let onShareImage: (MemoSummary) -> Void private var shareableMemo: MemoSummary? { guard store.selectedMemoIds.count == 1, let memo = store.memos.first(where: { store.selectedMemoIds.contains($0.id) }), !memo.isDeleted else { return nil } return memo } var body: some View { VStack(spacing: 0) { Capsule() .fill(AppTheme.sheetHandle) .frame(width: 42, height: 4) .padding(.top, 10) .padding(.bottom, 12) HStack(alignment: .top) { VStack(alignment: .leading, spacing: 3) { Text(env.preferences.t("批量操作", en: "Batch actions", pl: "Działania zbiorcze")) .font(.system(size: 16, weight: .heavy)) .foregroundStyle(AppTheme.title) Text(store.selectedMemoIds.isEmpty ? env.preferences.t("选择笔记", en: "Select notes", pl: "Wybierz notatki") : env.preferences.t("已选择 \(store.selectedMemoIds.count) 条", en: "\(store.selectedMemoIds.count) selected", pl: "Zaznaczone: \(store.selectedMemoIds.count)")) .font(.system(size: 12)) .foregroundStyle(AppTheme.secondary) } Spacer() Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: 14, weight: .bold)) .foregroundStyle(AppTheme.title) .frame(width: 34, height: 34) } .buttonStyle(.plain) .accessibilityLabel(env.preferences.t("关闭", en: "Close", pl: "Zamknij")) } .padding(.horizontal, 16) .padding(.bottom, 8) selectionAction( icon: "checkmark.square", label: store.allVisibleMemosSelected ? env.preferences.t("全不选当前列表", en: "Deselect current list", pl: "Odznacz bieżącą listę") : env.preferences.t("全选当前列表", en: "Select current list", pl: "Zaznacz bieżącą listę"), disabled: store.memos.isEmpty ) { store.toggleVisibleSelection() dismiss() } selectionAction( icon: "sparkles", label: store.nextSelectionPinValue ? env.preferences.t("置顶", en: "Pin", pl: "Przypnij") : env.preferences.t("取消置顶", en: "Unpin", pl: "Odepnij"), disabled: store.selectedMemoIds.isEmpty ) { let target = store.nextSelectionPinValue dismiss() Task { await store.pinSelection(env: env, isPinned: target) } } selectionAction( icon: "square.and.arrow.up", label: env.preferences.t("分享笔记", en: "Share note", pl: "Udostępnij notatkę"), disabled: shareableMemo == nil ) { guard let memo = shareableMemo else { return } dismiss() onShare(memo) } selectionAction( icon: "photo", label: env.preferences.t("分享为图片", en: "Share as image", pl: "Udostępnij jako obraz"), disabled: shareableMemo == nil ) { guard let memo = shareableMemo else { return } onShareImage(memo) dismiss() } selectionAction( icon: "xmark", label: env.preferences.t("取消选择", en: "Clear selection", pl: "Wyczyść zaznaczenie"), disabled: false ) { store.clearSelection() dismiss() } Spacer(minLength: 0) } .background(AppTheme.card) } private func selectionAction( icon: String, label: String, disabled: Bool, action: @escaping () -> Void ) -> some View { Button(action: action) { HStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 17, weight: .semibold)) .frame(width: 24) Text(label) .font(.system(size: 15, weight: .semibold)) Spacer() } .foregroundStyle(disabled ? AppTheme.muted : AppTheme.title) .padding(.horizontal, 18) .frame(minHeight: 48) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(disabled) } } struct MoveNotebookSheet: View { @Environment(AppEnvironment.self) private var env let notebooks: [Notebook] var onPick: (String) -> Void @Environment(\.dismiss) private var dismiss @State private var query = "" var body: some View { VStack(spacing: 0) { Capsule() .fill(AppTheme.sheetHandle) .frame(width: 42, height: 4) .padding(.top, 10) .padding(.bottom, 8) HStack { Text(env.preferences.t("移动到", en: "Move to", pl: "Przenieś do")) .font(.system(size: 15, weight: .heavy)) .foregroundStyle(AppTheme.title) Spacer() Button(env.preferences.t("取消", en: "Cancel", pl: "Anuluj")) { dismiss() } .font(.system(size: 14, weight: .semibold)) .foregroundStyle(AppTheme.slate) } .padding(.horizontal, 16) .padding(.bottom, 8) HStack(spacing: 8) { Image(systemName: "magnifyingglass") .foregroundStyle(AppTheme.secondary) TextField(env.preferences.t("搜索笔记本", en: "Search notebooks", pl: "Szukaj notatników"), text: $query) .font(.system(size: 14)) } .padding(.horizontal, 12) .frame(height: 36) .background(AppTheme.searchFill) .clipShape(RoundedRectangle(cornerRadius: 6)) .padding(.horizontal, 12) .padding(.bottom, 8) ScrollView { let items = NotebookHierarchy.treeItems(from: notebooks) let visible = NotebookHierarchy.searchVisibleIds(notebooks: notebooks, searchText: query) VStack(spacing: 0) { ForEach(items.filter { visible.contains($0.id) }) { item in Button { onPick(item.id) } label: { Text(item.name) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(AppTheme.title) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, CGFloat(min(item.depth * 18, 54))) .padding(.horizontal, 12) .frame(minHeight: 48) } .buttonStyle(.plain) } } } } .background(AppTheme.card) .presentationDetents([.medium, .large]) .presentationDragIndicator(.hidden) } }