Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Jot.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 2.5.8;
MARKETING_VERSION = 2.8.1;
PRODUCT_BUNDLE_IDENTIFIER = slf.PinStick;
PRODUCT_NAME = PinStick;
REGISTER_APP_GROUPS = YES;
Expand Down Expand Up @@ -456,7 +456,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 2.5.8;
MARKETING_VERSION = 2.8.1;
PRODUCT_BUNDLE_IDENTIFIER = slf.PinStick;
PRODUCT_NAME = PinStick;
REGISTER_APP_GROUPS = YES;
Expand All @@ -479,7 +479,7 @@
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 2.5.8;
MARKETING_VERSION = 2.8.1;
PRODUCT_BUNDLE_IDENTIFIER = slf.PinStickTests;
PRODUCT_NAME = PinStickTests;
SDKROOT = auto;
Expand All @@ -502,7 +502,7 @@
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 2.5.8;
MARKETING_VERSION = 2.8.1;
PRODUCT_BUNDLE_IDENTIFIER = slf.PinStickTests;
PRODUCT_NAME = PinStickTests;
SDKROOT = auto;
Expand All @@ -524,7 +524,7 @@
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 2.5.8;
MARKETING_VERSION = 2.8.1;
PRODUCT_BUNDLE_IDENTIFIER = slf.PinStickUITests;
PRODUCT_NAME = PinStickUITests;
SDKROOT = auto;
Expand All @@ -546,7 +546,7 @@
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 2.5.8;
MARKETING_VERSION = 2.8.1;
PRODUCT_BUNDLE_IDENTIFIER = slf.PinStickUITests;
PRODUCT_NAME = PinStickUITests;
SDKROOT = auto;
Expand Down
161 changes: 161 additions & 0 deletions MediaNoteSupport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import Foundation
import UniformTypeIdentifiers

enum MediaKind: String, Codable {
case image
case video
}

struct RemoteMediaItem: Identifiable, Equatable {
let id: String
let url: URL
let kind: MediaKind
}

struct LocalMediaItem: Equatable {
let url: URL
let kind: MediaKind
}

enum MediaNoteSupport {
static let noteStorageKey = "pinstick-note"
static let localMediaBookmarkKey = "pinstick-local-media-bookmark"

private static let imageExtensions = ["avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"]
private static let videoExtensions = ["m4v", "mov", "mp4", "ogg", "ogv", "webm"]
private static let trailingPunctuation = CharacterSet(charactersIn: "),.!?;:")

static func classifyMediaURL(_ url: URL) -> MediaKind? {
let path = url.path.lowercased()
let ext = (path as NSString).pathExtension
if imageExtensions.contains(ext) {
return .image
}
if videoExtensions.contains(ext) {
return .video
}
return nil
}

static func classifyLocalFile(url: URL) -> MediaKind? {
if let type = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType {
if type.conforms(to: .image) {
return .image
}
if type.conforms(to: .movie) || type.conforms(to: .video) {
return .video
}
}
return classifyMediaURL(url)
}

static func parseRemoteMedia(from noteText: String) -> [RemoteMediaItem] {
var items: [RemoteMediaItem] = []
var seen = Set<String>()

let markdownPattern = #"!\[[^\]]*\]\((https?://[^)\s]+)\)"#
if let regex = try? NSRegularExpression(pattern: markdownPattern) {
let range = NSRange(noteText.startIndex..<noteText.endIndex, in: noteText)
Comment on lines +56 to +58
regex.enumerateMatches(in: noteText, range: range) { match, _, _ in
guard let match,
match.numberOfRanges > 1,
let urlRange = Range(match.range(at: 1), in: noteText) else { return }
appendRemoteURL(String(noteText[urlRange]), to: &items, seen: &seen)
}
}

let urlPattern = #"https?://[^\s<>"')\]]+"#
if let regex = try? NSRegularExpression(pattern: urlPattern) {
let range = NSRange(noteText.startIndex..<noteText.endIndex, in: noteText)
regex.enumerateMatches(in: noteText, range: range) { match, _, _ in
guard let match, let urlRange = Range(match.range, in: noteText) else { return }
appendRemoteURL(String(noteText[urlRange]), to: &items, seen: &seen)
}
}

return items
}

static func removeRemoteMedia(url: URL, from noteText: String) -> String {
let urlString = url.absoluteString
var updated = noteText

let markdownPattern = #"!\[[^\]]*\]\(\#(NSRegularExpression.escapedPattern(for: urlString))\)\s*"#
if let regex = try? NSRegularExpression(pattern: markdownPattern) {
updated = regex.stringByReplacingMatches(
in: updated,
range: NSRange(updated.startIndex..<updated.endIndex, in: updated),
withTemplate: ""
)
}

updated = updated.replacingOccurrences(of: urlString, with: "")
while updated.contains("\n\n\n") {
updated = updated.replacingOccurrences(of: "\n\n\n", with: "\n\n")
}
return updated.trimmingCharacters(in: .whitespacesAndNewlines)
}

static func saveLocalMediaBookmark(for url: URL) {
guard url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }

do {
let data = try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
UserDefaults.standard.set(data, forKey: localMediaBookmarkKey)
} catch {
NSLog("Failed to save media bookmark: \(error.localizedDescription)")
}
}

