* feat(editor): add setting to turn off spellcheck underlines Add a device-local "Underline spelling mistakes" switch to the editing preferences. The rich-text editor and the mobile plain-text editor now set spellcheck from this preference and update live when it changes. Defaults to enabled, so existing behavior is unchanged. Refs #468 * feat(i18n): add Polish as an in-app locale Add Polish (pl) across clients, mirroring how Japanese was added: - Shared: full pl catalog, locale matching for pl/pl-*, Polish memo templates and factory AI prompts, mobile editor strings, and Polish as an AI translation target and selection-translate chip. - Web: "Polski" in the language picker; the catalog is lazy-loaded in its own i18n-pl chunk and kept out of the PWA precache like ja. - Android: pl locale option, mobile-only strings, web-clip labels. Messages that only had an English branch now use English for every non-Chinese locale, so Japanese no longer falls back to Chinese there. - iOS: .polish UI language (pl-PL), pl argument for every t() call, pl in CFBundleLocalizations and Localizable.xcstrings. - Companion: accepts locale "pl" and detects Polish requests. - Browser extension: _locales/pl messages. - Marketing site: /pl/ pages, hreflang, sitemap, language switcher. - Google Play listing copy for pl-PL (manual publish only). * fix(ios): show the note editor in Polish and Japanese The iOS editor received every interface language except English as Chinese, so with Polish or Japanese selected its toolbar and image gallery controls were labeled in Chinese. Pass Polish and Japanese through to the embedded editor, label its toolbar and the shared image gallery controls in both languages, and rebuild the checked-in EditorBundle. The Android editor uses the same gallery labels. Also translate the last iOS strings that had no Polish text: the follow-system language label, note created/updated times and the current-device marker. Refs #473 * fix(companion): recognize Polish typed without diacritics Requests in Latin letters without Polish diacritics were always treated as English, so with a Polish interface "Napisz plan na jutro" got an English reply. With a Polish interface, such text is now told apart by common English and Polish words; when it is still unclear, the recent conversation and then the interface language decide. Other interface languages keep treating Latin text as English. Refs #473 * chore(ios): regenerate the Xcode project for Polish Run xcodegen so the shipped Info.plist lists Polish in CFBundleLocalizations and the project knows the pl region. The hand-written AiDirectStream.swift entries are replaced by the IDs xcodegen generates. MARKETING_VERSION and CURRENT_PROJECT_VERSION stay in the project file because the release script and Xcode Cloud update them there. Refs #473 * i18n(pl): translate the sidebar shortcut, desktop menus and video scope Add Polish for features that reached main while this branch was open: the show/hide sidebar shortcut label, the desktop application and editing context menus, and the new "ai:generate" MCP scope. The desktop menus switch to Polish for pl and pl-* locales; other locales are unchanged. Refs #473
806 lines
31 KiB
Swift
806 lines
31 KiB
Swift
import SwiftUI
|
||
import UIKit
|
||
import WebKit
|
||
|
||
// MARK: - Target
|
||
|
||
struct ResourceTarget: Equatable, Hashable, Identifiable {
|
||
var kind: Kind
|
||
var href: String
|
||
var filename: String
|
||
var resourceId: String
|
||
|
||
var id: String { "\(kind.rawValue):\(resourceId)" }
|
||
|
||
enum Kind: String, Equatable {
|
||
case image
|
||
case attachment
|
||
}
|
||
|
||
/// Extract resource id from `/api/v1/resources/:id/blob` — matches shared `getResourceIdFromUrl`.
|
||
static func resourceId(from href: String) -> String? {
|
||
if let id = ResourceCache.resourceId(from: href), id != "blob", !id.isEmpty {
|
||
return id
|
||
}
|
||
return nil
|
||
}
|
||
|
||
static func parse(_ json: String) -> ResourceTarget? {
|
||
guard
|
||
let data = json.data(using: .utf8),
|
||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
let href = obj["href"] as? String,
|
||
let filename = obj["filename"] as? String,
|
||
let kindRaw = obj["kind"] as? String,
|
||
let kind = Kind(rawValue: kindRaw)
|
||
else { return nil }
|
||
let fromJson = (obj["resourceId"] as? String).flatMap { id -> String? in
|
||
id == "blob" || id.isEmpty ? nil : id
|
||
}
|
||
guard let id = resourceId(from: href) ?? fromJson else { return nil }
|
||
let cleanName = filename
|
||
.replacingOccurrences(of: #"^\s*(?:附件[::]|Attachment:)\s*"#, with: "", options: .regularExpression)
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let blobHref: String = {
|
||
if href.contains("/api/v1/resources/") {
|
||
return ResourceCache.normalizeProtectedResourcePath(href, baseURL: nil)
|
||
}
|
||
return "/api/v1/resources/\(id)/blob"
|
||
}()
|
||
return ResourceTarget(
|
||
kind: kind,
|
||
href: blobHref,
|
||
filename: cleanName.isEmpty ? (kind == .image ? "image-\(id)" : id) : cleanName,
|
||
resourceId: id
|
||
)
|
||
}
|
||
|
||
/// Build an image target from a preview source (API path / absolute URL / edgeever-res id).
|
||
static func image(fromPreviewSource source: String, alt: String) -> ResourceTarget? {
|
||
let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return nil }
|
||
if let id = resourceId(from: trimmed) {
|
||
let name = alt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return ResourceTarget(
|
||
kind: .image,
|
||
href: "/api/v1/resources/\(id)/blob",
|
||
filename: name.isEmpty ? "image-\(id)" : name,
|
||
resourceId: id
|
||
)
|
||
}
|
||
// edgeever-res://local/<id>
|
||
if trimmed.hasPrefix("edgeever-res:"),
|
||
let url = URL(string: trimmed),
|
||
let id = EdgeEverResourceSchemeHandler.resourceId(from: url),
|
||
id != "blob",
|
||
!id.isEmpty
|
||
{
|
||
let name = alt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return ResourceTarget(
|
||
kind: .image,
|
||
href: "/api/v1/resources/\(id)/blob",
|
||
filename: name.isEmpty ? "image-\(id)" : name,
|
||
resourceId: id
|
||
)
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// MARK: - Android-parity bottom sheet
|
||
|
||
/// Matches `MobileResourceActions` (RN): bottom sheet with Share / Download / Rename / Delete.
|
||
struct ResourceActionSheet: View {
|
||
@Environment(AppEnvironment.self) private var env
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
let target: ResourceTarget
|
||
/// When false, rename/delete are disabled (local-only / deleted memo).
|
||
var canMutate: Bool = true
|
||
var onContentChanged: (() -> Void)?
|
||
|
||
@State private var mode: Mode = .actions
|
||
@State private var filename = ""
|
||
@State private var pending = false
|
||
@State private var error: String?
|
||
@State private var showDeleteConfirm = false
|
||
/// Share sheet (系统分享)
|
||
@State private var shareFile: ExportFile?
|
||
/// Files export picker (下载 / 存储到「文件」)
|
||
@State private var exportFile: ExportFile?
|
||
@State private var feedback: ActionFeedback?
|
||
|
||
private enum Mode {
|
||
case actions
|
||
case rename
|
||
}
|
||
|
||
private struct ExportFile: Identifiable {
|
||
let id = UUID()
|
||
let url: URL
|
||
}
|
||
|
||
private struct ActionFeedback: Identifiable {
|
||
let id = UUID()
|
||
let title: String
|
||
let message: String
|
||
}
|
||
|
||
private var actionsTitle: String {
|
||
target.kind == .image
|
||
? env.preferences.t("图片操作", en: "Image actions", pl: "Działania na obrazie")
|
||
: env.preferences.t("附件操作", en: "Attachment actions", pl: "Działania na załączniku")
|
||
}
|
||
|
||
private var renameTitle: String {
|
||
target.kind == .image
|
||
? env.preferences.t("重命名图片", en: "Rename image", pl: "Zmień nazwę obrazu")
|
||
: env.preferences.t("重命名附件", en: "Rename attachment", pl: "Zmień nazwę załącznika")
|
||
}
|
||
|
||
private var deleteTitle: String {
|
||
target.kind == .image
|
||
? env.preferences.t("删除图片", en: "Delete image", pl: "Usuń obraz")
|
||
: env.preferences.t("删除附件", en: "Delete attachment", pl: "Usuń załącznik")
|
||
}
|
||
|
||
private var deleteConfirm: String {
|
||
target.kind == .image
|
||
? env.preferences.t(
|
||
"图片会从存储空间和当前笔记中永久删除,此操作无法撤销。",
|
||
en: "The image will be permanently removed from storage and this note. This cannot be undone.",
|
||
pl: "Obraz zostanie trwale usunięty z magazynu i z tej notatki. Tej operacji nie można cofnąć."
|
||
)
|
||
: env.preferences.t(
|
||
"附件会从存储空间和当前笔记中永久删除,此操作无法撤销。",
|
||
en: "The attachment will be permanently removed from storage and this note. This cannot be undone.",
|
||
pl: "Załącznik zostanie trwale usunięty z magazynu i z tej notatki. Tej operacji nie można cofnąć."
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// Handle
|
||
Capsule()
|
||
.fill(AppTheme.sheetHandle)
|
||
.frame(width: 42, height: 4)
|
||
.padding(.top, 10)
|
||
.padding(.bottom, 14)
|
||
|
||
// Header
|
||
HStack(alignment: .center, spacing: 8) {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(mode == .rename ? renameTitle : actionsTitle)
|
||
.font(.system(size: 19, weight: .heavy))
|
||
.foregroundStyle(AppTheme.title)
|
||
Text(target.filename)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(AppTheme.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
Spacer(minLength: 8)
|
||
Button {
|
||
dismiss()
|
||
} label: {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(AppTheme.slate)
|
||
.frame(width: 40, height: 40)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(pending)
|
||
.accessibilityLabel(env.preferences.t("取消", en: "Cancel", pl: "Anuluj"))
|
||
}
|
||
|
||
if mode == .actions {
|
||
actionsBody
|
||
} else {
|
||
renameBody
|
||
}
|
||
|
||
if pending {
|
||
HStack(spacing: 8) {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
Text(env.preferences.t("正在准备文件…", en: "Preparing file…", pl: "Przygotowywanie pliku…"))
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(AppTheme.secondary)
|
||
Spacer()
|
||
}
|
||
.padding(.top, 10)
|
||
}
|
||
|
||
if let error {
|
||
Text(error)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(AppTheme.danger)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.top, 10)
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 28)
|
||
.background(AppTheme.card)
|
||
.onAppear { filename = target.filename }
|
||
.confirmationDialog(deleteTitle, isPresented: $showDeleteConfirm, titleVisibility: .visible) {
|
||
Button(env.preferences.t("删除", en: "Delete", pl: "Usuń"), role: .destructive) {
|
||
Task { await runDelete() }
|
||
}
|
||
Button(env.preferences.t("取消", en: "Cancel", pl: "Anuluj"), role: .cancel) {}
|
||
} message: {
|
||
Text(deleteConfirm)
|
||
}
|
||
.sheet(item: $shareFile) { file in
|
||
ActivityShareView(items: [file.url]) { completed, activityType, activityError in
|
||
shareFile = nil
|
||
if let activityError {
|
||
feedback = ActionFeedback(
|
||
title: env.preferences.t("分享失败", en: "Share failed", pl: "Nie udało się udostępnić"),
|
||
message: activityError.localizedDescription
|
||
)
|
||
return
|
||
}
|
||
// User dismissed without choosing an action — not an error.
|
||
if completed, activityType != nil {
|
||
feedback = ActionFeedback(
|
||
title: env.preferences.t("已完成", en: "Done", pl: "Gotowe"),
|
||
message: env.preferences.t(
|
||
"已通过系统分享面板处理:\(target.filename)",
|
||
en: "Shared via system sheet: \(target.filename)",
|
||
pl: "Udostępniono przez arkusz systemowy: \(target.filename)"
|
||
)
|
||
)
|
||
}
|
||
}
|
||
.presentationDetents([.medium, .large])
|
||
}
|
||
.sheet(item: $exportFile) { file in
|
||
DocumentExportView(fileURL: file.url) { result in
|
||
exportFile = nil
|
||
switch result {
|
||
case .success(let saved):
|
||
if saved {
|
||
feedback = ActionFeedback(
|
||
title: env.preferences.t("下载成功", en: "Downloaded", pl: "Pobrano"),
|
||
message: env.preferences.t(
|
||
"已保存:\(target.filename)",
|
||
en: "Saved \(target.filename)",
|
||
pl: "Zapisano \(target.filename)"
|
||
)
|
||
)
|
||
}
|
||
// cancelled → no toast (matches Android SAF cancel)
|
||
case .failure(let err):
|
||
feedback = ActionFeedback(
|
||
title: env.preferences.t("无法下载", en: "Unable to download", pl: "Nie można pobrać"),
|
||
message: err.localizedDescription
|
||
)
|
||
}
|
||
}
|
||
.presentationDetents([.medium, .large])
|
||
}
|
||
.alert(item: $feedback) { item in
|
||
Alert(
|
||
title: Text(item.title),
|
||
message: Text(item.message),
|
||
dismissButton: .default(Text(env.preferences.t("好的", en: "OK", pl: "OK")))
|
||
)
|
||
}
|
||
.interactiveDismissDisabled(pending)
|
||
}
|
||
|
||
private var actionsBody: some View {
|
||
VStack(spacing: 2) {
|
||
actionRow(
|
||
systemImage: "square.and.arrow.up",
|
||
label: env.preferences.t("分享", en: "Share", pl: "Udostępnij"),
|
||
danger: false,
|
||
disabled: false
|
||
) {
|
||
Task { await runShare() }
|
||
}
|
||
actionRow(
|
||
systemImage: "arrow.down.circle",
|
||
label: env.preferences.t("下载", en: "Download", pl: "Pobierz"),
|
||
danger: false,
|
||
disabled: false
|
||
) {
|
||
Task { await runDownload() }
|
||
}
|
||
actionRow(
|
||
systemImage: "pencil",
|
||
label: env.preferences.t("重命名", en: "Rename", pl: "Zmień nazwę"),
|
||
danger: false,
|
||
disabled: !canMutate
|
||
) {
|
||
mode = .rename
|
||
}
|
||
Rectangle()
|
||
.fill(AppTheme.border)
|
||
.frame(height: 1)
|
||
.padding(.vertical, 4)
|
||
actionRow(
|
||
systemImage: "trash",
|
||
label: env.preferences.t("删除", en: "Delete", pl: "Usuń"),
|
||
danger: true,
|
||
disabled: !canMutate
|
||
) {
|
||
showDeleteConfirm = true
|
||
}
|
||
if !canMutate {
|
||
Text(env.preferences.t(
|
||
"资源同步完成后才能重命名或删除。",
|
||
en: "Rename and delete are available after the resource has synced.",
|
||
pl: "Zmiana nazwy i usuwanie będą dostępne po zsynchronizowaniu zasobu."
|
||
))
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(AppTheme.secondary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.top, 4)
|
||
}
|
||
}
|
||
.padding(.top, 8)
|
||
}
|
||
|
||
private var renameBody: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(env.preferences.t("文件名", en: "Filename", pl: "Nazwa pliku"))
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(AppTheme.slate)
|
||
TextField("", text: $filename)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.padding(.horizontal, 12)
|
||
.frame(minHeight: 46)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 9)
|
||
.stroke(AppTheme.border, lineWidth: 1)
|
||
)
|
||
.disabled(pending)
|
||
|
||
HStack(spacing: 10) {
|
||
Spacer()
|
||
Button {
|
||
mode = .actions
|
||
} label: {
|
||
Text(env.preferences.t("取消", en: "Cancel", pl: "Anuluj"))
|
||
.font(.system(size: 14, weight: .bold))
|
||
.foregroundStyle(AppTheme.slate)
|
||
.frame(minWidth: 84, minHeight: 42)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 9)
|
||
.stroke(AppTheme.border, lineWidth: 1)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(pending)
|
||
|
||
Button {
|
||
Task { await runRename() }
|
||
} label: {
|
||
Group {
|
||
if pending {
|
||
ProgressView().tint(.white)
|
||
} else {
|
||
Text(env.preferences.t("保存", en: "Save", pl: "Zapisz"))
|
||
.font(.system(size: 14, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
}
|
||
}
|
||
.frame(minWidth: 84, minHeight: 42)
|
||
.background(AppTheme.accentAction)
|
||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(pending || filename.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
.opacity(pending || filename.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0.45 : 1)
|
||
}
|
||
.padding(.top, 6)
|
||
}
|
||
.padding(.top, 14)
|
||
}
|
||
|
||
private func actionRow(
|
||
systemImage: String,
|
||
label: String,
|
||
danger: Bool,
|
||
disabled: Bool,
|
||
action: @escaping () -> Void
|
||
) -> some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 14) {
|
||
Image(systemName: systemImage)
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundStyle(danger ? AppTheme.danger : AppTheme.title)
|
||
.frame(width: 22)
|
||
Text(label)
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundStyle(danger ? AppTheme.danger : AppTheme.title)
|
||
Spacer()
|
||
}
|
||
.frame(minHeight: 52)
|
||
.padding(.horizontal, 6)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(disabled || pending)
|
||
.opacity(disabled || pending ? 0.45 : 1)
|
||
}
|
||
|
||
// MARK: - Actions
|
||
|
||
private func runShare() async {
|
||
pending = true
|
||
error = nil
|
||
defer { pending = false }
|
||
do {
|
||
let fileURL = try await fetchResourceFile()
|
||
// Present as a child sheet (do not dismiss first — that dropped all feedback).
|
||
shareFile = ExportFile(url: fileURL)
|
||
} catch {
|
||
// Keep menu open and surface the error (network / 401 / missing session).
|
||
self.error = error.localizedDescription
|
||
feedback = ActionFeedback(
|
||
title: env.preferences.t("分享失败", en: "Share failed", pl: "Nie udało się udostępnić"),
|
||
message: error.localizedDescription
|
||
)
|
||
}
|
||
}
|
||
|
||
private func runDownload() async {
|
||
pending = true
|
||
error = nil
|
||
defer { pending = false }
|
||
do {
|
||
let fileURL = try await fetchResourceFile()
|
||
// System Files export picker — user chooses a folder, then we toast success.
|
||
exportFile = ExportFile(url: fileURL)
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
feedback = ActionFeedback(
|
||
title: env.preferences.t("无法下载", en: "Unable to download", pl: "Nie można pobrać"),
|
||
message: error.localizedDescription
|
||
)
|
||
}
|
||
}
|
||
|
||
private func runRename() async {
|
||
let next = filename.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !next.isEmpty else { return }
|
||
pending = true
|
||
error = nil
|
||
defer { pending = false }
|
||
do {
|
||
_ = try await env.session.client.renameResource(id: target.resourceId, filename: next)
|
||
onContentChanged?()
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func runDelete() async {
|
||
pending = true
|
||
error = nil
|
||
defer { pending = false }
|
||
do {
|
||
try await env.session.client.deleteResource(id: target.resourceId)
|
||
onContentChanged?()
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func fetchResourceFile() async throws -> URL {
|
||
guard let base = env.session.session.flatMap({ URL(string: $0.baseUrl) }) else {
|
||
throw ResourceActionError.noSession
|
||
}
|
||
let token = env.session.session?.token
|
||
let path = "/api/v1/resources/\(target.resourceId)/blob"
|
||
let client = APIClient(baseURL: base, token: token)
|
||
let safeName = target.filename
|
||
.replacingOccurrences(of: "/", with: "_")
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let name = safeName.isEmpty ? "\(target.resourceId).bin" : safeName
|
||
return try await client.downloadResourceFile(path: path, suggestedFilename: name)
|
||
}
|
||
}
|
||
|
||
// MARK: - Fullscreen image preview (viewer)
|
||
|
||
/// SVG-friendly fullscreen preview using WKWebView (handles demo cat SVG).
|
||
/// Only pass `data:` URLs — never `edgeever-res://` (this web view has no scheme handler).
|
||
struct ResourceSVGPreview: UIViewRepresentable {
|
||
let dataURL: String
|
||
|
||
func makeUIView(context: Context) -> WKWebView {
|
||
let config = WKWebViewConfiguration()
|
||
let web = WKWebView(frame: .zero, configuration: config)
|
||
web.isOpaque = false
|
||
web.backgroundColor = .black
|
||
web.scrollView.backgroundColor = .black
|
||
web.scrollView.minimumZoomScale = 1
|
||
web.scrollView.maximumZoomScale = 4
|
||
return web
|
||
}
|
||
|
||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||
// Prefer embedding base64 bytes so we never depend on custom schemes.
|
||
let src = dataURL.replacingOccurrences(of: "\"", with: """)
|
||
let html = """
|
||
<!DOCTYPE html><html><head>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=4">
|
||
<style>
|
||
html,body{margin:0;height:100%;background:#000;display:flex;align-items:center;justify-content:center;}
|
||
img{max-width:100%;max-height:100%;object-fit:contain;}
|
||
</style></head><body>
|
||
<img src="\(src)" />
|
||
</body></html>
|
||
"""
|
||
webView.loadHTMLString(html, baseURL: nil)
|
||
}
|
||
}
|
||
|
||
/// Fullscreen preview for note images (Android viewer tap → modal).
|
||
///
|
||
/// TipTap in-note images use `edgeever-res://` for authenticated blobs. That scheme only works
|
||
/// inside the TipTap WKWebView (registered scheme handler). This host resolves blobs to
|
||
/// `UIImage` / `data:` URLs so the black preview sheet never shows a broken `?` icon.
|
||
struct ResourceImagePreviewHost: View {
|
||
let source: String
|
||
let alt: String
|
||
let baseURL: URL?
|
||
let token: String?
|
||
var onClose: () -> Void
|
||
/// When set, long-press opens the resource action sheet on top of the preview.
|
||
var canMutate: Bool = false
|
||
var onContentChanged: (() -> Void)? = nil
|
||
|
||
@State private var uiImage: UIImage?
|
||
@State private var svgDataURL: String?
|
||
@State private var failed = false
|
||
@State private var failedMessage: String?
|
||
@State private var actionsTarget: ResourceTarget?
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Color.black.ignoresSafeArea()
|
||
Group {
|
||
if let uiImage {
|
||
Image(uiImage: uiImage)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.padding(12)
|
||
} else if let svgDataURL {
|
||
ResourceSVGPreview(dataURL: svgDataURL)
|
||
.ignoresSafeArea()
|
||
} else if failed {
|
||
VStack(spacing: 10) {
|
||
Image(systemName: "photo")
|
||
.font(.system(size: 36, weight: .light))
|
||
.foregroundStyle(.white.opacity(0.45))
|
||
Text(failedMessage ?? (alt.isEmpty ? "无法加载图片" : alt))
|
||
.font(.system(size: 14, weight: .medium))
|
||
.foregroundStyle(.white.opacity(0.75))
|
||
.multilineTextAlignment(.center)
|
||
.padding(.horizontal, 24)
|
||
}
|
||
} else {
|
||
ProgressView().tint(.white)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.contentShape(Rectangle())
|
||
.onLongPressGesture(minimumDuration: 0.45) {
|
||
if let target = ResourceTarget.image(fromPreviewSource: source, alt: alt) {
|
||
actionsTarget = target
|
||
}
|
||
}
|
||
|
||
VStack {
|
||
HStack {
|
||
Spacer()
|
||
Button(action: onClose) {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 16, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 44, height: 44)
|
||
.background(Circle().fill(Color.white.opacity(0.18)))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(16)
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
.sheet(item: $actionsTarget) { target in
|
||
ResourceActionSheet(
|
||
target: target,
|
||
canMutate: canMutate,
|
||
onContentChanged: { onContentChanged?() }
|
||
)
|
||
.presentationDetents([.height(360), .medium])
|
||
.presentationDragIndicator(.hidden)
|
||
}
|
||
.task(id: source) { await resolve() }
|
||
}
|
||
|
||
private func resolve() async {
|
||
failed = false
|
||
failedMessage = nil
|
||
uiImage = nil
|
||
svgDataURL = nil
|
||
|
||
let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else {
|
||
failed = true
|
||
failedMessage = "无效图片"
|
||
return
|
||
}
|
||
|
||
// 1) Already a data URL (SVG or raster).
|
||
if trimmed.hasPrefix("data:") {
|
||
applyResolvedDisplay(trimmed)
|
||
return
|
||
}
|
||
|
||
// 2) Already hydrated into the in-memory blob map (edgeever-res://local/<id>).
|
||
if let fromBlob = await blobAsDisplay(fromEdgeEverURL: trimmed) {
|
||
applyResolvedDisplay(fromBlob)
|
||
return
|
||
}
|
||
|
||
// 3) Protected API path / absolute URL — fetch via the same loader TipTap uses.
|
||
let cache = ResourceCache()
|
||
// Prefer protected original path over display edgeever-res when both exist.
|
||
let fetchSource = trimmed.hasPrefix("edgeever-res:") ? trimmed : trimmed
|
||
if let resolved = await TipTapResourceLoader.loadResourceDataURL(
|
||
source: fetchSource,
|
||
baseURL: baseURL,
|
||
token: token,
|
||
resourceCache: cache
|
||
) {
|
||
if resolved.hasPrefix("data:") {
|
||
applyResolvedDisplay(resolved)
|
||
return
|
||
}
|
||
if let fromBlob = await blobAsDisplay(fromEdgeEverURL: resolved) {
|
||
applyResolvedDisplay(fromBlob)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 4) Last chance: if the in-note image was already shown, original path may be
|
||
// dataset.originalSrc; if we only got a dead edgeever-res id, try ResourceCache disk.
|
||
if let id = edgeEverResourceId(from: trimmed),
|
||
let cached = await cache.cachedData(for: id) {
|
||
let mime = TipTapResourceLoader.resolvedImageMime(header: nil, data: cached)
|
||
applyImageBytes(cached, mime: mime)
|
||
return
|
||
}
|
||
|
||
failed = true
|
||
failedMessage = "无法加载图片"
|
||
}
|
||
|
||
private func applyResolvedDisplay(_ display: String) {
|
||
if display.hasPrefix("data:image/svg") || display.contains("image/svg") {
|
||
svgDataURL = display
|
||
return
|
||
}
|
||
if let data = dataFromDataURL(display), let img = UIImage(data: data) {
|
||
uiImage = img
|
||
return
|
||
}
|
||
// Unknown data: still try SVG webview if it looks like data:
|
||
if display.hasPrefix("data:") {
|
||
svgDataURL = display
|
||
return
|
||
}
|
||
failed = true
|
||
failedMessage = "无法显示图片"
|
||
}
|
||
|
||
private func applyImageBytes(_ data: Data, mime: String) {
|
||
if mime.contains("svg") || TipTapResourceLoader.isSvgData(data) {
|
||
svgDataURL = "data:image/svg+xml;base64,\(data.base64EncodedString())"
|
||
return
|
||
}
|
||
if let img = UIImage(data: data) {
|
||
uiImage = img
|
||
return
|
||
}
|
||
// Fallback: feed as data URL into WKWebView.
|
||
svgDataURL = "data:\(mime);base64,\(data.base64EncodedString())"
|
||
}
|
||
|
||
/// Resolve `edgeever-res://local/<id>` → data: URL string for preview (never pass edgeever-res to WKWebView).
|
||
private func blobAsDisplay(fromEdgeEverURL urlString: String) async -> String? {
|
||
guard let id = edgeEverResourceId(from: urlString),
|
||
let entry = await ResourceBlobStore.shared.get(id: id)
|
||
else { return nil }
|
||
let mime = TipTapResourceLoader.resolvedImageMime(header: entry.mimeType, data: entry.data)
|
||
if mime.contains("svg") || TipTapResourceLoader.isSvgData(entry.data) {
|
||
return "data:image/svg+xml;base64,\(entry.data.base64EncodedString())"
|
||
}
|
||
return "data:\(mime);base64,\(entry.data.base64EncodedString())"
|
||
}
|
||
|
||
private func edgeEverResourceId(from urlString: String) -> String? {
|
||
guard urlString.hasPrefix("edgeever-res:"),
|
||
let url = URL(string: urlString)
|
||
else { return nil }
|
||
return EdgeEverResourceSchemeHandler.resourceId(from: url)
|
||
}
|
||
|
||
private func dataFromDataURL(_ url: String) -> Data? {
|
||
guard url.hasPrefix("data:"), let comma = url.firstIndex(of: ",") else { return nil }
|
||
let meta = String(url[..<comma])
|
||
let payload = String(url[url.index(after: comma)...])
|
||
if meta.contains(";base64") {
|
||
return Data(base64Encoded: payload)
|
||
}
|
||
return payload.removingPercentEncoding.flatMap { Data($0.utf8) }
|
||
}
|
||
}
|
||
|
||
// MARK: - Share / export helpers
|
||
|
||
private enum ResourceActionError: LocalizedError {
|
||
case noSession
|
||
case emptyFile
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .noSession: return "当前无法读取资源,请检查是否已登录实例。"
|
||
case .emptyFile: return "资源内容为空。"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// System share sheet with completion so the user always gets feedback.
|
||
struct ActivityShareView: UIViewControllerRepresentable {
|
||
let items: [Any]
|
||
var onComplete: (_ completed: Bool, _ activityType: UIActivity.ActivityType?, _ error: Error?) -> Void
|
||
|
||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||
let vc = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||
vc.completionWithItemsHandler = { activityType, completed, _, error in
|
||
onComplete(completed, activityType, error)
|
||
}
|
||
return vc
|
||
}
|
||
|
||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||
}
|
||
|
||
/// "Download" on iOS = export copy via Files document picker (Android SAF parity).
|
||
private struct DocumentExportView: UIViewControllerRepresentable {
|
||
let fileURL: URL
|
||
var 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))
|
||
}
|
||
}
|
||
}
|