diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 9ddfa11..168016f 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -190,6 +190,7 @@ final class AppController: NSObject, NSApplicationDelegate { store.source = requestedSource ?? .history store.applyHistoryPolicy() store.prepareForBarPresentation() + store.inlinePreviewVisible = false if barController == nil { barController = BarWindowController() @@ -200,9 +201,25 @@ final class AppController: NSObject, NSApplicationDelegate { func hideBar() { stopKeyMonitor() + store.inlinePreviewVisible = false + barController?.setInlinePreviewVisible(false) barController?.hide() } + func toggleInlinePreview() { + guard Settings.shared.clipPreviewStyle == .inlinePesty, + store.source != .pasteStack, + store.selectedItem != nil else { return } + store.inlinePreviewVisible.toggle() + barController?.setInlinePreviewVisible(store.inlinePreviewVisible) + } + + func hideInlinePreview() { + guard store.inlinePreviewVisible else { return } + store.inlinePreviewVisible = false + barController?.setInlinePreviewVisible(false) + } + func resizeVisibleBar(to height: Double) { barController?.resize(to: CGFloat(height)) } @@ -457,7 +474,11 @@ final class AppController: NSObject, NSApplicationDelegate { switch code { case kVK_Space: - if store.source == .pasteStack { + if Settings.shared.clipPreviewStyle == .inlinePesty, + store.source != .pasteStack, + store.selectedItem != nil { + toggleInlinePreview() + } else if store.source == .pasteStack { QuickLookService.shared.toggle(items: pasteSequence.displayEntries.map(\.item), selectedID: pasteSequence.selectedEntry?.item.id) } else { diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 7f66195..fb56514 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -2,6 +2,27 @@ import AppKit import Carbon.HIToolbox import Observation +enum ClipPreviewStyle: Int, CaseIterable, Identifiable { + case nativeQuickLook + case inlinePesty + + var id: Int { rawValue } + + var title: String { + switch self { + case .nativeQuickLook: "Native Quick Look" + case .inlinePesty: "Inline Pesty preview" + } + } + + var detail: String { + switch self { + case .nativeQuickLook: "Open a macOS Quick Look panel with Space." + case .inlinePesty: "Show a rich preview with link titles and favicons inside Pesty." + } + } +} + enum ShortcutModifier: CaseIterable, Identifiable { case command, option, control, shift var id: Int { carbonValue } @@ -154,6 +175,7 @@ final class Settings { static let ignoredSourceAppBundleIDs = "ignoredSourceAppBundleIDs" static let barHeight = "barHeight" static let showBarResizeHandle = "showBarResizeHandle" + static let clipPreviewStyle = "clipPreviewStyle" static let showMenuBarIcon = "showMenuBarIcon" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" @@ -267,6 +289,14 @@ final class Settings { didSet { guard isLoaded else { return }; d.set(showBarResizeHandle, forKey: Keys.showBarResizeHandle) } } + var clipPreviewStyle: ClipPreviewStyle { + didSet { + guard isLoaded else { return } + d.set(clipPreviewStyle.rawValue, forKey: Keys.clipPreviewStyle) + if clipPreviewStyle != .inlinePesty { ClipboardStore.shared.inlinePreviewVisible = false } + } + } + var showMenuBarIcon: Bool { didSet { guard isLoaded else { return } @@ -305,6 +335,7 @@ final class Settings { Keys.ignoredSourceAppBundleIDs: [], Keys.barHeight: 430.0, Keys.showBarResizeHandle: false, + Keys.clipPreviewStyle: ClipPreviewStyle.nativeQuickLook.rawValue, Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false @@ -330,6 +361,7 @@ final class Settings { .filter { !$0.isEmpty } barHeight = d.double(forKey: Keys.barHeight) showBarResizeHandle = d.bool(forKey: Keys.showBarResizeHandle) + clipPreviewStyle = ClipPreviewStyle(rawValue: d.integer(forKey: Keys.clipPreviewStyle)) ?? .nativeQuickLook showMenuBarIcon = d.bool(forKey: Keys.showMenuBarIcon) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 8f37080..5fc7a88 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -193,6 +193,29 @@ private struct GeneralSettings: View { } } + settingsGroup("Clip Previews") { + settingCard { + VStack(alignment: .leading, spacing: 8) { + LabeledContent("Preview style") { + Picker("", selection: $settings.clipPreviewStyle) { + ForEach(ClipPreviewStyle.allCases) { style in + Text(style.title).tag(style) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + .font(.system(size: 14)) + .padding(.vertical, 10) + Divider() + Text(settings.clipPreviewStyle.detail) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 10) + } + } + } + #if !MAS settingsGroup("Accessibility") { settingCard { diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index c42cc13..6c81204 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -18,6 +18,7 @@ final class ClipboardStore { var source: BarSource = .history var searchText: String = "" var selectedID: UUID? + var inlinePreviewVisible = false /// Used by the strip to restore its opening position without animating from /// whichever card was selected the last time the bar was visible. var initialScrollTargetID: UUID? diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 0c87646..dcae57e 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -10,30 +10,39 @@ struct BarView: View { private var showsStackDeck: Bool { store.source == .history && store.searchText.isEmpty && sequence.hasSavedStacks } - @State private var previewVisible = false @State private var resizeStartHeight: Double? + @State private var cardFrames: [UUID: CGRect] = [:] var body: some View { ZStack { panelBackground - } - .overlay(alignment: .top) { VStack(spacing: 0) { if settings.showBarResizeHandle { resizeHandle } topBar if store.source == .pasteStack { PasteStackContentView() } else { - HStack(spacing: 0) { - if previewVisible, let item = store.selectedItem { - SelectedClipPreviewView(item: item) - Divider() - } + if settings.clipPreviewStyle == .inlinePesty, + store.inlinePreviewVisible { + Spacer(minLength: 0) + strip.frame(height: 280) + } else { strip } } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + if settings.clipPreviewStyle == .inlinePesty, + store.source != .pasteStack, + store.inlinePreviewVisible, + let item = store.selectedItem { + PestyPreviewPopover( + item: item, + pointer: cardFrames[item.id].map { CGPoint(x: $0.midX, y: $0.midY) } ?? .zero) + } } + .coordinateSpace(name: "PestyBar") + .onPreferenceChange(ClipCardFramePreferenceKey.self) { cardFrames = $0 } .clipShape(RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight])) .ignoresSafeArea() } @@ -58,7 +67,7 @@ struct BarView: View { .layoutPriority(1) Spacer(minLength: 8) if store.source != .pasteStack { - previewButton + if settings.clipPreviewStyle == .inlinePesty { previewButton } startPasteStackButton } moreMenu @@ -68,14 +77,14 @@ struct BarView: View { } private var previewButton: some View { - Button { previewVisible.toggle() } label: { - Image(systemName: previewVisible ? "rectangle.on.rectangle" : "rectangle.on.rectangle.angled") + Button { AppController.shared.toggleInlinePreview() } label: { + Image(systemName: store.inlinePreviewVisible ? "rectangle.on.rectangle" : "rectangle.on.rectangle.angled") .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(previewVisible ? Theme.selection : Theme.textSecondary) + .foregroundStyle(store.inlinePreviewVisible ? Theme.selection : Theme.textSecondary) .frame(width: 30, height: 30) } .buttonStyle(.plain) - .help(previewVisible ? "Hide clip preview" : "Show clip preview") + .help(store.inlinePreviewVisible ? "Hide clip preview" : "Show clip preview") } private var startPasteStackButton: some View { @@ -201,6 +210,13 @@ struct BarView: View { index: index, selected: item.id == store.selectedID) .id(item.id) + .background { + GeometryReader { proxy in + Color.clear.preference( + key: ClipCardFramePreferenceKey.self, + value: [item.id: proxy.frame(in: .named("PestyBar"))]) + } + } .transition(.asymmetric( insertion: .scale(scale: 0.92).combined(with: .opacity), removal: .opacity)) @@ -250,6 +266,14 @@ struct BarView: View { } } +private struct ClipCardFramePreferenceKey: PreferenceKey { + static var defaultValue: [UUID: CGRect] = [:] + + static func reduce(value: inout [UUID: CGRect], nextValue: () -> [UUID: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, next in next }) + } +} + struct RoundedCorners: Shape { var radius: CGFloat var corners: RectCorner diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index 502c9dc..7161004 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -105,6 +105,11 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { panel.setFrame(frame, display: true) } + func setInlinePreviewVisible(_ visible: Bool) { + let height = visible ? max(CGFloat(Settings.shared.barHeight), 620) : CGFloat(Settings.shared.barHeight) + resize(to: height) + } + func windowDidResignKey(_ notification: Notification) { guard Settings.shared.hideOnClickOutside, !isPresenting, diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 36d4370..32ba404 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -136,7 +136,7 @@ struct ClipCardView: View { case .file: fileContent case .link: - LinkPreviewContent(text: item.text ?? item.displayTitle, compact: true) + LinkCardPreview(text: item.text ?? item.displayTitle) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) case .richText: RichTextContent(rtfData: item.rtfData, fallback: item.text ?? "", lineLimit: 10) diff --git a/Sources/Pesty/UI/ClipPreviewViews.swift b/Sources/Pesty/UI/ClipPreviewViews.swift index 438c16f..b107cdb 100644 --- a/Sources/Pesty/UI/ClipPreviewViews.swift +++ b/Sources/Pesty/UI/ClipPreviewViews.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import WebKit struct RichTextContent: View { let rtfData: Data? @@ -78,6 +79,163 @@ struct LinkPreviewContent: View { } } +struct LinkCardPreview: View { + let text: String + private let previews = LinkPreviewStore.shared + + private var url: URL? { URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) } + private var preview: LinkPreview? { previews.preview(for: url) } + private var host: String { url?.host ?? text } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Group { + if let image = preview?.image { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFill() + } else { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(0.16)) + .overlay { + Image(systemName: "link") + .font(.system(size: 26, weight: .medium)) + .foregroundStyle(Color.accentColor) + } + } + } + .frame(maxWidth: .infinity) + .frame(height: 104) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + HStack(spacing: 7) { + if let icon = preview?.icon { + Image(nsImage: icon) + .resizable() + .interpolation(.high) + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + } else { + Image(systemName: "link") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 16, height: 16) + } + Text(preview?.title ?? host) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(2) + } + } + .onAppear { previews.load(for: url) } + } +} + +struct PestyPreviewPopover: View { + let item: ClipItem + let pointer: CGPoint + + private var url: URL? { + guard item.type == .link else { return nil } + return URL(string: (item.text ?? item.displayTitle).trimmingCharacters(in: .whitespacesAndNewlines)) + } + + var body: some View { + GeometryReader { proxy in + let inset: CGFloat = 24 + let width = max(280, min(980, proxy.size.width - inset * 2)) + let targetX = pointer.x > 0 ? pointer.x : proxy.size.width / 2 + let left = min(max(inset, targetX - width / 2), max(inset, proxy.size.width - width - inset)) + let cardTop = pointer.y > 0 ? pointer.y : proxy.size.height - 44 + let height = min(520, max(220, cardTop - 84)) + let totalHeight = height + 13 + let arrowOffset = min(width / 2 - 22, max(-width / 2 + 22, targetX - left - width / 2)) + let centerY = max(20 + totalHeight / 2, cardTop - 12 - totalHeight / 2) + + VStack(spacing: 0) { + previewPanel + .frame(height: height) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(.white.opacity(0.22)) + } + PreviewPointer() + .fill(Color(nsColor: .windowBackgroundColor)) + .frame(width: 24, height: 13) + .offset(x: arrowOffset) + } + .frame(width: width) + .shadow(color: .black.opacity(0.38), radius: 24, y: 10) + .position(x: left + width / 2, y: centerY) + } + .allowsHitTesting(true) + } + + private var previewPanel: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Button { AppController.shared.hideInlinePreview() } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + Text(item.type.label) + .font(.system(size: 17, weight: .bold)) + Spacer() + if let url { + Button("Open in Safari") { NSWorkspace.shared.open(url) } + .buttonStyle(.bordered) + } + } + .padding(.horizontal, 16) + .frame(height: 52) + + Divider() + + Group { + if let url { + WebLinkPreview(url: url) + } else { + SelectedClipPreviewView(item: item) + } + } + .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) + .padding(10) + } + } +} + +private struct WebLinkPreview: NSViewRepresentable { + let url: URL + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.load(URLRequest(url: url)) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + guard webView.url != url else { return } + webView.load(URLRequest(url: url)) + } +} + +private struct PreviewPointer: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + path.closeSubpath() + return path + } +} + struct SelectedClipPreviewView: View { let item: ClipItem private var store: ClipboardStore { ClipboardStore.shared } diff --git a/Sources/Pesty/Util/LinkPreviewStore.swift b/Sources/Pesty/Util/LinkPreviewStore.swift index 4702bec..7bfb1bb 100644 --- a/Sources/Pesty/Util/LinkPreviewStore.swift +++ b/Sources/Pesty/Util/LinkPreviewStore.swift @@ -5,6 +5,7 @@ import Observation struct LinkPreview { var title: String? var icon: NSImage? + var image: NSImage? } @Observable @@ -34,9 +35,17 @@ final class LinkPreviewStore { request.timeoutInterval = 5 request.setValue("Pesty/1.0", forHTTPHeaderField: "User-Agent") URLSession.shared.dataTask(with: request) { data, _, _ in - let title = data.flatMap(Self.pageTitle(from:)) + let metadata = data.flatMap { Self.pageMetadata(from: $0, relativeTo: url) } DispatchQueue.main.async { - self.update(host: host, title: title, icon: nil, finished: false) + self.update(host: host, title: metadata?.title, icon: nil, image: nil, finished: false) + } + if let imageURL = metadata?.imageURL { + URLSession.shared.dataTask(with: imageURL) { imageData, _, _ in + let image = imageData.flatMap(NSImage.init(data:)) + DispatchQueue.main.async { + self.update(host: host, title: nil, icon: nil, image: image, finished: false) + } + }.resume() } }.resume() @@ -51,27 +60,36 @@ final class LinkPreviewStore { URLSession.shared.dataTask(with: iconURL) { data, _, _ in let icon = data.flatMap(NSImage.init(data:)) DispatchQueue.main.async { - self.update(host: host, title: nil, icon: icon, finished: true) + self.update(host: host, title: nil, icon: icon, image: nil, finished: true) } }.resume() } - private func update(host: String, title: String?, icon: NSImage?, finished: Bool) { + private func update(host: String, title: String?, icon: NSImage?, image: NSImage?, finished: Bool) { var preview = previews[host] ?? LinkPreview() if let title, !title.isEmpty { preview.title = title } if let icon { preview.icon = icon } + if let image { preview.image = image } previews[host] = preview if finished { loadingHosts.remove(host) } } - nonisolated private static func pageTitle(from data: Data) -> String? { + nonisolated private static func pageMetadata(from data: Data, relativeTo url: URL) -> (title: String?, imageURL: URL?)? { guard let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1), let expression = try? NSRegularExpression(pattern: "]*>(.*?)", options: [.caseInsensitive, .dotMatchesLineSeparators]), let match = expression.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), let range = Range(match.range(at: 1), in: html) else { return nil } - return html[range] + let title = html[range] .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) + let imagePattern = "]+(?:property|name)=[\\\"'](?:og:image|twitter:image)[\\\"'][^>]+content=[\\\"']([^\\\"']+)[\\\"']" + let imageExpression = try? NSRegularExpression(pattern: imagePattern, options: [.caseInsensitive]) + let imageURL: URL? = imageExpression.flatMap { expression in + guard let imageMatch = expression.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), + let imageRange = Range(imageMatch.range(at: 1), in: html) else { return nil } + return URL(string: String(html[imageRange]), relativeTo: url)?.absoluteURL + } + return (title.isEmpty ? nil : title, imageURL) } }