static func loadLocalMedia() -> LocalMediaItem? {
guard let data = UserDefaults.standard.data(forKey: localMediaBookmarkKey) else {
return nil
}

var isStale = false
do {
let url = try URL(
resolvingBookmarkData: data,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
if isStale {
saveLocalMediaBookmark(for: url)
}
guard url.startAccessingSecurityScopedResource() else { return nil }
guard let kind = classifyLocalFile(url: url) else {
url.stopAccessingSecurityScopedResource()
return nil
}
return LocalMediaItem(url: url, kind: kind)
} catch {
NSLog("Failed to resolve media bookmark: \(error.localizedDescription)")
return nil
}
}

static func clearLocalMedia() {
if let existing = loadLocalMedia() {
existing.url.stopAccessingSecurityScopedResource()
}
UserDefaults.standard.removeObject(forKey: localMediaBookmarkKey)
}

private static func appendRemoteURL(
_ rawURL: String,
to items: inout [RemoteMediaItem],
seen: inout Set<String>
) {
let trimmed = rawURL.trimmingCharacters(in: trailingPunctuation)
guard let url = URL(string: trimmed),
let kind = classifyMediaURL(url),
seen.insert(trimmed).inserted else { return }
items.append(RemoteMediaItem(id: trimmed, url: url, kind: kind))
}
}
83 changes: 83 additions & 0 deletions OverlayWindowSupport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import AppKit
import SwiftUI

enum OverlayWindowSupport {
static let opacityStorageKey = "pinstick-overlay-opacity"

static func defaultOpacity() -> Double {
let stored = UserDefaults.standard.double(forKey: opacityStorageKey)
if stored > 0 {
return min(1, max(0.4, stored))
}
return 0.7
}

static func saveOpacity(_ value: Double) {
let clamped = min(1, max(0.4, value))
UserDefaults.standard.set(clamped, forKey: opacityStorageKey)
}

static func applyOverlay(on window: NSWindow, opacity: Double) {
window.level = .floating
window.isOpaque = false
window.backgroundColor = .clear
window.alphaValue = opacity
window.ignoresMouseEvents = true
}

static func restoreNormalWindow(_ window: NSWindow, isPinned: Bool) {
window.ignoresMouseEvents = false
window.isOpaque = true
window.backgroundColor = nil
window.alphaValue = 1
window.level = isPinned ? .floating : .normal
}

static func frameInScreenCoordinates(for view: NSView) -> CGRect {
let bounds = view.bounds
let origin = view.convert(NSPoint(x: bounds.minX, y: bounds.minY), to: nil)
let size = view.convert(NSPoint(x: bounds.maxX, y: bounds.maxY), to: nil)
let windowRect = NSRect(
x: min(origin.x, size.x),
y: min(origin.y, size.y),
width: abs(size.x - origin.x),
height: abs(size.y - origin.y)
)
return view.window?.convertToScreen(windowRect) ?? .zero
}

static func isMouseOver(rect: CGRect) -> Bool {
guard !rect.isEmpty else { return false }
let mouse = NSEvent.mouseLocation
return rect.contains(mouse)
}
}

/// Polls mouse position without Accessibility permissions (unlike global event monitors).
final class OverlayMouseMonitor {
private var timer: Timer?
private weak var window: NSWindow?
private let hitTest: () -> Bool

init(window: NSWindow, hitTest: @escaping () -> Bool) {
self.window = window
self.hitTest = hitTest
}

func start() {
stop()
timer = Timer.scheduledTimer(withTimeInterval: 0.06, repeats: true) { [weak self] _ in
guard let self, let window = self.window else { return }
window.ignoresMouseEvents = !self.hitTest()
}
}

func stop() {
timer?.invalidate()
timer = nil
}

deinit {
stop()
}
}
Loading
Loading