Compare commits
5 Commits
794d927df1
...
18586623c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 18586623c3 | |||
| 510cc1df1f | |||
| bfdc1ced7d | |||
| 3dfd9f58a4 | |||
| f6d9d04b74 |
@@ -143,7 +143,13 @@ Notes uses a standard macOS window with system-inherited appearance and only the
|
|||||||
|
|
||||||
Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Delete is focused by default: Return confirms and Escape cancels; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed.
|
Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Delete is focused by default: Return confirms and Escape cancels; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed.
|
||||||
|
|
||||||
The bottom strip applies Markdown headings, bold, italic, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The formatting strip is always visible. Successful autosaves stay unobtrusive; only save, load, or deletion errors appear below the editor.
|
Notes actions are ordered Browse Notes, New Note, Copy as Markdown, Export as Markdown…, then Delete Note last.
|
||||||
|
|
||||||
|
Notes Actions also includes **Copy as Markdown** and **Export as Markdown…**. Both export the whole note, regardless of the selection, without modifying the editor or RTFD autosaves. Clipboard exports replace images with inline-code filename placeholders such as `<photo.png>`. File exports use a native save dialog: notes without images become a single `.md` file; notes with images become a new folder containing `note.md` and the original image files, linked with relative paths. Unsafe or duplicate image filenames are made safe and unique. Image folders must not already exist; a failed export does not leave a partially written folder. Unsupported or unreadable images report an error rather than being dropped.
|
||||||
|
|
||||||
|
The bottom strip applies Markdown headings, bold, italic, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The formatting strip is always visible. Successful autosaves stay unobtrusive; only save, load, deletion, or export errors appear below the editor.
|
||||||
|
|
||||||
|
Markdown highlighting and list alignment update in one formatting transaction per edit, so typing does not expose an intermediate unformatted layout.
|
||||||
|
|
||||||
Note text fades over 16 points at the editor’s scroll edges rather than clipping sharply above the formatting strip. Matching 16-point scroll insets keep the first and last lines clear of the fade when scrolled to either end. The 36-point title header and footer meet the editor without extra vertical gaps.
|
Note text fades over 16 points at the editor’s scroll edges rather than clipping sharply above the formatting strip. Matching 16-point scroll insets keep the first and last lines clear of the fade when scrolled to either end. The 36-point title header and footer meet the editor without extra vertical gaps.
|
||||||
|
|
||||||
@@ -165,6 +171,8 @@ Formatting shortcuts use the same actions as the bottom strip. They apply to the
|
|||||||
|
|
||||||
Edit Markdown directly: headings, bold, italic, strikethrough, links, quotes, inline code, and fenced code blocks render without their recognized formatting delimiters. Heading `#` prefixes, emphasis markers, link wrappers and destinations, quote prefixes, and code fences are hidden rather than deleted. Unmatched or escaped punctuation and literal code remain visible; fence lines retain their line breaks. Copying a selection containing hidden syntax and saving notes preserve the original Markdown. Arrow navigation and typing skip hidden syntax, while the formatting strip still toggles it. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
|
Edit Markdown directly: headings, bold, italic, strikethrough, links, quotes, inline code, and fenced code blocks render without their recognized formatting delimiters. Heading `#` prefixes, emphasis markers, link wrappers and destinations, quote prefixes, and code fences are hidden rather than deleted. Unmatched or escaped punctuation and literal code remain visible; fence lines retain their line breaks. Copying a selection containing hidden syntax and saving notes preserve the original Markdown. Arrow navigation and typing skip hidden syntax, while the formatting strip still toggles it. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
|
||||||
|
|
||||||
|
Typing `# ` through `###### ` at the visible start of an existing heading replaces its level rather than adding to it. Backspace at a heading's visible start removes the preceding newline, leaving a heading at the start of the document unchanged. Removing an empty line above preserves the heading; joining onto nonempty text inherits the preceding paragraph's type (`Text\n## Heading` becomes `TextHeading`). These edits support Undo and retain Markdown serialization inside the existing RTFD autosaves.
|
||||||
|
|
||||||
Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands change only the current line’s indentation, not nested children. Moving an item into or out of a numbered list updates the affected sibling sequences: a new nested sequence starts at 1, an existing sequence continues from its preceding sibling, and later siblings are renumbered in the saved and copied Markdown too. Existing standalone starting numbers and each marker’s delimiter are preserved. Indentation and numbering changes undo together. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time.
|
Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands change only the current line’s indentation, not nested children. Moving an item into or out of a numbered list updates the affected sibling sequences: a new nested sequence starts at 1, an existing sequence continues from its preceding sibling, and later siblings are renumbered in the saved and copied Markdown too. Existing standalone starting numbers and each marker’s delimiter are preserved. Indentation and numbering changes undo together. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time.
|
||||||
|
|
||||||
Checkbox conversion waits for a space or tab after the closing bracket: type `- [ ] `, `- [] `, or `- [x] `. A closing bracket alone remains literal, so typing directly after it does not briefly create and then remove a checkbox. Both `[]` and `[ ]` mean unchecked; toggling a compact `[]` task on produces `[x]`, and toggling it off produces `[ ]`.
|
Checkbox conversion waits for a space or tab after the closing bracket: type `- [ ] `, `- [] `, or `- [x] `. A closing bracket alone remains literal, so typing directly after it does not briefly create and then remove a checkbox. Both `[]` and `[ ]` mean unchecked; toggling a compact `[]` task on produces `[x]`, and toggling it off produces `[ ]`.
|
||||||
@@ -179,6 +187,8 @@ Typing, pasting, and Return at the visible start of an item use the position aft
|
|||||||
|
|
||||||
Fenced code uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) (Highlight.js) for language-aware token colors. Add a language after the opening backticks or tildes, for example `py`, `python`, `swift`, `js`, or `json`. Unlabeled fences use automatic detection; unknown languages remain plain monospace. Highlighting runs entirely offline.
|
Fenced code uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) (Highlight.js) for language-aware token colors. Add a language after the opening backticks or tildes, for example `py`, `python`, `swift`, `js`, or `json`. Unlabeled fences use automatic detection; unknown languages remain plain monospace. Highlighting runs entirely offline.
|
||||||
|
|
||||||
|
Embedded images display with 8-point rounded corners. Rounding only clips their rendering in Notes; saving and copying retain the original image data.
|
||||||
|
|
||||||
Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. No account, network access, or cloud sync is involved. Save failures appear below the editor and prevent switching, closing, or quitting until saving succeeds, preserving the unsaved buffer. The Close button and Command-W dismiss Conjure, including any open popup; leaving Notes or switching destinations removes its popup completely and restores the launcher’s original dimensions.
|
Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. No account, network access, or cloud sync is involved. Save failures appear below the editor and prevent switching, closing, or quitting until saving succeeds, preserving the unsaved buffer. The Close button and Command-W dismiss Conjure, including any open popup; leaving Notes or switching destinations removes its popup completely and restores the launcher’s original dimensions.
|
||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
var browseNotes: (() -> Void)?
|
var browseNotes: (() -> Void)?
|
||||||
var newNote: (() -> Void)?
|
var newNote: (() -> Void)?
|
||||||
var deleteNote: (() -> Void)?
|
var deleteNote: (() -> Void)?
|
||||||
|
var copyNoteMarkdown: (() -> Void)?
|
||||||
|
var exportNoteMarkdown: (() -> Void)?
|
||||||
private enum ChoiceID: Equatable {
|
private enum ChoiceID: Equatable {
|
||||||
case language(String)
|
case language(String)
|
||||||
case note(UUID)
|
case note(UUID)
|
||||||
@@ -86,7 +88,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text),
|
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text),
|
||||||
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"),
|
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"),
|
||||||
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N"),
|
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N"),
|
||||||
ActionEntry(title: "Delete Note", symbol: "trash", color: .systemRed)
|
ActionEntry(title: "Delete Note", symbol: "trash", color: .systemRed),
|
||||||
|
ActionEntry(title: "Copy as Markdown", symbol: "doc.on.doc", color: SunsetPalette.text),
|
||||||
|
ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up", color: SunsetPalette.text)
|
||||||
]
|
]
|
||||||
view = ActionsPanelView()
|
view = ActionsPanelView()
|
||||||
view.wantsLayer = true
|
view.wantsLayer = true
|
||||||
@@ -259,7 +263,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
}
|
}
|
||||||
|
|
||||||
func showNotesActions(relativeTo anchor: NSView) {
|
func showNotesActions(relativeTo anchor: NSView) {
|
||||||
show(title: "Notes", relativeTo: anchor, available: [12, 13, 14],
|
show(title: "Notes", relativeTo: anchor, available: [12, 13, 15, 16, 14],
|
||||||
searchAccessibilityLabel: "Search notes actions")
|
searchAccessibilityLabel: "Search notes actions")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,7 +470,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleCommandKey(_ key: String) -> Bool {
|
private func handleCommandKey(_ key: String) -> Bool {
|
||||||
guard isShown, availableEntries == [12, 13, 14] else { return false }
|
guard isShown, availableEntries == [12, 13, 15, 16, 14] else { return false }
|
||||||
switch key {
|
switch key {
|
||||||
case "p": close(); browseNotes?()
|
case "p": close(); browseNotes?()
|
||||||
case "n": close(); newNote?()
|
case "n": close(); newNote?()
|
||||||
@@ -540,6 +544,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
case 12: browseNotes?()
|
case 12: browseNotes?()
|
||||||
case 13: newNote?()
|
case 13: newNote?()
|
||||||
case 14: deleteNote?()
|
case 14: deleteNote?()
|
||||||
|
case 15: copyNoteMarkdown?()
|
||||||
|
case 16: exportNoteMarkdown?()
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ConjureKit
|
import ConjureKit
|
||||||
import Highlighter
|
import Highlighter
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum NotesAppearance {
|
enum NotesAppearance {
|
||||||
@@ -77,6 +78,8 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
actions.newNote = { [weak self] in self?.newNote() }
|
actions.newNote = { [weak self] in self?.newNote() }
|
||||||
actions.browseNotes = { [weak self] in self?.browseNotes() }
|
actions.browseNotes = { [weak self] in self?.browseNotes() }
|
||||||
actions.deleteNote = { [weak self] in self?.deleteNote() }
|
actions.deleteNote = { [weak self] in self?.deleteNote() }
|
||||||
|
actions.copyNoteMarkdown = { [weak self] in self?.copyAsMarkdown() }
|
||||||
|
actions.exportNoteMarkdown = { [weak self] in self?.exportAsMarkdown() }
|
||||||
let tools = [
|
let tools = [
|
||||||
("textformat.size", "Heading"), ("bold", "Bold"), ("italic", "Italic"),
|
("textformat.size", "Heading"), ("bold", "Bold"), ("italic", "Italic"),
|
||||||
("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"),
|
("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"),
|
||||||
@@ -274,8 +277,6 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
|
|
||||||
func textDidChange(_ notification: Notification) {
|
func textDidChange(_ notification: Notification) {
|
||||||
isDirty = true
|
isDirty = true
|
||||||
MarkdownHighlighting.apply(to: editor)
|
|
||||||
editor.refreshTasks()
|
|
||||||
_ = save()
|
_ = save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +537,51 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
catch { showError(error) }
|
catch { showError(error) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func copyAsMarkdown() {
|
||||||
|
actions.close(animated: false)
|
||||||
|
do {
|
||||||
|
let export = try NoteMarkdownExport(contents: editor.markdownContents)
|
||||||
|
let clipboard = NSPasteboard.general
|
||||||
|
clipboard.clearContents()
|
||||||
|
guard clipboard.setString(export.clipboardText, forType: .string) else {
|
||||||
|
throw CocoaError(.fileWriteUnknown)
|
||||||
|
}
|
||||||
|
} catch { showExportError(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportAsMarkdown() {
|
||||||
|
guard let window = view.window, window.attachedSheet == nil else { return }
|
||||||
|
actions.close(animated: false)
|
||||||
|
do {
|
||||||
|
let export = try NoteMarkdownExport(contents: editor.markdownContents)
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.title = "Export as Markdown"
|
||||||
|
panel.prompt = "Export"
|
||||||
|
panel.canCreateDirectories = true
|
||||||
|
panel.nameFieldStringValue = export.hasImages ? "Note" : "Note.md"
|
||||||
|
if export.hasImages {
|
||||||
|
panel.message = "Create a new folder containing note.md and its images. Choose a folder name that does not already exist."
|
||||||
|
} else {
|
||||||
|
panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText]
|
||||||
|
}
|
||||||
|
panel.beginSheetModal(for: window) { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { self.focusEditor() }
|
||||||
|
guard response == .OK, let url = panel.url else { return }
|
||||||
|
do { try export.write(to: url) }
|
||||||
|
catch { self.showExportError(error) }
|
||||||
|
}
|
||||||
|
} catch { showExportError(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showExportError(_ error: Error) {
|
||||||
|
status.isHidden = false
|
||||||
|
status.stringValue = "Couldn’t export note: \(error.localizedDescription)"
|
||||||
|
status.toolTip = status.stringValue
|
||||||
|
status.textColor = .systemRed
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private func deleteNote() {
|
private func deleteNote() {
|
||||||
guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return }
|
guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return }
|
||||||
actions.close(animated: false)
|
actions.close(animated: false)
|
||||||
@@ -581,8 +627,6 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
editor.undoManager?.removeAllActions()
|
editor.undoManager?.removeAllActions()
|
||||||
editor.isEditable = true
|
editor.isEditable = true
|
||||||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||||||
MarkdownHighlighting.apply(to: editor)
|
|
||||||
editor.refreshTasks()
|
|
||||||
noteTitle.stringValue = note.title
|
noteTitle.stringValue = note.title
|
||||||
status.isHidden = true
|
status.isHidden = true
|
||||||
}
|
}
|
||||||
@@ -619,6 +663,15 @@ private final class NotesView: NSView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class RoundedImageAttachmentCell: NSTextAttachmentCell {
|
||||||
|
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||||
|
NSBezierPath(roundedRect: cellFrame, xRadius: 8, yRadius: 8).addClip()
|
||||||
|
super.draw(withFrame: cellFrame, in: controlView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum MarkdownHighlighting {
|
enum MarkdownHighlighting {
|
||||||
@@ -677,6 +730,9 @@ enum MarkdownHighlighting {
|
|||||||
let size = image.size
|
let size = image.size
|
||||||
let scale = min(1, 460 / max(size.width, 1), 240 / max(size.height, 1))
|
let scale = min(1, 460 / max(size.width, 1), 240 / max(size.height, 1))
|
||||||
if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) }
|
if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) }
|
||||||
|
if !(cell is RoundedImageAttachmentCell) {
|
||||||
|
attachment.attachmentCell = RoundedImageAttachmentCell(imageCell: image)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
storage.removeAttribute(.strikethroughStyle, range: range)
|
storage.removeAttribute(.strikethroughStyle, range: range)
|
||||||
storage.addAttribute(hiddenMarkup, value: false, range: range)
|
storage.addAttribute(hiddenMarkup, value: false, range: range)
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
private static let listPattern = try! NSRegularExpression(
|
private static let listPattern = try! NSRegularExpression(
|
||||||
pattern: "(?m)^([ \\t]*)([-+*]|[0-9]+[.)])([ \\t]+)(?:(\\[([ xX]?)\\])(?=[ \\t]))?"
|
pattern: "(?m)^([ \\t]*)([-+*]|[0-9]+[.)])([ \\t]+)(?:(\\[([ xX]?)\\])(?=[ \\t]))?"
|
||||||
)
|
)
|
||||||
|
private static let headingPrefix = try! NSRegularExpression(pattern: "^#{1,6} ")
|
||||||
|
|
||||||
|
private static func heading(in source: NSString, at position: Int) -> NSRange? {
|
||||||
|
let line = source.lineRange(for: NSRange(location: position, length: 0))
|
||||||
|
guard let match = headingPrefix.firstMatch(in: source.substring(with: line),
|
||||||
|
range: NSRange(location: 0, length: line.length)),
|
||||||
|
!MarkdownHighlighting.codeBlocks(in: source as String).contains(where: {
|
||||||
|
NSIntersectionRange($0.range, line).length > 0
|
||||||
|
}) else { return nil }
|
||||||
|
return NSRange(location: line.location, length: match.range.length)
|
||||||
|
}
|
||||||
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
|
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
|
||||||
private var checkboxes: [NSButton] = []
|
private var checkboxes: [NSButton] = []
|
||||||
private var horizontalSelection: (anchor: Int, head: Int)?
|
private var horizontalSelection: (anchor: Int, head: Int)?
|
||||||
@@ -241,6 +252,8 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
guard isEditable else { return }
|
guard isEditable else { return }
|
||||||
let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true))
|
let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true))
|
||||||
guard range.location <= source.length, NSMaxRange(range) <= source.length else { return }
|
guard range.location <= source.length, NSMaxRange(range) <= source.length else { return }
|
||||||
|
let oldHeading = Self.heading(in: source.string as NSString, at: range.location)
|
||||||
|
var caretPosition = range.location + replacement.length
|
||||||
var existingWhitespace = false
|
var existingWhitespace = false
|
||||||
if typedInput, range.length == 0, replacement.length > 0 {
|
if typedInput, range.length == 0, replacement.length > 0 {
|
||||||
var end = 0
|
var end = 0
|
||||||
@@ -250,7 +263,22 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
existingWhitespace = !tail.isEmpty && tail.allSatisfy { $0 == " " || $0 == "\t" }
|
existingWhitespace = !tail.isEmpty && tail.allSatisfy { $0 == " " || $0 == "\t" }
|
||||||
}
|
}
|
||||||
source.replaceCharacters(in: range, with: replacement)
|
source.replaceCharacters(in: range, with: replacement)
|
||||||
if typedInput, range.length == 0, replacement.length > 0 {
|
// A newly typed prefix at the visible start replaces the paragraph's existing level.
|
||||||
|
if typedInput, range.length == 0, let heading = oldHeading,
|
||||||
|
range.location >= NSMaxRange(heading) {
|
||||||
|
let bodyStart = NSMaxRange(heading)
|
||||||
|
let commandLength = caretPosition - bodyStart
|
||||||
|
if commandLength > 0,
|
||||||
|
let command = Self.headingPrefix.firstMatch(in: (source.string as NSString).substring(
|
||||||
|
with: NSRange(location: bodyStart, length: commandLength)),
|
||||||
|
range: NSRange(location: 0, length: commandLength)),
|
||||||
|
command.range.length == commandLength {
|
||||||
|
source.deleteCharacters(in: heading)
|
||||||
|
caretPosition -= heading.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if typedInput, range.length == 0, replacement.length > 0,
|
||||||
|
caretPosition == range.location + replacement.length {
|
||||||
let line = (source.string as NSString).lineRange(for: NSRange(location: range.location, length: 0))
|
let line = (source.string as NSString).lineRange(for: NSRange(location: range.location, length: 0))
|
||||||
if let match = Self.listPattern.firstMatch(in: source.string, range: line) {
|
if let match = Self.listPattern.firstMatch(in: source.string, range: line) {
|
||||||
var marker = match.range(at: 2)
|
var marker = match.range(at: 2)
|
||||||
@@ -294,18 +322,30 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
}
|
}
|
||||||
let removed = NSRange(location: prefix, length: old.length - prefix - suffix)
|
let removed = NSRange(location: prefix, length: old.length - prefix - suffix)
|
||||||
let inserted = NSRange(location: prefix, length: new.length - prefix - suffix)
|
let inserted = NSRange(location: prefix, length: new.length - prefix - suffix)
|
||||||
|
let structural = semantic || caretPosition != range.location + replacement.length
|
||||||
if removed.length > 0 || inserted.length > 0 {
|
if removed.length > 0 || inserted.length > 0 {
|
||||||
if semantic { breakUndoCoalescing() }
|
if structural { breakUndoCoalescing() }
|
||||||
super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed)
|
super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed)
|
||||||
if semantic { breakUndoCoalescing() }
|
if structural { breakUndoCoalescing() }
|
||||||
}
|
}
|
||||||
refreshTasks()
|
let caret = renderedRange(for: NSRange(location: caretPosition, length: 0))
|
||||||
let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))
|
|
||||||
setSelectedRange(NSRange(location: hiddenBoundary(from: caret.location, forward: true), length: 0))
|
setSelectedRange(NSRange(location: hiddenBoundary(from: caret.location, forward: true), length: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
override func deleteBackward(_ sender: Any?) {
|
override func deleteBackward(_ sender: Any?) {
|
||||||
let selection = selectedRange()
|
let selection = selectedRange()
|
||||||
|
if selection.length == 0 {
|
||||||
|
let source = markdownString as NSString
|
||||||
|
let position = markdownInsertionRange(for: selection).location
|
||||||
|
if let heading = Self.heading(in: source, at: position), position == NSMaxRange(heading) {
|
||||||
|
guard heading.location > 0 else { return }
|
||||||
|
let previous = source.lineRange(for: NSRange(location: heading.location - 1, length: 0))
|
||||||
|
let empty = source.substring(with: previous).trimmingCharacters(in: .newlines).isEmpty
|
||||||
|
replaceMarkdown("", range: NSRange(location: heading.location - 1,
|
||||||
|
length: 1 + (empty ? 0 : heading.length)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
let boundary = hiddenBoundary(from: selection.location, forward: false)
|
let boundary = hiddenBoundary(from: selection.location, forward: false)
|
||||||
if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == boundary }) {
|
if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == boundary }) {
|
||||||
expand(task, backwards: true)
|
expand(task, backwards: true)
|
||||||
@@ -538,16 +578,18 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
checkbox.setAccessibilityLabel(title.isEmpty ? "Task" : title)
|
checkbox.setAccessibilityLabel(title.isEmpty ? "Task" : title)
|
||||||
checkbox.toolTip = title
|
checkbox.toolTip = title
|
||||||
}
|
}
|
||||||
applyListLayout()
|
applyFormatting()
|
||||||
positionCheckboxes()
|
positionCheckboxes()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyListLayout() {
|
private func applyFormatting() {
|
||||||
guard let storage = textStorage, let layoutManager else { return }
|
guard let storage = textStorage, let layoutManager else { return }
|
||||||
let range = NSRange(location: 0, length: storage.length)
|
let range = NSRange(location: 0, length: storage.length)
|
||||||
layoutManager.delegate = self
|
layoutManager.delegate = self
|
||||||
storage.beginEditing()
|
storage.beginEditing()
|
||||||
Self.restoreListLayout(in: storage)
|
Self.restoreListLayout(in: storage)
|
||||||
|
// Publish Markdown and list styles together, never the unindented reset between them.
|
||||||
|
MarkdownHighlighting.apply(to: self)
|
||||||
let markerFont = NotesAppearance.font
|
let markerFont = NotesAppearance.font
|
||||||
let column = ceil(tasks.reduce(CGFloat(22)) { width, task in
|
let column = ceil(tasks.reduce(CGFloat(22)) { width, task in
|
||||||
guard !task.attachment.isTask else { return width }
|
guard !task.attachment.isTask else { return width }
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import AppKit
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct NoteMarkdownExport {
|
||||||
|
public let clipboardText: String
|
||||||
|
public var hasImages: Bool { !images.isEmpty }
|
||||||
|
private let markdown: String
|
||||||
|
private let images: [(name: String, data: Data)]
|
||||||
|
|
||||||
|
public init(contents: NSAttributedString) throws {
|
||||||
|
let source = contents.string as NSString
|
||||||
|
var markdown = ""
|
||||||
|
var clipboard = ""
|
||||||
|
var images: [(name: String, data: Data)] = []
|
||||||
|
var usedNames: Set<String> = ["note.md"]
|
||||||
|
var cursor = 0
|
||||||
|
while cursor < source.length {
|
||||||
|
let range = source.range(of: "\u{fffc}", options: [], range: NSRange(location: cursor, length: source.length - cursor))
|
||||||
|
let end = range.location == NSNotFound ? source.length : range.location
|
||||||
|
let text = source.substring(with: NSRange(location: cursor, length: end - cursor))
|
||||||
|
markdown += text
|
||||||
|
clipboard += text
|
||||||
|
guard range.location != NSNotFound else { break }
|
||||||
|
if let value = contents.attribute(.attachment, at: range.location, effectiveRange: nil) {
|
||||||
|
guard let attachment = value as? NSTextAttachment else { throw ExportError.unreadableAttachment }
|
||||||
|
let (data, type) = try Self.imageData(attachment)
|
||||||
|
let originalName = attachment.fileWrapper?.preferredFilename ?? attachment.fileWrapper?.filename
|
||||||
|
let name = try Self.filename(originalName, type: type, index: images.count + 1, used: &usedNames)
|
||||||
|
var alt = ""
|
||||||
|
var ticks = 0
|
||||||
|
var longestTicks = 0
|
||||||
|
for character in name {
|
||||||
|
if "\\[]`*_<>!&".contains(character) { alt += "\\" }
|
||||||
|
alt.append(character)
|
||||||
|
ticks = character == "`" ? ticks + 1 : 0
|
||||||
|
longestTicks = max(longestTicks, ticks)
|
||||||
|
}
|
||||||
|
guard let target = name.addingPercentEncoding(withAllowedCharacters: Self.linkCharacters) else {
|
||||||
|
throw ExportError.unreadableAttachment
|
||||||
|
}
|
||||||
|
markdown += ")"
|
||||||
|
let fence = String(repeating: "`", count: longestTicks + 1)
|
||||||
|
clipboard += "\(fence)<\(name)>\(fence)"
|
||||||
|
images.append((name, data))
|
||||||
|
} else {
|
||||||
|
markdown += "\u{fffc}"
|
||||||
|
clipboard += "\u{fffc}"
|
||||||
|
}
|
||||||
|
cursor = NSMaxRange(range)
|
||||||
|
}
|
||||||
|
self.markdown = markdown
|
||||||
|
clipboardText = clipboard
|
||||||
|
self.images = images
|
||||||
|
}
|
||||||
|
|
||||||
|
public func write(to url: URL) throws {
|
||||||
|
guard hasImages else {
|
||||||
|
try markdown.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let manager = FileManager.default
|
||||||
|
guard !manager.fileExists(atPath: url.path) else {
|
||||||
|
throw CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: url.path])
|
||||||
|
}
|
||||||
|
let staging = url.deletingLastPathComponent().appendingPathComponent(".conjure-export-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try manager.createDirectory(at: staging, withIntermediateDirectories: false)
|
||||||
|
defer { try? manager.removeItem(at: staging) }
|
||||||
|
try markdown.write(to: staging.appendingPathComponent("note.md"), atomically: false, encoding: .utf8)
|
||||||
|
for image in images {
|
||||||
|
try image.data.write(to: staging.appendingPathComponent(image.name))
|
||||||
|
}
|
||||||
|
try manager.moveItem(at: staging, to: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func imageData(_ attachment: NSTextAttachment) throws -> (Data, UTType) {
|
||||||
|
if let wrapper = attachment.fileWrapper {
|
||||||
|
guard wrapper.isRegularFile, let data = wrapper.regularFileContents,
|
||||||
|
let source = CGImageSourceCreateWithData(data as CFData, nil),
|
||||||
|
let identifier = CGImageSourceGetType(source),
|
||||||
|
let type = UTType(identifier as String), type.conforms(to: .image),
|
||||||
|
CGImageSourceCreateImageAtIndex(source, 0, nil) != nil else {
|
||||||
|
throw ExportError.unreadableAttachment
|
||||||
|
}
|
||||||
|
return (data, type)
|
||||||
|
}
|
||||||
|
guard let image = attachment.image ?? (attachment.attachmentCell as? NSTextAttachmentCell)?.image,
|
||||||
|
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil),
|
||||||
|
let png = NSBitmapImageRep(cgImage: cgImage).representation(using: .png, properties: [:]) else {
|
||||||
|
throw ExportError.unreadableAttachment
|
||||||
|
}
|
||||||
|
return (png, .png)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func filename(_ original: String?, type: UTType, index: Int, used: inout Set<String>) throws -> String {
|
||||||
|
guard let detectedExtension = type.preferredFilenameExtension else { throw ExportError.unreadableAttachment }
|
||||||
|
var stem = "image-\(index)"
|
||||||
|
var ext = detectedExtension
|
||||||
|
if let original, isSafeFilename(original) {
|
||||||
|
let path = original as NSString
|
||||||
|
stem = path.deletingPathExtension
|
||||||
|
if UTType(filenameExtension: path.pathExtension) == type { ext = path.pathExtension }
|
||||||
|
}
|
||||||
|
var name = "\(stem).\(ext)"
|
||||||
|
var suffix = 2
|
||||||
|
while !used.insert(name.folding(options: .caseInsensitive, locale: Self.filenameLocale)).inserted {
|
||||||
|
name = "\(stem)-\(suffix).\(ext)"
|
||||||
|
suffix += 1
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let linkCharacters = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
|
||||||
|
private static let reservedNames = Set(["con", "prn", "aux", "nul"] + (1...9).flatMap { ["com\($0)", "lpt\($0)"] })
|
||||||
|
private static let filenameLocale = Locale(identifier: "en_US_POSIX")
|
||||||
|
|
||||||
|
private static func isSafeFilename(_ name: String) -> Bool {
|
||||||
|
guard !name.isEmpty, name.utf8.count <= 180,
|
||||||
|
!name.hasPrefix("."), !name.hasSuffix("."),
|
||||||
|
name == name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
name.rangeOfCharacter(from: .controlCharacters) == nil,
|
||||||
|
!name.contains("/"), !name.contains("\\"), !name.contains(":") else { return false }
|
||||||
|
let stem = (name as NSString).deletingPathExtension.lowercased()
|
||||||
|
return !reservedNames.contains(stem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ExportError: LocalizedError {
|
||||||
|
case unreadableAttachment
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
"This note contains an unsupported or unreadable image attachment. Nothing was exported."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,16 @@
|
|||||||
- this should keep window placement invariant of corner window sizes
|
- this should keep window placement invariant of corner window sizes
|
||||||
- [x] conjure notes:
|
- [x] conjure notes:
|
||||||
- [x] add action to delete a note (currently selected note)
|
- [x] add action to delete a note (currently selected note)
|
||||||
- [ ] make images have rounded corners
|
- [x] make images have rounded corners
|
||||||
|
- [x] bug: text flashes when inserting characters, which is visually disturbing.
|
||||||
|
- [x] fix heading editing using paragraph semantics:
|
||||||
|
- typing `#{1,6} ` at the visible paragraph start sets the absolute heading level, replacing any existing level; fenced code remains literal.
|
||||||
|
- Backspace at heading start deletes the preceding newline, not the heading prefix; at document start it does nothing.
|
||||||
|
- joining onto nonempty prose inherits the preceding paragraph's type: `Text\n## Heading` becomes `TextHeading`; removing an empty line above preserves the heading.
|
||||||
|
- keep native attributed-text editing, Undo, images, and Markdown serialization; retain RTFD autosaves rather than introducing a full document-model rewrite.
|
||||||
|
- [x] add whole-note Markdown export actions to the Notes Actions menu:
|
||||||
|
- Copy as Markdown: replace images with inline-code placeholders such as `<photo.png>`.
|
||||||
|
- Export as Markdown…: save a single `.md` file without images; with images, create a folder containing the Markdown and image files, using relative links.
|
||||||
|
- exports leave the original note and RTFD autosaves unchanged.
|
||||||
- [ ] the guidelines for centering the application when dragged are blurry on some external monitors.
|
- [ ] the guidelines for centering the application when dragged are blurry on some external monitors.
|
||||||
- track down the cause and fix it.
|
- track down the cause and fix it.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markdownExportPreservesWholeTextAndSnapshotsBeforeWriting() throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
|
||||||
|
let text = "\n # 日本語 🦊 \r\n**bold** and `code`\n- [ ] task\n\u{fffc}\n"
|
||||||
|
let source = NSMutableAttributedString(string: text)
|
||||||
|
source.addAttribute(.attachment, value: NSTextAttachment(), range: NSRange(location: 0, length: 1))
|
||||||
|
let export = try NoteMarkdownExport(contents: source)
|
||||||
|
#expect(!export.hasImages)
|
||||||
|
#expect(export.clipboardText == text)
|
||||||
|
#expect(source.string == text)
|
||||||
|
source.mutableString.setString("Edited after export began")
|
||||||
|
let file = directory.appendingPathComponent("Note.md")
|
||||||
|
try Data("old file".utf8).write(to: file)
|
||||||
|
try export.write(to: file)
|
||||||
|
#expect(try Data(contentsOf: file) == Data(text.utf8))
|
||||||
|
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path) == ["Note.md"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource() throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
let png = try markdownExportPNG()
|
||||||
|
let wrapper = FileWrapper(regularFileWithContents: png)
|
||||||
|
let name = "pixel [x] `1` #%.png"
|
||||||
|
wrapper.preferredFilename = name
|
||||||
|
let originalFilename = wrapper.filename
|
||||||
|
let attachment = NSTextAttachment(fileWrapper: wrapper)
|
||||||
|
let source = NSMutableAttributedString(string: "\n # 🦊\n")
|
||||||
|
source.append(NSAttributedString(attachment: attachment))
|
||||||
|
source.append(NSAttributedString(string: "\n- [ ] todo \n"))
|
||||||
|
let before = NSAttributedString(attributedString: source)
|
||||||
|
let export = try NoteMarkdownExport(contents: source)
|
||||||
|
#expect(export.hasImages)
|
||||||
|
#expect(export.clipboardText == "\n # 🦊\n``<pixel [x] `1` #%.png>``\n- [ ] todo \n")
|
||||||
|
#expect(source.isEqual(to: before))
|
||||||
|
#expect(wrapper.preferredFilename == name)
|
||||||
|
#expect(wrapper.filename == originalFilename)
|
||||||
|
#expect(wrapper.regularFileContents == png)
|
||||||
|
source.mutableString.setString("Changed")
|
||||||
|
wrapper.preferredFilename = "changed.png"
|
||||||
|
try export.write(to: directory)
|
||||||
|
let markdown = "\n # 🦊\n![pixel \\[x\\] \\`1\\` #%.png](pixel%20%5Bx%5D%20%601%60%20%23%25.png)\n- [ ] todo \n"
|
||||||
|
#expect(try String(contentsOf: directory.appendingPathComponent("note.md"), encoding: .utf8) == markdown)
|
||||||
|
#expect(try Data(contentsOf: directory.appendingPathComponent(name)) == png)
|
||||||
|
#expect(Set(try FileManager.default.contentsOfDirectory(atPath: directory.path)) == ["note.md", name])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markdownExportUsesSafeUniqueImageNamesAndReservesMarkdownFile() throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
let png = try markdownExportPNG()
|
||||||
|
let names = ["same.png", "SAME.png", "../escape.png", "note.md", "CON.png", "bad\\name.png"]
|
||||||
|
let source = NSMutableAttributedString(string: "")
|
||||||
|
var wrappers: [FileWrapper] = []
|
||||||
|
for name in names {
|
||||||
|
let wrapper = FileWrapper(regularFileWithContents: png)
|
||||||
|
wrapper.preferredFilename = name
|
||||||
|
wrappers.append(wrapper)
|
||||||
|
source.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: wrapper)))
|
||||||
|
source.append(NSAttributedString(string: "\n"))
|
||||||
|
}
|
||||||
|
let expected = ["same.png", "SAME-2.png", "image-3.png", "note.png", "image-5.png", "image-6.png"]
|
||||||
|
let export = try NoteMarkdownExport(contents: source)
|
||||||
|
#expect(export.clipboardText == expected.map { "`<\($0)>`\n" }.joined())
|
||||||
|
#expect(wrappers.map(\.preferredFilename) == names.map(Optional.some))
|
||||||
|
try export.write(to: directory)
|
||||||
|
#expect(try String(contentsOf: directory.appendingPathComponent("note.md"), encoding: .utf8) == expected.map { ")\n" }.joined())
|
||||||
|
#expect(Set(try FileManager.default.contentsOfDirectory(atPath: directory.path)) == Set(expected + ["note.md"]))
|
||||||
|
for name in expected {
|
||||||
|
#expect(try Data(contentsOf: directory.appendingPathComponent(name)) == png)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markdownExportPreservesExistingDirectoryAndRemovesFailedStagingDirectory() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let existing = root.appendingPathComponent("Existing")
|
||||||
|
try FileManager.default.createDirectory(at: existing, withIntermediateDirectories: true)
|
||||||
|
let marker = existing.appendingPathComponent("keep.txt")
|
||||||
|
let saved = Data("User data".utf8)
|
||||||
|
try saved.write(to: marker)
|
||||||
|
let wrapper = FileWrapper(regularFileWithContents: try markdownExportPNG())
|
||||||
|
wrapper.preferredFilename = "pixel.png"
|
||||||
|
let export = try NoteMarkdownExport(contents: NSAttributedString(attachment: NSTextAttachment(fileWrapper: wrapper)))
|
||||||
|
#expect(throws: (any Error).self) { try export.write(to: existing) }
|
||||||
|
#expect(try Data(contentsOf: marker) == saved)
|
||||||
|
#expect(try FileManager.default.contentsOfDirectory(atPath: existing.path) == ["keep.txt"])
|
||||||
|
let invalidDestination = root.appendingPathComponent(String(repeating: "x", count: 256))
|
||||||
|
#expect(throws: (any Error).self) { try export.write(to: invalidDestination) }
|
||||||
|
#expect(try FileManager.default.contentsOfDirectory(atPath: root.path) == ["Existing"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markdownExportSupportsNativeImageOnlyAttachmentsAndRejectsUnreadableAttachments() throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
let image = try #require(NSImage(data: markdownExportPNG()))
|
||||||
|
let attachment = NSTextAttachment()
|
||||||
|
attachment.attachmentCell = NSTextAttachmentCell(imageCell: image)
|
||||||
|
#expect(attachment.fileWrapper == nil)
|
||||||
|
let export = try NoteMarkdownExport(contents: NSAttributedString(attachment: attachment))
|
||||||
|
#expect(export.clipboardText == "`<image-1.png>`")
|
||||||
|
try export.write(to: directory)
|
||||||
|
let data = try Data(contentsOf: directory.appendingPathComponent("image-1.png"))
|
||||||
|
#expect(NSBitmapImageRep(data: data)?.pixelsWide == 1)
|
||||||
|
#expect(NSBitmapImageRep(data: data)?.pixelsHigh == 1)
|
||||||
|
for wrapper in [FileWrapper(regularFileWithContents: Data("not an image".utf8)), FileWrapper(directoryWithFileWrappers: [:])] {
|
||||||
|
let source = NSAttributedString(attachment: NSTextAttachment(fileWrapper: wrapper))
|
||||||
|
#expect(throws: (any Error).self) { try NoteMarkdownExport(contents: source) }
|
||||||
|
#expect(source.string == "\u{fffc}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func markdownExportPNG() throws -> Data {
|
||||||
|
let bitmap = try #require(NSBitmapImageRep(
|
||||||
|
bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1,
|
||||||
|
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
|
||||||
|
))
|
||||||
|
bitmap.setColor(.red, atX: 0, y: 0)
|
||||||
|
return try #require(bitmap.representation(using: .png, properties: [:]))
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
|
import Testing
|
||||||
|
@testable import Conjure
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let suite = "NotesHeadings.\(UUID().uuidString)"
|
||||||
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||||
|
defer {
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
try? FileManager.default.removeItem(at: directory)
|
||||||
|
}
|
||||||
|
let controller = NotesController(defaults: defaults, directory: directory)
|
||||||
|
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 500),
|
||||||
|
styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
|
window.contentViewController = controller
|
||||||
|
defer { _ = controller.close(); window.orderOut(nil) }
|
||||||
|
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||||
|
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
||||||
|
window.makeFirstResponder(editor)
|
||||||
|
func reset(_ source: String) {
|
||||||
|
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||||
|
MarkdownHighlighting.apply(to: editor)
|
||||||
|
editor.refreshTasks()
|
||||||
|
editor.breakUndoCoalescing()
|
||||||
|
editor.undoManager?.removeAllActions()
|
||||||
|
let title = (editor.string as NSString).range(of: "Title")
|
||||||
|
editor.setSelectedRange(NSRange(location: title.location, length: 0))
|
||||||
|
}
|
||||||
|
for (before, after) in [
|
||||||
|
("## Title", "## Title"),
|
||||||
|
("\n## Title", "## Title"),
|
||||||
|
("Text\n## Title", "TextTitle"),
|
||||||
|
("# Text\n## Title", "# TextTitle"),
|
||||||
|
("Text\n\n## Title", "Text\n## Title")
|
||||||
|
] {
|
||||||
|
reset(before)
|
||||||
|
editor.undoManager?.beginUndoGrouping()
|
||||||
|
editor.deleteBackward(nil)
|
||||||
|
editor.undoManager?.endUndoGrouping()
|
||||||
|
#expect(editor.markdownString == after)
|
||||||
|
if before != after {
|
||||||
|
editor.undoManager?.undo()
|
||||||
|
#expect(editor.markdownString == before)
|
||||||
|
editor.undoManager?.redo()
|
||||||
|
#expect(editor.markdownString == after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for level in 1...6 {
|
||||||
|
reset("### Title")
|
||||||
|
editor.undoManager?.beginUndoGrouping()
|
||||||
|
for character in String(repeating: "#", count: level) + " " {
|
||||||
|
editor.insertText(String(character), replacementRange: editor.selectedRange())
|
||||||
|
}
|
||||||
|
editor.undoManager?.endUndoGrouping()
|
||||||
|
let expected = String(repeating: "#", count: level) + " Title"
|
||||||
|
#expect(editor.markdownString == expected)
|
||||||
|
let title = (editor.string as NSString).range(of: "Title")
|
||||||
|
#expect(editor.selectedRange().location == title.location)
|
||||||
|
let sizes: [CGFloat] = [26, 23, 21, 19, 17, 15]
|
||||||
|
#expect((editor.textStorage?.attribute(.font, at: title.location, effectiveRange: nil) as? NSFont)?.pointSize == sizes[level - 1])
|
||||||
|
editor.undoManager?.undo()
|
||||||
|
#expect(editor.markdownString == "### Title")
|
||||||
|
editor.undoManager?.redo()
|
||||||
|
#expect(editor.markdownString == expected)
|
||||||
|
#expect(controller.save())
|
||||||
|
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
||||||
|
#expect(reloaded.contents.string == expected)
|
||||||
|
}
|
||||||
|
reset("```text\n## Title\n```")
|
||||||
|
editor.insertText("# ", replacementRange: editor.selectedRange())
|
||||||
|
#expect(editor.markdownString == "```text\n## # Title\n```")
|
||||||
|
}
|
||||||
@@ -41,6 +41,119 @@ func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws {
|
|||||||
#expect(reloaded.contents.string == source)
|
#expect(reloaded.contents.string == source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class NotesLayoutProbe: NSLayoutManager {
|
||||||
|
var observeFormatting: ((NSTextStorage) -> Void)?
|
||||||
|
|
||||||
|
override func processEditing(for textStorage: NSTextStorage, edited editMask: NSTextStorageEditActions,
|
||||||
|
range newCharRange: NSRange, changeInLength delta: Int,
|
||||||
|
invalidatedRange invalidatedCharRange: NSRange) {
|
||||||
|
observeFormatting?(textStorage)
|
||||||
|
super.processEditing(for: textStorage, edited: editMask, range: newCharRange,
|
||||||
|
changeInLength: delta, invalidatedRange: invalidatedCharRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func typingDoesNotExposeUnformattedExistingTextToLayout() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let suite = "NotesFlashing.\(UUID().uuidString)"
|
||||||
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||||
|
defer {
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
try? FileManager.default.removeItem(at: directory)
|
||||||
|
}
|
||||||
|
let store = try NotesStore(directory: directory)
|
||||||
|
let id = try store.create()
|
||||||
|
try store.update(id: id, contents: NSAttributedString(string: "# Heading\n- [ ] Task\nPlain"))
|
||||||
|
defaults.set(id.uuidString, forKey: "selectedNote")
|
||||||
|
let controller = NotesController(defaults: defaults, directory: directory)
|
||||||
|
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
|
||||||
|
styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
|
window.contentViewController = controller
|
||||||
|
defer { _ = controller.close(); window.orderOut(nil) }
|
||||||
|
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||||
|
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
||||||
|
let layout = NotesLayoutProbe()
|
||||||
|
try #require(editor.textContainer).replaceLayoutManager(layout)
|
||||||
|
editor.refreshTasks()
|
||||||
|
window.makeFirstResponder(editor)
|
||||||
|
layout.observeFormatting = { storage in
|
||||||
|
let text = storage.string as NSString
|
||||||
|
let heading = text.range(of: "Heading")
|
||||||
|
let task = text.range(of: "Task")
|
||||||
|
#expect((storage.attribute(.font, at: heading.location, effectiveRange: nil) as? NSFont)?.pointSize == 26)
|
||||||
|
#expect((storage.attribute(.paragraphStyle, at: task.location, effectiveRange: nil) as? NSParagraphStyle)?.headIndent == 22)
|
||||||
|
#expect(storage.attribute(MarkdownHighlighting.hiddenMarkup, at: 0, effectiveRange: nil) as? Bool == true)
|
||||||
|
}
|
||||||
|
defer { layout.observeFormatting = nil }
|
||||||
|
editor.insertText("!", replacementRange: NSRange(location: editor.string.utf16.count, length: 0))
|
||||||
|
#expect(editor.markdownString == "# Heading\n- [ ] Task\nPlain!")
|
||||||
|
#expect(try NotesStore(directory: directory).notes.first?.contents.string == editor.markdownString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let image = NSImage(size: NSSize(width: 80, height: 48), flipped: false) { rect in
|
||||||
|
NSColor.red.setFill()
|
||||||
|
rect.fill()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let tiff = try #require(image.tiffRepresentation)
|
||||||
|
let bitmap = try #require(NSBitmapImageRep(data: tiff))
|
||||||
|
let png = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||||
|
let pasteboard = NSPasteboard.withUniqueName()
|
||||||
|
defer { pasteboard.releaseGlobally() }
|
||||||
|
pasteboard.setData(png, forType: .png)
|
||||||
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
|
||||||
|
editor.isRichText = true
|
||||||
|
editor.importsGraphics = true
|
||||||
|
#expect(editor.readSelection(from: pasteboard))
|
||||||
|
|
||||||
|
func checkRoundedDrawing() throws {
|
||||||
|
MarkdownHighlighting.apply(to: editor)
|
||||||
|
let layout = try #require(editor.layoutManager)
|
||||||
|
layout.ensureLayout(for: try #require(editor.textContainer))
|
||||||
|
let output = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 200, pixelsHigh: 120,
|
||||||
|
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
|
||||||
|
let context = try #require(NSGraphicsContext(bitmapImageRep: output))
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||||
|
NSGraphicsContext.current = context
|
||||||
|
context.cgContext.clear(CGRect(x: 0, y: 0, width: 200, height: 120))
|
||||||
|
layout.drawGlyphs(forGlyphRange: layout.glyphRange(forCharacterRange: NSRange(location: 0, length: 1),
|
||||||
|
actualCharacterRange: nil), at: .zero)
|
||||||
|
var left = 200, right = -1, top = 120, bottom = -1
|
||||||
|
for y in 0..<120 {
|
||||||
|
for x in 0..<200 where (output.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.5 {
|
||||||
|
left = min(left, x); right = max(right, x)
|
||||||
|
top = min(top, y); bottom = max(bottom, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try #require(right > left && bottom > top)
|
||||||
|
for x in [left, right] {
|
||||||
|
for y in [top, bottom] {
|
||||||
|
#expect(try #require(output.colorAt(x: x, y: y)).alphaComponent < 0.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let center = try #require(output.colorAt(x: (left + right) / 2, y: (top + bottom) / 2)?.usingColorSpace(.deviceRGB))
|
||||||
|
#expect(center.redComponent > 0.9 && center.greenComponent < 0.1 && center.alphaComponent > 0.9)
|
||||||
|
}
|
||||||
|
try checkRoundedDrawing()
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
let store = try NotesStore(directory: directory)
|
||||||
|
let id = try store.create()
|
||||||
|
try store.update(id: id, contents: editor.markdownContents)
|
||||||
|
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
||||||
|
let attachment = try #require(reloaded.contents.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment)
|
||||||
|
#expect(attachment.fileWrapper?.regularFileContents == png)
|
||||||
|
editor.setMarkdownContents(reloaded.contents)
|
||||||
|
try checkRoundedDrawing()
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() throws {
|
func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
@@ -134,8 +247,6 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
|
|||||||
#expect(try shortcut("b"))
|
#expect(try shortcut("b"))
|
||||||
#expect(editor.markdownString == "fox 🦊")
|
#expect(editor.markdownString == "fox 🦊")
|
||||||
|
|
||||||
let hide = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.toolTip == "Hide formatting" })
|
|
||||||
hide.performClick(nil)
|
|
||||||
#expect(try shortcut("x", shift: true))
|
#expect(try shortcut("x", shift: true))
|
||||||
#expect(editor.markdownString == "~~fox 🦊~~")
|
#expect(editor.markdownString == "~~fox 🦊~~")
|
||||||
#expect(try shortcut("x", shift: true))
|
#expect(try shortcut("x", shift: true))
|
||||||
|
|||||||
Reference in New Issue
Block a user