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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<UUID>)`. 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
Expand Down Expand Up @@ -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
```

Expand Down
1 change: 1 addition & 0 deletions Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "新建便笺";
Expand Down
46 changes: 39 additions & 7 deletions Sources/Core.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions Sources/DeckViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
25 changes: 24 additions & 1 deletion Sources/EditorStyleEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand Down
77 changes: 73 additions & 4 deletions Sources/ExportImport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"))
Expand Down
Loading