WIP: add multi-file card stack preview

This commit is contained in:
Akshay Kolli
2026-07-01 14:33:04 -07:00
parent 6dcf0d523e
commit fdf9c6f326
3 changed files with 120 additions and 3 deletions

View File

@@ -64,6 +64,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
31. Confirm each card's source or type badge reads as an attached header-corner tile instead of a small floating icon. 31. Confirm each card's source or type badge reads as an attached header-corner tile instead of a small floating icon.
32. Confirm built-in collection chips use recognizable glyphs, while custom collection chips keep color-dot swatches. 32. Confirm built-in collection chips use recognizable glyphs, while custom collection chips keep color-dot swatches.
33. Confirm cards from known apps show app identity in the header tile, falling back to source initials when an app icon is unavailable. 33. Confirm cards from known apps show app identity in the header tile, falling back to source initials when an app icon is unavailable.
34. Confirm multi-file cards show a stacked file preview, while single-file cards keep the regular file layout.
## Copy And Paste ## Copy And Paste

View File

@@ -3571,7 +3571,15 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return linkMediaPreviewView(for: item, thumbnail: thumbnail) return linkMediaPreviewView(for: item, thumbnail: thumbnail)
} }
return linkPreviewView(for: item) return linkPreviewView(for: item)
case .file, .pdf: case .file:
if let thumbnail {
return mediaPreviewView(for: item, thumbnail: thumbnail)
}
if FilePayload.paths(from: item.payload).count > 1 {
return multiFilePreviewView(for: item)
}
return filePreviewView(for: item, thumbnail: thumbnail)
case .pdf:
if let thumbnail { if let thumbnail {
return mediaPreviewView(for: item, thumbnail: thumbnail) return mediaPreviewView(for: item, thumbnail: thumbnail)
} }
@@ -3824,6 +3832,109 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return container return container
} }
private func multiFilePreviewView(for item: ClipboardItem) -> NSView {
let container = NSView()
container.wantsLayer = true
container.layer?.backgroundColor = accentColor(for: item.kind).withAlphaComponent(0.08).cgColor
container.translatesAutoresizingMaskIntoConstraints = false
let urls = FilePayload.urls(from: item.payload)
let displayURLs = Array(urls.prefix(3))
let tileViews = displayURLs.enumerated().map { offset, url in
miniFileTile(for: url, index: offset, hiddenCount: max(0, urls.count - displayURLs.count))
}
let tileStack = NSStackView(views: tileViews)
tileStack.orientation = .horizontal
tileStack.alignment = .centerY
tileStack.spacing = layout.isCompact ? 6 : 8
tileStack.translatesAutoresizingMaskIntoConstraints = false
let title = NSTextField(labelWithString: titleText(for: item))
title.font = .systemFont(ofSize: 14, weight: .semibold)
title.textColor = .labelColor
title.maximumNumberOfLines = 1
title.lineBreakMode = .byTruncatingTail
title.toolTip = title.stringValue
let location = NSTextField(labelWithString: previewText(for: item))
location.font = .systemFont(ofSize: 12)
location.textColor = .secondaryLabelColor
location.maximumNumberOfLines = 1
location.lineBreakMode = .byTruncatingMiddle
location.toolTip = location.stringValue
let labels = NSStackView(views: [title, location])
labels.orientation = .vertical
labels.alignment = .leading
labels.spacing = 3
labels.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(tileStack)
container.addSubview(labels)
NSLayoutConstraint.activate([
tileStack.centerXAnchor.constraint(equalTo: container.centerXAnchor),
tileStack.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 14 : 16),
labels.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
labels.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
labels.topAnchor.constraint(equalTo: tileStack.bottomAnchor, constant: layout.isCompact ? 10 : 12),
labels.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -10),
title.widthAnchor.constraint(equalTo: labels.widthAnchor),
location.widthAnchor.constraint(equalTo: labels.widthAnchor)
])
return container
}
private func miniFileTile(for url: URL, index: Int, hiddenCount: Int) -> NSView {
let tile = NSView()
tile.wantsLayer = true
tile.layer?.cornerRadius = 10
tile.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.84).cgColor
tile.layer?.borderWidth = 0.8
tile.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.16).cgColor
tile.layer?.shadowColor = NSColor.black.cgColor
tile.layer?.shadowOpacity = 0.08
tile.layer?.shadowRadius = 5
tile.layer?.shadowOffset = NSSize(width: 0, height: 2)
tile.translatesAutoresizingMaskIntoConstraints = false
let iconName = url.hasDirectoryPath ? "folder.fill" : "doc.fill"
let icon = headerIcon(iconName, color: accentColor(for: .file))
icon.translatesAutoresizingMaskIntoConstraints = false
let extensionText = fileKindText(from: url.path, fallback: url.hasDirectoryPath ? "DIR" : "FILE")
let extensionPill = capsuleLabel(extensionText, color: accentColor(for: .file))
extensionPill.translatesAutoresizingMaskIntoConstraints = false
tile.addSubview(icon)
tile.addSubview(extensionPill)
var constraints = [
tile.widthAnchor.constraint(equalToConstant: layout.isCompact ? 48 : 54),
tile.heightAnchor.constraint(equalToConstant: layout.isCompact ? 58 : 64),
icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor, constant: -7),
icon.widthAnchor.constraint(equalToConstant: layout.isCompact ? 23 : 26),
icon.heightAnchor.constraint(equalToConstant: layout.isCompact ? 26 : 29),
extensionPill.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
extensionPill.bottomAnchor.constraint(equalTo: tile.bottomAnchor, constant: -7),
extensionPill.widthAnchor.constraint(lessThanOrEqualTo: tile.widthAnchor, constant: -8)
]
if hiddenCount > 0, index == 2 {
let morePill = capsuleLabel("+\(hiddenCount)", color: NSColor.black.withAlphaComponent(0.52))
morePill.translatesAutoresizingMaskIntoConstraints = false
tile.addSubview(morePill)
constraints += [
morePill.trailingAnchor.constraint(equalTo: tile.trailingAnchor, constant: -5),
morePill.topAnchor.constraint(equalTo: tile.topAnchor, constant: 5),
morePill.widthAnchor.constraint(lessThanOrEqualTo: tile.widthAnchor, constant: -10)
]
}
NSLayoutConstraint.activate(constraints)
return tile
}
private func audioPreviewView(for item: ClipboardItem) -> NSView { private func audioPreviewView(for item: ClipboardItem) -> NSView {
let container = NSView() let container = NSView()
container.wantsLayer = true container.wantsLayer = true
@@ -3962,7 +4073,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
switch item.kind { switch item.kind {
case .url: case .url:
return thumbnail == nil ? "link-preview" : "link-media-preview" return thumbnail == nil ? "link-preview" : "link-media-preview"
case .file, .pdf: case .file:
if thumbnail != nil {
return "file-media-preview"
}
return FilePayload.paths(from: item.payload).count > 1 ? "multi-file-preview" : "file-preview"
case .pdf:
return thumbnail == nil ? "file-preview" : "file-media-preview" return thumbnail == nil ? "file-preview" : "file-media-preview"
case .audio: case .audio:
return "audio-preview" return "audio-preview"

View File

@@ -1185,7 +1185,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: 2 files"]) XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: 2 files"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["2 files|\(directory.path)|2 files"]) XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["2 files|\(directory.path)|2 files"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"]) XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["multi-file-preview"])
} }
func testExistingFileCardsUseFullBleedMediaPreviewLayout() throws { func testExistingFileCardsUseFullBleedMediaPreviewLayout() throws {