| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | |
| 4 | // 🤖 "Media, Links & Docs" on the Mac — the per-thread inventory of shared |
| 5 | // photos/videos, links and files (spec: docs/superpowers/specs/ |
| 6 | // 2026-08-05-media-links-docs-design.md § Native; iOS twin |
| 7 | // MediaLinksDocsSheet.swift, web twin mediaview.js). Same shared extraction |
| 8 | // (ThreadMedia over MessageStore) and the same auto-run per-thread history |
| 9 | // walk. It lives in its own RESIZABLE window (MediaWindows) rather than a |
| 10 | // sheet — see that file for why. Viewing honours the Mac's deliberate |
| 11 | // anti-QuickLook stance (Lightbox.swift: decrypted bytes never touch disk |
| 12 | // uninvited): photos open an in-window viewer with ‹ › / arrow-key |
| 13 | // collection navigation, all in memory; a video tile shows its poster there |
| 14 | // (playing rides "Show in chat", where the feed's in-memory player lives); a |
| 15 | // doc tap saves to ~/Downloads — the feed's own file behaviour. |
| 16 | struct MediaLinksDocsSheet: View { |
| 17 | let instance: SavedInstance |
| 18 | let thread: ChatThread |
| 19 | @ObservedObject var store: ThreadStore |
| 20 | let model: ChatFeedModel |
| 21 | let onJump: (Int64) -> Void |
| 22 | |
| 23 | @Environment(\.openURL) private var openURL |
| 24 | @State private var tabs = ThreadMedia.Tabs() |
| 25 | @State private var loaded = false |
| 26 | @State private var tab = "media" |
| 27 | @StateObject private var backfill = HistoryBackfill() |
| 28 | // What's IN each photo (Vision, on device) — search matches "dog" or the |
| 29 | // text on a whiteboard. Indexes the thread's stills on open, resumably. |
| 30 | @StateObject private var imageIndex = ImageSearchIndex() |
| 31 | // The in-sheet photo viewer's position in the media tab (nil = closed). |
| 32 | @State private var viewerIndex: Int? |
| 33 | @State private var query = "" |
| 34 | // Unfurl titles the Links tab has fetched so far, so search matches a |
| 35 | // link by title too (a title only exists once its row was on screen — |
| 36 | // search never triggers fetches of its own). |
| 37 | @State private var linkTitles: [URL: String] = [:] |
| 38 | @State private var savingID: String? // one save at a time (blob id) |
| 39 | |
| 40 | private var hiddenUids: Set<String> { store.mutedPeople.union(store.blockedPeople) } |
| 41 | |
| 42 | // Moderation applies always; the query narrows the open tab (and the |
| 43 | // viewer's collection with it, so ‹ › walk the search results). |
| 44 | private func visible(_ entries: [ThreadMedia.Entry]) -> [ThreadMedia.Entry] { |
| 45 | // `_ = imageIndex.tick` isn't needed: the @StateObject's @Published |
| 46 | // tick re-renders the body, which re-runs this filter. |
| 47 | ThreadMedia.filtered(ThreadMedia.filtered(entries, hiddenUids: hiddenUids), |
| 48 | query: query, titleFor: { linkTitles[$0] }, |
| 49 | imageTextFor: { imageIndex.textFor($0) }) |
| 50 | } |
| 51 | |
| 52 | private var mediaEntries: [ThreadMedia.Entry] { visible(tabs.media) } |
| 53 | |
| 54 | var body: some View { |
| 55 | VStack(spacing: 0) { |
| 56 | Picker("Kind", selection: $tab) { |
| 57 | Text(segLabel("Media", mediaEntries.count)).tag("media") |
| 58 | Text(segLabel("Links", ThreadMedia.filtered(tabs.links, hiddenUids: hiddenUids).count)).tag("links") |
| 59 | Text(segLabel("Docs", ThreadMedia.filtered(tabs.docs, hiddenUids: hiddenUids).count)).tag("docs") |
| 60 | } |
| 61 | .pickerStyle(.segmented) |
| 62 | .labelsHidden() |
| 63 | .padding(.horizontal, 12) |
| 64 | .padding(.top, 10) |
| 65 | // Search within the open tab — sender, URL/title, filename |
| 66 | // (ThreadMedia.filtered(query:)); the sidebar search's shape. |
| 67 | HStack(spacing: 6) { |
| 68 | Image(systemName: "magnifyingglass") |
| 69 | .font(.system(size: 11)) |
| 70 | .foregroundStyle(.tertiary) |
| 71 | TextField("Search", text: $query) |
| 72 | .textFieldStyle(.plain) |
| 73 | .font(.system(size: 13)) |
| 74 | .accessibilityLabel("Search media, links and docs") |
| 75 | if !query.isEmpty { |
| 76 | Button { |
| 77 | query = "" |
| 78 | } label: { |
| 79 | Image(systemName: "xmark.circle.fill") |
| 80 | .foregroundStyle(.tertiary) |
| 81 | } |
| 82 | .buttonStyle(.plain) |
| 83 | .accessibilityLabel("Clear search") |
| 84 | } |
| 85 | } |
| 86 | .padding(.horizontal, 8) |
| 87 | .padding(.vertical, 5) |
| 88 | .background(RoundedRectangle(cornerRadius: 7, style: .continuous) |
| 89 | .fill(.primary.opacity(0.06))) |
| 90 | .padding(.horizontal, 12) |
| 91 | .padding(.top, 8) |
| 92 | if backfill.running { |
| 93 | progressRow(backfill.progress ?? "Loading earlier messages…") { backfill.cancel() } |
| 94 | } |
| 95 | if imageIndex.running { |
| 96 | progressRow(imageIndex.progress ?? "Indexing photos…") { imageIndex.cancel() } |
| 97 | } |
| 98 | content |
| 99 | .padding(.top, 6) |
| 100 | } |
| 101 | // The window (MediaWindows) owns the size; the content just fills it, |
| 102 | // with a floor so the grid never collapses. |
| 103 | .frame(minWidth: 480, minHeight: 400) |
| 104 | .overlay { |
| 105 | if let i = viewerIndex, mediaEntries.indices.contains(i) { |
| 106 | MediaViewerOverlay(entries: mediaEntries, index: i, |
| 107 | source: { viewerSource($0) }, |
| 108 | onStep: { viewerIndex = $0 }, |
| 109 | onSave: { if let att = mediaEntries[i].att { saveToDownloads(att) } }, |
| 110 | onClose: { viewerIndex = nil }) |
| 111 | } |
| 112 | } |
| 113 | .task(id: backfill.pageTick) { await reload() } |
| 114 | .onAppear { startWalkIfIncomplete() } |
| 115 | .onDisappear { |
| 116 | backfill.cancel() |
| 117 | imageIndex.cancel() |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | private func segLabel(_ label: String, _ n: Int) -> String { |
| 122 | n > 0 ? "\(label) · \(n.formatted())" : label |
| 123 | } |
| 124 | |
| 125 | // Inline, occupying layout (never a floating toast): a walk's narration |
| 126 | // plus its Stop — the history walk and the photo indexer share the shape. |
| 127 | private func progressRow(_ text: String, stop: @escaping () -> Void) -> some View { |
| 128 | HStack(spacing: 8) { |
| 129 | // The spinner is the "still working" signal: a count that sits on |
| 130 | // "0 of 5" while a slow photo decrypts otherwise reads as stuck. |
| 131 | ProgressView() |
| 132 | .controlSize(.small) |
| 133 | .scaleEffect(0.7) |
| 134 | .frame(width: 12, height: 12) |
| 135 | Text(text) |
| 136 | .font(.system(size: 12)) |
| 137 | .foregroundStyle(.secondary) |
| 138 | Spacer(minLength: 0) |
| 139 | Button("Stop", action: stop) |
| 140 | .buttonStyle(.plain) |
| 141 | .font(.system(size: 12, weight: .semibold)) |
| 142 | } |
| 143 | .padding(.horizontal, 12) |
| 144 | .padding(.top, 6) |
| 145 | .accessibilityElement(children: .combine) |
| 146 | } |
| 147 | |
| 148 | // Save a photo/video/file to ~/Downloads and reveal it — the Mac feed's |
| 149 | // own file behaviour (a deliberate save, never a silent temp file). |
| 150 | private func saveToDownloads(_ att: Attachment) { |
| 151 | guard savingID == nil else { return } |
| 152 | savingID = att.id |
| 153 | Task { |
| 154 | defer { savingID = nil } |
| 155 | guard let data = await model.blobData(att) else { return } |
| 156 | let name = AttachmentDownload.safeFilename(name: att.name, mime: att.type) |
| 157 | let dir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first |
| 158 | ?? FileManager.default.temporaryDirectory |
| 159 | let dest = uniqueDestination(in: dir, filename: name) |
| 160 | guard (try? data.write(to: dest, options: .atomic)) != nil else { return } |
| 161 | NSWorkspace.shared.activateFileViewerSelecting([dest]) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // Never overwrite: "name 2.ext", the Finder rule (NativeFeedView's twin). |
| 166 | private func uniqueDestination(in dir: URL, filename: String) -> URL { |
| 167 | let fm = FileManager.default |
| 168 | let name = filename.isEmpty ? "download" : filename |
| 169 | var candidate = dir.appendingPathComponent(name) |
| 170 | guard fm.fileExists(atPath: candidate.path) else { return candidate } |
| 171 | let ext = (name as NSString).pathExtension |
| 172 | let base = (name as NSString).deletingPathExtension |
| 173 | var n = 2 |
| 174 | repeat { |
| 175 | let next = ext.isEmpty ? "\(base) \(n)" : "\(base) \(n).\(ext)" |
| 176 | candidate = dir.appendingPathComponent(next) |
| 177 | n += 1 |
| 178 | } while fm.fileExists(atPath: candidate.path) |
| 179 | return candidate |
| 180 | } |
| 181 | |
| 182 | @ViewBuilder private var content: some View { |
| 183 | let all = tab == "media" ? tabs.media : tab == "links" ? tabs.links : tabs.docs |
| 184 | let entries = visible(all) |
| 185 | if entries.isEmpty { |
| 186 | Spacer() |
| 187 | if !loaded { |
| 188 | ProgressView() |
| 189 | } else if !ThreadMedia.filtered(all, hiddenUids: hiddenUids).isEmpty { |
| 190 | Text("Nothing matches.") |
| 191 | .foregroundStyle(.secondary) |
| 192 | } else { |
| 193 | Text(tab == "media" ? "No photos or videos shared yet." |
| 194 | : tab == "links" ? "No links shared yet." : "No files shared yet.") |
| 195 | .foregroundStyle(.secondary) |
| 196 | } |
| 197 | Spacer() |
| 198 | } else { |
| 199 | ScrollView { |
| 200 | LazyVStack(alignment: .leading, spacing: 6) { |
| 201 | ForEach(ThreadMedia.bucketByMonth(entries)) { bucket in |
| 202 | Text(bucket.label) |
| 203 | .font(.system(size: 11)) |
| 204 | .foregroundStyle(.secondary) |
| 205 | .padding(.horizontal, 14) |
| 206 | .padding(.top, 8) |
| 207 | if tab == "media" { |
| 208 | mediaGrid(bucket.items, all: entries) |
| 209 | } else if tab == "links" { |
| 210 | ForEach(bucket.items) { e in linkRow(e) } |
| 211 | } else { |
| 212 | ForEach(bucket.items) { e in docRow(e) } |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | .padding(.bottom, 12) |
| 217 | } |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | private func mediaGrid(_ items: [ThreadMedia.Entry], all: [ThreadMedia.Entry]) -> some View { |
| 222 | LazyVGrid(columns: [GridItem(.adaptive(minimum: 118), spacing: 2)], spacing: 2) { |
| 223 | ForEach(items) { e in |
| 224 | if let att = e.att { |
| 225 | MediaGridTile(att: att, source: tileSource(att)) |
| 226 | .onTapGesture { |
| 227 | viewerIndex = all.firstIndex { $0.id == e.id } ?? 0 |
| 228 | } |
| 229 | .accessibilityLabel("\(att.isVideo ? "Video" : "Photo") from \(e.name.isEmpty ? "someone" : e.name), \(relTime(e.ts / 1000))") |
| 230 | .accessibilityAddTraits(.isButton) |
| 231 | .contextMenu { |
| 232 | Button("Save to Downloads…") { saveToDownloads(att) } |
| 233 | Button("Show in Chat") { onJump(e.sid) } |
| 234 | } |
| 235 | } |
| 236 | } |
| 237 | } |
| 238 | .padding(.horizontal, 2) |
| 239 | } |
| 240 | |
| 241 | // Video tiles (and the viewer) show the poster frame — whole videos never |
| 242 | // decrypt for a thumbnail. |
| 243 | private func tileSource(_ att: Attachment) -> AvatarSource? { |
| 244 | guard var source = model.blobSource(att) else { return nil } |
| 245 | if att.isVideo, let poster = att.poster { source.blobID = poster } |
| 246 | return source |
| 247 | } |
| 248 | |
| 249 | private func viewerSource(_ e: ThreadMedia.Entry) -> AvatarSource? { |
| 250 | e.att.flatMap { tileSource($0) } |
| 251 | } |
| 252 | |
| 253 | private func linkRow(_ e: ThreadMedia.Entry) -> some View { |
| 254 | LinkUnfurlRow(e: e, unfurl: { await model.unfurl($0) }, |
| 255 | peek: { model.cachedUnfurl($0) }, |
| 256 | open: { openURL($0) }, |
| 257 | onTitle: { url, title in linkTitles[url] = title }) |
| 258 | .contextMenu { |
| 259 | if let url = e.url { |
| 260 | Button("Open in Browser") { openURL(url) } |
| 261 | Button("Copy Link") { |
| 262 | NSPasteboard.general.clearContents() |
| 263 | NSPasteboard.general.setString(url.absoluteString, forType: .string) |
| 264 | } |
| 265 | } |
| 266 | Button("Show in Chat") { onJump(e.sid) } |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | private func docRow(_ e: ThreadMedia.Entry) -> some View { |
| 271 | Button { if let att = e.att { saveToDownloads(att) } } label: { |
| 272 | HStack(spacing: 10) { |
| 273 | Text(fileIcon(e.att?.name)) |
| 274 | .font(.system(size: 18)) |
| 275 | VStack(alignment: .leading, spacing: 1) { |
| 276 | Text(e.att?.name ?? "file") |
| 277 | .font(.system(size: 13, weight: .medium)) |
| 278 | .lineLimit(1) |
| 279 | Text(docSubline(e)) |
| 280 | .font(.system(size: 11)) |
| 281 | .foregroundStyle(.secondary) |
| 282 | .lineLimit(1) |
| 283 | } |
| 284 | Spacer(minLength: 0) |
| 285 | // The save is a fetch + decrypt; show it's running rather |
| 286 | // than leaving the row inert until Finder pops. |
| 287 | if savingID == e.att?.id { |
| 288 | ProgressView() |
| 289 | .controlSize(.small) |
| 290 | .scaleEffect(0.7) |
| 291 | .frame(width: 12, height: 12) |
| 292 | } |
| 293 | } |
| 294 | .padding(.horizontal, 14) |
| 295 | .padding(.vertical, 5) |
| 296 | .contentShape(Rectangle()) |
| 297 | } |
| 298 | .buttonStyle(.plain) |
| 299 | .modifier(RowHover()) |
| 300 | .contextMenu { |
| 301 | if let att = e.att { |
| 302 | Button("Save to Downloads…") { saveToDownloads(att) } |
| 303 | } |
| 304 | Button("Show in Chat") { onJump(e.sid) } |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | private func docSubline(_ e: ThreadMedia.Entry) -> String { |
| 309 | var parts: [String] = [] |
| 310 | if let size = e.att?.size, size > 0 { parts.append(formatBytes(size)) } |
| 311 | parts.append("\(e.name.isEmpty ? "Someone" : e.name) · \(relTime(e.ts / 1000))") |
| 312 | return parts.joined(separator: " · ") |
| 313 | } |
| 314 | |
| 315 | // First load sweeps the whole store; each backfill page then folds ONLY |
| 316 | // the newly-filed (strictly older) rows — the web overlay's incremental |
| 317 | // onPage fold, not a full re-extract per tick. |
| 318 | @State private var oldestSID: Int64 = 0 |
| 319 | private func reload() async { |
| 320 | if oldestSID == 0 { |
| 321 | let rows = await MessageStore.shared.allMessages(instance: instance.id, |
| 322 | thread: thread.id) |
| 323 | tabs = ThreadMedia.extractAll(rows) |
| 324 | oldestSID = rows.last?.id ?? 0 // newest-first, so last = oldest |
| 325 | } else { |
| 326 | let older = await MessageStore.shared.page(instance: instance.id, |
| 327 | thread: thread.id, |
| 328 | before: oldestSID, limit: 50_000) |
| 329 | guard let first = older.first else { loaded = true; return } |
| 330 | // Strictly older rows: appending keeps each tab's newest-first order. |
| 331 | let ex = ThreadMedia.extractAll(older) |
| 332 | tabs.media.append(contentsOf: ex.media) |
| 333 | tabs.links.append(contentsOf: ex.links) |
| 334 | tabs.docs.append(contentsOf: ex.docs) |
| 335 | oldestSID = first.id // page() returns ascending, so first = oldest |
| 336 | } |
| 337 | loaded = true |
| 338 | // Every photo the thread now holds gets looked at once (on device). |
| 339 | imageIndex.ensure(instance: instance.id, entries: tabs.media, |
| 340 | load: { await model.blobData($0) }) |
| 341 | } |
| 342 | |
| 343 | private func startWalkIfIncomplete() { |
| 344 | Task { |
| 345 | let mark = await MessageStore.shared.backfillMark(instance: instance.id, |
| 346 | thread: thread.id) |
| 347 | guard !mark.complete else { return } |
| 348 | backfill.start(instance: instance, threadID: thread.id, |
| 349 | threadKind: thread.kind, threadKeyB64: thread.threadKeyB64) |
| 350 | } |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | // One grid tile: BlurHash ghost immediately, decrypted thumbnail when loaded. |
| 355 | // LazyVGrid materialises only visible tiles, so decryption stays proportional |
| 356 | // to what's viewed. |
| 357 | private struct MediaGridTile: View { |
| 358 | let att: Attachment |
| 359 | let source: AvatarSource? |
| 360 | |
| 361 | @State private var image: NSImage? |
| 362 | @State private var blur: NSImage? |
| 363 | |
| 364 | var body: some View { |
| 365 | GeometryReader { geo in |
| 366 | ZStack(alignment: .bottomTrailing) { |
| 367 | Rectangle().fill(Color(nsColor: .quaternarySystemFill)) |
| 368 | if let shown = image ?? blur { |
| 369 | Image(nsImage: shown) |
| 370 | .resizable() |
| 371 | .aspectRatio(contentMode: .fill) |
| 372 | .frame(width: geo.size.width, height: geo.size.height) |
| 373 | .clipped() |
| 374 | } |
| 375 | if att.isVideo, let dur = att.dur, dur > 0 { |
| 376 | Text(formatDuration(dur)) |
| 377 | .font(.system(size: 10, weight: .semibold)) |
| 378 | .foregroundStyle(.white) |
| 379 | .padding(.horizontal, 5) |
| 380 | .padding(.vertical, 1) |
| 381 | .background(.black.opacity(0.65), in: Capsule()) |
| 382 | .padding(4) |
| 383 | } |
| 384 | } |
| 385 | } |
| 386 | .aspectRatio(1, contentMode: .fit) |
| 387 | .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) |
| 388 | .task(id: att.id) { |
| 389 | if image == nil, let bh = att.bh, blur == nil, |
| 390 | let cg = await Task.detached(operation: { Blurhash.decode(bh) }).value { |
| 391 | blur = NSImage(cgImage: cg, size: .zero) |
| 392 | } |
| 393 | guard image == nil, let source else { return } |
| 394 | image = await BlobImageLoader.shared.image(source, maxPixel: 320) |
| 395 | } |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | private struct LinkUnfurlRow: View { |
| 400 | let e: ThreadMedia.Entry |
| 401 | let unfurl: (URL) async -> LinkPreview? |
| 402 | let peek: (URL) -> LinkPreview?? |
| 403 | let open: (URL) -> Void |
| 404 | let onTitle: (URL, String) -> Void // feeds the sheet's search-by-title memo |
| 405 | |
| 406 | @State private var title: String? |
| 407 | |
| 408 | var body: some View { |
| 409 | Button { if let url = e.url { open(url) } } label: { |
| 410 | VStack(alignment: .leading, spacing: 1) { |
| 411 | if let title, !title.isEmpty { |
| 412 | Text(title) |
| 413 | .font(.system(size: 13, weight: .semibold)) |
| 414 | .lineLimit(2) |
| 415 | } |
| 416 | Text(e.url?.absoluteString ?? "") |
| 417 | .font(.system(size: 11)) |
| 418 | .foregroundStyle(.secondary) |
| 419 | .lineLimit(1) |
| 420 | Text("\(e.name.isEmpty ? "Someone" : e.name) · \(relTime(e.ts / 1000))") |
| 421 | .font(.system(size: 11)) |
| 422 | .foregroundStyle(.tertiary) |
| 423 | } |
| 424 | .frame(maxWidth: .infinity, alignment: .leading) |
| 425 | .padding(.horizontal, 14) |
| 426 | .padding(.vertical, 5) |
| 427 | .contentShape(Rectangle()) |
| 428 | } |
| 429 | .buttonStyle(.plain) |
| 430 | .modifier(RowHover()) |
| 431 | .onHover { $0 ? NSCursor.pointingHand.push() : NSCursor.pop() } |
| 432 | .task(id: e.id) { |
| 433 | guard let url = e.url else { return } |
| 434 | if case .some(let hit) = peek(url) { title = hit?.title } else { title = await unfurl(url)?.title } |
| 435 | if let title, !title.isEmpty { onTitle(url, title) } |
| 436 | } |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | // Rows highlight under the pointer — the sidebar hover idiom |
| 441 | // (NativeChatView's `.primary.opacity(0.08)` wash), inset a touch so the |
| 442 | // wash doesn't kiss the sheet's edges. |
| 443 | private struct RowHover: ViewModifier { |
| 444 | @State private var hovering = false |
| 445 | func body(content: Content) -> some View { |
| 446 | content |
| 447 | .background(RoundedRectangle(cornerRadius: 8, style: .continuous) |
| 448 | .fill(.primary.opacity(hovering ? 0.07 : 0)) |
| 449 | .padding(.horizontal, 6)) |
| 450 | .onHover { hovering = $0 } |
| 451 | } |
| 452 | } |
| 453 | |
| 454 | // 🤖 The in-window collection viewer: the current photo (or a video's poster) |
| 455 | // over a scrim, ‹ › buttons + ←/→ arrow keys stepping through the whole tab, |
| 456 | // clamped at the ends (no wrap — the web lightbox's stepIndex rule). All in |
| 457 | // memory via BlobImageLoader; Escape (or a scrim click) closes the viewer |
| 458 | // without closing the window. |
| 459 | // |
| 460 | // Pinch to zoom, drag to pan, double-click to toggle — iOS gets all three |
| 461 | // from Quick Look, so the Mac hand-rolls them to keep the two in step (the |
| 462 | // feed's own lightbox deliberately doesn't zoom; here it's the whole point, |
| 463 | // since this is where you go to read the text in a screenshot). Zoom resets |
| 464 | // when you step to another photo, so every arrival starts fitted. |
| 465 | private struct MediaViewerOverlay: View { |
| 466 | let entries: [ThreadMedia.Entry] |
| 467 | let index: Int |
| 468 | let source: (ThreadMedia.Entry) -> AvatarSource? |
| 469 | let onStep: (Int) -> Void |
| 470 | let onSave: () -> Void |
| 471 | let onClose: () -> Void |
| 472 | |
| 473 | @State private var image: NSImage? |
| 474 | // Live gesture values plus what they settle to, so a second pinch picks |
| 475 | // up where the last left off (the feed lightbox's steady/live split). |
| 476 | @State private var scale: CGFloat = 1 |
| 477 | @State private var steadyScale: CGFloat = 1 |
| 478 | @State private var offset: CGSize = .zero |
| 479 | @State private var steadyOffset: CGSize = .zero |
| 480 | |
| 481 | private let maxScale: CGFloat = 8 |
| 482 | |
| 483 | var body: some View { |
| 484 | ZStack { |
| 485 | Color.black.opacity(0.82) |
| 486 | .onTapGesture { onClose() } |
| 487 | if let image { |
| 488 | // Fills the (resizable) window, so a screenshot full of small |
| 489 | // text is readable by growing the window — then zooming in. |
| 490 | Image(nsImage: image) |
| 491 | .resizable() |
| 492 | .interpolation(.high) |
| 493 | .aspectRatio(contentMode: .fit) |
| 494 | .scaleEffect(scale) |
| 495 | .offset(offset) |
| 496 | .gesture(magnify.simultaneously(with: pan)) |
| 497 | .onTapGesture(count: 2) { toggleZoom() } |
| 498 | .padding(.horizontal, 12) |
| 499 | .padding(.top, 44) |
| 500 | .padding(.bottom, 12) |
| 501 | .clipped() // a zoomed photo stays inside the viewer |
| 502 | .accessibilityLabel("Photo \((index + 1).formatted()) of \(entries.count.formatted())") |
| 503 | } else { |
| 504 | ProgressView() |
| 505 | .controlSize(.large) |
| 506 | .tint(.white) |
| 507 | } |
| 508 | HStack { |
| 509 | stepButton("chevron.left", "Previous photo", enabled: index > 0) { |
| 510 | onStep(index - 1) |
| 511 | } |
| 512 | Spacer() |
| 513 | stepButton("chevron.right", "Next photo", enabled: index < entries.count - 1) { |
| 514 | onStep(index + 1) |
| 515 | } |
| 516 | } |
| 517 | .padding(.horizontal, 8) |
| 518 | VStack { |
| 519 | HStack(spacing: 10) { |
| 520 | Spacer() |
| 521 | Button(action: onSave) { |
| 522 | Label("Save", systemImage: "arrow.down.circle") |
| 523 | .font(.system(size: 12, weight: .semibold)) |
| 524 | .foregroundStyle(.white) |
| 525 | .padding(.horizontal, 10) |
| 526 | .padding(.vertical, 5) |
| 527 | .background(.black.opacity(0.45), in: Capsule()) |
| 528 | } |
| 529 | .buttonStyle(.plain) |
| 530 | .keyboardShortcut("s", modifiers: .command) |
| 531 | .accessibilityLabel("Save photo to Downloads") |
| 532 | .arrowCursor() |
| 533 | Button { |
| 534 | onClose() |
| 535 | } label: { |
| 536 | Image(systemName: "xmark.circle.fill") |
| 537 | .font(.system(size: 20)) |
| 538 | .foregroundStyle(.white.opacity(0.8)) |
| 539 | } |
| 540 | .buttonStyle(.plain) |
| 541 | .keyboardShortcut(.cancelAction) |
| 542 | .accessibilityLabel("Close viewer") |
| 543 | .arrowCursor() |
| 544 | } |
| 545 | Spacer() |
| 546 | } |
| 547 | .padding(10) |
| 548 | // Hidden arrow-key handlers — ← / → step the collection. |
| 549 | Button("") { if index > 0 { onStep(index - 1) } } |
| 550 | .keyboardShortcut(.leftArrow, modifiers: []) |
| 551 | .opacity(0).frame(width: 0, height: 0) |
| 552 | Button("") { if index < entries.count - 1 { onStep(index + 1) } } |
| 553 | .keyboardShortcut(.rightArrow, modifiers: []) |
| 554 | .opacity(0).frame(width: 0, height: 0) |
| 555 | } |
| 556 | .task(id: entries[index].id) { |
| 557 | image = nil |
| 558 | resetZoom() |
| 559 | guard let src = source(entries[index]) else { return } |
| 560 | image = await BlobImageLoader.shared.image(src, maxPixel: nil) |
| 561 | } |
| 562 | } |
| 563 | |
| 564 | // Trackpad pinch. Clamped at 1 (fitted) so a photo can't shrink away. |
| 565 | private var magnify: some Gesture { |
| 566 | MagnifyGesture() |
| 567 | .onChanged { value in |
| 568 | scale = min(max(steadyScale * value.magnification, 1), maxScale) |
| 569 | } |
| 570 | .onEnded { _ in |
| 571 | steadyScale = scale |
| 572 | if scale <= 1 { resetPan() } |
| 573 | } |
| 574 | } |
| 575 | |
| 576 | // Drag pans, but only while zoomed — at 1× a drag would just slide the |
| 577 | // fitted photo off its own scrim. |
| 578 | private var pan: some Gesture { |
| 579 | DragGesture() |
| 580 | .onChanged { value in |
| 581 | guard scale > 1 else { return } |
| 582 | offset = CGSize(width: steadyOffset.width + value.translation.width, |
| 583 | height: steadyOffset.height + value.translation.height) |
| 584 | } |
| 585 | .onEnded { _ in steadyOffset = offset } |
| 586 | } |
| 587 | |
| 588 | private func toggleZoom() { |
| 589 | withAnimation(.easeOut(duration: 0.18)) { |
| 590 | if scale > 1 { |
| 591 | resetZoom() |
| 592 | } else { |
| 593 | scale = 2.5 |
| 594 | steadyScale = 2.5 |
| 595 | } |
| 596 | } |
| 597 | } |
| 598 | |
| 599 | private func resetZoom() { |
| 600 | scale = 1 |
| 601 | steadyScale = 1 |
| 602 | resetPan() |
| 603 | } |
| 604 | |
| 605 | private func resetPan() { |
| 606 | offset = .zero |
| 607 | steadyOffset = .zero |
| 608 | } |
| 609 | |
| 610 | private func stepButton(_ symbol: String, _ label: String, enabled: Bool, |
| 611 | action: @escaping () -> Void) -> some View { |
| 612 | Button(action: action) { |
| 613 | Image(systemName: symbol) |
| 614 | .font(.system(size: 17, weight: .semibold)) |
| 615 | .foregroundStyle(.white) |
| 616 | .frame(width: 34, height: 34) |
| 617 | .background(.black.opacity(0.45), in: Circle()) |
| 618 | } |
| 619 | .buttonStyle(.plain) |
| 620 | .arrowCursor() |
| 621 | .accessibilityLabel(label) |
| 622 | .opacity(enabled ? 1 : 0) |
| 623 | .disabled(!enabled) |
| 624 | } |
| 625 | } |
| 626 | |