import AppKit import SwiftUI struct PhotoTileView: View { let member: SimilarityGroup.Member let isRepresentative: Bool let isMarked: Bool let canMark: Bool let onToggle: () -> Void private let thumbnailSize: CGFloat = 168 var body: some View { VStack(alignment: .leading, spacing: 6) { ZStack(alignment: .topLeading) { ThumbnailView(url: member.id, size: thumbnailSize) .overlay { RoundedRectangle(cornerRadius: 6, style: .continuous) .strokeBorder(borderColor, lineWidth: isMarked ? 3 : 1) } .opacity(isMarked ? 0.55 : 1) Toggle(isOn: Binding(get: { isMarked }, set: { _ in onToggle() })) { EmptyView() } .toggleStyle(.checkbox) .labelsHidden() .disabled(!canMark) // 明るい写真の上でもチェックボックスが埋もれないように下敷きを敷く .padding(4) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 5, style: .continuous)) .padding(6) .help(canMark ? "ゴミ箱へ移動する対象にする" : "グループから最低1枚は残す必要があります") if isRepresentative { Text("残す候補") .font(.caption2.weight(.semibold)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.accentColor, in: Capsule()) .foregroundStyle(.white) .padding(6) .frame(maxWidth: thumbnailSize, alignment: .topTrailing) } } Text(member.asset.fileName) .font(.callout) .lineLimit(1) .truncationMode(.middle) VStack(alignment: .leading, spacing: 1) { Text("\(member.asset.dimensionText)・\(member.asset.fileSizeText)") Text(member.asset.captureDateText) if isRepresentative { Text("この写真が基準") } else if let tag = member.exactSetTag { // 代表とは別だが、同じ印どうしは中身が完全に一致している Text("\(member.relation.distanceText)・同一 \(tag)") .foregroundStyle(.orange) } else { Text(member.relation.distanceText) .foregroundStyle(member.relation.isExact ? .orange : .secondary) } } .font(.caption) .foregroundStyle(.secondary) } .frame(width: thumbnailSize) .contentShape(.rect) // クリックは選択に一本化する。ダブルクリックを併置すると単クリックと取り合いになり、 // チェックが二度切り替わってしまうため、プレビューは右クリックメニューから開く。 .onTapGesture { if canMark { onToggle() } } .contextMenu { Button("プレビューで開く") { NSWorkspace.shared.open(member.id) } Button("Finder で表示") { NSWorkspace.shared.activateFileViewerSelecting([member.id]) } Button("パスをコピー") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(member.id.path, forType: .string) } } } private var borderColor: Color { if isMarked { return .red } return isRepresentative ? .accentColor : Color.primary.opacity(0.12) } }