diff --git a/README.md b/README.md index b634805..c874efd 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,27 @@ Tasks live inline in the note body as `☐` / `☑` prefixes, so a note stays pl text. Markdown export writes them as standard `- [ ]` / `- [x]` task syntax and import reads that back. All Notes shows a `done/total` count per note. +## Images + +Paste (`⌘V` with an image on the clipboard), drag an image file in, or +right-click → **Insert Image…**. The picture renders inline at full width by +default; underneath it the body keeps a one-line token, +`![image](noty-img://)`. Unlike the other markdown markers the token +never surfaces just because the caret is near — clicking the image parks the +caret right after it, and pressing delete there reveals the token, fully +selected, as a confirmation step: delete again to remove the image, or type, +press Return/Space, paste or click elsewhere to keep it and the picture +comes straight back. + +Hover an image for a drag handle at its bottom-right corner; drag to resize, +and the height follows the aspect ratio. The width is written back into the +token — `![image|300](noty-img://…)` — so sizes survive restarts and +export/import. + +Images live as files under `~/Library/Application Support/Noty/Images/` and +are deleted when the last note referencing them is deleted. `.stickies` +export archives carry them as base64, so a round-trip keeps every picture. + ## Everything else - **Archived, not deleted.** Archiving pulls a note out of the deck but keeps it @@ -307,6 +328,7 @@ Sources/ NoteEditor.swift NSTextView bridge, find, 250 ms autosave LibraryWindow.swift All Notes / Archive ExportImport.swift md / txt / single file / .stickies + ImageStore.swift on-disk image files, noty-img:// token helpers UndoToast.swift the ten-second undo after a delete ``` diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index c1ce5ff..9499637 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -106,6 +106,7 @@ "menu.export" = "Export"; "menu.hide" = "Hide Noty"; "menu.import" = "Import…"; +"menu.insert_image" = "Insert Image…"; "menu.keep_deck_open" = "Keep deck open"; "menu.launch_at_login" = "Launch at login"; "menu.new_note" = "New Note"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 76e13cf..6ec546a 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -106,6 +106,7 @@ "menu.export" = "导出"; "menu.hide" = "隐藏 Noty"; "menu.import" = "导入…"; +"menu.insert_image" = "插入图片…"; "menu.keep_deck_open" = "保持便笺栏展开"; "menu.launch_at_login" = "登录时启动"; "menu.new_note" = "新建便笺"; diff --git a/Sources/Core.swift b/Sources/Core.swift index c1cf5df..4723775 100644 --- a/Sources/Core.swift +++ b/Sources/Core.swift @@ -280,13 +280,42 @@ struct Note: Identifiable, Hashable { } /// Title shown in the fan / lists, derived from the first non-empty line. + /// Image tokens are stripped out, so a note that opens with a picture is + /// named by its first words rather than by `![image](noty-img://…)`. static func derivedTitle(from body: String) -> String { - let line = body.split(whereSeparator: \.isNewline).first.map(String.init) ?? "" - var clean = line.trimmingCharacters(in: .whitespaces) - .replacingOccurrences(of: "^#{1,6}\\s*", with: "", options: .regularExpression) - clean = Tasks.stripped(clean) - if clean.isEmpty { return "" } - return clean.count > 60 ? String(clean.prefix(60)) + "…" : clean + for raw in body.split(whereSeparator: \.isNewline) { + var clean = strippingImageTokens(String(raw)) + .trimmingCharacters(in: .whitespaces) + .replacingOccurrences(of: "^#{1,6}\\s*", with: "", options: .regularExpression) + clean = Tasks.stripped(clean) + if clean.isEmpty { continue } + return clean.count > 60 ? String(clean.prefix(60)) + "…" : clean + } + return "" + } + + /// The line with every image token removed. A token-only line becomes "", + /// and a mixed line keeps just its words — one-line summaries (title, + /// preview) never leak the raw `noty-img` URL into the UI. + static func strippingImageTokens(_ line: String) -> String { + let tokens = ImageStore.tokens(in: line) + guard !tokens.isEmpty else { return line } + var ns = line as NSString + for token in tokens.reversed() { + ns = ns.replacingCharacters(in: token.range, with: "") as NSString + } + return ns as String + } + + /// True when a line holds nothing but one image token. One-line summaries + /// (title, preview) skip these so a leading picture does not leak its raw + /// `noty-img` URL into the UI. + static func isImageTokenLine(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return false } + let tokens = ImageStore.tokens(in: trimmed) + guard tokens.count == 1, let t = tokens.first else { return false } + return t.range.location == 0 && t.range.length == (trimmed as NSString).length } var displayTitle: String { @@ -312,9 +341,12 @@ struct Note: Identifiable, Hashable { /// Collapsed snippet used as list subtitle. /// If the note has an independent custom title, the first line of the body is /// part of the content and included in the preview; otherwise the first line - /// is skipped because it already serves as the title. + /// is skipped because it already serves as the title. Image tokens are + /// stripped first, so the skipped/taken lines line up with `derivedTitle`. var preview: String { let lines = body.split(whereSeparator: \.isNewline).map(String.init) + .map(Self.strippingImageTokens) + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } let rest = (hasCustomTitle ? lines : Array(lines.dropFirst())) .joined(separator: " ") .trimmingCharacters(in: .whitespaces) diff --git a/Sources/DeckViews.swift b/Sources/DeckViews.swift index 71758fa..47aaf65 100644 --- a/Sources/DeckViews.swift +++ b/Sources/DeckViews.swift @@ -604,6 +604,8 @@ struct NotePreviewCard: View { } let lines = note.body.split(whereSeparator: \.isNewline).map(String.init) + .map(Note.strippingImageTokens) + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } let previewLines = Array((note.hasCustomTitle ? lines : Array(lines.dropFirst())).prefix(4)) if !previewLines.isEmpty { VStack(alignment: .leading, spacing: 3) { diff --git a/Sources/EditorStyleEngine.swift b/Sources/EditorStyleEngine.swift index 0e4550e..7049566 100644 --- a/Sources/EditorStyleEngine.swift +++ b/Sources/EditorStyleEngine.swift @@ -100,6 +100,7 @@ enum EditorStyleEngine { static func apply(to textView: NSTextView, ranges: [NSRange], revealing activeLine: NSRange?, + forceRevealImageID: String? = nil, ink: NSColor, size: CGFloat, markdownEnabled: Bool, @@ -136,7 +137,8 @@ enum EditorStyleEngine { let fragment = storage.mutableString.substring(with: range) if markdownEnabled { markdown(storage, fragment, offset: range.location, ink: ink, - size: size, revealing: activeLine, bodyFont: bodyFont) + size: size, revealing: activeLine, + forceRevealImageID: forceRevealImageID, bodyFont: bodyFont) } styleCompletedTasks(storage, fragment, offset: range.location, ink: ink, isCompletedTask: isCompletedTask) @@ -187,6 +189,7 @@ enum EditorStyleEngine { private static func markdown(_ storage: NSTextStorage, _ fragment: String, offset: Int, ink: NSColor, size: CGFloat, revealing activeLine: NSRange?, + forceRevealImageID: String?, bodyFont: @escaping FontProvider) { let local = fragment as NSString let full = NSRange(location: 0, length: local.length) @@ -219,6 +222,22 @@ enum EditorStyleEngine { } } + // Image tokens NEVER reveal on the caret line — a note should read as + // a note, not as markup. They hide whole and are drawn as overlays by + // NoteImages; the one exception is the delete-confirmation reveal + // TaskTextView drives via forceRevealImageID. Styled first because + // `![image](noty-img://…)` also matches the link pattern below. + let imageTokens = ImageStore.tokens(in: fragment) + for token in imageTokens { + let range = global(token.range) + if token.id == forceRevealImageID { + storage.addAttribute(.foregroundColor, value: faint, range: range) + } else { + storage.addAttribute(.notyHidden, value: true, range: range) + storage.addAttribute(.foregroundColor, value: faint, range: range) + } + } + each(heading) { match in let level = match.range(at: 1).length let bump = max(1.5, 7 - CGFloat(level) * 1.1) @@ -229,6 +248,10 @@ enum EditorStyleEngine { // [label](url) — the label is what stays; the brackets and the URL go the // way of every other marker. each(link) { match in + // Image tokens were claimed above; the link pattern matches them too. + guard !imageTokens.contains(where: { + NSIntersectionRange($0.range, match.range).length > 0 + }) else { return } let label = match.range(at: 1) storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: global(label)) diff --git a/Sources/ExportImport.swift b/Sources/ExportImport.swift index b0d563c..b9c9650 100644 --- a/Sources/ExportImport.swift +++ b/Sources/ExportImport.swift @@ -4,10 +4,14 @@ import UniformTypeIdentifiers // MARK: - Archive format struct StickyArchive: Codable { - var version = 2 + var version = 3 var app = "Noty" var exported = Date() var notes: [StickyNote] + /// Image id → base64 file bytes for every `noty-img` token used by `notes`, + /// so an archive is self-contained. Optional because archives written + /// before image support (version ≤ 2) simply lack the key. + var images: [String: String]? } struct StickyNote: Codable { @@ -127,7 +131,9 @@ enum Transfer { panel.allowsOtherFileTypes = true guard panel.runModal() == .OK, let url = panel.url else { return } - let archive = StickyArchive(notes: notes.map(StickyNote.init)) + var archive = StickyArchive(notes: notes.map(StickyNote.init)) + let images = archiveImages(for: notes) + archive.images = images.isEmpty ? nil : images let enc = JSONEncoder() enc.outputFormatting = [.prettyPrinted, .sortedKeys] enc.dateEncodingStrategy = .iso8601 @@ -139,12 +145,28 @@ enum Transfer { } } + /// The bytes behind every image token in the exported notes, keyed by id. + /// Ids are stable across export/import, so one image shared by several + /// notes is stored once. + private static func archiveImages(for notes: [Note]) -> [String: String] { + var out: [String: String] = [:] + for n in notes { + for id in ImageStore.referencedIDs(in: n.body) { + guard out[id] == nil, let data = ImageStore.data(id: id) else { continue } + out[id] = data.base64EncodedString() + } + } + return out + } + private static func markdownBody(_ n: Note) -> String { let source = Tasks.toMarkdown(n.body) let lines = source.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) let first = lines.first?.trimmingCharacters(in: .whitespaces) ?? "" // Promote a bare first line to an H1 so the file reads as a document. - if !first.isEmpty && !first.hasPrefix("#") && !first.hasPrefix("- [") { + // An image token must stay verbatim, or the round trip loses the image. + if !first.isEmpty && !first.hasPrefix("#") && !first.hasPrefix("- [") + && !Note.isImageTokenLine(first) { return (["# " + first] + lines.dropFirst()).joined(separator: "\n") } return source @@ -174,7 +196,14 @@ enum Transfer { let dec = JSONDecoder() dec.dateDecodingStrategy = .iso8601 if let archive = try? dec.decode(StickyArchive.self, from: data) { - incoming += archive.notes.map(\.note) + let remapped = restoreImages(archive.images ?? [:]) + incoming += archive.notes.map { sticky in + var n = sticky.note + if !remapped.isEmpty { + n.body = Self.remapImageIDs(in: n.body, mapping: remapped) + } + return n + } } else { failed.append(url.lastPathComponent) } @@ -203,6 +232,46 @@ enum Transfer { // MARK: Helpers + /// Writes an archive's bundled images back to disk. An id already present + /// is the same image (export copies the file unchanged), so it is skipped + /// and its tokens stay valid. `ImageStore.save` always mints a fresh id, + /// so the returned old→new mapping must be applied to the imported bodies. + private static func restoreImages(_ images: [String: String]) -> [String: String] { + var remapped: [String: String] = [:] + for (oldID, b64) in images { + guard ImageStore.data(id: oldID) == nil else { continue } + guard let data = Data(base64Encoded: b64), + let newID = ImageStore.save(data: data, ext: imageExt(for: data)) else { continue } + if newID != oldID { remapped[oldID] = newID } + } + return remapped + } + + /// Rewrites image tokens to the ids the images actually landed under. + /// Tokens are replaced whole (preserving any width) in reverse order so + /// the ranges stay valid against the original string. + private static func remapImageIDs(in body: String, mapping: [String: String]) -> String { + var result = body as NSString + for token in ImageStore.tokens(in: body).reversed() { + guard let newID = mapping[token.id] else { continue } + result = result.replacingCharacters( + in: token.range, + with: ImageStore.token(id: newID, width: token.width)) as NSString + } + return result as String + } + + /// Archives carry only the bytes, but `save(data:ext:)` wants an extension, + /// so sniff the common formats. NSImage sniffs content too, so a wrong + /// guess would only affect the file name, never rendering. + private static func imageExt(for data: Data) -> String { + let head = [UInt8](data.prefix(4)) + if head.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "png" } + if head.starts(with: [0xFF, 0xD8, 0xFF]) { return "jpg" } + if head.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "gif" } + return "png" + } + private static func safeName(_ n: Note) -> String { let raw = n.displayTitle let cleaned = raw.components(separatedBy: CharacterSet(charactersIn: "/\\:*?\"<>|\n\r\t")) diff --git a/Sources/ImageStore.swift b/Sources/ImageStore.swift new file mode 100644 index 0000000..633d245 --- /dev/null +++ b/Sources/ImageStore.swift @@ -0,0 +1,164 @@ +import Foundation +import AppKit + +/// Images referenced from note bodies live as plain files under +/// `Application Support/Noty/Images`, one `.png` (or original ext) per +/// image, with the note text holding only a `![image](noty-img://)` +/// token. Keeping bytes out of SQLite keeps the encrypted database small and +/// lets a single image be shared across notes. +enum ImageStore { + static let scheme = "noty-img" + + /// Created lazily so an app that never embeds an image never makes the folder. + static let directory: URL = { + let dir = Paths.support.appendingPathComponent("Images", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + + /// Ids come from note text, which the user can type freely — anything that + /// is not UUID-ish must never reach the filesystem, or a crafted token + /// could point outside the Images folder. + private static let idAllowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-") + + private static func isValidID(_ id: String) -> Bool { + !id.isEmpty && id.unicodeScalars.allSatisfy { idAllowed.contains($0) } + } + + private static func isValidExt(_ ext: String) -> Bool { + !ext.isEmpty && ext.count <= 10 && ext.allSatisfy { $0.isLetter || $0.isNumber } + } + + private static func fileURL(id: String, ext: String = "png") -> URL { + directory.appendingPathComponent("\(id).\(ext)") + } + + /// Find an image file regardless of extension (imports may keep jpg etc.). + private static func existingFileURL(id: String) -> URL? { + guard isValidID(id) else { return nil } + let plain = fileURL(id: id) + if FileManager.default.fileExists(atPath: plain.path) { return plain } + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: directory.path) else { return nil } + let prefix = id + "." + guard let name = contents.first(where: { $0.hasPrefix(prefix) && !$0.dropFirst(prefix.count).contains(".") }) else { return nil } + return directory.appendingPathComponent(name) + } + + // MARK: - Saving + + /// Absurdly large paste sources (screenshots at 5K, camera dumps) would + /// balloon the folder; anything past this size is scaled down before PNG. + private static let maxSide: CGFloat = 2048 + + static func save(image: NSImage) -> String? { + var source = image + let size = image.size + let longest = max(size.width, size.height) + if longest > maxSide, longest > 0 { + let scale = maxSide / longest + let scaled = NSSize(width: floor(size.width * scale), height: floor(size.height * scale)) + let down = NSImage(size: scaled) + down.lockFocus() + image.draw(in: NSRect(origin: .zero, size: scaled), + from: NSRect(origin: .zero, size: size), + operation: .sourceOver, fraction: 1) + down.unlockFocus() + source = down + } + guard let tiff = source.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) else { return nil } + return save(data: png, ext: "png") + } + + static func save(data: Data, ext: String) -> String? { + let ext = ext.lowercased() + guard !data.isEmpty, isValidExt(ext) else { return nil } + let id = UUID().uuidString + do { + try data.write(to: fileURL(id: id, ext: ext), options: [.atomic]) + return id + } catch { + return nil + } + } + + // MARK: - Tokens + + /// The full markdown token line (no trailing newline). Integral widths are + /// written without decimals so the note text stays tidy. + static func token(id: String, width: CGFloat?) -> String { + guard let w = width, w > 0 else { return "![image](\(scheme)://\(id))" } + let s: String + if w.rounded() == w { + s = String(Int(w)) + } else { + s = String(format: "%.1f", Double(w)) + } + return "![image|\(s)](\(scheme)://\(id))" + } + + /// One shared regex; tokens are matched over the text as NSString so the + /// reported NSRanges line up with NSTextStorage indexing. + private static let tokenRegex: NSRegularExpression = { + // width group first, id group second + let pattern = "!\\[image(?:\\|([0-9]+(?:\\.[0-9]+)?))?\\]\\(\(scheme)://([A-Za-z0-9-]+)\\)" + // try! is safe: the pattern is a compile-time constant. + return try! NSRegularExpression(pattern: pattern) + }() + + static func tokens(in text: String) -> [(id: String, width: CGFloat?, range: NSRange)] { + let ns = text as NSString + let full = NSRange(location: 0, length: ns.length) + return tokenRegex.matches(in: text, range: full).map { m in + var width: CGFloat? + let wr = m.range(at: 1) + if wr.location != NSNotFound { width = CGFloat(Double(ns.substring(with: wr)) ?? 0) } + return (id: ns.substring(with: m.range(at: 2)), width: width, range: m.range) + } + } + + static func referencedIDs(in text: String) -> [String] { + tokens(in: text).map(\.id) + } + + // MARK: - Loading + + /// The editor asks for images on every style pass; decoding PNGs each time + /// would hitch typing, so decoded NSImages are cached by id. NSCache evicts + /// under memory pressure, which a plain dictionary would not. + private static let cache = NSCache() + + static func image(id: String) -> NSImage? { + let key = id as NSString + if let hit = cache.object(forKey: key) { return hit } + guard let url = existingFileURL(id: id), let img = NSImage(contentsOf: url) else { return nil } + cache.setObject(img, forKey: key) + return img + } + + /// Raw file bytes for export; not cached, since export is rare and the + /// caller usually wants the original encoding rather than a re-encoded PNG. + static func data(id: String) -> Data? { + guard let url = existingFileURL(id: id) else { return nil } + return try? Data(contentsOf: url) + } + + // MARK: - Deleting / listing + + static func delete(ids: [String]) { + for id in ids { + cache.removeObject(forKey: id as NSString) + guard let url = existingFileURL(id: id) else { continue } + try? FileManager.default.removeItem(at: url) + } + } + + static func allIDs() -> [String] { + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: directory.path) else { return [] } + return contents.compactMap { name in + let id = (name as NSString).deletingPathExtension + return isValidID(id) ? id : nil + } + } +} diff --git a/Sources/NoteEditor.swift b/Sources/NoteEditor.swift index c77c18f..a563a84 100644 --- a/Sources/NoteEditor.swift +++ b/Sources/NoteEditor.swift @@ -117,6 +117,175 @@ final class HidingLayoutManager: NSLayoutManager { /// toggles it, Return carries the list on, and finished lines get struck through. final class TaskTextView: NSTextView { + /// Owns the image overlays and the line-height delegate for image tokens. + /// Installed by NoteTextView.makeNSView; kept on the view so the editor + /// coordinator can refresh it after every style pass. + var imageOverlays: NoteImageOverlayManager? + + /// The image token currently revealed for delete-confirmation, if any. + /// Image markup is never shown just because the caret is near it; the only + /// way to see the path is to press delete at the image, which selects the + /// token text so a second delete removes it and a paste replaces it. + var revealedImageID: String? + + override func deleteBackward(_ sender: Any?) { + // A range selection means the user deliberately selected content — + // delete it straight away, confirmation is for caret deletions only. + if selectedRange().length == 0, let token = hiddenImageTokenAtDeletionPoint() { + revealedImageID = token.id + setSelectedRange(token.range) + return + } + super.deleteBackward(sender) + } + + /// While a token sits revealed for delete-confirmation its whole range + /// stays selected; typing, Return or pasting would silently replace the + /// markup and orphan the image. Treat those as "keep it": move the caret + /// below the image, which makes the coordinator clear the reveal and + /// re-hide the line. + private func cancelImageRevealIfSelected() -> Bool { + guard let id = revealedImageID, let storage = textStorage, + let token = ImageStore.tokens(in: storage.string).first(where: { $0.id == id }), + selectedRange() == token.range else { return false } + var caret = NSMaxRange(token.range) + if caret < storage.length, (storage.string as NSString).character(at: caret) == 10 { + caret += 1 + } + setSelectedRange(NSRange(location: caret, length: 0)) + return true + } + + override func insertText(_ string: Any) { + if cancelImageRevealIfSelected() { return } + super.insertText(string) + } + + override func insertNewline(_ sender: Any?) { + if cancelImageRevealIfSelected() { return } + super.insertNewline(sender) + } + + /// Symmetric with backspace-after-the-image: forward-deleting INTO a + /// hidden token reveals it for confirmation instead of eating a markup + /// character the user cannot see. + override func deleteForward(_ sender: Any?) { + if selectedRange().length == 0, let storage = textStorage, + let token = ImageStore.tokens(in: storage.string) + .first(where: { $0.range.location == selectedRange().location }), + storage.attribute(.notyHidden, at: token.range.location, + effectiveRange: nil) != nil { + revealedImageID = token.id + setSelectedRange(token.range) + return + } + super.deleteForward(sender) + } + + // MARK: Character-like image navigation + + /// A hidden image token collapses to zero glyphs but should still walk + /// like one character: arrow keys never park the caret inside the dozens + /// of invisible markup characters. Left/right snap to the edge in the + /// direction of travel; up/down land on the nearer edge. + override func moveRight(_ sender: Any?) { + super.moveRight(sender) + snapCaretOutOfHiddenImageToken(edge: .trailing) + } + + override func moveLeft(_ sender: Any?) { + super.moveLeft(sender) + snapCaretOutOfHiddenImageToken(edge: .leading) + } + + override func moveUp(_ sender: Any?) { + super.moveUp(sender) + snapCaretOutOfHiddenImageToken(edge: .nearest) + } + + override func moveDown(_ sender: Any?) { + super.moveDown(sender) + snapCaretOutOfHiddenImageToken(edge: .nearest) + } + + private enum ImageCaretEdge { case leading, trailing, nearest } + + private func snapCaretOutOfHiddenImageToken(edge: ImageCaretEdge) { + guard let storage = textStorage else { return } + let caret = selectedRange() + guard caret.length == 0 else { return } + for token in ImageStore.tokens(in: storage.string) + where caret.location > token.range.location && caret.location < NSMaxRange(token.range) { + guard storage.attribute(.notyHidden, at: token.range.location, + effectiveRange: nil) != nil else { return } + let target: Int + switch edge { + case .leading: target = token.range.location + case .trailing: target = NSMaxRange(token.range) + case .nearest: + let mid = token.range.location + token.range.length / 2 + target = caret.location <= mid ? token.range.location : NSMaxRange(token.range) + } + setSelectedRange(NSRange(location: target, length: 0)) + return + } + } + + /// The hidden image token a caret-backspace would eat into, if any: the + /// caret sits inside/right after the markup (arrow keys can walk it onto + /// the collapsed line), or directly below the image where deleting would + /// consume the token line's newline. + private func hiddenImageTokenAtDeletionPoint() -> (id: String, width: CGFloat?, range: NSRange)? { + guard let storage = textStorage else { return nil } + let caret = selectedRange().location + let ns = storage.string as NSString + for token in ImageStore.tokens(in: storage.string) { + guard token.range.location < storage.length, + storage.attribute(.notyHidden, at: token.range.location, + effectiveRange: nil) != nil else { continue } + if caret > token.range.location, caret <= NSMaxRange(token.range) { return token } + if caret == NSMaxRange(token.range) + 1, caret > 0, + ns.character(at: caret - 1) == 10 { return token } + } + return nil + } + + override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { + super.init(frame: frameRect, textContainer: container) + registerForDraggedTypes([.fileURL, .tiff, .png]) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + registerForDraggedTypes([.fileURL, .tiff, .png]) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + TaskTextView.wireEditMenu() + } + + /// The Edit menu is built in AppDelegate, but the insert action belongs to + /// whichever note has focus, so the item's target is left nil and travels + /// the responder chain to this view. + private static var editMenuWired = false + + private static func wireEditMenu() { + guard !editMenuWired, let mainMenu = NSApp.mainMenu else { return } + guard let edit = mainMenu.items.first(where: { + $0.submenu?.title == L10n.text("menu.edit") + })?.submenu else { return } + let action = #selector(insertImageFromPanel(_:)) + guard !edit.items.contains(where: { $0.action == action }) else { + editMenuWired = true + return + } + edit.addItem(.separator()) + edit.addItem(withTitle: L10n.text("menu.insert_image"), + action: action, keyEquivalent: "") + editMenuWired = true + } + override func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) if toggleBox(at: point) { return } @@ -196,6 +365,91 @@ final class TaskTextView: NSTextView { didChangeText() return true } + + // MARK: Images + + /// A plain-text view validates ⌘V off when the pasteboard holds only image + /// data, which would keep paste(_:) from ever seeing it. Claim the command + /// whenever the clipboard can provide an image. + override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + if (item.action == #selector(paste(_:)) || item.action == #selector(pasteAsPlainText(_:))), + ImagePasteboard.canProvideImage(.general) { + return true + } + return super.validateUserInterfaceItem(item) + } + + /// An image on the pasteboard becomes a token on its own line; everything + /// else keeps the plain-text paste behaviour. + override func paste(_ sender: Any?) { + if cancelImageRevealIfSelected() { return } + let ids = ImagePasteboard.imageIDs(from: .general) + guard !ids.isEmpty else { + super.paste(sender) + return + } + insertImageTokens(ids.map { ImageStore.token(id: $0, width: nil) }) + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + ImagePasteboard.canProvideImage(sender.draggingPasteboard) + ? .copy : super.draggingEntered(sender) + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let ids = ImagePasteboard.imageIDs(from: sender.draggingPasteboard) + guard !ids.isEmpty else { return super.performDragOperation(sender) } + let point = convert(sender.draggingLocation, from: nil) + setSelectedRange(NSRange(location: characterIndexForInsertion(at: point), length: 0)) + insertImageTokens(ids.map { ImageStore.token(id: $0, width: nil) }) + return true + } + + override func menu(for event: NSEvent) -> NSMenu? { + let menu = super.menu(for: event) ?? NSMenu() + menu.addItem(.separator()) + let item = NSMenuItem(title: L10n.text("menu.insert_image"), + action: #selector(insertImageFromPanel(_:)), + keyEquivalent: "") + item.target = self + menu.addItem(item) + return menu + } + + @objc func insertImageFromPanel(_ sender: Any?) { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.image] + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + guard panel.runModal() == .OK else { return } + let ids = panel.urls.compactMap { ImagePasteboard.saveFile(at: $0) } + insertImageTokens(ids.map { ImageStore.token(id: $0, width: nil) }) + } + + /// Insert each token on its own line at the caret as one undoable edit — + /// the same shouldChangeText / replaceCharacters / didChangeText pattern + /// as EditorBridge.toggleTaskLine, so undo and the style pipeline see a + /// normal text change. The caret lands on a fresh line BELOW the token: + /// leaving it on the token's own line would trip the caret-line reveal and + /// show raw markup instead of the image the user just dropped in. + func insertImageTokens(_ tokens: [String]) { + guard !tokens.isEmpty, let storage = textStorage else { return } + let ns = storage.string as NSString + var range = selectedRange() + if range.location == NSNotFound { range = NSRange(location: ns.length, length: 0) } + range = NSIntersectionRange(range, NSRange(location: 0, length: ns.length)) + let atLineStart = range.location == 0 || ns.character(at: range.location - 1) == 10 + let atLineEnd = NSMaxRange(range) >= ns.length + || ns.character(at: NSMaxRange(range)) == 10 + var insertion = tokens.joined(separator: "\n") + "\n" + if !atLineStart { insertion = "\n" + insertion } + if !atLineEnd { insertion += "\n" } + guard shouldChangeText(in: range, replacementString: insertion) else { return } + storage.replaceCharacters(in: range, with: insertion) + didChangeText() + setSelectedRange(NSRange(location: range.location + (insertion as NSString).length, + length: 0)) + } } struct NoteTextView: NSViewRepresentable { @@ -270,6 +524,10 @@ struct NoteTextView: NSViewRepresentable { textDirection: textDirection) Self.applyTextDirection(textDirection, to: tv) context.coordinator.attach(to: tv) + let overlays = NoteImageOverlayManager() + overlays.attach(to: tv, scrollView: scroll) + tv.imageOverlays = overlays + overlays.refresh() if autofocus { DispatchQueue.main.async { tv.window?.makeFirstResponder(tv) } } @@ -311,6 +569,7 @@ struct NoteTextView: NSViewRepresentable { EditorStyleEngine.apply(to: tv, ranges: ranges, revealing: activeLine, + forceRevealImageID: (tv as? TaskTextView)?.revealedImageID, ink: ink, size: size, markdownEnabled: markdownEnabled, @@ -382,6 +641,29 @@ struct NoteTextView: NSViewRepresentable { guard !edits.hasPendingEdits else { return } let line = activeLine(in: tv) + + // A revealed (delete-confirmation) image token hides again once the + // caret leaves its markup or the markup stops parsing as a token. + if let taskView = tv as? TaskTextView, let id = taskView.revealedImageID { + let token = ImageStore.tokens(in: taskView.string).first { $0.id == id } + let caret = taskView.selectedRange() + let inside = token.map { t in + // The reveal's whole-token selection counts as inside; a + // bare caret only up to the markup's end, so cancelling + // (caret parked just past it) still re-hides the line. + caret.length > 0 + ? NSIntersectionRange(caret, t.range).length > 0 + : caret.location >= t.range.location && caret.location < NSMaxRange(t.range) + } ?? false + if !inside { + taskView.revealedImageID = nil + let previous = lastLine + lastLine = line + applyIncremental([previous, line], to: tv, invalidateCursors: false) + return + } + } + guard parent.markdownEnabled else { lastLine = line return @@ -434,6 +716,9 @@ struct NoteTextView: NSViewRepresentable { textDirection: parent.textDirection) isApplyingStyles = false lastLine = line + // The hidden-token set may have changed; overlays and reserved line + // heights are rebuilt from the freshly styled attributes. + (tv as? TaskTextView)?.imageOverlays?.refresh() if invalidateCursors { tv.window?.invalidateCursorRects(for: tv) } } @@ -462,6 +747,7 @@ struct NoteTextView: NSViewRepresentable { lastLine = line needsFullPass = false rememberConfiguration() + (tv as? TaskTextView)?.imageOverlays?.refresh() tv.window?.invalidateCursorRects(for: tv) } diff --git a/Sources/NoteImages.swift b/Sources/NoteImages.swift new file mode 100644 index 0000000..abfd1f5 --- /dev/null +++ b/Sources/NoteImages.swift @@ -0,0 +1,500 @@ +import AppKit +import UniformTypeIdentifiers + +// MARK: - Pasteboard / file intake + +/// Turns drag-and-drop and pasteboard payloads into saved image ids. Files keep +/// their original encoding (a JPEG stays a JPEG); raw image data is re-encoded +/// by ImageStore. +enum ImagePasteboard { + + static func isImageFile(_ url: URL) -> Bool { + if let type = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType { + return type.conforms(to: .image) + } + return UTType(filenameExtension: url.pathExtension)?.conforms(to: .image) ?? false + } + + static func saveFile(at url: URL) -> String? { + guard isImageFile(url), let data = try? Data(contentsOf: url) else { return nil } + let ext = url.pathExtension.lowercased() + return ImageStore.save(data: data, ext: ext.isEmpty ? "png" : ext) + } + + /// Save every image the pasteboard carries and return their ids. File URLs + /// win over raw image data: dropping a file from Finder should not funnel + /// its bytes through a TIFF re-encode. + static func imageIDs(from pasteboard: NSPasteboard) -> [String] { + let urls = (pasteboard.readObjects(forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true]) as? [URL]) ?? [] + let fileIDs = urls.compactMap { saveFile(at: $0) } + if !fileIDs.isEmpty { return fileIDs } + if let image = NSImage(pasteboard: pasteboard), let id = ImageStore.save(image: image) { + return [id] + } + return [] + } + + /// Type check only — must not read file bytes the way `imageIDs` does, since + /// it runs on every `draggingEntered`. + static func canProvideImage(_ pasteboard: NSPasteboard) -> Bool { + let urls = (pasteboard.readObjects(forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true]) as? [URL]) ?? [] + if urls.contains(where: isImageFile) { return true } + return pasteboard.canReadObject(forClasses: [NSImage.self], options: nil) + } +} + +// MARK: - Display metrics + +/// How large an image token renders. The width the token records always wins; +/// an un-sized token falls back to the natural width capped so a screenshot can +/// never swallow the whole note. +enum NoteImageMetrics { + /// Air above and below the image inside the inflated line fragment. + static let verticalPadding: CGFloat = 3 + static let minWidth: CGFloat = 40 + static let defaultMaxWidth: CGFloat = 320 + + static func displaySize(id: String, tokenWidth: CGFloat?, containerWidth: CGFloat) + -> (width: CGFloat, height: CGFloat, hasFile: Bool) { + let image = ImageStore.image(id: id) + let natural = image?.size ?? .zero + let usable = natural.width > 0 && natural.height > 0 + let cap = max(minWidth, containerWidth) + let width: CGFloat + if let tokenWidth, tokenWidth > 0 { + width = min(max(minWidth, tokenWidth), cap) + } else if usable { + width = min(natural.width, cap, defaultMaxWidth) + } else { + // Missing file: the placeholder still needs a sensible footprint. + width = min(160, cap) + } + let height = usable ? width * natural.height / natural.width : width * 0.6 + return (width, height, usable) + } +} + +// MARK: - Line-fragment inflation + +/// TextKit 1 consults this delegate for every line fragment. A hidden image +/// token collapses to zero glyphs, which would leave the line one text-line +/// tall with the overlay spilling over the next paragraph — so the token's +/// line is inflated to the image's display height and the text below flows +/// around it. +final class ImageLineLayoutDelegate: NSObject, NSLayoutManagerDelegate { + /// Hidden token character ranges and the size each line must reserve. + /// Rebuilt by the overlay manager after every style pass. + var heights: [(range: NSRange, height: CGFloat, width: CGFloat)] = [] + + /// Fires after layout so overlays can be re-anchored to their fragments. + var onLayoutComplete: () -> Void = {} + + func layoutManager(_ layoutManager: NSLayoutManager, + shouldSetLineFragmentRect lineFragmentRect: UnsafeMutablePointer, + lineFragmentUsedRect: UnsafeMutablePointer, + baselineOffset: UnsafeMutablePointer, + in textContainer: NSTextContainer, + forGlyphRange glyphRange: NSRange) -> Bool { + guard !heights.isEmpty else { return false } + let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, + actualGlyphRange: nil) + for entry in heights where NSIntersectionRange(entry.range, charRange).length > 0 { + var changed = false + let needed = entry.height + NoteImageMetrics.verticalPadding * 2 + if lineFragmentUsedRect.pointee.height < needed { + lineFragmentUsedRect.pointee.size.height = needed + if lineFragmentRect.pointee.height < needed { + lineFragmentRect.pointee.size.height = needed + } + changed = true + } + // The collapsed token is zero glyphs wide; giving its used rect the + // image's width lets the caret rest at the picture's right edge, so + // the image arrow-keys and clicks like one big character. + if lineFragmentUsedRect.pointee.width < entry.width { + lineFragmentUsedRect.pointee.size.width = entry.width + changed = true + } + return changed + } + return false + } + + func layoutManager(_ layoutManager: NSLayoutManager, + didCompleteLayoutFor textContainer: NSTextContainer?, + atEnd layoutFinishedFlag: Bool) { + onLayoutComplete() + } +} + +// MARK: - Resize handle + +/// The grip in an overlay's bottom-right corner. Runs its own event loop so the +/// drag stays smooth while the text reflows under it; reports the pointer in +/// text-view coordinates, once per event, with `finished` on mouse-up. +final class ImageResizeHandle: NSView { + var onDrag: (_ point: NSPoint, _ finished: Bool) -> Void = { _, _ in } + + override var intrinsicContentSize: NSSize { NSSize(width: 16, height: 16) } + + override func mouseDown(with event: NSEvent) { + guard let window, let anchor = superview?.superview else { return } + onDrag(anchor.convert(event.locationInWindow, from: nil), false) + while true { + guard let next = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) else { break } + let point = anchor.convert(next.locationInWindow, from: nil) + onDrag(point, next.type == .leftMouseUp) + if next.type == .leftMouseUp { break } + } + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .resizeLeftRight) + } + + override func draw(_ dirtyRect: NSRect) { + let rect = bounds.insetBy(dx: 3.5, dy: 3.5) + NSColor.black.withAlphaComponent(0.55).setFill() + NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3).fill() + NSColor.white.withAlphaComponent(0.9).setStroke() + let marks = NSBezierPath() + marks.lineWidth = 1 + // Two diagonal ticks read as "drag me" in either language direction. + for inset: CGFloat in [2.5, 5.5] { + marks.move(to: NSPoint(x: rect.maxX - inset, y: rect.minY + 1)) + marks.line(to: NSPoint(x: rect.maxX - 1, y: rect.minY + inset)) + } + marks.stroke() + } +} + +// MARK: - Overlay view + +/// One image token's visual stand-in: the image (or a dashed placeholder when +/// the file is gone), a hover-visible resize grip, and a selection ring. The +/// overlay only consumes clicks on its grip and for selection; everything else +/// in the text view belongs to the text. +final class NoteImageOverlayView: NSView { + let imageID: String + let hasFile: Bool + + var onSelect: (NoteImageOverlayView) -> Void = { _ in } + var onDrag: (NSPoint, Bool) -> Void = { _, _ in } + + private let imageView = NSImageView() + private let placeholderIcon = NSImageView() + fileprivate let handle = ImageResizeHandle() + private var trackingAreaRef: NSTrackingArea? + private(set) var isSelected = false + + init(imageID: String, image: NSImage?) { + self.imageID = imageID + self.hasFile = image != nil + super.init(frame: .zero) + + wantsLayer = true + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.image = image + imageView.isHidden = image == nil + addSubview(imageView) + + placeholderIcon.image = NSImage(systemSymbolName: "photo", + accessibilityDescription: nil) + placeholderIcon.contentTintColor = .secondaryLabelColor + placeholderIcon.isHidden = image != nil + addSubview(placeholderIcon) + + handle.onDrag = { [weak self] point, finished in + self?.onDrag(point, finished) + } + handle.isHidden = true + addSubview(handle) + } + + required init?(coder: NSCoder) { fatalError("overlays are created in code") } + + override var isFlipped: Bool { true } + + func setSelected(_ selected: Bool) { + guard selected != isSelected else { return } + isSelected = selected + needsDisplay = true + handle.isHidden = !selected + } + + override func layout() { + super.layout() + imageView.frame = bounds + let iconSize: CGFloat = 22 + placeholderIcon.frame = NSRect(x: (bounds.width - iconSize) / 2, + y: (bounds.height - iconSize) / 2, + width: iconSize, height: iconSize) + handle.frame = NSRect(x: bounds.width - 18, y: bounds.height - 18, + width: 16, height: 16) + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingAreaRef { removeTrackingArea(trackingAreaRef) } + let area = NSTrackingArea(rect: bounds, + options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], + owner: self) + addTrackingArea(area) + trackingAreaRef = area + } + + override func mouseEntered(with event: NSEvent) { handle.isHidden = false } + override func mouseExited(with event: NSEvent) { handle.isHidden = !isSelected } + + override func mouseDown(with event: NSEvent) { + onSelect(self) + } + + override func draw(_ dirtyRect: NSRect) { + if !hasFile { + // A deleted file must not vanish silently — the token in the text + // still points at it, so the note shows where it used to be. + let rect = bounds.insetBy(dx: 1, dy: 1) + let path = NSBezierPath(roundedRect: rect, xRadius: 6, yRadius: 6) + NSColor.secondaryLabelColor.withAlphaComponent(0.12).setFill() + path.fill() + let dashed = NSBezierPath(roundedRect: rect, xRadius: 6, yRadius: 6) + dashed.setLineDash([4, 3], count: 2, phase: 0) + NSColor.secondaryLabelColor.withAlphaComponent(0.6).setStroke() + dashed.stroke() + } + if isSelected { + let ring = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), + xRadius: 4, yRadius: 4) + ring.lineWidth = 2 + NSColor.controlAccentColor.setStroke() + ring.stroke() + } + } +} + +// MARK: - Overlay manager + +/// Keeps one overlay per hidden image token, anchored to the token's collapsed +/// glyph line. The plaintext token stays the source of truth; everything here +/// is derived view state that can be thrown away and rebuilt from the text. +final class NoteImageOverlayManager: NSObject { + + private struct Slot { + let id: String + let tokenRange: NSRange + var width: CGFloat + var height: CGFloat + let hasFile: Bool + } + + private weak var textView: TaskTextView? + private let layoutDelegate = ImageLineLayoutDelegate() + private var overlays: [String: NoteImageOverlayView] = [:] + private var slots: [String: Slot] = [:] + private var observers: [NSObjectProtocol] = [] + /// ensureLayout can complete layout synchronously, which re-enters here via + /// the delegate callback; the flag keeps that from recursing. + private var isRepositioning = false + + deinit { + observers.forEach { NotificationCenter.default.removeObserver($0) } + } + + func attach(to textView: TaskTextView, scrollView: NSScrollView) { + self.textView = textView + textView.layoutManager?.delegate = layoutDelegate + layoutDelegate.onLayoutComplete = { [weak self] in self?.reposition() } + + // Scrolling moves every overlay; resizing re-wraps the text and can + // change display widths (they are capped by the container), so a frame + // change rebuilds metrics while a pure scroll only re-anchors. + let clip = scrollView.contentView + clip.postsBoundsChangedNotifications = true + clip.postsFrameChangedNotifications = true + let center = NotificationCenter.default + observers.append(center.addObserver(forName: NSView.boundsDidChangeNotification, + object: clip, queue: .main) { [weak self] _ in + self?.reposition() + }) + observers.append(center.addObserver(forName: NSView.frameDidChangeNotification, + object: clip, queue: .main) { [weak self] _ in + self?.refresh() + }) + } + + /// Called by the editor coordinator after each style pass: the hidden set + /// may have changed, so rebuild the height table, reflow, and re-anchor. + func refresh() { + guard let tv = textView, let storage = tv.textStorage else { return } + let oldRanges = layoutDelegate.heights.map(\.range) + layoutDelegate.heights = currentTokens(in: storage).map { + let size = displaySize(for: $0.token) + return ($0.token.range, size.height, size.width) + } + let changed = oldRanges + layoutDelegate.heights.map(\.range) + for range in changed where range.location != NSNotFound { + tv.layoutManager?.invalidateLayout(forCharacterRange: range, + actualCharacterRange: nil) + } + reposition() + } + + // MARK: Resize + + /// Live: the overlay and the reserved line height follow the pointer; the + /// text is only rewritten on mouse-up, so a cancelled drag costs nothing. + private func handleDrag(_ key: String, point: NSPoint, finished: Bool) { + guard let tv = textView, let overlay = overlays[key], + var slot = slots[key], let storage = tv.textStorage else { return } + let cap = tv.textContainer?.size.width ?? slot.width + let width = min(max(NoteImageMetrics.minWidth, + point.x - overlay.frame.minX), max(NoteImageMetrics.minWidth, cap)) + guard abs(width - slot.width) > 0.25, slot.height > 0 else { return } + let height = width * slot.height / slot.width + slot.width = width + slot.height = height + slots[key] = slot + overlay.frame.size = NSSize(width: width, height: height) + + if !finished { + layoutDelegate.heights = layoutDelegate.heights.map { + $0.range == slot.tokenRange + ? (range: $0.range, height: height, width: width) : $0 + } + tv.layoutManager?.invalidateLayout(forCharacterRange: slot.tokenRange, + actualCharacterRange: nil) + return + } + commitWidth(width, for: slot, in: tv, storage: storage) + } + + /// Rewrite the token's width field as one undoable edit. The token carries + /// its own size, so a resize survives restarts and rides through export. + private func commitWidth(_ width: CGFloat, for slot: Slot, + in tv: TaskTextView, storage: NSTextStorage) { + // The drag reflowed the text; re-find the token by id near its last + // known spot rather than trusting the stale range. + let tokens = ImageStore.tokens(in: storage.string) + guard let token = tokens.filter({ $0.id == slot.id }) + .min(by: { abs($0.range.location - slot.tokenRange.location) + < abs($1.range.location - slot.tokenRange.location) }) else { return } + let replacement = ImageStore.token(id: token.id, width: width.rounded()) + guard (replacement as NSString) != (storage.string as NSString).substring(with: token.range) as NSString, + tv.shouldChangeText(in: token.range, replacementString: replacement) else { return } + let selection = tv.selectedRange() + storage.replaceCharacters(in: token.range, with: replacement) + tv.didChangeText() + let delta = (replacement as NSString).length - token.range.length + if delta != 0, selection.location > NSMaxRange(token.range) { + tv.setSelectedRange(NSRange(location: selection.location + delta, + length: selection.length)) + } + } + + // MARK: Anchoring + + private func currentTokens(in storage: NSTextStorage) + -> [(token: (id: String, width: CGFloat?, range: NSRange), hidden: Bool)] { + let length = storage.length + return ImageStore.tokens(in: storage.string).map { token in + let hidden = token.range.location < length + && storage.attribute(.notyHidden, at: token.range.location, + effectiveRange: nil) != nil + return (token, hidden) + }.filter(\.hidden) + } + + private func displaySize(for token: (id: String, width: CGFloat?, range: NSRange)) + -> (width: CGFloat, height: CGFloat, hasFile: Bool) { + let container = textView?.textContainer?.size.width ?? NoteImageMetrics.defaultMaxWidth + return NoteImageMetrics.displaySize(id: token.id, tokenWidth: token.width, + containerWidth: container) + } + + private func reposition() { + guard !isRepositioning else { return } + isRepositioning = true + defer { isRepositioning = false } + + guard let tv = textView, let storage = tv.textStorage, + let lm = tv.layoutManager, let tc = tv.textContainer else { + removeAll() + return + } + + var wanted: [String: (slot: Slot, frame: NSRect)] = [:] + let origin = tv.textContainerOrigin + for entry in currentTokens(in: storage) { + let size = displaySize(for: entry.token) + lm.ensureLayout(for: tc) + let glyphs = lm.glyphRange(forCharacterRange: entry.token.range, + actualCharacterRange: nil) + guard glyphs.length > 0, glyphs.location != NSNotFound else { continue } + let used = lm.lineFragmentUsedRect(forGlyphAt: glyphs.location, + effectiveRange: nil) + guard used.height > 0 else { continue } + let frame = NSRect(x: used.minX + origin.x, + y: used.minY + origin.y + NoteImageMetrics.verticalPadding, + width: size.width, height: size.height) + let key = "\(entry.token.range.location):\(entry.token.id)" + wanted[key] = (Slot(id: entry.token.id, tokenRange: entry.token.range, + width: size.width, height: size.height, + hasFile: size.hasFile), frame) + } + + for (key, overlay) in overlays where wanted[key] == nil { + overlay.removeFromSuperview() + overlays.removeValue(forKey: key) + slots.removeValue(forKey: key) + } + for (key, info) in wanted { + slots[key] = info.slot + if let overlay = overlays[key] { + overlay.frame = info.frame + } else { + let overlay = NoteImageOverlayView(imageID: info.slot.id, + image: info.slot.hasFile + ? ImageStore.image(id: info.slot.id) : nil) + overlay.frame = info.frame + overlay.onSelect = { [weak self] picked in self?.select(picked) } + overlay.onDrag = { [weak self] point, finished in + self?.handleDrag(key, point: point, finished: finished) + } + tv.addSubview(overlay) + overlays[key] = overlay + } + } + } + + private func select(_ picked: NoteImageOverlayView) { + for (_, overlay) in overlays { + overlay.setSelected(overlay === picked) + } + // Clicking the picture also parks the caret right after the token + // line. Without this there is no way to point at an image and press + // delete — the gesture that reveals its markup for confirmation. + guard let key = overlays.first(where: { $0.value === picked })?.key, + let slot = slots[key], + let tv = textView, let storage = tv.textStorage else { return } + let tokens = ImageStore.tokens(in: storage.string) + guard let token = tokens.filter({ $0.id == slot.id }) + .min(by: { abs($0.range.location - slot.tokenRange.location) + < abs($1.range.location - slot.tokenRange.location) }) else { return } + var caret = NSMaxRange(token.range) + if caret < storage.length, (storage.string as NSString).character(at: caret) == 10 { + caret += 1 + } + tv.setSelectedRange(NSRange(location: caret, length: 0)) + tv.window?.makeFirstResponder(tv) + } + + private func removeAll() { + for (_, overlay) in overlays { overlay.removeFromSuperview() } + overlays.removeAll() + slots.removeAll() + layoutDelegate.heights = [] + } +} diff --git a/Sources/NoteStore.swift b/Sources/NoteStore.swift index 4fc0afb..5d6fae5 100644 --- a/Sources/NoteStore.swift +++ b/Sources/NoteStore.swift @@ -21,6 +21,7 @@ final class NoteStore: ObservableObject { private init() { notes = store.load() migrateDerivedTitles() + removeOrphanedImages() if notes.isEmpty { seedWelcomeNote() } } @@ -115,13 +116,19 @@ final class NoteStore: ObservableObject { /// Removes the note but keeps it recoverable for ten seconds. func delete(id: String) { guard let i = notes.firstIndex(where: { $0.id == id }) else { return } + // A second delete before the first undo window closes replaces + // pendingUndo; the earlier note is then unrecoverable, so its images + // are cleaned up now rather than leaked. Done while the new note is + // still in `notes` so an image it shares with the earlier note is not + // mistaken for unreferenced. + finalizePendingDelete() let doomed = notes[i] notes.remove(at: i) store.delete(id: id) pendingUndo = PendingDelete(note: doomed, deadline: Date().addingTimeInterval(10)) undoTimer?.invalidate() undoTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { [weak self] _ in - DispatchQueue.main.async { self?.pendingUndo = nil } + DispatchQueue.main.async { self?.expireUndo() } } } @@ -133,6 +140,33 @@ final class NoteStore: ObservableObject { pendingUndo = nil } + private func expireUndo() { + finalizePendingDelete() + pendingUndo = nil + } + + /// Image cleanup waits out the undo window: the files must survive as long + /// as the note can come back. Ids another note still references are kept — + /// one image file may be shared by several notes. + private func finalizePendingDelete() { + guard let p = pendingUndo else { return } + let doomed = Set(ImageStore.referencedIDs(in: p.note.body)) + guard !doomed.isEmpty else { return } + let stillUsed = Set(notes.flatMap { ImageStore.referencedIDs(in: $0.body) }) + let unreferenced = doomed.subtracting(stillUsed) + if !unreferenced.isEmpty { ImageStore.delete(ids: unreferenced.sorted()) } + } + + /// Quitting inside the undo window strands the pending note's images (the + /// row is already gone from SQLite, so undo cannot survive a relaunch). + /// Sweeping unreferenced files once at launch also covers any leak from a + /// crash between saving an image and persisting the body that uses it. + private func removeOrphanedImages() { + let used = Set(notes.flatMap { ImageStore.referencedIDs(in: $0.body) }) + let orphans = ImageStore.allIDs().filter { !used.contains($0) } + if !orphans.isEmpty { ImageStore.delete(ids: orphans) } + } + /// Move a note `slots` positions up or down the deck, rewriting the order /// column densely so repeated drags cannot drift the values apart. func reorder(id: String, by slots: Int) { diff --git a/Tests/EditorStyleEngineTests.swift b/Tests/EditorStyleEngineTests.swift index 22db8fb..a5dc5e8 100644 --- a/Tests/EditorStyleEngineTests.swift +++ b/Tests/EditorStyleEngineTests.swift @@ -24,6 +24,7 @@ struct EditorStyleEngineTests { testLegacyArchiveDefaultsToAutomaticDirection() testTextDirectionDatabaseMigration() LocalizationTests.run { check($0, $1) } + ImageInteractionTests.run { check($0, $1) } testCustomNoteTitleBehavior() guard failures == 0 else { diff --git a/Tests/ImageInteractionTests.swift b/Tests/ImageInteractionTests.swift new file mode 100644 index 0000000..df43e6f --- /dev/null +++ b/Tests/ImageInteractionTests.swift @@ -0,0 +1,168 @@ +import AppKit + +/// Checks for the image-token interaction model: tokens stay hidden no matter +/// where the caret is, arrow keys cross a token like one character, delete +/// reveals the markup for confirmation, and typing/Return cancels that reveal +/// instead of destroying the token. +enum ImageInteractionTests { + + private static let tokenID = "ABC12345-1111-2222-3333-444455556666" + private static var token: String { ImageStore.token(id: tokenID, width: nil) } + + static func run(_ check: (Bool, String) -> Void) { + tokenStaysHiddenOnCaretLine(check) + forceRevealShowsToken(check) + tokenLineReservesImageSize(check) + arrowKeysSnapAcrossToken(check) + deleteRevealsThenSecondDeleteRemoves(check) + typingCancelsRevealWithoutTouchingToken(check) + titlesAndPreviewsStripTokens(check) + } + + // MARK: Helpers + + private static func makeView(_ source: String) -> TaskTextView { + let storage = NSTextStorage(string: source) + let layout = HidingLayoutManager() + let container = NSTextContainer( + size: NSSize(width: 500, height: CGFloat.greatestFiniteMagnitude)) + layout.addTextContainer(container) + storage.addLayoutManager(layout) + return TaskTextView(frame: NSRect(x: 0, y: 0, width: 500, height: 500), + textContainer: container) + } + + @discardableResult + private static func style(_ tv: NSTextView, revealing: NSRange? = nil, + forceRevealImageID: String? = nil) -> [NSRange] { + EditorStyleEngine.apply(to: tv, + ranges: [NSRange(location: 0, length: tv.textStorage?.length ?? 0)], + revealing: revealing, + forceRevealImageID: forceRevealImageID, + ink: .textColor, + size: 13.5, + markdownEnabled: true, + bodyFont: { NSFont.systemFont(ofSize: $0) }, + isCompletedTask: { _ in false }) + } + + private static func isHidden(_ tv: NSTextView, at location: Int) -> Bool { + tv.textStorage?.attribute(.notyHidden, at: location, effectiveRange: nil) != nil + } + + // MARK: Tests + + /// The caret landing on the token line must NOT reveal the markup. + private static func tokenStaysHiddenOnCaretLine(_ check: (Bool, String) -> Void) { + let text = token + "\n" + let tv = makeView(text) + let tokenLine = (text as NSString).lineRange(for: NSRange(location: 0, length: 0)) + style(tv, revealing: tokenLine) + check(isHidden(tv, at: 0), "image token stays hidden when the caret is on its line") + } + + /// The delete-confirmation path reveals exactly one token by id. + private static func forceRevealShowsToken(_ check: (Bool, String) -> Void) { + let text = token + "\n" + let tv = makeView(text) + style(tv, forceRevealImageID: tokenID) + check(!isHidden(tv, at: 0), "force-revealed token is visible") + style(tv) + check(isHidden(tv, at: 0), "token hides again once the reveal id is gone") + } + + /// The token's line fragment reserves the image's height AND width, so the + /// caret can rest at the picture's right edge. + private static func tokenLineReservesImageSize(_ check: (Bool, String) -> Void) { + let text = token + "\n" + let tv = makeView(text) + style(tv) + guard let layout = tv.layoutManager, let container = tv.textContainer, + let range = ImageStore.tokens(in: text).first?.range else { + check(false, "token parses for layout test") + return + } + let delegate = ImageLineLayoutDelegate() + delegate.heights = [(range: range, height: 200, width: 320)] + layout.delegate = delegate + layout.ensureLayout(for: container) + let used = layout.lineFragmentUsedRect(forGlyphAt: 0, effectiveRange: nil) + check(used.height >= 206, "token line reserves image height, got \(used.height)") + check(used.width >= 320, "token line reserves image width, got \(used.width)") + } + + /// Left/right arrows cross the hidden token as one unit: below line → + /// after image → before image → previous position, and back again. + private static func arrowKeysSnapAcrossToken(_ check: (Bool, String) -> Void) { + let text = token + "\n" + let tv = makeView(text) + style(tv) + let tokenRange = ImageStore.tokens(in: text).first!.range + tv.layoutManager?.ensureLayout(for: tv.textContainer!) + + tv.setSelectedRange(NSRange(location: (text as NSString).length, length: 0)) + tv.moveLeft(nil) + check(tv.selectedRange().location == NSMaxRange(tokenRange), + "left from the line below lands after the image, got \(tv.selectedRange().location)") + tv.moveLeft(nil) + check(tv.selectedRange().location == tokenRange.location, + "next left lands before the image, got \(tv.selectedRange().location)") + tv.moveRight(nil) + check(tv.selectedRange().location == NSMaxRange(tokenRange), + "right from before the image lands after it, got \(tv.selectedRange().location)") + } + + /// First delete reveals (selects) the token; second delete removes it. + private static func deleteRevealsThenSecondDeleteRemoves(_ check: (Bool, String) -> Void) { + let text = token + "\n" + let tv = makeView(text) + style(tv) + let tokenRange = ImageStore.tokens(in: text).first!.range + tv.layoutManager?.ensureLayout(for: tv.textContainer!) + + tv.setSelectedRange(NSRange(location: (text as NSString).length, length: 0)) + tv.deleteBackward(nil) + check(tv.string == text, "first delete leaves the text untouched") + check(tv.selectedRange() == tokenRange, "first delete selects the whole token") + check(tv.revealedImageID == tokenID, "first delete records the revealed id") + + tv.deleteBackward(nil) + check(tv.string == "\n", "second delete removes the token, got \(tv.string.debugDescription)") + } + + /// Typing (or Return/paste, same guard) while the token sits revealed + /// cancels the reveal and keeps the markup intact. + private static func typingCancelsRevealWithoutTouchingToken(_ check: (Bool, String) -> Void) { + let text = token + "\n" + let tv = makeView(text) + style(tv) + tv.layoutManager?.ensureLayout(for: tv.textContainer!) + + tv.setSelectedRange(NSRange(location: (text as NSString).length, length: 0)) + tv.deleteBackward(nil) + tv.insertText(" ") + check(tv.string == text, "typing during reveal does not replace the token") + check(tv.selectedRange().location == (text as NSString).length, + "cancel parks the caret below the image, got \(tv.selectedRange().location)") + + // Same guard on Return. + tv.deleteBackward(nil) + tv.insertNewline(nil) + check(tv.string == text, "Return during reveal does not replace the token") + } + + /// Titles and previews never show the raw path, even on mixed lines. + private static func titlesAndPreviewsStripTokens(_ check: (Bool, String) -> Void) { + let mixed = "call Dana \(token) about the lease" + let title = Note.derivedTitle(from: mixed) + check(!title.contains("noty-img"), "title strips an inline token, got \(title)") + check(title.hasPrefix("call Dana"), "title keeps the words around the token, got \(title)") + + let leading = token + "\nactual content" + check(Note.derivedTitle(from: leading) == "actual content", + "token-only first line is skipped for the title") + + let note = Note(id: "t", title: "", body: mixed, color: 0) + check(!note.preview.contains("noty-img"), "preview strips tokens, got \(note.preview)") + } +} diff --git a/scripts/test-editor.sh b/scripts/test-editor.sh index 2a9f325..402d71f 100755 --- a/scripts/test-editor.sh +++ b/scripts/test-editor.sh @@ -18,8 +18,7 @@ swiftc -parse-as-library -swift-version 5 \ -target "$(uname -m)-apple-macosx15.0" \ -sdk "$SDK" \ "${APP_SOURCES[@]}" \ - "$ROOT/Tests/LocalizationTests.swift" \ - "$ROOT/Tests/EditorStyleEngineTests.swift" \ + "$ROOT"/Tests/*.swift \ -o "$OUT/EditorStyleEngineTests" "$OUT/EditorStyleEngineTests"