refactor: use structured rich text for Notes editing
This commit is contained in:
+19
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "fc870f54e8ae0c2fe51ba1e87667691472a20f609075b305c4117df7d7db9336",
|
"originHash" : "ca20de906df51b7d817aeab261f0e858854d41b957c0169d04e413adab8a05bf",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "highlighterswift",
|
"identity" : "highlighterswift",
|
||||||
@@ -9,6 +9,24 @@
|
|||||||
"revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0",
|
"revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0",
|
||||||
"version" : "3.1.0"
|
"version" : "3.1.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-cmark",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-cmark.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a",
|
||||||
|
"version" : "0.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-markdown",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-markdown",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "ea79e83c8744d2b50b0dc2d5bbd1e857e1253bf9",
|
||||||
|
"version" : "0.6.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
+7
-2
@@ -5,9 +5,14 @@ let package = Package(
|
|||||||
name: "Conjure",
|
name: "Conjure",
|
||||||
platforms: [.macOS(.v14)],
|
platforms: [.macOS(.v14)],
|
||||||
products: [.executable(name: "Conjure", targets: ["Conjure"])],
|
products: [.executable(name: "Conjure", targets: ["Conjure"])],
|
||||||
dependencies: [.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.1.0")],
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.1.0"),
|
||||||
|
.package(url: "https://github.com/swiftlang/swift-markdown", exact: "0.6.0"),
|
||||||
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "ConjureKit", resources: [.process("Resources")]),
|
.target(name: "ConjureKit", dependencies: [
|
||||||
|
.product(name: "Markdown", package: "swift-markdown"),
|
||||||
|
], resources: [.process("Resources")]),
|
||||||
.executableTarget(name: "Conjure", dependencies: [
|
.executableTarget(name: "Conjure", dependencies: [
|
||||||
"ConjureKit", .product(name: "Highlighter", package: "HighlighterSwift"),
|
"ConjureKit", .product(name: "Highlighter", package: "HighlighterSwift"),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ Commands explicitly declare `Application.Command.presentsView`. The shared dispa
|
|||||||
|
|
||||||
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes starts with a compact 570 × 404 layout inspired by Raycast Notes, with rounded corners and a centered muted note title, while using Conjure’s shared warm gradient background, cream text, and amber list markers and caret. Drag a window edge or corner to resize it; Notes remembers its size across reopening and app restarts, independently of other launcher views. The minimum size is 570 × 260, and restored dimensions fit the current screen’s visible area. The title bar offers Close; the bottom-right action pill offers **Browse Notes** and **Actions**, with **New Note** in Actions and on Command-N. Browse Notes (Command-P) opens Conjure’s styled, scrollable searchable picker upwards from the footer, shared with translation language selection—not a native menu. Fuzzy-search note titles, then select with Up/Down, Control-J/K, and Return or by clicking a note. A checkmark marks the current note, which is selected and scrolled into view on opening. Reopening Notes restores the last selection. Each note’s first nonempty line becomes its title; duplicate titles remain independently selectable.
|
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes starts with a compact 570 × 404 layout inspired by Raycast Notes, with rounded corners and a centered muted note title, while using Conjure’s shared warm gradient background, cream text, and amber list markers and caret. Drag a window edge or corner to resize it; Notes remembers its size across reopening and app restarts, independently of other launcher views. The minimum size is 570 × 260, and restored dimensions fit the current screen’s visible area. The title bar offers Close; the bottom-right action pill offers **Browse Notes** and **Actions**, with **New Note** in Actions and on Command-N. Browse Notes (Command-P) opens Conjure’s styled, scrollable searchable picker upwards from the footer, shared with translation language selection—not a native menu. Fuzzy-search note titles, then select with Up/Down, Control-J/K, and Return or by clicking a note. A checkmark marks the current note, which is selected and scrolled into view on opening. Reopening Notes restores the last selection. Each note’s first nonempty line becomes its title; duplicate titles remain independently selectable.
|
||||||
|
|
||||||
Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, while retaining Conjure’s warm palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading’s size, and fenced code is not treated as headings. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance.
|
Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, while retaining Conjure’s warm palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading’s size, and code-block contents remain literal. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -149,9 +149,9 @@ Notes actions are ordered Browse Notes, New Note, Copy as Markdown, Export as Ma
|
|||||||
|
|
||||||
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.
|
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, underline, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Underline uses inline HTML `<u>…</u>` so it survives Markdown export and RTFD autosaves. 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.
|
The bottom strip applies headings, bold, italic, underline, strikethrough, inline code, links, code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting is stored as native attributed text and paragraph metadata, not hidden Markdown characters. Underline exports as `<u>…</u>`. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type returns the paragraphs to body text. 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.
|
Typing, toolbar actions, and Vim share one attributed-document edit and Undo path. Completed shortcuts publish their final formatting and list layout before autosaving; edits do not serialize and reconstruct the document as Markdown.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -172,27 +172,23 @@ Formatting shortcuts use the same actions as the bottom strip. They apply to the
|
|||||||
| Bullet list | Command-Shift-L |
|
| Bullet list | Command-Shift-L |
|
||||||
| Checklist | Command-Shift-T |
|
| Checklist | Command-Shift-T |
|
||||||
|
|
||||||
Edit Markdown directly: headings, bold, italic, underline, strikethrough, links, quotes, and inline code render without their recognized formatting delimiters. Heading `#` prefixes, emphasis markers, underline tags, link wrappers and destinations, and quote prefixes are hidden rather than deleted. Code-block fences and their language labels stay visible, with syntax highlighting inside the block. Inline code uses Conjure’s orange accent text on a lightly tinted inline container; this background is display-only and stays out of saved contents. Unmatched or escaped punctuation and literal code remain visible. 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.
|
Notes uses Notion-style Markdown input shortcuts: completing `**bold**`, `*italic*`, `~~strike~~`, inline backticks, `<u>underline</u>`, or `[label](url)` consumes the syntax and creates formatting on the visible text. At paragraph start, `# ` through `###### ` set the absolute heading level; `> `, `- `, `1. `, and `- [ ] ` create quotes and lists. There are no hidden delimiter positions for the caret to enter or accidentally delete. Unmatched syntax stays literal. Plain-text paste stays literal rather than being parsed as Markdown; rich-text copy/paste between Notes editors preserves semantic formatting and image bytes. Whole-note Markdown export generates normalized syntax rather than preserving the original delimiter spelling.
|
||||||
|
|
||||||
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`). Return at a heading's visible start inserts an empty line above it, keeping its hidden prefix with the heading text, including after Backspace. Both commands keep the caret on the heading's visible line rather than the preceding line. These edits support Undo and retain Markdown serialization inside the existing RTFD autosaves.
|
Backspace at a heading's visible start removes the preceding newline, leaving a heading at the document start unchanged. Removing an empty line above preserves its heading type; joining onto nonempty prose inherits the preceding paragraph's type. Return at a heading's start inserts an empty line above it; Return within or at the end of a heading creates a body paragraph. These operations use the same visible text positions as native selection and support Undo.
|
||||||
|
|
||||||
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 on a bullet, numbered, or task item increases its nesting depth; Shift-Tab decreases it, stopping at the root. Carets and Unicode selections remain at the same text offsets. A new nested numbered sequence starts at 1 and following peers are renumbered. Return continues a nonempty list item, resets a new task to unchecked, and outdents an empty item one level before returning a root-level empty item to body text.
|
||||||
|
|
||||||
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 `[ ]`.
|
Type a space after `[ ]`, `[]`, or `[x]` at a bullet's start to make a task. Checklist buttons toggle semantic checked state without changing text or moving the caret. Bullets and numbers are drawn in the paragraph gutter, not stored as text or synthetic attachments. Arrow keys, selections, typing, and Vim operate on the actual item body. Plain copying copies visible text; **Copy as Markdown** includes the list syntax.
|
||||||
|
|
||||||
When typing a list marker before existing whitespace on an otherwise blank line, the marker stays literal until you explicitly type Space or Tab immediately after it. Existing whitespace does not complete the marker for you. Tab completes a still-literal marker; on an already rendered list item it indents the line. Loading or pasting valid list Markdown still renders it directly.
|
Bullets and checkboxes are centered in their shared gutter; numbered markers are right-aligned on the body text baseline. Wrapped lines share the same text column, including nested items and gutters widened for longer numbers.
|
||||||
|
|
||||||
List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Left/Right crosses a marker and its single required separator (a space or tab) in one visible step, between the marker's start and the item body. Only that separator is hidden: additional spaces and tabs are visible body text with normal cursor stops. Shift-Left/Right uses the same boundaries and preserves the selection anchor when extending or reversing direction. Visible indentation and body whitespace retain normal character stops. Selecting only a token copies only its original marker; a keyboard selection across the marker also includes its required separator. Click a checkbox to toggle completion without moving the selection. Backspace at the start of the body expands the original marker and deletes its final character, preserving the separator; forward Delete immediately before the token expands the marker and deletes its first character. Each edit is one Undo step. Removing an item's preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid list token. Saved notes and copied text retain the original Markdown spelling, numbers, case, and spacing, alongside embedded images; alignment formatting is display-only. Thematic breaks and list syntax inside fenced code or ordinary prose stay literal.
|
Type three backticks, optionally followed by a language such as `swift` or `python`, then Return to enter a code block. The input fence is consumed; code content stays literal and uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) for offline token colors. Return on an empty code paragraph returns to body text; the Code block button also toggles the paragraph type. Unspecified languages use automatic detection, and unknown languages remain plain monospace. Markdown export regenerates fences and language labels.
|
||||||
|
|
||||||
Unordered markers display as `•`; bullets and checkboxes are centered in the shared marker column. Checkboxes align vertically with the text’s cap-height center, using its baseline rather than the attachment frame. Numbered markers display their original number and delimiter, right-aligned before the item text and resting on the same text baseline. Item text and wrapped lines retain their shared alignment, including nested lists and columns widened by longer numbers. The stored and copied Markdown retains the original bullet spelling.
|
|
||||||
|
|
||||||
Typing, pasting, and Return at the visible start of an item use the position after its preserved Markdown separator. Continuing to type after a marker converts therefore keeps the item rendered, rather than inserting characters into the marker syntax.
|
|
||||||
|
|
||||||
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.
|
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.
|
Paste images or drag image files into the editor to embed them locally. Command-Z undoes edits; Command-Shift-Z redoes them. Switching notes clears Undo history so edits cannot spill into another note.
|
||||||
|
|
||||||
|
Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. Each package also contains versioned `ConjureNote.json` metadata for paragraph types and inline marks that RTFD does not preserve by itself. Metadata is validated against the stored text; corrupt or unsupported metadata reports an error instead of being interpreted as legacy Markdown. Older Markdown-backed RTFD notes are converted in memory on load. Their first save preserves the original package in `LegacyBackups/<note-id>.rtfd` before atomically writing the structured package; backup failures prevent migration. No account, network access, or cloud sync is involved. Save failures preserve the unsaved buffer and prevent switching, closing, or quitting until saving succeeds. The Close button and Command-W dismiss Conjure, including open popups; leaving Notes removes its popup and restores the launcher’s original dimensions.
|
||||||
|
|
||||||
### Vim editing
|
### Vim editing
|
||||||
|
|
||||||
@@ -216,7 +212,7 @@ Normal mode uses a translucent, one-character-wide block cursor. Insert mode and
|
|||||||
| Native Undo / Redo | `u` / Control-R; Command-Z / Command-Shift-Z remain available |
|
| Native Undo / Redo | `u` / Control-R; Command-Z / Command-Shift-Z remain available |
|
||||||
| Return to Normal (from Insert/Visual) | Escape or Control-[ |
|
| Return to Normal (from Insert/Visual) | Escape or Control-[ |
|
||||||
|
|
||||||
Counts work before motions and operators, including `3j`, `2dd`, and `2d3w`. Vertical movement uses logical lines rather than wrapped display rows and retains the desired column across shorter lines. Character motions and selections respect composed Unicode characters and skip hidden Markdown syntax. Whole-token operators carry recognized inline formatting delimiters with their visible text; linewise operations include heading and list prefixes. Yank/delete/change use a local unnamed register, preserving attributed text and embedded images for `p`/`P` without replacing the system clipboard. Editing and Undo still use the native text view and RTFD autosaves.
|
Counts work before motions and operators, including `3j`, `2dd`, and `2d3w`. Vertical movement uses logical lines rather than wrapped display rows and retains the desired column across shorter lines. Character motions respect composed Unicode characters and use the actual attributed text offsets. Yank/delete/change use a local unnamed register that preserves formatting and embedded images for `p`/`P` without replacing the system clipboard. A change command and its replacement typing undo together across real key events; focus changes, mode changes, and Command shortcuts finish the insertion group. Vim and ordinary editing use the same document and Undo manager.
|
||||||
|
|
||||||
Escape closes an open picker first. With Vim enabled, Escape returns Insert or Visual mode to Normal; in Normal mode it dismisses Notes, just as when Vim is disabled. Command-W still dismisses the window. Command shortcuts, formatting controls, and native Insert-mode input remain available. Pending commands cancel on focus changes and Command shortcuts. This is a supported Vim subset, not an embedded Vim: search, text objects, named registers, macros, and dot-repeat are not implemented.
|
Escape closes an open picker first. With Vim enabled, Escape returns Insert or Visual mode to Normal; in Normal mode it dismisses Notes, just as when Vim is disabled. Command-W still dismisses the window. Command shortcuts, formatting controls, and native Insert-mode input remain available. Pending commands cancel on focus changes and Command shortcuts. This is a supported Vim subset, not an embedded Vim: search, text objects, named registers, macros, and dot-repeat are not implemented.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ConjureKit
|
import ConjureKit
|
||||||
import Highlighter
|
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -236,66 +235,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
|
|
||||||
|
|
||||||
@objc private func formatNote(_ sender: NSButton) {
|
@objc private func formatNote(_ sender: NSButton) {
|
||||||
guard editor.isEditable else { return }
|
editor.format(sender.tag)
|
||||||
let contents = editor.markdownContents
|
|
||||||
let source = contents.string as NSString
|
|
||||||
var selection = editor.markdownInsertionRange(for: editor.selectedRange())
|
|
||||||
if sender.tag == 6 {
|
|
||||||
selection = source.lineRange(for: selection)
|
|
||||||
if selection.length > 0, source.character(at: NSMaxRange(selection) - 1) == 10 { selection.length -= 1 }
|
|
||||||
}
|
|
||||||
let wrappers = [1: ("**", "**"), 2: ("*", "*"), 3: ("~~", "~~"), 4: ("`", "`"),
|
|
||||||
5: ("[", "](url)"), 6: ("```\n", "\n```"), 11: ("<u>", "</u>")]
|
|
||||||
if let (open, close) = wrappers[sender.tag] {
|
|
||||||
let before = selection.location - open.utf16.count
|
|
||||||
let after = NSMaxRange(selection)
|
|
||||||
var stars = 0
|
|
||||||
if open == "*" {
|
|
||||||
while selection.location > stars, after + stars < source.length,
|
|
||||||
source.character(at: selection.location - stars - 1) == 42,
|
|
||||||
source.character(at: after + stars) == 42 { stars += 1 }
|
|
||||||
}
|
|
||||||
if before >= 0, after + close.utf16.count <= source.length,
|
|
||||||
source.substring(with: NSRange(location: before, length: open.utf16.count)) == open,
|
|
||||||
source.substring(with: NSRange(location: after, length: close.utf16.count)) == close,
|
|
||||||
open != "*" || stars % 2 == 1 {
|
|
||||||
let body = contents.attributedSubstring(from: selection)
|
|
||||||
selection = NSRange(location: before, length: selection.length + open.utf16.count + close.utf16.count)
|
|
||||||
replaceMarkdown(body, range: selection, selection: NSRange(location: before, length: body.length))
|
|
||||||
} else {
|
|
||||||
let body = NSMutableAttributedString(string: open, attributes: editor.typingAttributes)
|
|
||||||
body.append(contents.attributedSubstring(from: selection))
|
|
||||||
body.append(NSAttributedString(string: close, attributes: editor.typingAttributes))
|
|
||||||
replaceMarkdown(body, range: selection,
|
|
||||||
selection: NSRange(location: selection.location + open.utf16.count, length: selection.length))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let prefixes = [0: "# ", 7: "> ", 8: "1. ", 9: "- ", 10: "- [ ] "]
|
|
||||||
guard let prefix = prefixes[sender.tag] else { return }
|
|
||||||
let length = selection.length > 0 && source.character(at: NSMaxRange(selection) - 1) == 10
|
|
||||||
? selection.length - 1 : selection.length
|
|
||||||
let lines = source.lineRange(for: NSRange(location: selection.location, length: length))
|
|
||||||
let body = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: lines))
|
|
||||||
let rows = body.string.components(separatedBy: "\n")
|
|
||||||
var offset = body.length, firstDelta = 0
|
|
||||||
for (index, line) in rows.enumerated().reversed() {
|
|
||||||
offset -= line.utf16.count + (index < rows.count - 1 ? 1 : 0)
|
|
||||||
if index == rows.count - 1, index > 0, line.isEmpty { continue }
|
|
||||||
let indent = String(line.prefix { $0 == " " || $0 == "\t" })
|
|
||||||
let content = String(line.dropFirst(indent.count))
|
|
||||||
let item = Self.listMatch(in: content as NSString)
|
|
||||||
let kind = item.map { $0.range(at: 6).location != NSNotFound ? 10 : $0.range(at: 3).location != NSNotFound ? 8 : 9 }
|
|
||||||
let removing = kind == sender.tag || (item == nil && content.hasPrefix(prefix))
|
|
||||||
let oldPrefix = item?.range.length ?? (removing ? prefix.utf16.count : 0)
|
|
||||||
let newPrefix = removing ? "" : sender.tag == 8 ? "\(index + 1). " : prefix
|
|
||||||
body.replaceCharacters(in: NSRange(location: offset + indent.utf16.count, length: oldPrefix),
|
|
||||||
with: NSAttributedString(string: newPrefix, attributes: editor.typingAttributes))
|
|
||||||
if index == 0 { firstDelta = newPrefix.utf16.count - oldPrefix }
|
|
||||||
}
|
|
||||||
let start = max(lines.location, selection.location + firstDelta)
|
|
||||||
let end = max(start, NSMaxRange(selection) + body.length - lines.length)
|
|
||||||
replaceMarkdown(body, range: lines, selection: NSRange(location: start, length: end - start))
|
|
||||||
}
|
|
||||||
focusEditor()
|
focusEditor()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,203 +246,12 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
_ = save()
|
_ = save()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let listItem = try! NSRegularExpression(
|
|
||||||
pattern: "^([ \\t]*)([-+*]|([0-9]+)([.)]))([ \\t]+|$)(?:\\[([ xX]?)\\]([ \\t]+|$))?"
|
|
||||||
)
|
|
||||||
|
|
||||||
private static func listMatch(in line: NSString) -> NSTextCheckingResult? {
|
|
||||||
guard let item = listItem.firstMatch(in: line as String, range: NSRange(location: 0, length: line.length)) else { return nil }
|
|
||||||
let marker = line.substring(with: item.range(at: 2))
|
|
||||||
// Spaced thematic breaks are not list items or list ancestors.
|
|
||||||
if marker == "-" || marker == "*" {
|
|
||||||
let characters = (line as String).lazy.filter { !$0.isWhitespace }
|
|
||||||
let bullet = marker.first!
|
|
||||||
if characters.count >= 3, characters.allSatisfy({ $0 == bullet }) { return nil }
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func indentationWidth(_ indentation: String) -> Int {
|
|
||||||
indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func outdentLength(_ indentation: String) -> Int {
|
|
||||||
indentation.hasPrefix("\t") ? 1 : indentation.prefix(2).prefix { $0 == " " }.count
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func continuedPrefix(_ item: NSTextCheckingResult, in line: NSString) -> String? {
|
|
||||||
var marker = line.substring(with: item.range(at: 2))
|
|
||||||
if item.range(at: 3).location != NSNotFound {
|
|
||||||
guard let number = UInt64(line.substring(with: item.range(at: 3))), number < UInt64.max else { return nil }
|
|
||||||
marker = "\(number + 1)" + line.substring(with: item.range(at: 4))
|
|
||||||
}
|
|
||||||
var prefix = line.substring(with: item.range(at: 1)) + marker + line.substring(with: item.range(at: 5))
|
|
||||||
if item.range(at: 6).location != NSNotFound {
|
|
||||||
prefix += "[ ]" + line.substring(with: item.range(at: 7))
|
|
||||||
}
|
|
||||||
return prefix
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setSelectionWithUndo(_ selection: NSRange) {
|
|
||||||
let previous = editor.selectedRange()
|
|
||||||
editor.undoManager?.registerUndo(withTarget: self) { $0.setSelectionWithUndo(previous) }
|
|
||||||
editor.setSelectedRange(selection)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func replaceListMarkdown(_ replacement: String, range: NSRange, selection: NSRange) {
|
|
||||||
replaceMarkdown(NSAttributedString(string: replacement, attributes: editor.typingAttributes), range: range, selection: selection)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func replaceMarkdown(_ replacement: NSAttributedString, range: NSRange, selection: NSRange) {
|
|
||||||
editor.undoManager?.beginUndoGrouping()
|
|
||||||
// Native text undo restores its diff boundary, not the user's selection.
|
|
||||||
setSelectionWithUndo(editor.selectedRange())
|
|
||||||
editor.replaceMarkdown(replacement, range: range)
|
|
||||||
setSelectionWithUndo(editor.renderedRange(for: selection))
|
|
||||||
editor.undoManager?.endUndoGrouping()
|
|
||||||
}
|
|
||||||
|
|
||||||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
|
||||||
let newline = commandSelector == #selector(NSResponder.insertNewline(_:))
|
|
||||||
let indenting = commandSelector == #selector(NSResponder.insertTab(_:))
|
|
||||||
let outdenting = commandSelector == #selector(NSResponder.insertBacktab(_:))
|
|
||||||
guard textView === editor, editor.isEditable, newline || indenting || outdenting else { return false }
|
|
||||||
let source = editor.markdownString as NSString
|
|
||||||
let selection = editor.markdownInsertionRange(for: editor.selectedRange())
|
|
||||||
var start = 0, end = 0
|
|
||||||
source.getLineStart(&start, end: nil, contentsEnd: &end,
|
|
||||||
for: NSRange(location: selection.location, length: 0))
|
|
||||||
let line = source.substring(with: NSRange(location: start, length: end - start)) as NSString
|
|
||||||
guard let item = Self.listMatch(in: line), NSMaxRange(selection) <= end else { return false }
|
|
||||||
let codeBlocks = MarkdownHighlighting.codeBlocks(in: source as String)
|
|
||||||
guard !codeBlocks.contains(where: {
|
|
||||||
NSIntersectionRange($0.range, NSRange(location: start, length: end - start)).length > 0
|
|
||||||
}) else { return false }
|
|
||||||
let indent = line.substring(with: item.range(at: 1))
|
|
||||||
if indenting {
|
|
||||||
let marker = editor.renderedRange(for:
|
|
||||||
NSRange(location: start + item.range(at: 2).location, length: 0)).location
|
|
||||||
// A literal marker still needs native Tab to complete its separator.
|
|
||||||
guard editor.textStorage?.attribute(.attachment, at: marker, effectiveRange: nil) is NSTextAttachment else { return false }
|
|
||||||
}
|
|
||||||
if indenting || outdenting {
|
|
||||||
let removed = outdenting ? Self.outdentLength(indent) : 0
|
|
||||||
guard indenting || removed > 0 else { return true }
|
|
||||||
let replacement = indenting ? " " : ""
|
|
||||||
let delta = replacement.utf16.count - removed
|
|
||||||
var selectedStart = max(start, selection.location + delta)
|
|
||||||
var selectedEnd = max(start, NSMaxRange(selection) + delta)
|
|
||||||
func isListLine(_ range: NSRange) -> Bool {
|
|
||||||
Self.listMatch(in: source.substring(with: range) as NSString) != nil
|
|
||||||
&& !codeBlocks.contains { NSIntersectionRange($0.range, range).length > 0 }
|
|
||||||
}
|
|
||||||
var block = source.lineRange(for: NSRange(location: start, length: 0))
|
|
||||||
while block.location > 0 {
|
|
||||||
let previous = source.lineRange(for: NSRange(location: block.location - 1, length: 0))
|
|
||||||
guard isListLine(previous) else { break }
|
|
||||||
block = NSUnionRange(previous, block)
|
|
||||||
}
|
|
||||||
while NSMaxRange(block) < source.length {
|
|
||||||
let next = source.lineRange(for: NSRange(location: NSMaxRange(block), length: 0))
|
|
||||||
guard isListLine(next) else { break }
|
|
||||||
block = NSUnionRange(block, next)
|
|
||||||
}
|
|
||||||
let body = NSMutableAttributedString(attributedString: editor.markdownContents.attributedSubstring(from: block))
|
|
||||||
body.replaceCharacters(in: NSRange(location: start - block.location, length: removed), with: replacement)
|
|
||||||
let changed = body.string as NSString
|
|
||||||
let oldDepth = Self.indentationWidth(indent)
|
|
||||||
let newDepth = Self.indentationWidth(replacement + (indent as NSString).substring(from: removed))
|
|
||||||
var ancestors: [(depth: Int, number: UInt64?, delimiter: String)] = []
|
|
||||||
var edits: [(range: NSRange, number: String)] = []
|
|
||||||
var oldLevel = true, newLevel = true
|
|
||||||
var offset = 0
|
|
||||||
while offset < changed.length {
|
|
||||||
let row = changed.lineRange(for: NSRange(location: offset, length: 0))
|
|
||||||
let text = changed.substring(with: row) as NSString
|
|
||||||
guard let match = Self.listMatch(in: text) else { break }
|
|
||||||
let depth = Self.indentationWidth(text.substring(with: match.range(at: 1)))
|
|
||||||
if offset + block.location > start {
|
|
||||||
if depth < oldDepth { oldLevel = false }
|
|
||||||
if depth < newDepth { newLevel = false }
|
|
||||||
}
|
|
||||||
while let last = ancestors.last, last.depth > depth { ancestors.removeLast() }
|
|
||||||
let peer = ancestors.last?.depth == depth ? ancestors.removeLast() : nil
|
|
||||||
var number: UInt64?
|
|
||||||
var delimiter = ""
|
|
||||||
if match.range(at: 3).location != NSNotFound {
|
|
||||||
number = UInt64(text.substring(with: match.range(at: 3)))
|
|
||||||
delimiter = text.substring(with: match.range(at: 4))
|
|
||||||
if offset + block.location >= start,
|
|
||||||
(oldLevel && depth == oldDepth) || (newLevel && depth == newDepth) {
|
|
||||||
if let peer, peer.delimiter == delimiter, let previous = peer.number, previous < UInt64.max {
|
|
||||||
number = previous + 1
|
|
||||||
} else if !ancestors.isEmpty || offset + block.location > start {
|
|
||||||
number = 1
|
|
||||||
}
|
|
||||||
if let number, UInt64(text.substring(with: match.range(at: 3))) != number {
|
|
||||||
let digits = match.range(at: 3)
|
|
||||||
edits.append((NSRange(location: offset + digits.location, length: digits.length), String(number)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ancestors.append((depth, number, delimiter))
|
|
||||||
offset = NSMaxRange(row)
|
|
||||||
}
|
|
||||||
for edit in edits.reversed() {
|
|
||||||
body.replaceCharacters(in: edit.range, with: edit.number)
|
|
||||||
let location = block.location + edit.range.location
|
|
||||||
let difference = edit.number.utf16.count - edit.range.length
|
|
||||||
func adjusted(_ position: Int) -> Int {
|
|
||||||
if position <= location { return position }
|
|
||||||
if position < location + edit.range.length { return location + min(position - location, edit.number.utf16.count) }
|
|
||||||
return position + difference
|
|
||||||
}
|
|
||||||
selectedStart = adjusted(selectedStart)
|
|
||||||
selectedEnd = adjusted(selectedEnd)
|
|
||||||
}
|
|
||||||
replaceMarkdown(body, range: block,
|
|
||||||
selection: NSRange(location: selectedStart, length: selectedEnd - selectedStart))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
let markerEnd = item.range(at: 6).location == NSNotFound
|
|
||||||
? NSMaxRange(item.range(at: 2)) : NSMaxRange(item.range(at: 6)) + 1
|
|
||||||
guard selection.location >= start + markerEnd else { return false }
|
|
||||||
let body = line.substring(from: item.range.length)
|
|
||||||
if body.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
||||||
var replacement = indent.isEmpty ? "" : line.substring(from: Self.outdentLength(indent))
|
|
||||||
var previous = start
|
|
||||||
let depth = Self.indentationWidth(indent)
|
|
||||||
while depth > 0, previous > 0 {
|
|
||||||
let range = source.lineRange(for: NSRange(location: previous - 1, length: 0))
|
|
||||||
previous = range.location
|
|
||||||
let ancestor = source.substring(with: range) as NSString
|
|
||||||
if let parent = Self.listMatch(in: ancestor),
|
|
||||||
!codeBlocks.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) {
|
|
||||||
let parentIndent = ancestor.substring(with: parent.range(at: 1))
|
|
||||||
if Self.indentationWidth(parentIndent) < depth {
|
|
||||||
replacement = Self.continuedPrefix(parent, in: ancestor) ?? ""
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} else if !(ancestor as String).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
let leading = (ancestor as String).prefix { $0 == " " || $0 == "\t" }
|
|
||||||
if Self.indentationWidth(String(leading)) < depth { break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
replaceListMarkdown(replacement, range: NSRange(location: start, length: end - start),
|
|
||||||
selection: NSRange(location: start + replacement.utf16.count, length: 0))
|
|
||||||
} else {
|
|
||||||
guard let prefix = Self.continuedPrefix(item, in: line) else { return false }
|
|
||||||
replaceListMarkdown("\n" + prefix, range: selection,
|
|
||||||
selection: NSRange(location: selection.location + 1 + prefix.utf16.count, length: 0))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func save() -> Bool {
|
func save() -> Bool {
|
||||||
guard isDirty, let store, let selectedID else { return true }
|
guard isDirty, let store, let selectedID else { return true }
|
||||||
do {
|
do {
|
||||||
try store.update(id: selectedID, contents: editor.markdownContents)
|
try store.update(id: selectedID, contents: editor.documentContents)
|
||||||
isDirty = false
|
isDirty = false
|
||||||
noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note"
|
noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note"
|
||||||
status.isHidden = true
|
status.isHidden = true
|
||||||
@@ -530,7 +279,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
func handleCommandKey(_ event: NSEvent) -> Bool {
|
func handleCommandKey(_ event: NSEvent) -> Bool {
|
||||||
guard store != nil, view.window?.attachedSheet == nil else { return false }
|
guard store != nil, view.window?.attachedSheet == nil else { return false }
|
||||||
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||||||
if modifiers.contains(.command) { editor.clearVimCommand() }
|
if modifiers.contains(.command) { editor.endInsertionSession(); editor.clearVimCommand() }
|
||||||
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
|
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
|
||||||
if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) {
|
if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) {
|
||||||
guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor,
|
guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor,
|
||||||
@@ -571,7 +320,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
private func copyAsMarkdown() {
|
private func copyAsMarkdown() {
|
||||||
actions.close(animated: false)
|
actions.close(animated: false)
|
||||||
do {
|
do {
|
||||||
let export = try NoteMarkdownExport(contents: editor.markdownContents)
|
let export = try NoteMarkdownExport(contents: editor.documentContents)
|
||||||
let clipboard = NSPasteboard.general
|
let clipboard = NSPasteboard.general
|
||||||
clipboard.clearContents()
|
clipboard.clearContents()
|
||||||
guard clipboard.setString(export.clipboardText, forType: .string) else {
|
guard clipboard.setString(export.clipboardText, forType: .string) else {
|
||||||
@@ -584,7 +333,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
guard let window = view.window, window.attachedSheet == nil else { return }
|
guard let window = view.window, window.attachedSheet == nil else { return }
|
||||||
actions.close(animated: false)
|
actions.close(animated: false)
|
||||||
do {
|
do {
|
||||||
let export = try NoteMarkdownExport(contents: editor.markdownContents)
|
let export = try NoteMarkdownExport(contents: editor.documentContents)
|
||||||
let panel = NSSavePanel()
|
let panel = NSSavePanel()
|
||||||
panel.title = "Export as Markdown"
|
panel.title = "Export as Markdown"
|
||||||
panel.prompt = "Export"
|
panel.prompt = "Export"
|
||||||
@@ -635,7 +384,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
try store.delete(id: id)
|
try store.delete(id: id)
|
||||||
self.selectedID = nil
|
self.selectedID = nil
|
||||||
self.defaults.removeObject(forKey: "selectedNote")
|
self.defaults.removeObject(forKey: "selectedNote")
|
||||||
self.editor.setMarkdownContents(NSAttributedString(string: ""))
|
self.editor.setDocumentContents(NSAttributedString(string: ""))
|
||||||
self.editor.undoManager?.removeAllActions()
|
self.editor.undoManager?.removeAllActions()
|
||||||
self.editor.isEditable = false
|
self.editor.isEditable = false
|
||||||
self.noteTitle.stringValue = "Untitled Note"
|
self.noteTitle.stringValue = "Untitled Note"
|
||||||
@@ -654,7 +403,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
guard let note = store?.notes.first(where: { $0.id == id }) else { return }
|
guard let note = store?.notes.first(where: { $0.id == id }) else { return }
|
||||||
selectedID = id
|
selectedID = id
|
||||||
defaults.set(id.uuidString, forKey: "selectedNote")
|
defaults.set(id.uuidString, forKey: "selectedNote")
|
||||||
editor.setMarkdownContents(note.contents)
|
editor.setDocumentContents(note.contents)
|
||||||
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))
|
||||||
@@ -694,174 +443,3 @@ 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
|
|
||||||
enum MarkdownHighlighting {
|
|
||||||
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
|
|
||||||
static let hiddenMarkup = NSAttributedString.Key("Conjure.hiddenMarkup")
|
|
||||||
static let codeBackground = NSAttributedString.Key("Conjure.codeBackground")
|
|
||||||
private static let inlineCode = try! NSRegularExpression(pattern: "(?<![\\\\`])(?<marker>`+)(?<body>[^`\\n]+)\\k<marker>(?!`)")
|
|
||||||
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
|
|
||||||
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>(?![*_])"), [.font: NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize, weight: .bold)]),
|
|
||||||
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>[*_])(?<body>[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k<marker>(?![*_])"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
|
|
||||||
(try! NSRegularExpression(pattern: "(?<!\\\\)!?\\[(?<body>[^\\]\\n]+)\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
|
|
||||||
(try! NSRegularExpression(pattern: "(?<!\\\\)~~(?<body>\\S(?:[^\\n]*?\\S)?)~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
|
|
||||||
(try! NSRegularExpression(pattern: "(?<!\\\\)<u>(?<body>[^\\n]+?)</u>"), [.underlineStyle: NSUnderlineStyle.single.rawValue]),
|
|
||||||
(try! NSRegularExpression(pattern: "(?m)^(?:>[ \\t]*)+(?<body>[^\\n]+)"), [:]),
|
|
||||||
]
|
|
||||||
private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) (?<body>.+)$")
|
|
||||||
private static let headingFonts = [11.0, 8, 6, 4, 2, 0].map {
|
|
||||||
NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize + $0, weight: .semibold)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let highlighter: Highlighter = {
|
|
||||||
guard let value = Highlighter() else { preconditionFailure("Missing bundled syntax highlighter") }
|
|
||||||
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: NotesAppearance.font.pointSize)
|
|
||||||
return value
|
|
||||||
}()
|
|
||||||
private static let openingFence = try! NSRegularExpression(pattern: "(?m)^ {0,3}(`{3,}|~{3,})([^\\r\\n]*)\\r?\\n")
|
|
||||||
|
|
||||||
static func codeBlocks(in text: String) -> [(range: NSRange, body: NSRange, language: String?)] {
|
|
||||||
let source = text as NSString
|
|
||||||
var blocks: [(NSRange, NSRange, String?)] = []
|
|
||||||
var cursor = 0
|
|
||||||
while let opening = openingFence.firstMatch(in: text, range: NSRange(location: cursor, length: source.length - cursor)) {
|
|
||||||
let fence = source.substring(with: opening.range(at: 1))
|
|
||||||
let marker = NSRegularExpression.escapedPattern(for: String(fence.first!))
|
|
||||||
let closing = try! NSRegularExpression(pattern: "(?m)^ {0,3}\(marker){\(fence.count),}[ \\t]*\\r?$")
|
|
||||||
let start = NSMaxRange(opening.range)
|
|
||||||
let end = closing.firstMatch(in: text, range: NSRange(location: start, length: source.length - start))
|
|
||||||
let bodyEnd = end?.range.location ?? source.length
|
|
||||||
cursor = end.map { NSMaxRange($0.range) } ?? source.length
|
|
||||||
let language = source.substring(with: opening.range(at: 2)).split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
|
||||||
blocks.append((NSRange(location: opening.range.location, length: cursor - opening.range.location),
|
|
||||||
NSRange(location: start, length: bodyEnd - start), language))
|
|
||||||
if cursor == source.length { break }
|
|
||||||
}
|
|
||||||
return blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
static func removeCodeBackground(from contents: NSMutableAttributedString) {
|
|
||||||
let range = NSRange(location: 0, length: contents.length)
|
|
||||||
contents.enumerateAttribute(codeBackground, in: range) { value, run, _ in
|
|
||||||
if value as? Bool == true { contents.removeAttribute(.backgroundColor, range: run) }
|
|
||||||
}
|
|
||||||
contents.removeAttribute(codeBackground, range: range)
|
|
||||||
}
|
|
||||||
// Whole visible tokens carry their delimiters through Vim delete/yank/change operations.
|
|
||||||
static func completeInlineRange(_ selection: NSRange, in contents: NSAttributedString) -> NSRange {
|
|
||||||
var range = selection
|
|
||||||
let full = NSRange(location: 0, length: contents.length)
|
|
||||||
let matches = ([inlineCode] + rules.map(\.0)).flatMap { $0.matches(in: contents.string, range: full) }
|
|
||||||
var previous: NSRange
|
|
||||||
repeat {
|
|
||||||
previous = range
|
|
||||||
for match in matches {
|
|
||||||
let body = match.range(withName: "body")
|
|
||||||
guard body.location != NSNotFound, body.length > 0,
|
|
||||||
body.location > match.range.location, NSMaxRange(body) < NSMaxRange(match.range),
|
|
||||||
range.location <= body.location, NSMaxRange(range) >= NSMaxRange(body),
|
|
||||||
contents.attribute(hiddenMarkup, at: match.range.location, effectiveRange: nil) as? Bool == true
|
|
||||||
else { continue }
|
|
||||||
range = NSUnionRange(range, match.range)
|
|
||||||
}
|
|
||||||
} while range != previous
|
|
||||||
return range
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static func apply(to editor: NSTextView) {
|
|
||||||
guard let storage = editor.textStorage else { return }
|
|
||||||
let text = editor.string
|
|
||||||
let range = NSRange(location: 0, length: text.utf16.count)
|
|
||||||
storage.beginEditing()
|
|
||||||
defer { storage.endEditing() }
|
|
||||||
storage.enumerateAttribute(.attachment, in: range) { value, _, _ in
|
|
||||||
guard let attachment = value as? NSTextAttachment,
|
|
||||||
let cell = attachment.attachmentCell as? NSTextAttachmentCell,
|
|
||||||
let image = cell.image else { return }
|
|
||||||
let size = image.size
|
|
||||||
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 !(cell is RoundedImageAttachmentCell) {
|
|
||||||
attachment.attachmentCell = RoundedImageAttachmentCell(imageCell: image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
storage.removeAttribute(.strikethroughStyle, range: range)
|
|
||||||
storage.removeAttribute(.underlineStyle, range: range)
|
|
||||||
removeCodeBackground(from: storage)
|
|
||||||
storage.addAttribute(codeBackground, value: false, range: range)
|
|
||||||
storage.addAttribute(hiddenMarkup, value: false, range: range)
|
|
||||||
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
|
|
||||||
.paragraphStyle: NotesAppearance.paragraph], range: range)
|
|
||||||
let blocks = codeBlocks(in: text)
|
|
||||||
func hideSyntax(_ match: NSTextCheckingResult) {
|
|
||||||
let body = match.range(withName: "body")
|
|
||||||
guard body.location != NSNotFound else { return }
|
|
||||||
if body.location > match.range.location {
|
|
||||||
storage.addAttribute(hiddenMarkup, value: true,
|
|
||||||
range: NSRange(location: match.range.location, length: body.location - match.range.location))
|
|
||||||
}
|
|
||||||
if NSMaxRange(body) < NSMaxRange(match.range) {
|
|
||||||
storage.addAttribute(hiddenMarkup, value: true,
|
|
||||||
range: NSRange(location: NSMaxRange(body), length: NSMaxRange(match.range) - NSMaxRange(body)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let codeSpans = inlineCode.matches(in: text, range: range).filter { match in
|
|
||||||
!blocks.contains { NSIntersectionRange($0.range, match.range).length > 0 }
|
|
||||||
}
|
|
||||||
for match in codeSpans {
|
|
||||||
let body = match.range(withName: "body")
|
|
||||||
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent,
|
|
||||||
.backgroundColor: SunsetPalette.selectionFill, codeBackground: true], range: body)
|
|
||||||
hideSyntax(match)
|
|
||||||
}
|
|
||||||
for (expression, attributes) in rules {
|
|
||||||
for match in expression.matches(in: text, range: range) {
|
|
||||||
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }),
|
|
||||||
!codeSpans.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
|
|
||||||
storage.addAttributes(attributes, range: match.range)
|
|
||||||
if match.range(withName: "marker").length == 3,
|
|
||||||
let font = attributes[.font] as? NSFont {
|
|
||||||
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask),
|
|
||||||
range: match.range)
|
|
||||||
}
|
|
||||||
hideSyntax(match)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for match in heading.matches(in: text, range: range) {
|
|
||||||
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
|
|
||||||
hideSyntax(match)
|
|
||||||
let font = headingFonts[match.range(at: 1).length - 1]
|
|
||||||
storage.enumerateAttribute(.font, in: match.range) { value, run, _ in
|
|
||||||
let traits = (value as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
|
|
||||||
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: traits), range: run)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for block in blocks {
|
|
||||||
storage.addAttributes([.font: NotesAppearance.font,
|
|
||||||
.foregroundColor: SunsetPalette.secondaryText], range: block.range)
|
|
||||||
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body)
|
|
||||||
let code = (text as NSString).substring(with: block.body)
|
|
||||||
guard let highlighted = highlighter.highlight(code, as: block.language),
|
|
||||||
highlighted.string == code else { continue }
|
|
||||||
highlighted.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlighted.length)) { color, range, _ in
|
|
||||||
if let color {
|
|
||||||
storage.addAttribute(.foregroundColor, value: color,
|
|
||||||
range: NSRange(location: block.body.location + range.location, length: range.length))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
editor.typingAttributes = [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
|
|
||||||
.paragraphStyle: NotesAppearance.paragraph]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+533
-646
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ struct NotesVimState {
|
|||||||
var linewiseRegister = false
|
var linewiseRegister = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: project the whole note for each command; cache visible offsets if large notes make motions slow.
|
// ponytail: scan the whole note for each command; cache character offsets if large notes make motions slow.
|
||||||
@MainActor
|
@MainActor
|
||||||
private struct VimBuffer {
|
private struct VimBuffer {
|
||||||
var characters: [Character] = []
|
var characters: [Character] = []
|
||||||
@@ -29,12 +29,8 @@ private struct VimBuffer {
|
|||||||
var offset = 0
|
var offset = 0
|
||||||
for character in contents.string {
|
for character in contents.string {
|
||||||
let size = String(character).utf16.count
|
let size = String(character).utf16.count
|
||||||
let hidden = contents.attribute(MarkdownHighlighting.hiddenMarkup, at: offset, effectiveRange: nil) as? Bool == true
|
|
||||||
|| contents.attribute(listWhitespace, at: offset, effectiveRange: nil) as? Bool == true
|
|
||||||
if !hidden {
|
|
||||||
characters.append(character)
|
characters.append(character)
|
||||||
ranges.append(NSRange(location: offset, length: size))
|
ranges.append(NSRange(location: offset, length: size))
|
||||||
}
|
|
||||||
offset += size
|
offset += size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,10 +94,12 @@ extension NotesTextView {
|
|||||||
var vimMode: NotesVimMode { vimState.mode }
|
var vimMode: NotesVimMode { vimState.mode }
|
||||||
|
|
||||||
func resetVim() {
|
func resetVim() {
|
||||||
|
endInsertionSession()
|
||||||
let register = vimState.register, linewise = vimState.linewiseRegister
|
let register = vimState.register, linewise = vimState.linewiseRegister
|
||||||
vimState = NotesVimState()
|
vimState = NotesVimState()
|
||||||
vimState.register = register
|
vimState.register = register
|
||||||
vimState.linewiseRegister = linewise
|
vimState.linewiseRegister = linewise
|
||||||
|
needsDisplay = true
|
||||||
vimModeChanged?()
|
vimModeChanged?()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,19 +111,22 @@ extension NotesTextView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func setVimMode(_ mode: NotesVimMode) {
|
private func setVimMode(_ mode: NotesVimMode) {
|
||||||
|
if vimState.mode == .insert { endInsertionSession() }
|
||||||
breakUndoCoalescing()
|
breakUndoCoalescing()
|
||||||
clearVimCommand()
|
clearVimCommand()
|
||||||
vimState.mode = mode
|
vimState.mode = mode
|
||||||
vimState.anchor = nil
|
vimState.anchor = nil
|
||||||
vimState.head = nil
|
vimState.head = nil
|
||||||
vimState.column = nil
|
vimState.column = nil
|
||||||
|
if mode == .insert { beginInsertionSession() }
|
||||||
|
needsDisplay = true
|
||||||
vimModeChanged?()
|
vimModeChanged?()
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func handleVimEscape() -> Bool {
|
func handleVimEscape() -> Bool {
|
||||||
guard vimEnabled, vimState.mode != .normal, !hasMarkedText() else { return false }
|
guard vimEnabled, vimState.mode != .normal, !hasMarkedText() else { return false }
|
||||||
let buffer = VimBuffer(attributedString())
|
let buffer = VimBuffer(documentContents)
|
||||||
var head = vimState.head ?? buffer.index(selectedRange().location)
|
var head = vimState.head ?? buffer.index(selectedRange().location)
|
||||||
if vimState.mode == .insert && head > buffer.lineStart(head) { head -= 1 }
|
if vimState.mode == .insert && head > buffer.lineStart(head) { head -= 1 }
|
||||||
setVimMode(.normal)
|
setVimMode(.normal)
|
||||||
@@ -192,11 +193,12 @@ extension NotesTextView {
|
|||||||
vimState.count = min(9999, vimState.count * 10 + digit)
|
vimState.count = min(9999, vimState.count * 10 + digit)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
let buffer = VimBuffer(attributedString())
|
let buffer = VimBuffer(documentContents)
|
||||||
if vimState.mode == .normal, selectedRange().length > 0 {
|
if vimState.mode == .normal, selectedRange().length > 0 {
|
||||||
vimState.mode = .visual
|
vimState.mode = .visual
|
||||||
vimState.anchor = buffer.index(selectedRange().location)
|
vimState.anchor = buffer.index(selectedRange().location)
|
||||||
vimState.head = max(vimState.anchor!, buffer.index(NSMaxRange(selectedRange())) - 1)
|
vimState.head = max(vimState.anchor!, buffer.index(NSMaxRange(selectedRange())) - 1)
|
||||||
|
needsDisplay = true
|
||||||
vimModeChanged?()
|
vimModeChanged?()
|
||||||
}
|
}
|
||||||
let origin = buffer.normal(vimState.head ?? buffer.index(selectedRange().location))
|
let origin = buffer.normal(vimState.head ?? buffer.index(selectedRange().location))
|
||||||
@@ -213,7 +215,7 @@ extension NotesTextView {
|
|||||||
if key == "g" { vimState.awaitingG = true; return true }
|
if key == "g" { vimState.awaitingG = true; return true }
|
||||||
if "dcy".contains(key) {
|
if "dcy".contains(key) {
|
||||||
if visual {
|
if visual {
|
||||||
let range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine)
|
let range = selectedRange()
|
||||||
applyVimOperator(key, range: range, linewise: vimState.mode == .visualLine)
|
applyVimOperator(key, range: range, linewise: vimState.mode == .visualLine)
|
||||||
} else if vimState.pending == key {
|
} else if vimState.pending == key {
|
||||||
var end = buffer.lineEnd(origin)
|
var end = buffer.lineEnd(origin)
|
||||||
@@ -305,22 +307,22 @@ extension NotesTextView {
|
|||||||
setVimCaret(target, buffer: buffer)
|
setVimCaret(target, buffer: buffer)
|
||||||
insertNewline(nil)
|
insertNewline(nil)
|
||||||
if key == "O" {
|
if key == "O" {
|
||||||
let updated = VimBuffer(attributedString())
|
let updated = VimBuffer(documentContents)
|
||||||
let caret = updated.index(selectedRange().location)
|
let caret = updated.index(selectedRange().location)
|
||||||
setVimCaret(updated.lineStart(max(0, caret - 1)), buffer: updated)
|
setVimCaret(updated.lineStart(max(0, caret - 1)), buffer: updated)
|
||||||
}
|
}
|
||||||
case "x":
|
case "x":
|
||||||
if visual { applyVimOperator("d", range: vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine), linewise: vimState.mode == .visualLine) }
|
if visual { applyVimOperator("d", range: selectedRange(), linewise: vimState.mode == .visualLine) }
|
||||||
else {
|
else {
|
||||||
let end = min(buffer.lineEnd(origin), origin + count)
|
let end = min(buffer.lineEnd(origin), origin + count)
|
||||||
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
|
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
|
||||||
if end > origin { applyVimOperator("d", range: vimSourceRange(for: NSRange(location: startOffset, length: endOffset - startOffset)), linewise: false) }
|
if end > origin { applyVimOperator("d", range: NSRange(location: startOffset, length: endOffset - startOffset), linewise: false) }
|
||||||
}
|
}
|
||||||
case "D", "C":
|
case "D", "C":
|
||||||
var end = buffer.lineEnd(origin)
|
var end = buffer.lineEnd(origin)
|
||||||
for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) }
|
for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) }
|
||||||
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
|
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
|
||||||
applyVimOperator(key == "D" ? "d" : "c", range: vimSourceRange(for: NSRange(location: startOffset, length: endOffset - startOffset)), linewise: false)
|
applyVimOperator(key == "D" ? "d" : "c", range: NSRange(location: startOffset, length: endOffset - startOffset), linewise: false)
|
||||||
case "p", "P": pasteVimRegister(before: key == "P", count: count, origin: origin, buffer: buffer)
|
case "p", "P": pasteVimRegister(before: key == "P", count: count, origin: origin, buffer: buffer)
|
||||||
case "u":
|
case "u":
|
||||||
setVimMode(.normal)
|
setVimMode(.normal)
|
||||||
@@ -348,7 +350,7 @@ extension NotesTextView {
|
|||||||
let high = min(buffer.count, max(origin, target) + (inclusive ? 1 : 0))
|
let high = min(buffer.count, max(origin, target) + (inclusive ? 1 : 0))
|
||||||
let start = buffer.offset(low), end = buffer.offset(high)
|
let start = buffer.offset(low), end = buffer.offset(high)
|
||||||
// A motion against a boundary is a no-op, not a character deletion.
|
// A motion against a boundary is a no-op, not a character deletion.
|
||||||
if end > start { applyVimOperator(operation, range: vimSourceRange(for: NSRange(location: start, length: end - start)), linewise: false) }
|
if end > start { applyVimOperator(operation, range: NSRange(location: start, length: end - start), linewise: false) }
|
||||||
else { clearVimCommand() }
|
else { clearVimCommand() }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -358,24 +360,21 @@ extension NotesTextView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func vimSourceRange(for rendered: NSRange, linewise: Bool = false) -> NSRange {
|
|
||||||
let range = markdownRange(for: MarkdownHighlighting.completeInlineRange(rendered, in: attributedString()))
|
|
||||||
return linewise ? (markdownString as NSString).lineRange(for: range) : range
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyVimLineOperator(_ operation: Character, start: Int, end: Int, buffer: VimBuffer) {
|
private func applyVimLineOperator(_ operation: Character, start: Int, end: Int, buffer: VimBuffer) {
|
||||||
let source = markdownString as NSString
|
let first = buffer.offset(buffer.lineStart(start))
|
||||||
let startOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineStart(start)), length: 0)).location
|
let lastLineEnd = buffer.lineEnd(end)
|
||||||
let endOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineEnd(end)), length: 0)).location
|
let last = buffer.offset(lastLineEnd + (buffer.newline(lastLineEnd) ? 1 : 0))
|
||||||
let first = source.lineRange(for: NSRange(location: startOffset, length: 0)).location
|
|
||||||
let last = NSMaxRange(source.lineRange(for: NSRange(location: endOffset, length: 0)))
|
|
||||||
applyVimOperator(operation, range: NSRange(location: first, length: last - first), linewise: true)
|
applyVimOperator(operation, range: NSRange(location: first, length: last - first), linewise: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyVimOperator(_ operation: Character, range: NSRange, linewise: Bool) {
|
private func applyVimOperator(_ operation: Character, range: NSRange, linewise: Bool) {
|
||||||
let source = markdownContents
|
let source = documentContents
|
||||||
let register = NSMutableAttributedString(attributedString: source.attributedSubstring(from: range))
|
let register = NSMutableAttributedString(attributedString: source.attributedSubstring(from: range))
|
||||||
if linewise && !register.string.hasSuffix("\n") { register.append(NSAttributedString(string: "\n")) }
|
if linewise && !register.string.hasSuffix("\n") {
|
||||||
|
var attributes = register.length > 0 ? register.attributes(at: register.length - 1, effectiveRange: nil) : [:]
|
||||||
|
attributes[.attachment] = nil
|
||||||
|
register.append(NSAttributedString(string: "\n", attributes: attributes))
|
||||||
|
}
|
||||||
vimState.register = register
|
vimState.register = register
|
||||||
vimState.linewiseRegister = linewise
|
vimState.linewiseRegister = linewise
|
||||||
let originalCaret = selectedRange().location
|
let originalCaret = selectedRange().location
|
||||||
@@ -386,12 +385,14 @@ extension NotesTextView {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var deletion = range
|
var deletion = range
|
||||||
var replacement = ""
|
var replacement = NSAttributedString(string: "")
|
||||||
|
var attributes = range.location < source.length ? source.attributes(at: range.location, effectiveRange: nil) : typingAttributes
|
||||||
|
attributes[.attachment] = nil
|
||||||
if linewise {
|
if linewise {
|
||||||
let text = source.string as NSString
|
let text = source.string as NSString
|
||||||
if operation == "c" {
|
if operation == "c" {
|
||||||
// cc keeps the following line separate while opening a blank line for insertion.
|
// cc keeps the following line separate while opening a blank line for insertion.
|
||||||
if NSMaxRange(range) < text.length { replacement = "\n" }
|
if NSMaxRange(range) < text.length { replacement = NSAttributedString(string: "\n", attributes: attributes) }
|
||||||
} else if NSMaxRange(range) == text.length, range.location > 0 {
|
} else if NSMaxRange(range) == text.length, range.location > 0 {
|
||||||
let previous = text.rangeOfComposedCharacterSequence(at: range.location - 1)
|
let previous = text.rangeOfComposedCharacterSequence(at: range.location - 1)
|
||||||
if text.substring(with: previous).allSatisfy(\.isNewline) {
|
if text.substring(with: previous).allSatisfy(\.isNewline) {
|
||||||
@@ -400,10 +401,9 @@ extension NotesTextView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
replaceMarkdown(replacement, range: deletion)
|
replaceDocument(replacement, range: deletion, selection: NSRange(location: deletion.location, length: 0))
|
||||||
if operation == "c" {
|
if operation == "c" { typingAttributes = attributes }
|
||||||
setSelectedRange(renderedRange(for: NSRange(location: deletion.location, length: 0)))
|
else { normalizeVimCaret() }
|
||||||
} else { normalizeVimCaret() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pasteVimRegister(before: Bool, count: Int, origin: Int, buffer: VimBuffer) {
|
private func pasteVimRegister(before: Bool, count: Int, origin: Int, buffer: VimBuffer) {
|
||||||
@@ -412,11 +412,10 @@ extension NotesTextView {
|
|||||||
for _ in 0..<count { insertion.append(register) }
|
for _ in 0..<count { insertion.append(register) }
|
||||||
var range: NSRange
|
var range: NSRange
|
||||||
if vimState.mode == .visual || vimState.mode == .visualLine {
|
if vimState.mode == .visual || vimState.mode == .visualLine {
|
||||||
range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine)
|
range = selectedRange()
|
||||||
} else if vimState.linewiseRegister {
|
} else if vimState.linewiseRegister {
|
||||||
let source = markdownString as NSString
|
let source = documentContents.string as NSString
|
||||||
let position = markdownRange(for: NSRange(location: buffer.offset(origin), length: 0)).location
|
let line = source.lineRange(for: NSRange(location: buffer.offset(origin), length: 0))
|
||||||
let line = source.lineRange(for: NSRange(location: position, length: 0))
|
|
||||||
let offset = before ? line.location : NSMaxRange(line)
|
let offset = before ? line.location : NSMaxRange(line)
|
||||||
range = NSRange(location: offset, length: 0)
|
range = NSRange(location: offset, length: 0)
|
||||||
if offset == source.length, offset == 0 || !source.substring(from: offset - 1).allSatisfy(\.isNewline) {
|
if offset == source.length, offset == 0 || !source.substring(from: offset - 1).allSatisfy(\.isNewline) {
|
||||||
@@ -427,20 +426,20 @@ extension NotesTextView {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let index = before ? origin : min(buffer.lineEnd(origin), origin + 1)
|
let index = before ? origin : min(buffer.lineEnd(origin), origin + 1)
|
||||||
range = markdownInsertionRange(for: NSRange(location: buffer.offset(index), length: 0))
|
range = NSRange(location: buffer.offset(index), length: 0)
|
||||||
}
|
}
|
||||||
setVimMode(.normal)
|
setVimMode(.normal)
|
||||||
replaceMarkdown(insertion, range: range)
|
replaceDocument(insertion, range: range)
|
||||||
let caret = vimState.linewiseRegister ? range.location + (insertion.string.hasPrefix("\n") ? 1 : 0) : range.location + insertion.length
|
let caret = vimState.linewiseRegister ? range.location + (insertion.string.hasPrefix("\n") ? 1 : 0) : range.location + insertion.length
|
||||||
setSelectedRange(renderedRange(for: NSRange(location: caret, length: 0)))
|
setSelectedRange(NSRange(location: caret, length: 0))
|
||||||
if !vimState.linewiseRegister {
|
if !vimState.linewiseRegister {
|
||||||
let updated = VimBuffer(attributedString())
|
let updated = VimBuffer(documentContents)
|
||||||
setVimCaret(updated.normal(max(0, updated.index(selectedRange().location) - 1)), buffer: updated)
|
setVimCaret(updated.normal(max(0, updated.index(selectedRange().location) - 1)), buffer: updated)
|
||||||
} else { normalizeVimCaret() }
|
} else { normalizeVimCaret() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func normalizeVimCaret() {
|
private func normalizeVimCaret() {
|
||||||
let buffer = VimBuffer(attributedString())
|
let buffer = VimBuffer(documentContents)
|
||||||
setVimCaret(buffer.normal(buffer.index(selectedRange().location)), buffer: buffer)
|
setVimCaret(buffer.normal(buffer.index(selectedRange().location)), buffer: buffer)
|
||||||
vimState.anchor = nil
|
vimState.anchor = nil
|
||||||
vimState.head = nil
|
vimState.head = nil
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
import AppKit
|
||||||
|
import CryptoKit
|
||||||
|
import Markdown
|
||||||
|
|
||||||
|
public enum NoteAttributes {
|
||||||
|
public static let block = NSAttributedString.Key("ConjureNote.block")
|
||||||
|
public static let headingLevel = NSAttributedString.Key("ConjureNote.headingLevel")
|
||||||
|
public static let depth = NSAttributedString.Key("ConjureNote.depth")
|
||||||
|
public static let number = NSAttributedString.Key("ConjureNote.number")
|
||||||
|
public static let checked = NSAttributedString.Key("ConjureNote.checked")
|
||||||
|
public static let language = NSAttributedString.Key("ConjureNote.language")
|
||||||
|
public static let inlineCode = NSAttributedString.Key("ConjureNote.inlineCode")
|
||||||
|
public static let bold = NSAttributedString.Key("ConjureNote.bold")
|
||||||
|
public static let italic = NSAttributedString.Key("ConjureNote.italic")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attributed string is the document; Markdown is only an import/export format.
|
||||||
|
@MainActor
|
||||||
|
public enum NoteDocument {
|
||||||
|
static let metadataFilename = "ConjureNote.json"
|
||||||
|
|
||||||
|
public static func importMarkdown(_ source: NSAttributedString) -> NSAttributedString {
|
||||||
|
MarkdownImporter(source).contents()
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func markdown(_ contents: NSAttributedString) -> NSAttributedString {
|
||||||
|
let result = NSMutableAttributedString(string: "")
|
||||||
|
let text = contents.string as NSString
|
||||||
|
var cursor = 0
|
||||||
|
while cursor < contents.length {
|
||||||
|
let range = text.paragraphRange(for: NSRange(location: cursor, length: 0))
|
||||||
|
let attributes = contents.attributes(at: cursor, effectiveRange: nil)
|
||||||
|
let block = attributes[NoteAttributes.block] as? String
|
||||||
|
if block == "code" {
|
||||||
|
let language = attributes[NoteAttributes.language] as? String ?? ""
|
||||||
|
var end = NSMaxRange(range)
|
||||||
|
while end < contents.length,
|
||||||
|
contents.attribute(NoteAttributes.block, at: end, effectiveRange: nil) as? String == "code",
|
||||||
|
(contents.attribute(NoteAttributes.language, at: end, effectiveRange: nil) as? String ?? "") == language {
|
||||||
|
end = NSMaxRange(text.paragraphRange(for: NSRange(location: end, length: 0)))
|
||||||
|
}
|
||||||
|
let code = contents.attributedSubstring(from: NSRange(location: cursor, length: end - cursor))
|
||||||
|
let fence = String(repeating: "`", count: max(3, longestRun(of: "`", in: code.string) + 1))
|
||||||
|
result.append(NSAttributedString(string: fence + language.replacingOccurrences(of: "\n", with: " ") + "\n"))
|
||||||
|
result.append(code)
|
||||||
|
if !code.string.hasSuffix("\n") { result.append(NSAttributedString(string: "\n")) }
|
||||||
|
result.append(NSAttributedString(string: fence))
|
||||||
|
if code.string.hasSuffix("\n") || end < contents.length { result.append(NSAttributedString(string: "\n")) }
|
||||||
|
cursor = end
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let depth = max(0, attributes[NoteAttributes.depth] as? Int ?? 0)
|
||||||
|
var prefix = ""
|
||||||
|
switch block {
|
||||||
|
case "heading": prefix = String(repeating: "#", count: min(6, max(1, attributes[NoteAttributes.headingLevel] as? Int ?? 1))) + " "
|
||||||
|
case "quote": prefix = String(repeating: "> ", count: depth + 1)
|
||||||
|
case "bullet": prefix = String(repeating: " ", count: depth) + "- "
|
||||||
|
case "ordered": prefix = String(repeating: " ", count: depth) + "\(attributes[NoteAttributes.number] as? Int ?? 1). "
|
||||||
|
case "task": prefix = String(repeating: " ", count: depth) + ((attributes[NoteAttributes.checked] as? Bool ?? false) ? "- [x] " : "- [ ] ")
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
result.append(NSAttributedString(string: prefix))
|
||||||
|
var bodyEnd = NSMaxRange(range)
|
||||||
|
while bodyEnd > range.location, [10, 13, 0x2028, 0x2029].contains(text.character(at: bodyEnd - 1)) { bodyEnd -= 1 }
|
||||||
|
appendInline(contents, range: NSRange(location: cursor, length: bodyEnd - cursor), to: result)
|
||||||
|
if bodyEnd < NSMaxRange(range) {
|
||||||
|
result.append(contents.attributedSubstring(from: NSRange(location: bodyEnd, length: NSMaxRange(range) - bodyEnd)))
|
||||||
|
}
|
||||||
|
cursor = NSMaxRange(range)
|
||||||
|
}
|
||||||
|
return NSAttributedString(attributedString: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func fileWrapper(_ contents: NSAttributedString) throws -> FileWrapper {
|
||||||
|
let wrapper = try contents.fileWrapper(
|
||||||
|
from: NSRange(location: 0, length: contents.length),
|
||||||
|
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
||||||
|
)
|
||||||
|
guard wrapper.fileWrappers?[metadataFilename] == nil else { throw DocumentError.reservedMetadataFilename }
|
||||||
|
// Bind metadata to the text AppKit actually stores (RTF normalizes line endings).
|
||||||
|
guard let stored = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil) else {
|
||||||
|
throw DocumentError.corruptMetadata
|
||||||
|
}
|
||||||
|
guard stored.length == contents.length, stored.string == contents.string else {
|
||||||
|
throw DocumentError.textChangedDuringEncoding
|
||||||
|
}
|
||||||
|
let metadata = Metadata(version: 1, length: stored.length, digest: digest(stored.string), runs: runs(in: contents))
|
||||||
|
let json = FileWrapper(regularFileWithContents: try JSONEncoder().encode(metadata))
|
||||||
|
json.preferredFilename = metadataFilename
|
||||||
|
wrapper.addFileWrapper(json)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func contents(from wrapper: FileWrapper) throws -> NSAttributedString {
|
||||||
|
guard wrapper.isDirectory else { throw DocumentError.corruptMetadata }
|
||||||
|
try validate(wrapper)
|
||||||
|
guard let file = wrapper.fileWrappers?[metadataFilename], file.isRegularFile,
|
||||||
|
let data = file.regularFileContents else { throw DocumentError.corruptMetadata }
|
||||||
|
let metadata: Metadata
|
||||||
|
do { metadata = try JSONDecoder().decode(Metadata.self, from: data) }
|
||||||
|
catch { throw DocumentError.corruptMetadata }
|
||||||
|
guard metadata.version == 1 else { throw DocumentError.unsupportedVersion(metadata.version) }
|
||||||
|
guard let decoded = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil),
|
||||||
|
metadata.length == decoded.length, metadata.digest == digest(decoded.string) else {
|
||||||
|
throw DocumentError.corruptMetadata
|
||||||
|
}
|
||||||
|
let result = NSMutableAttributedString(attributedString: decoded)
|
||||||
|
var end = 0
|
||||||
|
for run in metadata.runs {
|
||||||
|
guard run.location == end, run.length > 0, run.location >= 0,
|
||||||
|
run.location <= result.length, run.length <= result.length - run.location,
|
||||||
|
run.values.isValid else { throw DocumentError.corruptMetadata }
|
||||||
|
result.addAttributes(run.values.attributes, range: NSRange(location: run.location, length: run.length))
|
||||||
|
end += run.length
|
||||||
|
}
|
||||||
|
guard end == result.length else { throw DocumentError.corruptMetadata }
|
||||||
|
return NSAttributedString(attributedString: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func validate(_ wrapper: FileWrapper) throws {
|
||||||
|
guard !wrapper.isSymbolicLink else { throw DocumentError.corruptMetadata }
|
||||||
|
if wrapper.isDirectory {
|
||||||
|
guard let children = wrapper.fileWrappers else { throw DocumentError.corruptMetadata }
|
||||||
|
for (name, child) in children {
|
||||||
|
guard !name.isEmpty, name != ".", name != "..",
|
||||||
|
!name.contains("/"), !name.contains("\\"),
|
||||||
|
child.preferredFilename.map({ !$0.isEmpty && $0 != "." && $0 != ".." && !$0.contains("/") && !$0.contains("\\") }) ?? true else { throw DocumentError.corruptMetadata }
|
||||||
|
try validate(child)
|
||||||
|
}
|
||||||
|
} else if !wrapper.isRegularFile { throw DocumentError.corruptMetadata }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DocumentError: LocalizedError {
|
||||||
|
case corruptMetadata
|
||||||
|
case unsupportedVersion(Int)
|
||||||
|
case textChangedDuringEncoding
|
||||||
|
case reservedMetadataFilename
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .corruptMetadata: "This note's structured metadata is missing, corrupt, or does not match its text. The original note was not changed."
|
||||||
|
case .unsupportedVersion(let version): "This note uses unsupported structured metadata version \(version). The original note was not changed."
|
||||||
|
case .textChangedDuringEncoding: "The note could not be saved without changing its text. The original note was not changed."
|
||||||
|
case .reservedMetadataFilename: "An attachment uses the reserved filename ConjureNote.json. Rename the attachment before saving. The original note was not changed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func digest(_ string: String) -> String {
|
||||||
|
SHA256.hash(data: Data(string.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Metadata: Codable {
|
||||||
|
let version: Int
|
||||||
|
let length: Int
|
||||||
|
let digest: String
|
||||||
|
let runs: [Run]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Run: Codable {
|
||||||
|
let location: Int
|
||||||
|
let length: Int
|
||||||
|
let values: Values
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Values: Codable {
|
||||||
|
var block: String?
|
||||||
|
var headingLevel: Int?
|
||||||
|
var depth: Int?
|
||||||
|
var number: Int?
|
||||||
|
var checked: Bool?
|
||||||
|
var language: String?
|
||||||
|
var inlineCode: Bool?
|
||||||
|
var bold: Bool?
|
||||||
|
var italic: Bool?
|
||||||
|
var underline: Int?
|
||||||
|
var strike: Int?
|
||||||
|
var link: String?
|
||||||
|
|
||||||
|
init(_ attributes: [NSAttributedString.Key: Any]) {
|
||||||
|
block = attributes[NoteAttributes.block] as? String
|
||||||
|
headingLevel = attributes[NoteAttributes.headingLevel] as? Int
|
||||||
|
depth = attributes[NoteAttributes.depth] as? Int
|
||||||
|
number = attributes[NoteAttributes.number] as? Int
|
||||||
|
checked = attributes[NoteAttributes.checked] as? Bool
|
||||||
|
language = attributes[NoteAttributes.language] as? String
|
||||||
|
inlineCode = attributes[NoteAttributes.inlineCode] as? Bool
|
||||||
|
bold = attributes[NoteAttributes.bold] as? Bool
|
||||||
|
italic = attributes[NoteAttributes.italic] as? Bool
|
||||||
|
underline = attributes[.underlineStyle] as? Int
|
||||||
|
strike = attributes[.strikethroughStyle] as? Int
|
||||||
|
link = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
var isValid: Bool {
|
||||||
|
(block == nil || ["heading", "quote", "bullet", "ordered", "task", "code"].contains(block!))
|
||||||
|
&& (headingLevel == nil || (1...6).contains(headingLevel!))
|
||||||
|
&& (depth == nil || depth! >= 0)
|
||||||
|
&& (number == nil || number! >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var attributes: [NSAttributedString.Key: Any] {
|
||||||
|
var result: [NSAttributedString.Key: Any] = [:]
|
||||||
|
result[NoteAttributes.block] = block
|
||||||
|
result[NoteAttributes.headingLevel] = headingLevel
|
||||||
|
result[NoteAttributes.depth] = depth
|
||||||
|
result[NoteAttributes.number] = number
|
||||||
|
result[NoteAttributes.checked] = checked
|
||||||
|
result[NoteAttributes.language] = language
|
||||||
|
result[NoteAttributes.inlineCode] = inlineCode
|
||||||
|
result[NoteAttributes.bold] = bold
|
||||||
|
result[NoteAttributes.italic] = italic
|
||||||
|
result[.underlineStyle] = underline
|
||||||
|
result[.strikethroughStyle] = strike
|
||||||
|
result[.link] = link
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runs(in contents: NSAttributedString) -> [Run] {
|
||||||
|
var result: [Run] = []
|
||||||
|
contents.enumerateAttributes(in: NSRange(location: 0, length: contents.length)) { attributes, range, _ in
|
||||||
|
result.append(Run(location: range.location, length: range.length, values: Values(attributes)))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func longestRun(of character: Character, in text: String) -> Int {
|
||||||
|
var longest = 0
|
||||||
|
var count = 0
|
||||||
|
for value in text {
|
||||||
|
count = value == character ? count + 1 : 0
|
||||||
|
longest = max(longest, count)
|
||||||
|
}
|
||||||
|
return longest
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func appendInline(_ contents: NSAttributedString, range: NSRange, to output: NSMutableAttributedString) {
|
||||||
|
let semantic = NSMutableAttributedString(string: (contents.string as NSString).substring(with: range))
|
||||||
|
contents.enumerateAttributes(in: range) { attributes, run, _ in
|
||||||
|
let traits = (attributes[.font] as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
|
||||||
|
var marks: [NSAttributedString.Key: Any] = [:]
|
||||||
|
if attributes[NoteAttributes.bold] as? Bool ?? traits.contains(.boldFontMask) { marks[NoteAttributes.bold] = true }
|
||||||
|
if attributes[NoteAttributes.italic] as? Bool ?? traits.contains(.italicFontMask) { marks[NoteAttributes.italic] = true }
|
||||||
|
if attributes[NoteAttributes.inlineCode] as? Bool ?? false { marks[NoteAttributes.inlineCode] = true }
|
||||||
|
if (attributes[.underlineStyle] as? Int ?? 0) != 0 { marks[.underlineStyle] = 1 }
|
||||||
|
if (attributes[.strikethroughStyle] as? Int ?? 0) != 0 { marks[.strikethroughStyle] = 1 }
|
||||||
|
marks[.link] = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String
|
||||||
|
semantic.addAttributes(marks, range: NSRange(location: run.location - range.location, length: run.length))
|
||||||
|
}
|
||||||
|
// Markdown emphasis cannot open/close against whitespace. Trim each semantic
|
||||||
|
// span, not each display-font/color run; underline and links retain spaces.
|
||||||
|
let string = semantic.string as NSString
|
||||||
|
for key in [NoteAttributes.bold, NoteAttributes.italic, .strikethroughStyle] {
|
||||||
|
var edges: [NSRange] = []
|
||||||
|
semantic.enumerateAttribute(key, in: NSRange(location: 0, length: semantic.length)) { value, span, _ in
|
||||||
|
guard value != nil else { return }
|
||||||
|
var start = span.location
|
||||||
|
var end = NSMaxRange(span)
|
||||||
|
while start < end, string.character(at: start) == 32 || string.character(at: start) == 9 { start += 1 }
|
||||||
|
while end > start, string.character(at: end - 1) == 32 || string.character(at: end - 1) == 9 { end -= 1 }
|
||||||
|
if start > span.location { edges.append(NSRange(location: span.location, length: start - span.location)) }
|
||||||
|
if end < NSMaxRange(span) { edges.append(NSRange(location: end, length: NSMaxRange(span) - end)) }
|
||||||
|
}
|
||||||
|
for edge in edges { semantic.removeAttribute(key, range: edge) }
|
||||||
|
}
|
||||||
|
var active: [(open: String, close: String)] = []
|
||||||
|
semantic.enumerateAttributes(in: NSRange(location: 0, length: semantic.length)) { attributes, run, _ in
|
||||||
|
let value = contents.attributedSubstring(from: NSRange(location: range.location + run.location, length: run.length))
|
||||||
|
let text = value.string
|
||||||
|
let code = attributes[NoteAttributes.inlineCode] as? Bool ?? false
|
||||||
|
var desired: [(open: String, close: String)] = []
|
||||||
|
if let link = attributes[.link] as? String {
|
||||||
|
let destination = link.replacingOccurrences(of: "\\", with: "%5C").replacingOccurrences(of: " ", with: "%20").replacingOccurrences(of: "<", with: "%3C").replacingOccurrences(of: ">", with: "%3E").replacingOccurrences(of: "\n", with: "%0A")
|
||||||
|
desired.append(("[", "](<\(destination)>)"))
|
||||||
|
}
|
||||||
|
for (enabled, delimiter) in [(attributes[NoteAttributes.bold] as? Bool ?? false, "**"), (attributes[NoteAttributes.italic] as? Bool ?? false, "*"), ((attributes[.underlineStyle] as? Int ?? 0) != 0, "<u>"), ((attributes[.strikethroughStyle] as? Int ?? 0) != 0, "~~")] where enabled {
|
||||||
|
desired.append((delimiter, delimiter == "<u>" ? "</u>" : delimiter))
|
||||||
|
}
|
||||||
|
var common = 0
|
||||||
|
while common < min(active.count, desired.count),
|
||||||
|
active[common].open == desired[common].open, active[common].close == desired[common].close { common += 1 }
|
||||||
|
for mark in active.dropFirst(common).reversed() { output.append(NSAttributedString(string: mark.close)) }
|
||||||
|
for mark in desired.dropFirst(common) { output.append(NSAttributedString(string: mark.open)) }
|
||||||
|
active = desired
|
||||||
|
if code {
|
||||||
|
let fence = String(repeating: "`", count: max(1, longestRun(of: "`", in: text) + 1))
|
||||||
|
let pad = text.hasPrefix("`") || text.hasSuffix("`") || (text.hasPrefix(" ") && text.hasSuffix(" ") && !text.allSatisfy({ $0 == " " }))
|
||||||
|
output.append(NSAttributedString(string: fence + (pad ? " " : "")))
|
||||||
|
output.append(value)
|
||||||
|
output.append(NSAttributedString(string: (pad ? " " : "") + fence))
|
||||||
|
} else {
|
||||||
|
var index = 0
|
||||||
|
for character in text {
|
||||||
|
let length = String(character).utf16.count
|
||||||
|
if length == 1, "\\`*_{}[]<>()#+-.!|>~&".contains(character) { output.append(NSAttributedString(string: "\\")) }
|
||||||
|
output.append(value.attributedSubstring(from: NSRange(location: index, length: length)))
|
||||||
|
index += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for mark in active.reversed() { output.append(NSAttributedString(string: mark.close)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MarkdownImporter {
|
||||||
|
private let source: NSAttributedString
|
||||||
|
private let text: NSString
|
||||||
|
private let bytes: [UInt8]
|
||||||
|
private let lineStarts: [Int]
|
||||||
|
private let utf16Offsets: [Int]
|
||||||
|
private let result: NSMutableAttributedString
|
||||||
|
private var deletions: [NSRange] = []
|
||||||
|
private var replacements: [(NSRange, NSAttributedString)] = []
|
||||||
|
|
||||||
|
init(_ source: NSAttributedString) {
|
||||||
|
self.source = source
|
||||||
|
text = source.string as NSString
|
||||||
|
bytes = Array(source.string.utf8)
|
||||||
|
var starts = [0]
|
||||||
|
for (index, byte) in bytes.enumerated() where byte == 10 { starts.append(index + 1) }
|
||||||
|
lineStarts = starts
|
||||||
|
var offsets = Array(repeating: 0, count: bytes.count + 1)
|
||||||
|
var byteOffset = 0
|
||||||
|
var utf16Offset = 0
|
||||||
|
for scalar in source.string.unicodeScalars {
|
||||||
|
for index in 0..<scalar.utf8.count { offsets[byteOffset + index] = utf16Offset }
|
||||||
|
byteOffset += scalar.utf8.count
|
||||||
|
utf16Offset += scalar.utf16.count
|
||||||
|
}
|
||||||
|
offsets[bytes.count] = utf16Offset
|
||||||
|
utf16Offsets = offsets
|
||||||
|
result = NSMutableAttributedString(attributedString: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contents() -> NSAttributedString {
|
||||||
|
let document = Document(parsing: source.string)
|
||||||
|
visit(document)
|
||||||
|
var merged: [NSRange] = []
|
||||||
|
for range in deletions.filter({ $0.length > 0 }).sorted(by: { $0.location < $1.location }) {
|
||||||
|
if let last = merged.last, NSMaxRange(last) >= range.location {
|
||||||
|
merged[merged.count - 1] = NSUnionRange(last, range)
|
||||||
|
} else { merged.append(range) }
|
||||||
|
}
|
||||||
|
var edits = replacements
|
||||||
|
for range in merged where !replacements.contains(where: { NSIntersectionRange($0.0, range).length > 0 }) {
|
||||||
|
edits.append((range, NSAttributedString(string: "")))
|
||||||
|
}
|
||||||
|
for (range, value) in edits.sorted(by: { $0.0.location > $1.0.location }) {
|
||||||
|
result.replaceCharacters(in: range, with: value)
|
||||||
|
}
|
||||||
|
return NSAttributedString(attributedString: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func offset(_ location: SourceLocation) -> Int {
|
||||||
|
guard location.line > 0, location.line <= lineStarts.count else { return text.length }
|
||||||
|
let byte = min(bytes.count, lineStarts[location.line - 1] + max(0, location.column - 1))
|
||||||
|
return utf16Offsets[byte]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func range(_ markup: any Markup) -> NSRange? {
|
||||||
|
guard let range = markup.range else { return nil }
|
||||||
|
let start = offset(range.lowerBound)
|
||||||
|
let end = offset(range.upperBound)
|
||||||
|
guard end >= start else { return nil }
|
||||||
|
return NSRange(location: start, length: end - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mark(_ range: NSRange, _ attributes: [NSAttributedString.Key: Any], paragraph: Bool = false) {
|
||||||
|
let target = paragraph ? text.paragraphRange(for: range) : range
|
||||||
|
result.addAttributes(attributes, range: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func inlineContents(_ node: any Markup, sourceRange: NSRange) -> NSAttributedString {
|
||||||
|
var cursor = sourceRange.location
|
||||||
|
var attachments: [NSTextAttachment] = []
|
||||||
|
source.enumerateAttribute(.attachment, in: sourceRange) { value, _, _ in
|
||||||
|
if let attachment = value as? NSTextAttachment { attachments.append(attachment) }
|
||||||
|
}
|
||||||
|
var attachmentIndex = 0
|
||||||
|
func literal(_ string: String) -> NSMutableAttributedString {
|
||||||
|
let remaining = NSRange(location: cursor, length: max(0, NSMaxRange(sourceRange) - cursor))
|
||||||
|
let match = text.range(of: string, options: [], range: remaining)
|
||||||
|
let value: NSMutableAttributedString
|
||||||
|
if !string.isEmpty, match.location != NSNotFound {
|
||||||
|
value = NSMutableAttributedString(attributedString: result.attributedSubstring(from: match))
|
||||||
|
cursor = NSMaxRange(match)
|
||||||
|
} else {
|
||||||
|
let attributes = cursor < result.length ? result.attributes(at: cursor, effectiveRange: nil) : [:]
|
||||||
|
value = NSMutableAttributedString(string: string, attributes: attributes)
|
||||||
|
}
|
||||||
|
let characters = string as NSString
|
||||||
|
for index in 0..<characters.length where characters.character(at: index) == 0xfffc {
|
||||||
|
if attachmentIndex < attachments.count {
|
||||||
|
value.addAttribute(.attachment, value: attachments[attachmentIndex], range: NSRange(location: index, length: 1))
|
||||||
|
}
|
||||||
|
attachmentIndex += 1
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
func children(_ container: any Markup) -> NSMutableAttributedString {
|
||||||
|
let output = NSMutableAttributedString(string: "")
|
||||||
|
var underline: [(offset: Int, literal: String)] = []
|
||||||
|
for child in container.children {
|
||||||
|
if let html = child as? InlineHTML {
|
||||||
|
if html.rawHTML.lowercased() == "<u>" { underline.append((output.length, html.rawHTML)); continue }
|
||||||
|
if html.rawHTML.lowercased() == "</u>", let start = underline.popLast() {
|
||||||
|
output.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: start.offset, length: output.length - start.offset))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.append(render(child))
|
||||||
|
}
|
||||||
|
// Unmatched HTML is unsupported literal content, not a disappearing delimiter.
|
||||||
|
for start in underline.reversed() { output.insert(NSAttributedString(string: start.literal), at: start.offset) }
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
func render(_ child: any Markup) -> NSMutableAttributedString {
|
||||||
|
switch child {
|
||||||
|
case let value as Text: return literal(value.string)
|
||||||
|
case let value as InlineCode:
|
||||||
|
let output = literal(value.code)
|
||||||
|
output.addAttribute(NoteAttributes.inlineCode, value: true, range: NSRange(location: 0, length: output.length))
|
||||||
|
return output
|
||||||
|
case is SoftBreak, is LineBreak:
|
||||||
|
let remaining = NSRange(location: cursor, length: max(0, NSMaxRange(sourceRange) - cursor))
|
||||||
|
let newline = text.rangeOfCharacter(from: .newlines, options: [], range: remaining)
|
||||||
|
let output = NSMutableAttributedString(string: "")
|
||||||
|
if newline.location != NSNotFound {
|
||||||
|
var start = newline.location
|
||||||
|
while start > cursor, text.character(at: start - 1) == 32 || text.character(at: start - 1) == 9 { start -= 1 }
|
||||||
|
if start < newline.location { output.append(result.attributedSubstring(from: NSRange(location: start, length: newline.location - start))) }
|
||||||
|
cursor = newline.location
|
||||||
|
}
|
||||||
|
output.append(literal("\n"))
|
||||||
|
return output
|
||||||
|
case let html as InlineHTML: return literal(html.rawHTML)
|
||||||
|
case is Strong, is Emphasis, is Strikethrough, is Link:
|
||||||
|
let output = children(child)
|
||||||
|
let range = NSRange(location: 0, length: output.length)
|
||||||
|
if child is Strong { output.addAttribute(NoteAttributes.bold, value: true, range: range) }
|
||||||
|
if child is Emphasis { output.addAttribute(NoteAttributes.italic, value: true, range: range) }
|
||||||
|
if child is Strikethrough { output.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range) }
|
||||||
|
if let link = child as? Link, let destination = link.destination { output.addAttribute(.link, value: destination, range: range) }
|
||||||
|
return output
|
||||||
|
default: return literal(child.format())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let output = children(node)
|
||||||
|
// The parser deliberately omits terminal Markdown hard-break whitespace.
|
||||||
|
// It is still native note text and must survive migration/import.
|
||||||
|
var suffix = NSMaxRange(sourceRange)
|
||||||
|
while suffix > sourceRange.location, text.character(at: suffix - 1) == 32 || text.character(at: suffix - 1) == 9 { suffix -= 1 }
|
||||||
|
let rendered = output.string as NSString
|
||||||
|
var renderedSuffix = rendered.length
|
||||||
|
while renderedSuffix > 0, rendered.character(at: renderedSuffix - 1) == 32 || rendered.character(at: renderedSuffix - 1) == 9 { renderedSuffix -= 1 }
|
||||||
|
let missing = NSMaxRange(sourceRange) - suffix - (rendered.length - renderedSuffix)
|
||||||
|
if missing > 0 { output.append(result.attributedSubstring(from: NSRange(location: NSMaxRange(sourceRange) - missing, length: missing))) }
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private func visit(_ node: any Markup, listDepth: Int = 0) {
|
||||||
|
guard let whole = range(node) else {
|
||||||
|
for child in node.children { visit(child, listDepth: listDepth) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch node {
|
||||||
|
case let heading as Heading:
|
||||||
|
mark(whole, [NoteAttributes.block: "heading", NoteAttributes.headingLevel: heading.level], paragraph: true)
|
||||||
|
replacements.append((whole, inlineContents(node, sourceRange: whole)))
|
||||||
|
return
|
||||||
|
case let code as CodeBlock:
|
||||||
|
let full = text.paragraphRange(for: whole)
|
||||||
|
var value = code.code
|
||||||
|
if NSMaxRange(full) == text.length, !source.string.hasSuffix("\n"), value.hasSuffix("\n") { value.removeLast() }
|
||||||
|
// An empty code paragraph still needs a real character on which semantics can live.
|
||||||
|
if value.isEmpty { value = "\n" }
|
||||||
|
var attributes: [NSAttributedString.Key: Any] = [NoteAttributes.block: "code"]
|
||||||
|
if let language = code.language { attributes[NoteAttributes.language] = language }
|
||||||
|
let valueContents = NSMutableAttributedString(string: value, attributes: whole.location < result.length ? result.attributes(at: whole.location, effectiveRange: nil) : [:])
|
||||||
|
valueContents.addAttributes(attributes, range: NSRange(location: 0, length: valueContents.length))
|
||||||
|
var attachmentIndex = 0
|
||||||
|
var attachments: [NSTextAttachment] = []
|
||||||
|
source.enumerateAttribute(.attachment, in: full) { value, _, _ in
|
||||||
|
if let attachment = value as? NSTextAttachment { attachments.append(attachment) }
|
||||||
|
}
|
||||||
|
let characters = value as NSString
|
||||||
|
for index in 0..<characters.length where characters.character(at: index) == 0xfffc {
|
||||||
|
if attachmentIndex < attachments.count { valueContents.addAttribute(.attachment, value: attachments[attachmentIndex], range: NSRange(location: index, length: 1)) }
|
||||||
|
attachmentIndex += 1
|
||||||
|
}
|
||||||
|
replacements.append((full, valueContents))
|
||||||
|
return
|
||||||
|
case is BlockQuote:
|
||||||
|
var quoteDepth = 0
|
||||||
|
var ancestor = node.parent
|
||||||
|
while let current = ancestor {
|
||||||
|
if current is BlockQuote { quoteDepth += 1 }
|
||||||
|
ancestor = current.parent
|
||||||
|
}
|
||||||
|
mark(whole, [NoteAttributes.block: "quote", NoteAttributes.depth: quoteDepth], paragraph: true)
|
||||||
|
var cursor = text.paragraphRange(for: NSRange(location: whole.location, length: 0)).location
|
||||||
|
while cursor < NSMaxRange(whole) {
|
||||||
|
let line = text.paragraphRange(for: NSRange(location: cursor, length: 0))
|
||||||
|
var end = cursor
|
||||||
|
while end < NSMaxRange(line), text.character(at: end) == 32 { end += 1 }
|
||||||
|
while end < NSMaxRange(line), text.character(at: end) == 62 {
|
||||||
|
end += 1
|
||||||
|
if end < NSMaxRange(line), text.character(at: end) == 32 { end += 1 }
|
||||||
|
}
|
||||||
|
if end > cursor { deletions.append(NSRange(location: cursor, length: end - cursor)) }
|
||||||
|
cursor = NSMaxRange(line)
|
||||||
|
}
|
||||||
|
case is OrderedList, is UnorderedList:
|
||||||
|
let ordered = node as? OrderedList
|
||||||
|
var number = Int(ordered?.startIndex ?? 1)
|
||||||
|
for child in node.children {
|
||||||
|
guard let item = child as? ListItem else { continue }
|
||||||
|
for block in item.children {
|
||||||
|
if block is OrderedList || block is UnorderedList {
|
||||||
|
visit(block, listDepth: listDepth + 1)
|
||||||
|
} else {
|
||||||
|
if let itemRange = range(item), let body = range(block) {
|
||||||
|
var attributes: [NSAttributedString.Key: Any] = [NoteAttributes.block: ordered == nil ? "bullet" : "ordered", NoteAttributes.depth: listDepth]
|
||||||
|
if ordered != nil { attributes[NoteAttributes.number] = number }
|
||||||
|
if let checked = item.checkbox {
|
||||||
|
attributes[NoteAttributes.block] = "task"
|
||||||
|
switch checked { case .checked: attributes[NoteAttributes.checked] = true; case .unchecked: attributes[NoteAttributes.checked] = false }
|
||||||
|
}
|
||||||
|
mark(body, attributes, paragraph: true)
|
||||||
|
if body.location > itemRange.location, block.indexInParent == 0 {
|
||||||
|
let start = text.paragraphRange(for: NSRange(location: itemRange.location, length: 0)).location
|
||||||
|
deletions.append(NSRange(location: start, length: body.location - start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(block, listDepth: listDepth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
number += 1
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case is Paragraph:
|
||||||
|
replacements.append((whole, inlineContents(node, sourceRange: whole)))
|
||||||
|
return
|
||||||
|
case is Document, is ListItem:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
// Tables, HTML, remote image syntax and other unsupported constructs stay literal.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for child in node.children { visit(child, listDepth: listDepth) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ public struct NoteMarkdownExport {
|
|||||||
private let images: [(name: String, data: Data)]
|
private let images: [(name: String, data: Data)]
|
||||||
|
|
||||||
public init(contents: NSAttributedString) throws {
|
public init(contents: NSAttributedString) throws {
|
||||||
|
let contents = NoteDocument.markdown(contents)
|
||||||
let source = contents.string as NSString
|
let source = contents.string as NSString
|
||||||
var markdown = ""
|
var markdown = ""
|
||||||
var clipboard = ""
|
var clipboard = ""
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public struct Note: Identifiable {
|
|||||||
public final class NotesStore {
|
public final class NotesStore {
|
||||||
public private(set) var notes: [Note] = []
|
public private(set) var notes: [Note] = []
|
||||||
private let directory: URL
|
private let directory: URL
|
||||||
|
private var legacyIDs: Set<UUID> = []
|
||||||
|
|
||||||
public init(directory: URL = FileManager.default.urls(
|
public init(directory: URL = FileManager.default.urls(
|
||||||
for: .applicationSupportDirectory, in: .userDomainMask
|
for: .applicationSupportDirectory, in: .userDomainMask
|
||||||
@@ -34,16 +35,26 @@ public final class NotesStore {
|
|||||||
at: directory, includingPropertiesForKeys: nil
|
at: directory, includingPropertiesForKeys: nil
|
||||||
).filter { $0.pathExtension.lowercased() == "rtfd" }
|
).filter { $0.pathExtension.lowercased() == "rtfd" }
|
||||||
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||||
notes = try files.map { url in
|
var loaded: [Note] = []
|
||||||
|
for url in files {
|
||||||
guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else {
|
guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else {
|
||||||
throw CocoaError(.fileReadCorruptFile)
|
throw CocoaError(.fileReadCorruptFile)
|
||||||
}
|
}
|
||||||
let contents = try NSAttributedString(
|
let wrapper = try FileWrapper(url: url, options: .immediate)
|
||||||
url: url, options: [.documentType: NSAttributedString.DocumentType.rtfd],
|
guard wrapper.isDirectory else { throw CocoaError(.fileReadCorruptFile) }
|
||||||
documentAttributes: nil
|
let contents: NSAttributedString
|
||||||
)
|
if wrapper.fileWrappers?[NoteDocument.metadataFilename] != nil {
|
||||||
return Note(id: id, contents: contents)
|
contents = try NoteDocument.contents(from: wrapper)
|
||||||
|
} else {
|
||||||
|
guard let legacy = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil) else {
|
||||||
|
throw CocoaError(.fileReadCorruptFile)
|
||||||
}
|
}
|
||||||
|
contents = NoteDocument.importMarkdown(legacy)
|
||||||
|
legacyIDs.insert(id)
|
||||||
|
}
|
||||||
|
loaded.append(Note(id: id, contents: contents))
|
||||||
|
}
|
||||||
|
notes = loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
public func create() throws -> UUID {
|
public func create() throws -> UUID {
|
||||||
@@ -69,14 +80,29 @@ public final class NotesStore {
|
|||||||
}
|
}
|
||||||
try FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString + ".rtfd"))
|
try FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString + ".rtfd"))
|
||||||
notes.remove(at: index)
|
notes.remove(at: index)
|
||||||
|
legacyIDs.remove(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func save(_ note: Note) throws {
|
private func save(_ note: Note) throws {
|
||||||
let wrapper = try note.contents.fileWrapper(
|
let wrapper = try NoteDocument.fileWrapper(note.contents)
|
||||||
from: NSRange(location: 0, length: note.contents.length),
|
|
||||||
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
|
||||||
)
|
|
||||||
let url = directory.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true)
|
let url = directory.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true)
|
||||||
|
if legacyIDs.contains(note.id) {
|
||||||
|
let backups = directory.appendingPathComponent("LegacyBackups", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true)
|
||||||
|
let backup = backups.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true)
|
||||||
|
if !FileManager.default.fileExists(atPath: backup.path) {
|
||||||
|
// copyItem leaves the live legacy package untouched if backup creation fails.
|
||||||
|
try FileManager.default.copyItem(at: url, to: backup)
|
||||||
|
} else {
|
||||||
|
// A previous failed save may already have preserved the original package.
|
||||||
|
let original = try FileWrapper(url: backup, options: .immediate)
|
||||||
|
guard original.isDirectory,
|
||||||
|
NSAttributedString(rtfdFileWrapper: original, documentAttributes: nil) != nil else {
|
||||||
|
throw CocoaError(.fileWriteFileExists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
try wrapper.write(to: url, options: .atomic, originalContentsURL: url)
|
try wrapper.write(to: url, options: .atomic, originalContentsURL: url)
|
||||||
|
legacyIDs.remove(note.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
- typing `#{1,6} ` at the visible paragraph start sets the absolute heading level, replacing any existing level; fenced code remains literal.
|
- 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.
|
- 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.
|
- 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.
|
- keep native attributed-text editing, Undo, images, Markdown export, and RTFD autosaves; structured paragraph metadata is stored alongside RTFD content.
|
||||||
- [x] add whole-note Markdown export actions to the Notes Actions menu:
|
- [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>`.
|
- 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.
|
- Export as Markdown…: save a single `.md` file without images; with images, create a folder containing the Markdown and image files, using relative links.
|
||||||
@@ -21,10 +21,14 @@
|
|||||||
- [x] keep the caret on the heading's visible line after Backspace and Return, not on the preceding line.
|
- [x] keep the caret on the heading's visible line after Backspace and Return, not on the preceding line.
|
||||||
- [x] improve:
|
- [x] improve:
|
||||||
- add cmd+shift+s for strikethrough and cmd+shift+u for underline
|
- add cmd+shift+s for strikethrough and cmd+shift+u for underline
|
||||||
- don't hide the backticks surrounding a codeblock
|
- code-block fences were made visible in source editing; superseded by the structured editing cutover below.
|
||||||
- give inline codeblocks (e.g. `some code snippet`) an inline container and an orange text color matching the color scheme of the conjure application
|
- give inline codeblocks (e.g. `some code snippet`) an inline container and an orange text color matching the color scheme of the conjure application
|
||||||
- [x] feature: vim motions
|
- [x] feature: vim motions
|
||||||
- opt-in Normal/Insert/Visual editing, counted motions, delete/change/yank, rich-text paste, and native Undo/Redo.
|
- opt-in Normal/Insert/Visual editing, counted motions, delete/change/yank, rich-text paste, and native Undo/Redo.
|
||||||
|
- [x] use Notion-style structured editing instead of hidden Markdown source:
|
||||||
|
- Markdown input shortcuts become attributed marks and paragraph types; native editing and Vim share visible offsets and Undo.
|
||||||
|
- preserve image bytes and versioned semantic metadata in RTFD packages; back up legacy packages before their first structured save.
|
||||||
|
- generate normalized Markdown for export; plain-text paste stays literal.
|
||||||
- [ ] feature: configurable color scheme
|
- [ ] feature: configurable color scheme
|
||||||
- general design should stay consistent
|
- general design should stay consistent
|
||||||
- only colors should be configurable; not the way the colors are used/presented (e.g. frosted glass effect with color blobs behind shouldn't change)
|
- only colors should be configurable; not the way the colors are used/presented (e.g. frosted glass effect with color blobs behind shouldn't change)
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Conjure
|
@testable import Conjure
|
||||||
|
|
||||||
@Test @MainActor func fencedCodeHighlightsTokensWithoutChangingSourceOrApplyingMarkdown() throws {
|
@Test @MainActor
|
||||||
|
func codeBlocksHighlightTokensWithoutInterpretingTheirLiteralMarkdown() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
for fence in ["```", "~~~~"] {
|
for fence in ["```", "~~~~"] {
|
||||||
let text = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```"
|
let source = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```"
|
||||||
let editor = NSTextView()
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500))
|
||||||
editor.string = text
|
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||||
MarkdownHighlighting.apply(to: editor)
|
|
||||||
let storage = try #require(editor.textStorage)
|
let storage = try #require(editor.textStorage)
|
||||||
func attributes(_ token: String) -> [NSAttributedString.Key: Any] {
|
func attributes(_ token: String) -> [NSAttributedString.Key: Any] {
|
||||||
storage.attributes(at: (text as NSString).range(of: token).location, effectiveRange: nil)
|
storage.attributes(at: (editor.string as NSString).range(of: token).location, effectiveRange: nil)
|
||||||
}
|
}
|
||||||
let keyword = try #require(attributes("def")[.foregroundColor] as? NSColor)
|
let keyword = try #require(attributes("def")[.foregroundColor] as? NSColor)
|
||||||
let name = try #require(attributes("main")[.foregroundColor] as? NSColor)
|
let name = try #require(attributes("main")[.foregroundColor] as? NSColor)
|
||||||
@@ -19,20 +20,23 @@ import Testing
|
|||||||
#expect(keyword != name)
|
#expect(keyword != name)
|
||||||
#expect(comment != keyword)
|
#expect(comment != keyword)
|
||||||
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true)
|
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true)
|
||||||
#expect(storage.string == text)
|
#expect(attributes("**not bold**")[NoteAttributes.bold] as? Bool != true)
|
||||||
editor.string = "```python\ndef main():\n pass"
|
#expect(attributes("# literal")[NoteAttributes.headingLevel] == nil)
|
||||||
MarkdownHighlighting.apply(to: editor)
|
#expect(attributes("# literal")[NoteAttributes.block] as? String == "code")
|
||||||
|
#expect(editor.string == "Heading 🦊\ndef main():\n # comment\n return \"**not bold**\"\nAfter\n# literal")
|
||||||
|
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "```python\ndef main():\n pass")))
|
||||||
let unfinished = try #require(editor.textStorage)
|
let unfinished = try #require(editor.textStorage)
|
||||||
#expect(unfinished.attribute(.foregroundColor, at: 10, effectiveRange: nil) as? NSColor == keyword)
|
#expect(unfinished.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == keyword)
|
||||||
|
#expect(editor.string == "def main():\n pass")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor func noteHeadingsShrinkByLevelWithoutChangingMarkdownOrCode() throws {
|
@Test @MainActor
|
||||||
|
func headingLevelsUseDescendingSizesAndCodeRemainsBodySized() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
let editor = NSTextView()
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 600))
|
||||||
let text = "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\nBody\n## **Bold** and *italic*\n```text\n# Literal\n```\n####### Not a heading"
|
let source = "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\nBody\n## **Bold** and *italic*\n```text\n# Literal\n```\n####### Not a heading"
|
||||||
editor.string = text
|
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||||
MarkdownHighlighting.apply(to: editor)
|
|
||||||
let storage = try #require(editor.textStorage)
|
let storage = try #require(editor.textStorage)
|
||||||
func font(_ token: String) throws -> NSFont {
|
func font(_ token: String) throws -> NSFont {
|
||||||
try #require(storage.attribute(.font, at: (editor.string as NSString).range(of: token).location,
|
try #require(storage.attribute(.font, at: (editor.string as NSString).range(of: token).location,
|
||||||
@@ -41,13 +45,14 @@ import Testing
|
|||||||
let sizes = try ["One", "Two", "Three", "Four", "Five", "Six"].map { try font($0).pointSize }
|
let sizes = try ["One", "Two", "Three", "Four", "Five", "Six"].map { try font($0).pointSize }
|
||||||
for (larger, smaller) in zip(sizes, sizes.dropFirst()) { #expect(larger > smaller) }
|
for (larger, smaller) in zip(sizes, sizes.dropFirst()) { #expect(larger > smaller) }
|
||||||
#expect(try font("Six").pointSize >= font("Body").pointSize)
|
#expect(try font("Six").pointSize >= font("Body").pointSize)
|
||||||
#expect(try font("**Bold**").pointSize == font("Two").pointSize)
|
#expect(try font("Bold").pointSize == font("Two").pointSize)
|
||||||
#expect(try font("*italic*").pointSize == font("Two").pointSize)
|
#expect(try font("italic").pointSize == font("Two").pointSize)
|
||||||
#expect(try font("Literal").pointSize == font("Body").pointSize)
|
#expect(try font("Literal").pointSize == font("Body").pointSize)
|
||||||
#expect(try font("Not a heading").pointSize == font("Body").pointSize)
|
#expect(try font("Not a heading").pointSize == font("Body").pointSize)
|
||||||
#expect(try font("Three").isFixedPitch)
|
#expect(try font("Three").isFixedPitch)
|
||||||
#expect(editor.string == text)
|
let three = (editor.string as NSString).range(of: "Three")
|
||||||
storage.replaceCharacters(in: (editor.string as NSString).range(of: "### Three"), with: "Three")
|
editor.setSelectedRange(three)
|
||||||
MarkdownHighlighting.apply(to: editor)
|
for _ in 0..<4 { editor.format(0) }
|
||||||
|
#expect(editor.string.contains("Three"))
|
||||||
#expect(try font("Three").pointSize == font("Body").pointSize)
|
#expect(try font("Three").pointSize == font("Body").pointSize)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markdownImportProducesVisibleTextAndSemanticFormatting() throws {
|
||||||
|
let markdown = "# Title 🦊\n**bold** *italic* ***both*** <u>under</u> ~~gone~~ `let x = 1` [site](https://example.com)\n\n> quote\n\n- [ ] todo\n- [x] done\n - nested\n\n3. third\n4. fourth\n\n```swift\n# literal **code**\n```\n"
|
||||||
|
let document = NoteDocument.importMarkdown(NSAttributedString(string: markdown))
|
||||||
|
#expect(document.string == "Title 🦊\nbold italic both under gone let x = 1 site\n\nquote\n\ntodo\ndone\nnested\n\nthird\nfourth\n\n# literal **code**\n")
|
||||||
|
func attribute(_ key: NSAttributedString.Key, _ text: String) throws -> Any? {
|
||||||
|
let range = (document.string as NSString).range(of: text)
|
||||||
|
#expect(range.location != NSNotFound)
|
||||||
|
return document.attribute(key, at: range.location, effectiveRange: nil)
|
||||||
|
}
|
||||||
|
#expect(try attribute(NoteAttributes.block, "Title") as? String == "heading")
|
||||||
|
#expect(try attribute(NoteAttributes.headingLevel, "Title") as? Int == 1)
|
||||||
|
#expect(try attribute(NoteAttributes.bold, "bold") as? Bool == true)
|
||||||
|
#expect(try attribute(NoteAttributes.italic, "italic") as? Bool == true)
|
||||||
|
#expect(try attribute(NoteAttributes.bold, "both") as? Bool == true)
|
||||||
|
#expect(try attribute(NoteAttributes.italic, "both") as? Bool == true)
|
||||||
|
#expect(try attribute(.underlineStyle, "under") as? Int == NSUnderlineStyle.single.rawValue)
|
||||||
|
#expect(try attribute(.strikethroughStyle, "gone") as? Int == NSUnderlineStyle.single.rawValue)
|
||||||
|
#expect(try attribute(NoteAttributes.inlineCode, "let x") as? Bool == true)
|
||||||
|
#expect(try attribute(.link, "site") as? String == "https://example.com")
|
||||||
|
#expect(try attribute(NoteAttributes.block, "quote") as? String == "quote")
|
||||||
|
#expect(try attribute(NoteAttributes.checked, "todo") as? Bool == false)
|
||||||
|
#expect(try attribute(NoteAttributes.checked, "done") as? Bool == true)
|
||||||
|
#expect(try attribute(NoteAttributes.depth, "nested") as? Int == 1)
|
||||||
|
#expect(try attribute(NoteAttributes.number, "third") as? Int == 3)
|
||||||
|
#expect(try attribute(NoteAttributes.number, "fourth") as? Int == 4)
|
||||||
|
#expect(try attribute(NoteAttributes.block, "# literal") as? String == "code")
|
||||||
|
#expect(try attribute(NoteAttributes.language, "# literal") as? String == "swift")
|
||||||
|
#expect(try attribute(NoteAttributes.bold, "**code**") == nil)
|
||||||
|
let restored = NoteDocument.importMarkdown(NoteDocument.markdown(document))
|
||||||
|
#expect(restored.string == document.string)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markdownImportHandlesEveryHeadingEscapesAndUnsupportedLiteralContent() {
|
||||||
|
for level in 1...6 {
|
||||||
|
let document = NoteDocument.importMarkdown(NSAttributedString(string: String(repeating: "#", count: level) + " Heading"))
|
||||||
|
#expect(document.string == "Heading")
|
||||||
|
#expect(document.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
|
||||||
|
}
|
||||||
|
let escaped = NoteDocument.importMarkdown(NSAttributedString(string: "\\*literal\\* & café 🦊"))
|
||||||
|
#expect(escaped.string == "*literal* & café 🦊")
|
||||||
|
#expect(escaped.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) == nil)
|
||||||
|
for literal in ["", "<div>literal **content**</div>", "| A | B |\n| - | - |\n| x | y |", "---"] {
|
||||||
|
#expect(NoteDocument.importMarkdown(NSAttributedString(string: literal)).string == literal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markdownConversionPreservesNativeAttachmentsAndLiteralCode() throws {
|
||||||
|
let attachment = NSTextAttachment()
|
||||||
|
let source = NSMutableAttributedString(string: "**fox ")
|
||||||
|
source.append(NSAttributedString(attachment: attachment))
|
||||||
|
source.append(NSAttributedString(string: "**\n```text\n`literal` **not bold**\n```"))
|
||||||
|
let document = NoteDocument.importMarkdown(source)
|
||||||
|
#expect(document.string == "fox \u{fffc}\n`literal` **not bold**")
|
||||||
|
#expect(document.attribute(.attachment, at: 4, effectiveRange: nil) as? NSTextAttachment === attachment)
|
||||||
|
let markdown = NoteDocument.markdown(document)
|
||||||
|
#expect(markdown.string.contains("```text\n`literal` **not bold**\n```"))
|
||||||
|
let location = (markdown.string as NSString).range(of: "\u{fffc}").location
|
||||||
|
#expect(markdown.attribute(.attachment, at: location, effectiveRange: nil) as? NSTextAttachment === attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markdownExportRecognizesNativeMarksAndEscapesLiteralSyntax() {
|
||||||
|
let native = NSMutableAttributedString(string: "bold italic under strike link literal *stars* #tag")
|
||||||
|
let text = native.string as NSString
|
||||||
|
native.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: 14), range: text.range(of: "bold"))
|
||||||
|
native.addAttribute(.font, value: NSFontManager.shared.convert(NSFont.systemFont(ofSize: 14), toHaveTrait: .italicFontMask), range: text.range(of: "italic"))
|
||||||
|
native.addAttribute(.underlineStyle, value: 1, range: text.range(of: "under"))
|
||||||
|
native.addAttribute(.strikethroughStyle, value: 1, range: text.range(of: "strike"))
|
||||||
|
native.addAttribute(.link, value: URL(string: "https://example.com")!, range: text.range(of: "link"))
|
||||||
|
let output = NoteDocument.markdown(native)
|
||||||
|
#expect(output.string == "**bold** *italic* <u>under</u> ~~strike~~ [link](<https://example.com>) literal \\*stars\\* \\#tag")
|
||||||
|
#expect(NoteDocument.importMarkdown(output).string == native.string)
|
||||||
|
native.addAttribute(NoteAttributes.bold, value: false, range: text.range(of: "bold"))
|
||||||
|
#expect(NoteDocument.markdown(native).string.hasPrefix("bold "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func structuredPackageRoundTripsAllSemanticAttributesIncludingFalseAndZero() throws {
|
||||||
|
let document = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "###### Heading\n- [ ] todo\n 0. nested\n```swift\nlet x = 1\n```\n**bold** *italic* <u>under</u> ~~strike~~ `code` [link](https://example.com)")))
|
||||||
|
document.addAttributes([NoteAttributes.bold: false, NoteAttributes.italic: false, NoteAttributes.inlineCode: false, NoteAttributes.depth: 0], range: NSRange(location: 0, length: 1))
|
||||||
|
let wrapper = try NoteDocument.fileWrapper(document)
|
||||||
|
let serialized = try #require(wrapper.serializedRepresentation)
|
||||||
|
let received = try #require(FileWrapper(serializedRepresentation: serialized))
|
||||||
|
let restored = try NoteDocument.contents(from: received)
|
||||||
|
#expect(restored.string == document.string)
|
||||||
|
let keys: [NSAttributedString.Key] = [NoteAttributes.block, NoteAttributes.headingLevel, NoteAttributes.depth, NoteAttributes.number, NoteAttributes.checked, NoteAttributes.language, NoteAttributes.inlineCode, NoteAttributes.bold, NoteAttributes.italic, .underlineStyle, .strikethroughStyle, .link]
|
||||||
|
for index in 0..<document.length {
|
||||||
|
for key in keys {
|
||||||
|
let expected = document.attribute(key, at: index, effectiveRange: nil)
|
||||||
|
let actual = restored.attribute(key, at: index, effectiveRange: nil)
|
||||||
|
if let expected { #expect((expected as? NSObject)?.isEqual(actual) == true) }
|
||||||
|
else { #expect(actual == nil || (key == .underlineStyle || key == .strikethroughStyle) && actual as? Int == 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func structuredPackagesRejectMissingCorruptMismatchedAndUnsafeMetadata() throws {
|
||||||
|
let document = NSAttributedString(string: "safe text")
|
||||||
|
for mutation in ["version", "length", "digest", "runs"] {
|
||||||
|
let wrapper = try NoteDocument.fileWrapper(document)
|
||||||
|
let original = try #require(wrapper.fileWrappers?["ConjureNote.json"])
|
||||||
|
let data = try #require(original.regularFileContents)
|
||||||
|
var metadata = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||||
|
switch mutation {
|
||||||
|
case "version": metadata[mutation] = 999
|
||||||
|
case "length": metadata[mutation] = 999
|
||||||
|
case "digest": metadata[mutation] = "not the text digest"
|
||||||
|
default: metadata[mutation] = [["location": 1, "length": 9, "values": [:]]]
|
||||||
|
}
|
||||||
|
wrapper.removeFileWrapper(original)
|
||||||
|
let replacement = FileWrapper(regularFileWithContents: try JSONSerialization.data(withJSONObject: metadata))
|
||||||
|
replacement.preferredFilename = "ConjureNote.json"
|
||||||
|
wrapper.addFileWrapper(replacement)
|
||||||
|
#expect(throws: NoteDocument.DocumentError.self) { try NoteDocument.contents(from: wrapper) }
|
||||||
|
}
|
||||||
|
let missing = try document.fileWrapper(from: NSRange(location: 0, length: document.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd])
|
||||||
|
#expect(throws: NoteDocument.DocumentError.self) { try NoteDocument.contents(from: missing) }
|
||||||
|
let unsafe = try NoteDocument.fileWrapper(document)
|
||||||
|
let link = FileWrapper(symbolicLinkWithDestinationURL: URL(fileURLWithPath: "/etc/passwd"))
|
||||||
|
link.preferredFilename = "outside"
|
||||||
|
unsafe.addFileWrapper(link)
|
||||||
|
#expect(throws: NoteDocument.DocumentError.self) { try NoteDocument.contents(from: unsafe) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func structuredPackagesRejectNonPackageRootsAndReservedAttachmentFilenames() throws {
|
||||||
|
#expect(throws: NoteDocument.DocumentError.self) {
|
||||||
|
try NoteDocument.contents(from: FileWrapper(regularFileWithContents: Data()))
|
||||||
|
}
|
||||||
|
let attachment = FileWrapper(regularFileWithContents: Data("attachment".utf8))
|
||||||
|
attachment.preferredFilename = "ConjureNote.json"
|
||||||
|
#expect(throws: NoteDocument.DocumentError.self) {
|
||||||
|
try NoteDocument.fileWrapper(NSAttributedString(attachment: NSTextAttachment(fileWrapper: attachment)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markdownLazyContinuationUsesASTTextWithoutDuplicatingSourceOrLosingLinebreaks() {
|
||||||
|
for (source, expected) in [
|
||||||
|
("- [ ] task\nnext", "task\nnext"),
|
||||||
|
("- [ ] task\n**bold** and `code`\nlast", "task\nbold and code\nlast"),
|
||||||
|
("> quote\n[link](https://example.com)\nnext", "quote\nlink\nnext"),
|
||||||
|
("- item\n***both*** <u>under</u>\nlast", "item\nboth under\nlast"),
|
||||||
|
] {
|
||||||
|
let contents = NoteDocument.importMarkdown(NSAttributedString(string: source))
|
||||||
|
#expect(contents.string == expected)
|
||||||
|
}
|
||||||
|
let attachment = NSTextAttachment()
|
||||||
|
let source = NSMutableAttributedString(string: "# Heading\n- [ ] task\n**bold** and `code`\n")
|
||||||
|
source.append(NSAttributedString(attachment: attachment))
|
||||||
|
let contents = NoteDocument.importMarkdown(source)
|
||||||
|
#expect(contents.string == "Heading\ntask\nbold and code\n\u{fffc}")
|
||||||
|
#expect(contents.attribute(.attachment, at: contents.length - 1, effectiveRange: nil) as? NSTextAttachment === attachment)
|
||||||
|
let bold = (contents.string as NSString).range(of: "bold")
|
||||||
|
#expect(contents.attribute(NoteAttributes.bold, at: bold.location, effectiveRange: nil) as? Bool == true)
|
||||||
|
let code = (contents.string as NSString).range(of: "code")
|
||||||
|
#expect(contents.attribute(NoteAttributes.inlineCode, at: code.location, effectiveRange: nil) as? Bool == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markdownExportGroupsSemanticMarksAcrossDisplayRunsAndKeepsWhitespace() {
|
||||||
|
let contents = NSMutableAttributedString(string: "fox 🦊 \n")
|
||||||
|
let marked = NSRange(location: 0, length: (contents.string as NSString).range(of: " \n").location)
|
||||||
|
contents.addAttributes([.underlineStyle: 1, NoteAttributes.bold: false, NoteAttributes.italic: false], range: NSRange(location: 0, length: contents.length))
|
||||||
|
contents.addAttribute(.font, value: NSFont.systemFont(ofSize: 14), range: NSRange(location: 0, length: 4))
|
||||||
|
contents.addAttributes([.font: NSFont.systemFont(ofSize: 18), .foregroundColor: NSColor.red], range: NSRange(location: 4, length: marked.length - 4))
|
||||||
|
#expect(NoteDocument.markdown(contents).string == "<u>fox 🦊 </u>\n")
|
||||||
|
contents.addAttribute(NoteAttributes.bold, value: true, range: marked)
|
||||||
|
#expect(NoteDocument.markdown(contents).string == "**<u>fox 🦊</u>**<u> </u>\n")
|
||||||
|
|
||||||
|
for source in ["- [ ] todo \n", "first \nsecond \t\n", "trailing "] {
|
||||||
|
let imported = NoteDocument.importMarkdown(NSAttributedString(string: source))
|
||||||
|
let expected = source.hasPrefix("- [ ] ") ? String(source.dropFirst(6)) : source
|
||||||
|
#expect(imported.string == expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ func markdownExportPreservesWholeTextAndSnapshotsBeforeWriting() throws {
|
|||||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
defer { try? FileManager.default.removeItem(at: directory) }
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
|
||||||
let text = "\n # 日本語 🦊 \r\n**bold** and `code`\n- [ ] task\n\u{fffc}\n"
|
let text = "\n日本語 🦊 \r\nplain and code\nordinary task\n\u{fffc}\n"
|
||||||
let source = NSMutableAttributedString(string: text)
|
let source = NSMutableAttributedString(string: text)
|
||||||
source.addAttribute(.attachment, value: NSTextAttachment(), range: NSRange(location: 0, length: 1))
|
source.addAttribute(.attachment, value: NSTextAttachment(), range: NSRange(location: 0, length: 1))
|
||||||
let export = try NoteMarkdownExport(contents: source)
|
let export = try NoteMarkdownExport(contents: source)
|
||||||
@@ -32,13 +32,13 @@ func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource(
|
|||||||
wrapper.preferredFilename = name
|
wrapper.preferredFilename = name
|
||||||
let originalFilename = wrapper.filename
|
let originalFilename = wrapper.filename
|
||||||
let attachment = NSTextAttachment(fileWrapper: wrapper)
|
let attachment = NSTextAttachment(fileWrapper: wrapper)
|
||||||
let source = NSMutableAttributedString(string: "\n # 🦊\n")
|
let source = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "\n# 🦊\n")))
|
||||||
source.append(NSAttributedString(attachment: attachment))
|
source.append(NSAttributedString(attachment: attachment))
|
||||||
source.append(NSAttributedString(string: "\n- [ ] todo \n"))
|
source.append(NoteDocument.importMarkdown(NSAttributedString(string: "\n- [ ] todo \n")))
|
||||||
let before = NSAttributedString(attributedString: source)
|
let before = NSAttributedString(attributedString: source)
|
||||||
let export = try NoteMarkdownExport(contents: source)
|
let export = try NoteMarkdownExport(contents: source)
|
||||||
#expect(export.hasImages)
|
#expect(export.hasImages)
|
||||||
#expect(export.clipboardText == "\n # 🦊\n``<pixel [x] `1` #%.png>``\n- [ ] todo \n")
|
#expect(export.clipboardText == "\n# 🦊\n``<pixel [x] `1` #%.png>``\n- [ ] todo \n")
|
||||||
#expect(source.isEqual(to: before))
|
#expect(source.isEqual(to: before))
|
||||||
#expect(wrapper.preferredFilename == name)
|
#expect(wrapper.preferredFilename == name)
|
||||||
#expect(wrapper.filename == originalFilename)
|
#expect(wrapper.filename == originalFilename)
|
||||||
@@ -46,7 +46,7 @@ func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource(
|
|||||||
source.mutableString.setString("Changed")
|
source.mutableString.setString("Changed")
|
||||||
wrapper.preferredFilename = "changed.png"
|
wrapper.preferredFilename = "changed.png"
|
||||||
try export.write(to: directory)
|
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"
|
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 String(contentsOf: directory.appendingPathComponent("note.md"), encoding: .utf8) == markdown)
|
||||||
#expect(try Data(contentsOf: directory.appendingPathComponent(name)) == png)
|
#expect(try Data(contentsOf: directory.appendingPathComponent(name)) == png)
|
||||||
#expect(Set(try FileManager.default.contentsOfDirectory(atPath: directory.path)) == ["note.md", name])
|
#expect(Set(try FileManager.default.contentsOfDirectory(atPath: directory.path)) == ["note.md", name])
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ func vimNormalCursorIsWideButInsertAndDisabledCursorsStayThin() throws {
|
|||||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 50))
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 50))
|
||||||
editor.font = NotesAppearance.font
|
editor.font = NotesAppearance.font
|
||||||
editor.drawsBackground = false
|
editor.drawsBackground = false
|
||||||
editor.setMarkdownContents(NSAttributedString(string: "text"))
|
editor.setDocumentContents(NSAttributedString(string: "text"))
|
||||||
func paintedWidth() throws -> Int {
|
func paintedWidth() throws -> Int {
|
||||||
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 100, pixelsHigh: 50,
|
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 100, pixelsHigh: 50,
|
||||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import Testing
|
|||||||
@testable import Conjure
|
@testable import Conjure
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
|
func headingShortcutsConsumeSyntaxAndPersistAllSixLevels() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
let suite = "NotesHeadings.\(UUID().uuidString)"
|
let suite = "NotesHeadings.\(UUID().uuidString)"
|
||||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||||
@@ -21,90 +21,60 @@ func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
|
|||||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||||
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
||||||
window.makeFirstResponder(editor)
|
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 before in ["\n# Title", "\n## Title", "Text\n\n###### Title", "- [ ] 🦊\n\n## Title"] {
|
|
||||||
reset(before)
|
|
||||||
editor.undoManager?.beginUndoGrouping()
|
|
||||||
editor.doCommand(by: #selector(NSResponder.deleteBackward(_:)))
|
|
||||||
editor.undoManager?.endUndoGrouping()
|
|
||||||
let joined = editor.markdownString
|
|
||||||
editor.breakUndoCoalescing()
|
|
||||||
editor.undoManager?.removeAllActions()
|
|
||||||
editor.undoManager?.beginUndoGrouping()
|
|
||||||
editor.doCommand(by: #selector(NSResponder.insertNewline(_:)))
|
|
||||||
editor.undoManager?.endUndoGrouping()
|
|
||||||
#expect(editor.markdownString == before)
|
|
||||||
#expect(editor.selectedRange() == NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0))
|
|
||||||
editor.undoManager?.undo()
|
|
||||||
#expect(editor.markdownString == joined)
|
|
||||||
editor.undoManager?.redo()
|
|
||||||
#expect(editor.markdownString == before)
|
|
||||||
#expect(controller.save())
|
|
||||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == before)
|
|
||||||
}
|
|
||||||
for (before, position, after) in [
|
|
||||||
("## Title", 3, "\n## Title"),
|
|
||||||
("## Title", 5, "## Ti\ntle"),
|
|
||||||
("## Title", 8, "## Title\n"),
|
|
||||||
("Text\n## Title", 4, "Text\n\n## Title"),
|
|
||||||
("```text\n## Title\n```", 11, "```text\n## \nTitle\n```")
|
|
||||||
] {
|
|
||||||
reset(before)
|
|
||||||
editor.setSelectedRange(NSRange(location: position, length: 0))
|
|
||||||
editor.doCommand(by: #selector(NSResponder.insertNewline(_:)))
|
|
||||||
#expect(editor.markdownString == after)
|
|
||||||
}
|
|
||||||
for level in 1...6 {
|
for level in 1...6 {
|
||||||
reset("### Title")
|
editor.setDocumentContents(NSAttributedString(string: "Title"))
|
||||||
editor.undoManager?.beginUndoGrouping()
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
|
editor.beginInsertionSession()
|
||||||
for character in String(repeating: "#", count: level) + " " {
|
for character in String(repeating: "#", count: level) + " " {
|
||||||
editor.insertText(String(character), replacementRange: editor.selectedRange())
|
editor.insertText(String(character), replacementRange: editor.selectedRange())
|
||||||
}
|
}
|
||||||
editor.undoManager?.endUndoGrouping()
|
editor.endInsertionSession()
|
||||||
let expected = String(repeating: "#", count: level) + " Title"
|
#expect(editor.string == "Title")
|
||||||
#expect(editor.markdownString == expected)
|
#expect(editor.selectedRange() == NSRange(location: 0, length: 0))
|
||||||
let title = (editor.string as NSString).range(of: "Title")
|
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "heading")
|
||||||
#expect(editor.selectedRange().location == title.location)
|
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
|
||||||
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()
|
editor.undoManager?.undo()
|
||||||
#expect(editor.markdownString == "### Title")
|
#expect(editor.string == "Title")
|
||||||
|
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil)
|
||||||
editor.undoManager?.redo()
|
editor.undoManager?.redo()
|
||||||
#expect(editor.markdownString == expected)
|
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
|
||||||
#expect(controller.save())
|
#expect(controller.save())
|
||||||
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
||||||
#expect(reloaded.contents.string == expected)
|
#expect(reloaded.contents.string == "Title")
|
||||||
|
#expect(reloaded.contents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
|
||||||
}
|
}
|
||||||
reset("```text\n## Title\n```")
|
}
|
||||||
editor.insertText("# ", replacementRange: editor.selectedRange())
|
|
||||||
#expect(editor.markdownString == "```text\n## # Title\n```")
|
@Test @MainActor
|
||||||
|
func headingReturnSplitsVisibleParagraphAndUndoRestoresItsSemantics() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500))
|
||||||
|
editor.allowsUndo = true
|
||||||
|
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
|
window.contentView = editor
|
||||||
|
window.makeFirstResponder(editor)
|
||||||
|
defer { window.orderOut(nil) }
|
||||||
|
for position in [2, 5] {
|
||||||
|
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "## Title")))
|
||||||
|
editor.setSelectedRange(NSRange(location: position, length: 0))
|
||||||
|
editor.undoManager?.removeAllActions()
|
||||||
|
editor.insertNewline(nil)
|
||||||
|
#expect(editor.string == (position == 2 ? "Ti\ntle" : "Title\n"))
|
||||||
|
#expect(editor.selectedRange() == NSRange(location: position + 1, length: 0))
|
||||||
|
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 2)
|
||||||
|
editor.insertText("body", replacementRange: editor.selectedRange())
|
||||||
|
#expect(editor.documentContents.attribute(NoteAttributes.block, at: position + 1, effectiveRange: nil) == nil)
|
||||||
|
editor.undoManager?.undo()
|
||||||
|
editor.undoManager?.undo()
|
||||||
|
#expect(editor.string == "Title")
|
||||||
|
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 2)
|
||||||
|
}
|
||||||
|
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "```text\n## Title\n```")))
|
||||||
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
|
for character in "# " { editor.insertText(String(character), replacementRange: editor.selectedRange()) }
|
||||||
|
#expect(editor.string == "# ## Title")
|
||||||
|
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "code")
|
||||||
|
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -116,7 +86,8 @@ func headingCommandsKeepCaretOnTheVisibleBodyLine() throws {
|
|||||||
window.makeFirstResponder(editor)
|
window.makeFirstResponder(editor)
|
||||||
defer { window.orderOut(nil) }
|
defer { window.orderOut(nil) }
|
||||||
for source in ["\n# Title", "Text\n\n## Title", "Text\n\n\n###### Title"] {
|
for source in ["\n# Title", "Text\n\n## Title", "Text\n\n\n###### Title"] {
|
||||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||||
|
let before = editor.string
|
||||||
editor.setSelectedRange(NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0))
|
editor.setSelectedRange(NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0))
|
||||||
for command in [#selector(NSResponder.deleteBackward(_:)), #selector(NSResponder.insertNewline(_:))] {
|
for command in [#selector(NSResponder.deleteBackward(_:)), #selector(NSResponder.insertNewline(_:))] {
|
||||||
editor.doCommand(by: command)
|
editor.doCommand(by: command)
|
||||||
@@ -128,6 +99,6 @@ func headingCommandsKeepCaretOnTheVisibleBodyLine() throws {
|
|||||||
#expect(caretRect.origin == titleRect.origin)
|
#expect(caretRect.origin == titleRect.origin)
|
||||||
#expect(caretRect.height == titleRect.height)
|
#expect(caretRect.height == titleRect.height)
|
||||||
}
|
}
|
||||||
#expect(editor.markdownString == source)
|
#expect(editor.string == before)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,92 +4,98 @@ import Testing
|
|||||||
@testable import Conjure
|
@testable import Conjure
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws {
|
func importedFormattingBecomesVisibleTextAndNativeSemanticMarks() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 800))
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 800))
|
||||||
let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ <u>underlined</u> `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*"
|
let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ <u>underlined</u> `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```"
|
||||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||||
MarkdownHighlighting.apply(to: editor)
|
#expect(editor.string == "Heading\nSubheading\nitalic bold emphasis strong gone underlined *literal*\nQuote\nLabel\n# Code *literal*")
|
||||||
editor.refreshTasks()
|
func attribute(_ key: NSAttributedString.Key, in token: String) -> Any? {
|
||||||
let layout = try #require(editor.layoutManager)
|
editor.documentContents.attribute(key, at: (editor.string as NSString).range(of: token).location, effectiveRange: nil)
|
||||||
let container = try #require(editor.textContainer)
|
|
||||||
layout.ensureLayout(for: container)
|
|
||||||
func visibleText() -> String {
|
|
||||||
var result = ""
|
|
||||||
let text = editor.string as NSString
|
|
||||||
for index in 0..<text.length {
|
|
||||||
let glyph = layout.glyphIndexForCharacter(at: index)
|
|
||||||
if !layout.propertyForGlyph(at: glyph).contains(.null) {
|
|
||||||
result += text.substring(with: NSRange(location: index, length: 1))
|
|
||||||
}
|
}
|
||||||
}
|
#expect(attribute(NoteAttributes.headingLevel, in: "Heading") as? Int == 1)
|
||||||
return result
|
#expect(attribute(NoteAttributes.headingLevel, in: "Subheading") as? Int == 2)
|
||||||
}
|
for token in ["italic", "emphasis"] { #expect(attribute(NoteAttributes.italic, in: token) as? Bool == true) }
|
||||||
#expect(visibleText() == "Heading\nSubheading\nitalic bold emphasis strong gone underlined *literal*\nQuote\nLabel\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*")
|
for token in ["bold", "strong"] { #expect(attribute(NoteAttributes.bold, in: token) as? Bool == true) }
|
||||||
#expect(editor.markdownString == source)
|
#expect(attribute(.strikethroughStyle, in: "gone") as? Int == NSUnderlineStyle.single.rawValue)
|
||||||
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
|
#expect(attribute(.underlineStyle, in: "underlined") as? Int == NSUnderlineStyle.single.rawValue)
|
||||||
let pasteboard = NSPasteboard.withUniqueName()
|
#expect(attribute(NoteAttributes.inlineCode, in: "*literal*") as? Bool == true)
|
||||||
defer { pasteboard.releaseGlobally() }
|
#expect(attribute(NoteAttributes.block, in: "Quote") as? String == "quote")
|
||||||
#expect(editor.writeSelection(to: pasteboard, types: editor.writablePasteboardTypes))
|
#expect(String(describing: try #require(attribute(.link, in: "Label"))) == "https://example.com")
|
||||||
#expect(pasteboard.string(forType: .string) == source)
|
#expect(attribute(NoteAttributes.block, in: "# Code") as? String == "code")
|
||||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
#expect(attribute(NoteAttributes.bold, in: "# Code") as? Bool != true)
|
||||||
defer { try? FileManager.default.removeItem(at: directory) }
|
#expect(attribute(NoteAttributes.headingLevel, in: "# Code") == nil)
|
||||||
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)
|
|
||||||
#expect(reloaded.contents.string == source)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class NotesLayoutProbe: NSLayoutManager {
|
@Test @MainActor
|
||||||
var observeFormatting: ((NSTextStorage) -> Void)?
|
func completedInlineShortcutsConsumeDelimitersAndKeepUndoableNativeMarks() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
override func processEditing(for textStorage: NSTextStorage, edited editMask: NSTextStorageEditActions,
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||||
range newCharRange: NSRange, changeInLength delta: Int,
|
editor.allowsUndo = true
|
||||||
invalidatedRange invalidatedCharRange: NSRange) {
|
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
observeFormatting?(textStorage)
|
window.contentView = editor
|
||||||
super.processEditing(for: textStorage, edited: editMask, range: newCharRange,
|
window.makeFirstResponder(editor)
|
||||||
changeInLength: delta, invalidatedRange: invalidatedCharRange)
|
defer { window.orderOut(nil) }
|
||||||
|
for (source, key) in [("**fox 🦊**", NoteAttributes.bold), ("*fox 🦊*", NoteAttributes.italic),
|
||||||
|
("`fox 🦊`", NoteAttributes.inlineCode), ("~~fox 🦊~~", .strikethroughStyle),
|
||||||
|
("<u>fox 🦊</u>", .underlineStyle), ("[fox 🦊](https://example.com)", .link)] {
|
||||||
|
editor.setDocumentContents(NSAttributedString(string: ""))
|
||||||
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
|
editor.beginInsertionSession()
|
||||||
|
for character in source { editor.insertText(String(character), replacementRange: editor.selectedRange()) }
|
||||||
|
editor.endInsertionSession()
|
||||||
|
#expect(editor.string == "fox 🦊")
|
||||||
|
#expect(editor.selectedRange() == NSRange(location: "fox 🦊".utf16.count, length: 0))
|
||||||
|
let mark = try #require(editor.documentContents.attribute(key, at: 0, effectiveRange: nil))
|
||||||
|
if key == .link {
|
||||||
|
#expect(String(describing: mark) == "https://example.com")
|
||||||
|
} else if key == .underlineStyle || key == .strikethroughStyle {
|
||||||
|
#expect(mark as? Int == NSUnderlineStyle.single.rawValue)
|
||||||
|
} else {
|
||||||
|
#expect(mark as? Bool == true)
|
||||||
|
}
|
||||||
|
editor.undoManager?.undo()
|
||||||
|
#expect(editor.string.isEmpty)
|
||||||
|
editor.undoManager?.redo()
|
||||||
|
#expect(editor.string == "fox 🦊")
|
||||||
|
#expect(editor.documentContents.attribute(key, at: 0, effectiveRange: nil) != nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func typingDoesNotExposeUnformattedExistingTextToLayout() throws {
|
func richCopyPasteKeepsNativeMarksUnicodeAndImageBytesWithoutMarkdownParsing() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
let suite = "NotesFlashing.\(UUID().uuidString)"
|
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1,
|
||||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
|
||||||
defer {
|
bitmap.setColor(.red, atX: 0, y: 0)
|
||||||
defaults.removePersistentDomain(forName: suite)
|
let png = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||||
try? FileManager.default.removeItem(at: directory)
|
let file = FileWrapper(regularFileWithContents: png)
|
||||||
}
|
file.preferredFilename = "pixel.png"
|
||||||
let store = try NotesStore(directory: directory)
|
let contents = NSMutableAttributedString(attributedString:
|
||||||
let id = try store.create()
|
NoteDocument.importMarkdown(NSAttributedString(string: "- [x] **🦊 café** ")))
|
||||||
try store.update(id: id, contents: NSAttributedString(string: "# Heading\n- [ ] Task\nPlain"))
|
contents.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: file)))
|
||||||
defaults.set(id.uuidString, forKey: "selectedNote")
|
let source = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
|
||||||
let controller = NotesController(defaults: defaults, directory: directory)
|
source.setDocumentContents(contents)
|
||||||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
|
source.setSelectedRange(NSRange(location: 0, length: source.string.utf16.count))
|
||||||
styleMask: .borderless, backing: .buffered, defer: false)
|
let board = NSPasteboard.withUniqueName()
|
||||||
window.contentViewController = controller
|
defer { board.releaseGlobally() }
|
||||||
defer { _ = controller.close(); window.orderOut(nil) }
|
#expect(source.writeSelection(to: board, types: source.writablePasteboardTypes))
|
||||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
#expect(board.string(forType: .string) == source.string)
|
||||||
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
let destination = NotesTextView(frame: source.frame)
|
||||||
let layout = NotesLayoutProbe()
|
destination.allowsUndo = true
|
||||||
try #require(editor.textContainer).replaceLayoutManager(layout)
|
#expect(destination.readSelection(from: board))
|
||||||
editor.refreshTasks()
|
#expect(destination.string == source.string)
|
||||||
window.makeFirstResponder(editor)
|
#expect(destination.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
|
||||||
layout.observeFormatting = { storage in
|
#expect(destination.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "task")
|
||||||
let text = storage.string as NSString
|
#expect(destination.documentContents.attribute(NoteAttributes.checked, at: 0, effectiveRange: nil) as? Bool == true)
|
||||||
let heading = text.range(of: "Heading")
|
let image = try #require(destination.documentContents.attribute(.attachment,
|
||||||
let task = text.range(of: "Task")
|
at: destination.string.utf16.count - 1, effectiveRange: nil) as? NSTextAttachment)
|
||||||
#expect((storage.attribute(.font, at: heading.location, effectiveRange: nil) as? NSFont)?.pointSize == 26)
|
#expect(image.fileWrapper?.regularFileContents == png)
|
||||||
#expect((storage.attribute(.paragraphStyle, at: task.location, effectiveRange: nil) as? NSParagraphStyle)?.headIndent == 22)
|
destination.undoManager?.undo()
|
||||||
#expect(storage.attribute(MarkdownHighlighting.hiddenMarkup, at: 0, effectiveRange: nil) as? Bool == true)
|
#expect(destination.string.isEmpty)
|
||||||
}
|
destination.undoManager?.redo()
|
||||||
defer { layout.observeFormatting = nil }
|
#expect(destination.string == source.string)
|
||||||
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
|
@Test @MainActor
|
||||||
@@ -112,7 +118,7 @@ func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
|
|||||||
#expect(editor.readSelection(from: pasteboard))
|
#expect(editor.readSelection(from: pasteboard))
|
||||||
|
|
||||||
func checkRoundedDrawing() throws {
|
func checkRoundedDrawing() throws {
|
||||||
MarkdownHighlighting.apply(to: editor)
|
editor.applyDocumentStyle()
|
||||||
let layout = try #require(editor.layoutManager)
|
let layout = try #require(editor.layoutManager)
|
||||||
layout.ensureLayout(for: try #require(editor.textContainer))
|
layout.ensureLayout(for: try #require(editor.textContainer))
|
||||||
let output = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 200, pixelsHigh: 120,
|
let output = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 200, pixelsHigh: 120,
|
||||||
@@ -146,60 +152,40 @@ func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
|
|||||||
defer { try? FileManager.default.removeItem(at: directory) }
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
let store = try NotesStore(directory: directory)
|
let store = try NotesStore(directory: directory)
|
||||||
let id = try store.create()
|
let id = try store.create()
|
||||||
try store.update(id: id, contents: editor.markdownContents)
|
try store.update(id: id, contents: editor.documentContents)
|
||||||
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
||||||
let attachment = try #require(reloaded.contents.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment)
|
let attachment = try #require(reloaded.contents.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment)
|
||||||
#expect(attachment.fileWrapper?.regularFileContents == png)
|
#expect(attachment.fileWrapper?.regularFileContents == png)
|
||||||
editor.setMarkdownContents(reloaded.contents)
|
editor.setDocumentContents(reloaded.contents)
|
||||||
try checkRoundedDrawing()
|
try checkRoundedDrawing()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() throws {
|
func nativeFormattingSurvivesVisibleBoundaryEditingAndUndo() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
let suite = "NotesMarkup.\(UUID().uuidString)"
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
editor.allowsUndo = true
|
||||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
defer {
|
window.contentView = editor
|
||||||
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: 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)
|
|
||||||
window.makeFirstResponder(editor)
|
window.makeFirstResponder(editor)
|
||||||
editor.insertText("# Title\n*word*", replacementRange: NSRange(location: 0, length: 0))
|
defer { window.orderOut(nil) }
|
||||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "# Title\n*word*")))
|
||||||
editor.insertText("A", replacementRange: editor.selectedRange())
|
editor.insertText("A", replacementRange: NSRange(location: 0, length: 0))
|
||||||
#expect(editor.markdownString == "# ATitle\n*word*")
|
#expect(editor.string == "ATitle\nword")
|
||||||
editor.breakUndoCoalescing()
|
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 1)
|
||||||
editor.undoManager?.removeAllActions()
|
|
||||||
editor.undoManager?.beginUndoGrouping()
|
|
||||||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||||||
|
editor.undoManager?.removeAllActions()
|
||||||
editor.deleteBackward(nil)
|
editor.deleteBackward(nil)
|
||||||
editor.undoManager?.endUndoGrouping()
|
#expect(editor.string == "ATitle\nwor")
|
||||||
#expect(editor.markdownString == "# ATitle\n*wor*")
|
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: "ATitle\n".utf16.count, effectiveRange: nil) as? Bool == true)
|
||||||
editor.undoManager?.undo()
|
editor.undoManager?.undo()
|
||||||
#expect(editor.markdownString == "# ATitle\n*word*")
|
#expect(editor.string == "ATitle\nword")
|
||||||
let word = (editor.string as NSString).range(of: "word")
|
let word = (editor.string as NSString).range(of: "word")
|
||||||
editor.setSelectedRange(NSRange(location: word.location, length: 0))
|
|
||||||
editor.moveRight(nil)
|
|
||||||
#expect(editor.selectedRange().location == word.location + 1)
|
|
||||||
editor.setSelectedRange(NSRange(location: NSMaxRange(word), length: 0))
|
|
||||||
editor.moveRight(nil)
|
|
||||||
#expect(editor.selectedRange().location == editor.string.utf16.count)
|
|
||||||
editor.setSelectedRange(NSRange(location: word.location, length: 0))
|
|
||||||
let italic = try #require(descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Italic" })
|
|
||||||
// Selecting the body and toggling formatting must remove its hidden delimiters.
|
|
||||||
editor.setSelectedRange(word)
|
editor.setSelectedRange(word)
|
||||||
italic.performClick(nil)
|
editor.format(2)
|
||||||
#expect(editor.markdownString == "# ATitle\nword")
|
#expect(editor.string == "ATitle\nword")
|
||||||
#expect(controller.save())
|
#expect(editor.selectedRange() == word)
|
||||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "# ATitle\nword")
|
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: word.location, effectiveRange: nil) as? Bool != true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -234,62 +220,51 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
|
|||||||
}
|
}
|
||||||
editor.insertText("fox 🦊", replacementRange: editor.selectedRange())
|
editor.insertText("fox 🦊", replacementRange: editor.selectedRange())
|
||||||
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
|
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
|
||||||
|
let selection = editor.selectedRange()
|
||||||
#expect(try shortcut("b"))
|
#expect(try shortcut("b"))
|
||||||
#expect(editor.markdownString == "**fox 🦊**")
|
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
|
||||||
#expect(try shortcut("i"))
|
#expect(try shortcut("i"))
|
||||||
#expect(editor.markdownString == "***fox 🦊***")
|
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) as? Bool == true)
|
||||||
let font = try #require(editor.textStorage?.attribute(.font, at: editor.selectedRange().location,
|
let font = try #require(editor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)
|
||||||
effectiveRange: nil) as? NSFont)
|
|
||||||
let traits = NSFontManager.shared.traits(of: font)
|
let traits = NSFontManager.shared.traits(of: font)
|
||||||
#expect(traits.contains(.boldFontMask) && traits.contains(.italicFontMask))
|
#expect(traits.contains(.boldFontMask) && traits.contains(.italicFontMask))
|
||||||
#expect(try shortcut("i"))
|
#expect(try shortcut("i"))
|
||||||
#expect(editor.markdownString == "**fox 🦊**")
|
|
||||||
#expect(try shortcut("b"))
|
#expect(try shortcut("b"))
|
||||||
#expect(editor.markdownString == "fox 🦊")
|
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
|
||||||
|
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) as? Bool != true)
|
||||||
#expect(try shortcut("s", shift: true))
|
#expect(try shortcut("s", shift: true))
|
||||||
#expect(editor.markdownString == "~~fox 🦊~~")
|
#expect(editor.documentContents.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||||
#expect(editor.textStorage?.attribute(.strikethroughStyle, at: editor.selectedRange().location,
|
|
||||||
effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
|
||||||
#expect(try shortcut("s", shift: true))
|
#expect(try shortcut("s", shift: true))
|
||||||
#expect(editor.markdownString == "fox 🦊")
|
#expect(editor.documentContents.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) == nil)
|
||||||
editor.breakUndoCoalescing()
|
|
||||||
editor.undoManager?.removeAllActions()
|
editor.undoManager?.removeAllActions()
|
||||||
editor.undoManager?.beginUndoGrouping()
|
|
||||||
#expect(try shortcut("u", shift: true))
|
#expect(try shortcut("u", shift: true))
|
||||||
editor.undoManager?.endUndoGrouping()
|
#expect(editor.documentContents.attribute(.underlineStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||||
#expect(editor.markdownString == "<u>fox 🦊</u>")
|
#expect(editor.string == "fox 🦊")
|
||||||
#expect(editor.textStorage?.attribute(.underlineStyle, at: editor.selectedRange().location,
|
#expect(editor.selectedRange() == selection)
|
||||||
effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
|
||||||
#expect(controller.save())
|
#expect(controller.save())
|
||||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "<u>fox 🦊</u>")
|
let saved = try #require(NotesStore(directory: directory).notes.first)
|
||||||
#expect(try NoteMarkdownExport(contents: editor.markdownContents).clipboardText == "<u>fox 🦊</u>")
|
#expect(saved.contents.string == "fox 🦊")
|
||||||
|
#expect(saved.contents.attribute(.underlineStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||||
|
#expect(try NoteMarkdownExport(contents: editor.documentContents).clipboardText == "<u>fox 🦊</u>")
|
||||||
editor.undoManager?.undo()
|
editor.undoManager?.undo()
|
||||||
#expect(editor.markdownString == "fox 🦊")
|
#expect(editor.documentContents.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil)
|
||||||
#expect(try shortcut("u", shift: true))
|
|
||||||
#expect(try shortcut("u", shift: true))
|
|
||||||
#expect(editor.markdownString == "fox 🦊")
|
|
||||||
#expect(editor.textStorage?.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil)
|
|
||||||
#expect(try shortcut("l", shift: true))
|
#expect(try shortcut("l", shift: true))
|
||||||
#expect(editor.markdownString == "- fox 🦊")
|
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "bullet")
|
||||||
#expect(try shortcut("l", shift: true))
|
#expect(try shortcut("l", shift: true))
|
||||||
#expect(editor.markdownString == "fox 🦊")
|
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil)
|
||||||
#expect(!(try shortcut("b", option: true)))
|
#expect(!(try shortcut("b", option: true)))
|
||||||
#expect(editor.markdownString == "fox 🦊")
|
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
|
||||||
|
|
||||||
editor.breakUndoCoalescing()
|
|
||||||
editor.undoManager?.removeAllActions()
|
editor.undoManager?.removeAllActions()
|
||||||
editor.undoManager?.beginUndoGrouping()
|
|
||||||
#expect(try shortcut("e"))
|
#expect(try shortcut("e"))
|
||||||
editor.undoManager?.endUndoGrouping()
|
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: 0, effectiveRange: nil) as? Bool == true)
|
||||||
#expect(editor.markdownString == "`fox 🦊`")
|
|
||||||
editor.undoManager?.undo()
|
editor.undoManager?.undo()
|
||||||
#expect(editor.markdownString == "fox 🦊")
|
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: 0, effectiveRange: nil) as? Bool != true)
|
||||||
#expect(try shortcut("k"))
|
#expect(try shortcut("k"))
|
||||||
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes actions" })
|
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes actions" })
|
||||||
#expect(window.firstResponder !== editor)
|
#expect(window.firstResponder !== editor)
|
||||||
_ = try shortcut("b")
|
_ = try shortcut("b")
|
||||||
#expect(editor.markdownString == "fox 🦊")
|
#expect(editor.string == "fox 🦊")
|
||||||
|
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
|
||||||
#expect(search.stringValue.isEmpty)
|
#expect(search.stringValue.isEmpty)
|
||||||
#expect(controller.handle(#selector(NSResponder.cancelOperation(_:))))
|
#expect(controller.handle(#selector(NSResponder.cancelOperation(_:))))
|
||||||
#expect(controller.save())
|
#expect(controller.save())
|
||||||
@@ -297,30 +272,28 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func inlineCodeContainerStaysWithinCodeAndDisappearsWhenDelimitersAreRemoved() throws {
|
func inlineCodeContainerIsLimitedToItsMarkAndDisappearsWhenToggledOff() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 300))
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 300))
|
||||||
let source = "Before `some *literal* code 🦊` after\n```text\n`not inline` <u>literal</u>\n```"
|
let source = "Before `some *literal* code 🦊` after\n```text\n`not inline` <u>literal</u>\n```"
|
||||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||||
let storage = try #require(editor.textStorage)
|
let storage = try #require(editor.textStorage)
|
||||||
let layout = try #require(editor.layoutManager)
|
let body = (editor.string as NSString).range(of: "some *literal* code 🦊")
|
||||||
layout.ensureLayout(for: try #require(editor.textContainer))
|
|
||||||
let body = (source as NSString).range(of: "some *literal* code 🦊")
|
|
||||||
for index in body.location..<NSMaxRange(body) {
|
for index in body.location..<NSMaxRange(body) {
|
||||||
#expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.accent)
|
#expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.accent)
|
||||||
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.selectionFill)
|
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.selectionFill)
|
||||||
}
|
}
|
||||||
for token in ["Before", "after", "not inline"] {
|
for token in ["Before", "after", "not inline"] {
|
||||||
let index = (source as NSString).range(of: token).location
|
let index = (editor.string as NSString).range(of: token).location
|
||||||
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) == nil)
|
#expect(storage.attribute(NoteAttributes.inlineCode, at: index, effectiveRange: nil) as? Bool != true)
|
||||||
}
|
}
|
||||||
let literalUnderline = (source as NSString).range(of: "<u>literal</u>")
|
let literalUnderline = (editor.string as NSString).range(of: "<u>literal</u>")
|
||||||
#expect(storage.attribute(.underlineStyle, at: literalUnderline.location, effectiveRange: nil) == nil)
|
#expect(storage.attribute(.underlineStyle, at: literalUnderline.location, effectiveRange: nil) == nil)
|
||||||
#expect(editor.markdownString == source)
|
let before = editor.string
|
||||||
#expect(editor.markdownContents.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil)
|
editor.setSelectedRange(body)
|
||||||
editor.replaceMarkdown("some *literal* code 🦊",
|
editor.format(4)
|
||||||
range: NSRange(location: body.location - 1, length: body.length + 2))
|
#expect(editor.string == before)
|
||||||
let plain = (editor.string as NSString).range(of: "some")
|
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: body.location, effectiveRange: nil) as? Bool != true)
|
||||||
#expect(storage.attribute(.backgroundColor, at: plain.location, effectiveRange: nil) == nil)
|
#expect(storage.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil)
|
||||||
#expect(storage.attribute(.foregroundColor, at: plain.location, effectiveRange: nil) as? NSColor == SunsetPalette.text)
|
#expect(storage.attribute(.foregroundColor, at: body.location, effectiveRange: nil) as? NSColor == SunsetPalette.text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,30 +22,30 @@ func notesRoundTripIndependentMarkdownUnicodeAndImageContents() throws {
|
|||||||
let imageFile = FileWrapper(regularFileWithContents: png)
|
let imageFile = FileWrapper(regularFileWithContents: png)
|
||||||
imageFile.preferredFilename = "pixel.png"
|
imageFile.preferredFilename = "pixel.png"
|
||||||
let attachment = NSTextAttachment(fileWrapper: imageFile)
|
let attachment = NSTextAttachment(fileWrapper: imageFile)
|
||||||
let first = NSMutableAttributedString(string: "\n # 日本語 🦊 \n**bold** and `code`\n")
|
let first = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "\n # 日本語 🦊 \n**bold** and `code`\n")))
|
||||||
first.append(NSAttributedString(attachment: attachment))
|
first.append(NSAttributedString(attachment: attachment))
|
||||||
let expectedFirstText = first.string
|
let expectedFirstText = first.string
|
||||||
try store.update(id: firstID, contents: first)
|
try store.update(id: firstID, contents: first)
|
||||||
first.mutableString.setString("Mutated caller buffer")
|
first.mutableString.setString("Mutated caller buffer")
|
||||||
#expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText)
|
#expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText)
|
||||||
#expect(store.notes.first { $0.id == firstID }?.title == "# 日本語 🦊")
|
#expect(store.notes.first { $0.id == firstID }?.title == "日本語 🦊")
|
||||||
|
|
||||||
try store.update(id: secondID, contents: NSAttributedString(string: "Second note"))
|
try store.update(id: secondID, contents: NSAttributedString(string: "Second note"))
|
||||||
try store.update(id: secondID, contents: NSAttributedString(string: "## Edited\n- café\n- Ελληνικά"))
|
try store.update(id: secondID, contents: NoteDocument.importMarkdown(NSAttributedString(string: "## Edited\n- café\n- Ελληνικά")))
|
||||||
#expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText)
|
#expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText)
|
||||||
|
|
||||||
let reloaded = try NotesStore(directory: directory)
|
let reloaded = try NotesStore(directory: directory)
|
||||||
#expect(Set(reloaded.notes.map(\.id)) == Set([firstID, secondID]))
|
#expect(Set(reloaded.notes.map(\.id)) == Set([firstID, secondID]))
|
||||||
let restoredFirst = try #require(reloaded.notes.first { $0.id == firstID })
|
let restoredFirst = try #require(reloaded.notes.first { $0.id == firstID })
|
||||||
#expect(restoredFirst.contents.string == expectedFirstText)
|
#expect(restoredFirst.contents.string == expectedFirstText)
|
||||||
#expect(restoredFirst.title == "# 日本語 🦊")
|
#expect(restoredFirst.title == "日本語 🦊")
|
||||||
let restoredAttachment = try #require(restoredFirst.contents.attribute(
|
let restoredAttachment = try #require(restoredFirst.contents.attribute(
|
||||||
.attachment, at: restoredFirst.contents.length - 1, effectiveRange: nil
|
.attachment, at: restoredFirst.contents.length - 1, effectiveRange: nil
|
||||||
) as? NSTextAttachment)
|
) as? NSTextAttachment)
|
||||||
let restoredPNG = try #require(restoredAttachment.fileWrapper?.regularFileContents)
|
let restoredPNG = try #require(restoredAttachment.fileWrapper?.regularFileContents)
|
||||||
#expect(restoredPNG == png)
|
#expect(restoredPNG == png)
|
||||||
#expect(NSImage(data: restoredPNG) != nil)
|
#expect(NSImage(data: restoredPNG) != nil)
|
||||||
#expect(reloaded.notes.first { $0.id == secondID }?.contents.string == "## Edited\n- café\n- Ελληνικά")
|
#expect(reloaded.notes.first { $0.id == secondID }?.contents.string == "Edited\ncafé\nΕλληνικά")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -131,3 +131,85 @@ func deletingNotesPersistsWithoutChangingOtherNotesAndPreservesMemoryOnFailure()
|
|||||||
#expect(store.notes.isEmpty)
|
#expect(store.notes.isEmpty)
|
||||||
#expect(try NotesStore(directory: directory).notes.isEmpty)
|
#expect(try NotesStore(directory: directory).notes.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func legacyNotesConvertWithoutWritingAndBackUpOriginalPackageOnlyOnFirstSave() throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
let id = UUID()
|
||||||
|
let url = directory.appendingPathComponent(id.uuidString + ".rtfd")
|
||||||
|
let legacy = NSMutableAttributedString(string: "# Heading\n- [ ] task\n**bold** and `code`\n")
|
||||||
|
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(.blue, atX: 0, y: 0)
|
||||||
|
let png = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||||
|
let image = FileWrapper(regularFileWithContents: png)
|
||||||
|
image.preferredFilename = "legacy.png"
|
||||||
|
legacy.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: image)))
|
||||||
|
let wrapper = try legacy.fileWrapper(from: NSRange(location: 0, length: legacy.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd])
|
||||||
|
try wrapper.write(to: url, options: .atomic, originalContentsURL: nil)
|
||||||
|
let originalRTF = try Data(contentsOf: url.appendingPathComponent("TXT.rtf"))
|
||||||
|
let store = try NotesStore(directory: directory)
|
||||||
|
let note = try #require(store.notes.first)
|
||||||
|
#expect(note.contents.string == "Heading\ntask\nbold and code\n\u{fffc}")
|
||||||
|
#expect(note.title == "Heading")
|
||||||
|
#expect(try Data(contentsOf: url.appendingPathComponent("TXT.rtf")) == originalRTF)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.appendingPathComponent("ConjureNote.json").path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("LegacyBackups").path))
|
||||||
|
|
||||||
|
try store.update(id: id, contents: note.contents)
|
||||||
|
let backup = directory.appendingPathComponent("LegacyBackups").appendingPathComponent(id.uuidString + ".rtfd")
|
||||||
|
#expect(try Data(contentsOf: backup.appendingPathComponent("TXT.rtf")) == originalRTF)
|
||||||
|
#expect(try Data(contentsOf: backup.appendingPathComponent("legacy.png")) == png)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: backup.appendingPathComponent("ConjureNote.json").path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: url.appendingPathComponent("ConjureNote.json").path))
|
||||||
|
let reloaded = try NotesStore(directory: directory)
|
||||||
|
#expect(reloaded.notes.first?.contents.string == note.contents.string)
|
||||||
|
let restored = try #require(reloaded.notes.first?.contents)
|
||||||
|
let attachment = try #require(restored.attribute(.attachment, at: restored.length - 1, effectiveRange: nil) as? NSTextAttachment)
|
||||||
|
#expect(attachment.fileWrapper?.regularFileContents == png)
|
||||||
|
try reloaded.update(id: id, contents: NSAttributedString(string: "Changed"))
|
||||||
|
#expect(try Data(contentsOf: backup.appendingPathComponent("TXT.rtf")) == originalRTF)
|
||||||
|
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.appendingPathComponent("LegacyBackups").path) == [id.uuidString + ".rtfd"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func failedLegacyBackupBlocksMigrationAndPreservesMemoryAndOriginalSource() throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
let id = UUID()
|
||||||
|
let url = directory.appendingPathComponent(id.uuidString + ".rtfd")
|
||||||
|
let source = NSAttributedString(string: "## Original")
|
||||||
|
let wrapper = try source.fileWrapper(from: NSRange(location: 0, length: source.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd])
|
||||||
|
try wrapper.write(to: url, options: .atomic, originalContentsURL: nil)
|
||||||
|
let original = try Data(contentsOf: url.appendingPathComponent("TXT.rtf"))
|
||||||
|
let store = try NotesStore(directory: directory)
|
||||||
|
let blocker = directory.appendingPathComponent("LegacyBackups")
|
||||||
|
try Data("not a directory".utf8).write(to: blocker)
|
||||||
|
#expect(throws: (any Error).self) { try store.update(id: id, contents: NSAttributedString(string: "Unsaved")) }
|
||||||
|
#expect(store.notes.first?.contents.string == "Original")
|
||||||
|
#expect(try Data(contentsOf: url.appendingPathComponent("TXT.rtf")) == original)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.appendingPathComponent("ConjureNote.json").path))
|
||||||
|
try FileManager.default.removeItem(at: blocker)
|
||||||
|
try store.update(id: id, contents: NSAttributedString(string: "Saved"))
|
||||||
|
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "Saved")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func corruptStructuredMetadataIsNeverReinterpretedAsLegacyMarkdown() throws {
|
||||||
|
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: NSAttributedString(string: "# literal"))
|
||||||
|
let package = directory.appendingPathComponent(id.uuidString + ".rtfd")
|
||||||
|
let metadata = package.appendingPathComponent("ConjureNote.json")
|
||||||
|
let corrupt = Data("invalid json".utf8)
|
||||||
|
try corrupt.write(to: metadata)
|
||||||
|
let originalRTF = try Data(contentsOf: package.appendingPathComponent("TXT.rtf"))
|
||||||
|
#expect(throws: NoteDocument.DocumentError.self) { try NotesStore(directory: directory) }
|
||||||
|
#expect(try Data(contentsOf: metadata) == corrupt)
|
||||||
|
#expect(try Data(contentsOf: package.appendingPathComponent("TXT.rtf")) == originalRTF)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("LegacyBackups").path))
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws {
|
|||||||
for character in keys { try vimKey(String(character), editor: editor) }
|
for character in keys { try vimKey(String(character), editor: editor) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func vimEventTurn() {
|
||||||
|
// Let AppKit close its ordinary event-scoped undo groups between key events.
|
||||||
|
CFRunLoopRunInMode(.defaultMode, 0.001, false)
|
||||||
|
}
|
||||||
|
|
||||||
@Test(arguments: [
|
@Test(arguments: [
|
||||||
("one two three", "dw", "two three"),
|
("one two three", "dw", "two three"),
|
||||||
("one,two three", "dw", ",two three"),
|
("one,two three", "dw", ",two three"),
|
||||||
@@ -52,11 +58,11 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws {
|
|||||||
@MainActor func vimOperatorsRespectVisibleTextAndBoundaries(_ scenario: (String, String, String)) throws {
|
@MainActor func vimOperatorsRespectVisibleTextAndBoundaries(_ scenario: (String, String, String)) throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||||
editor.setMarkdownContents(NSAttributedString(string: scenario.0))
|
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: scenario.0)))
|
||||||
editor.vimEnabled = true
|
editor.vimEnabled = true
|
||||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
try vimKeys(scenario.1, editor: editor)
|
try vimKeys(scenario.1, editor: editor)
|
||||||
#expect(editor.markdownString == scenario.2)
|
#expect(editor.documentContents.string == scenario.2)
|
||||||
#expect(editor.vimMode == (scenario.1.contains("c") ? .insert : .normal))
|
#expect(editor.vimMode == (scenario.1.contains("c") ? .insert : .normal))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,25 +70,29 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws {
|
|||||||
func vimMotionsCountCharactersWordsAndLogicalLines() throws {
|
func vimMotionsCountCharactersWordsAndLogicalLines() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 90, height: 400))
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 90, height: 400))
|
||||||
editor.setMarkdownContents(NSAttributedString(string: "## 🧑🏽💻é abc,def\nx\n long final line"))
|
let contents = NSMutableAttributedString(string: "🧑🏽💻é abc,def\nx\n long final line")
|
||||||
|
contents.addAttributes([NoteAttributes.block: "heading", NoteAttributes.headingLevel: 2],
|
||||||
|
range: (contents.string as NSString).paragraphRange(for: NSRange(location: 0, length: 0)))
|
||||||
|
editor.setDocumentContents(contents)
|
||||||
editor.vimEnabled = true
|
editor.vimEnabled = true
|
||||||
editor.setSelectedRange(NSRange(location: 3, length: 0))
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
try vimKeys("l", editor: editor)
|
try vimKeys("l", editor: editor)
|
||||||
#expect(editor.selectedRange().location == 10) // One composed emoji, not seven UTF-16 units.
|
#expect(editor.selectedRange().location == 7) // One composed emoji, not seven UTF-16 units.
|
||||||
try vimKeys("l", editor: editor)
|
try vimKeys("l", editor: editor)
|
||||||
#expect(editor.selectedRange().location == 12) // Combining accent remains a single character.
|
#expect(editor.selectedRange().location == 9) // Combining accent remains a single character.
|
||||||
try vimKeys("w", editor: editor)
|
try vimKeys("w", editor: editor)
|
||||||
#expect(editor.selectedRange().location == 13)
|
#expect(editor.selectedRange().location == 10)
|
||||||
try vimKeys("e", editor: editor)
|
try vimKeys("e", editor: editor)
|
||||||
#expect(editor.selectedRange().location == 15)
|
#expect(editor.selectedRange().location == 12)
|
||||||
try vimKeys("w", editor: editor)
|
try vimKeys("w", editor: editor)
|
||||||
#expect(editor.selectedRange().location == 16)
|
|
||||||
try vimKeys("b", editor: editor)
|
|
||||||
#expect(editor.selectedRange().location == 13)
|
#expect(editor.selectedRange().location == 13)
|
||||||
|
try vimKeys("b", editor: editor)
|
||||||
|
#expect(editor.selectedRange().location == 10)
|
||||||
try vimKeys("0", editor: editor)
|
try vimKeys("0", editor: editor)
|
||||||
#expect(editor.selectedRange().location == 3)
|
#expect(editor.selectedRange().location == 0)
|
||||||
try vimKeys("4ljj", editor: editor)
|
try vimKeys("4ljj", editor: editor)
|
||||||
let final = (editor.string as NSString).range(of: " long final line").location
|
let finalRange = try #require(editor.string.range(of: " long final line"))
|
||||||
|
let final = NSRange(finalRange, in: editor.string).location
|
||||||
#expect(editor.selectedRange().location == final + 4) // Column survives a shorter line.
|
#expect(editor.selectedRange().location == final + 4) // Column survives a shorter line.
|
||||||
try vimKeys("^", editor: editor)
|
try vimKeys("^", editor: editor)
|
||||||
#expect(editor.selectedRange().location == final + 2)
|
#expect(editor.selectedRange().location == final + 2)
|
||||||
@@ -91,13 +101,13 @@ func vimMotionsCountCharactersWordsAndLogicalLines() throws {
|
|||||||
try vimKeys("G$", editor: editor)
|
try vimKeys("G$", editor: editor)
|
||||||
#expect(editor.selectedRange().location == editor.string.utf16.count - 1)
|
#expect(editor.selectedRange().location == editor.string.utf16.count - 1)
|
||||||
try vimKeys("gg0", editor: editor)
|
try vimKeys("gg0", editor: editor)
|
||||||
#expect(editor.selectedRange().location == 3)
|
#expect(editor.selectedRange().location == 0)
|
||||||
editor.setMarkdownContents(NSAttributedString(string: "one two"))
|
editor.setDocumentContents(NSAttributedString(string: "one two"))
|
||||||
editor.setSelectedRange(NSRange(location: 0, length: 3))
|
editor.setSelectedRange(NSRange(location: 0, length: 3))
|
||||||
try vimKeys("d", editor: editor)
|
try vimKeys("d", editor: editor)
|
||||||
#expect(editor.markdownString == " two") // Native mouse selections also accept Visual operators.
|
#expect(editor.documentContents.string == " two") // Native mouse selections also accept Visual operators.
|
||||||
try vimKey("ø", editor: editor, flags: .option)
|
try vimKey("ø", editor: editor, flags: .option)
|
||||||
#expect(editor.markdownString == " two") // Modified text cannot leak into Normal mode.
|
#expect(editor.documentContents.string == " two") // Modified text cannot leak into Normal mode.
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -111,32 +121,37 @@ func vimYankPasteAndUndoPreserveRichAttachments() throws {
|
|||||||
editor.isRichText = true
|
editor.isRichText = true
|
||||||
editor.allowsUndo = true
|
editor.allowsUndo = true
|
||||||
let attachment = NSTextAttachment()
|
let attachment = NSTextAttachment()
|
||||||
let source = NSMutableAttributedString(string: "## Image ")
|
let source = NSMutableAttributedString(string: "Image ")
|
||||||
source.append(NSAttributedString(attachment: attachment))
|
source.append(NSAttributedString(attachment: attachment))
|
||||||
source.append(NSAttributedString(string: "\nTail"))
|
source.append(NSAttributedString(string: "\nTail"))
|
||||||
editor.setMarkdownContents(source)
|
source.addAttributes([NoteAttributes.block: "heading", NoteAttributes.headingLevel: 2], range: NSRange(location: 0, length: 8))
|
||||||
|
source.addAttributes([NoteAttributes.bold: true, .underlineStyle: NSUnderlineStyle.single.rawValue], range: NSRange(location: 0, length: 5))
|
||||||
|
editor.setDocumentContents(source)
|
||||||
editor.vimEnabled = true
|
editor.vimEnabled = true
|
||||||
editor.setSelectedRange(NSRange(location: 3, length: 0))
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
try vimKeys("yyp", editor: editor)
|
try vimKeys("yyp", editor: editor)
|
||||||
#expect(editor.markdownString == "## Image \u{fffc}\n## Image \u{fffc}\nTail")
|
#expect(editor.documentContents.string == "Image \u{fffc}\nImage \u{fffc}\nTail")
|
||||||
let pasted = editor.markdownContents
|
let pasted = editor.documentContents
|
||||||
#expect(pasted.attribute(.attachment, at: 20, effectiveRange: nil) as? NSTextAttachment === attachment)
|
#expect(pasted.attribute(.attachment, at: 14, effectiveRange: nil) as? NSTextAttachment === attachment)
|
||||||
|
#expect(pasted.attribute(NoteAttributes.headingLevel, at: 8, effectiveRange: nil) as? Int == 2)
|
||||||
|
#expect(pasted.attribute(NoteAttributes.bold, at: 8, effectiveRange: nil) as? Bool == true)
|
||||||
|
#expect(pasted.attribute(.underlineStyle, at: 8, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||||
try vimKeys("u", editor: editor)
|
try vimKeys("u", editor: editor)
|
||||||
#expect(editor.markdownString == source.string)
|
#expect(editor.documentContents.string == source.string)
|
||||||
try vimKey("r", editor: editor, flags: .control)
|
try vimKey("r", editor: editor, flags: .control)
|
||||||
#expect(editor.markdownString == pasted.string)
|
#expect(editor.documentContents.string == pasted.string)
|
||||||
editor.setMarkdownContents(NSAttributedString(string: "one two"))
|
editor.setDocumentContents(NSAttributedString(string: "one two"))
|
||||||
editor.undoManager?.removeAllActions()
|
editor.undoManager?.removeAllActions()
|
||||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
try vimKeys("ywP", editor: editor)
|
try vimKeys("ywP", editor: editor)
|
||||||
#expect(editor.markdownString == "one one two")
|
#expect(editor.documentContents.string == "one one two")
|
||||||
try vimKeys("u", editor: editor)
|
try vimKeys("u", editor: editor)
|
||||||
#expect(editor.markdownString == "one two")
|
#expect(editor.documentContents.string == "one two")
|
||||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
try vimKeys("cw", editor: editor)
|
try vimKeys("cw", editor: editor)
|
||||||
try vimKeys("new", editor: editor)
|
try vimKeys("new", editor: editor)
|
||||||
try vimKey("\u{1b}", editor: editor, code: 53)
|
try vimKey("\u{1b}", editor: editor, code: 53)
|
||||||
#expect(editor.markdownString == "new two")
|
#expect(editor.documentContents.string == "new two")
|
||||||
#expect(editor.vimMode == .normal)
|
#expect(editor.vimMode == .normal)
|
||||||
#expect(editor.selectedRange().location == 2)
|
#expect(editor.selectedRange().location == 2)
|
||||||
}
|
}
|
||||||
@@ -154,15 +169,120 @@ func vimInsertEntryAndLinewisePasteKeepParagraphsSeparate() throws {
|
|||||||
("one", "o", "one\n"),
|
("one", "o", "one\n"),
|
||||||
("one", "O", "\none")
|
("one", "O", "\none")
|
||||||
] {
|
] {
|
||||||
editor.setMarkdownContents(NSAttributedString(string: before))
|
editor.setDocumentContents(NSAttributedString(string: before))
|
||||||
editor.vimEnabled = true
|
editor.vimEnabled = true
|
||||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
try vimKeys(keys, editor: editor)
|
try vimKeys(keys, editor: editor)
|
||||||
#expect(editor.markdownString == after)
|
#expect(editor.documentContents.string == after)
|
||||||
if keys == "O" { #expect(editor.selectedRange().location == 0) }
|
if keys == "O" { #expect(editor.selectedRange().location == 0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func vimDeletingFinalBoldCharacterOnlyDeletesThatCharacter() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||||
|
let contents = NSMutableAttributedString(string: "ab tail")
|
||||||
|
contents.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 2))
|
||||||
|
editor.setDocumentContents(contents)
|
||||||
|
editor.vimEnabled = true
|
||||||
|
editor.setSelectedRange(NSRange(location: 1, length: 0))
|
||||||
|
try vimKeys("x", editor: editor)
|
||||||
|
#expect(editor.documentContents.string == "a tail")
|
||||||
|
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
|
||||||
|
#expect(editor.vimState.register?.string == "b")
|
||||||
|
#expect(editor.vimState.register?.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
|
||||||
|
try vimKeys("P", editor: editor)
|
||||||
|
#expect(editor.documentContents.string == "ab tail")
|
||||||
|
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 1, effectiveRange: nil) as? Bool == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func vimCharacterRegisterPreservesNativeImageAttributes() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||||
|
let attachment = NSTextAttachment()
|
||||||
|
let imageBytes = Data([0, 1, 2, 3])
|
||||||
|
attachment.fileWrapper = FileWrapper(regularFileWithContents: imageBytes)
|
||||||
|
let contents = NSMutableAttributedString(attributedString: NSAttributedString(attachment: attachment))
|
||||||
|
contents.append(NSAttributedString(string: " tail"))
|
||||||
|
editor.setDocumentContents(contents)
|
||||||
|
editor.vimEnabled = true
|
||||||
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
|
try vimKeys("ylp", editor: editor)
|
||||||
|
#expect(editor.documentContents.string == "\u{fffc}\u{fffc} tail")
|
||||||
|
let pasted = try #require(editor.documentContents.attribute(.attachment, at: 1, effectiveRange: nil) as? NSTextAttachment)
|
||||||
|
#expect(pasted.fileWrapper?.regularFileContents == imageBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: ["escape", "focus", "toggle", "reset"])
|
||||||
|
@MainActor func vimChangeAndTypingUndoTogetherAcrossEvents(_ ending: String) throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||||
|
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
|
window.contentView = editor
|
||||||
|
window.makeFirstResponder(editor)
|
||||||
|
defer { window.orderOut(nil) }
|
||||||
|
editor.allowsUndo = true
|
||||||
|
let original = NSMutableAttributedString(string: "one two")
|
||||||
|
original.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 3))
|
||||||
|
editor.setDocumentContents(original)
|
||||||
|
editor.vimEnabled = true
|
||||||
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
|
let undo = try #require(editor.undoManager)
|
||||||
|
undo.removeAllActions()
|
||||||
|
try vimKeys("cw", editor: editor)
|
||||||
|
vimEventTurn()
|
||||||
|
for character in "new" {
|
||||||
|
try vimKey(String(character), editor: editor)
|
||||||
|
vimEventTurn()
|
||||||
|
}
|
||||||
|
#expect(editor.documentContents.string == "new two")
|
||||||
|
switch ending {
|
||||||
|
case "escape": try vimKey("\u{1b}", editor: editor, code: 53)
|
||||||
|
case "focus": #expect(window.makeFirstResponder(nil))
|
||||||
|
case "toggle": editor.vimEnabled = false
|
||||||
|
default: editor.resetVim()
|
||||||
|
}
|
||||||
|
vimEventTurn()
|
||||||
|
#expect(undo.canUndo)
|
||||||
|
undo.undo()
|
||||||
|
#expect(editor.documentContents.string == original.string)
|
||||||
|
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
|
||||||
|
#expect(!undo.canUndo)
|
||||||
|
undo.redo()
|
||||||
|
#expect(editor.documentContents.string == "new two")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func vimSeparateInsertionSessionsHaveSeparateUndoOperations() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||||
|
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
|
window.contentView = editor
|
||||||
|
window.makeFirstResponder(editor)
|
||||||
|
defer { window.orderOut(nil) }
|
||||||
|
editor.allowsUndo = true
|
||||||
|
editor.setDocumentContents(NSAttributedString(string: "tail"))
|
||||||
|
editor.vimEnabled = true
|
||||||
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
|
let undo = try #require(editor.undoManager)
|
||||||
|
undo.removeAllActions()
|
||||||
|
try vimKeys("ione ", editor: editor)
|
||||||
|
vimEventTurn()
|
||||||
|
#expect(editor.documentContents.string == "one tail")
|
||||||
|
#expect(editor.vimMode == .insert)
|
||||||
|
try vimKey("\u{1b}", editor: editor, code: 53)
|
||||||
|
try vimKeys("0itwo ", editor: editor)
|
||||||
|
vimEventTurn()
|
||||||
|
try vimKey("\u{1b}", editor: editor, code: 53)
|
||||||
|
#expect(editor.documentContents.string == "two one tail")
|
||||||
|
try vimKeys("u", editor: editor)
|
||||||
|
#expect(editor.documentContents.string == "one tail")
|
||||||
|
try vimKeys("u", editor: editor)
|
||||||
|
#expect(editor.documentContents.string == "tail")
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
|
func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
@@ -185,7 +305,7 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
|
|||||||
let toggle = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" })
|
let toggle = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" })
|
||||||
#expect(!editor.vimEnabled)
|
#expect(!editor.vimEnabled)
|
||||||
try vimKeys("hello", editor: editor)
|
try vimKeys("hello", editor: editor)
|
||||||
#expect(editor.markdownString == "hello")
|
#expect(editor.documentContents.string == "hello")
|
||||||
toggle.performClick(nil)
|
toggle.performClick(nil)
|
||||||
#expect(defaults.bool(forKey: "notesVimEnabled"))
|
#expect(defaults.bool(forKey: "notesVimEnabled"))
|
||||||
#expect(editor.vimMode == .normal)
|
#expect(editor.vimMode == .normal)
|
||||||
@@ -204,9 +324,9 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
|
|||||||
window.sendEvent(escape)
|
window.sendEvent(escape)
|
||||||
#expect(window.firstResponder === editor)
|
#expect(window.firstResponder === editor)
|
||||||
#expect(window.isVisible)
|
#expect(window.isVisible)
|
||||||
let beforeMotion = editor.markdownString
|
let beforeMotion = editor.documentContents.string
|
||||||
try vimKeys("w", editor: editor)
|
try vimKeys("w", editor: editor)
|
||||||
#expect(editor.markdownString == beforeMotion)
|
#expect(editor.documentContents.string == beforeMotion)
|
||||||
try vimKeys("d", editor: editor)
|
try vimKeys("d", editor: editor)
|
||||||
window.sendEvent(escape)
|
window.sendEvent(escape)
|
||||||
#expect(!window.isVisible)
|
#expect(!window.isVisible)
|
||||||
@@ -215,9 +335,9 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
|
|||||||
let reopened = try #require(descendants(reopenedContent).compactMap { $0 as? NotesTextView }.first)
|
let reopened = try #require(descendants(reopenedContent).compactMap { $0 as? NotesTextView }.first)
|
||||||
#expect(reopened.vimEnabled)
|
#expect(reopened.vimEnabled)
|
||||||
#expect(reopened.vimMode == .normal)
|
#expect(reopened.vimMode == .normal)
|
||||||
let beforeReopenedMotion = reopened.markdownString
|
let beforeReopenedMotion = reopened.documentContents.string
|
||||||
try vimKeys("w", editor: reopened)
|
try vimKeys("w", editor: reopened)
|
||||||
#expect(reopened.markdownString == beforeReopenedMotion)
|
#expect(reopened.documentContents.string == beforeReopenedMotion)
|
||||||
let newToggle = try #require(descendants(reopenedContent).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" })
|
let newToggle = try #require(descendants(reopenedContent).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" })
|
||||||
newToggle.performClick(nil)
|
newToggle.performClick(nil)
|
||||||
#expect(!defaults.bool(forKey: "notesVimEnabled"))
|
#expect(!defaults.bool(forKey: "notesVimEnabled"))
|
||||||
|
|||||||
@@ -1,41 +1,44 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Conjure
|
@testable import Conjure
|
||||||
|
|
||||||
@Test @MainActor func numberedMarkersShareTheBodyTextBaseline() throws {
|
@Test @MainActor func numberedMarkersShareTheBodyTextBaseline() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
|
||||||
for source in ["1. 1", "123) 123", " 12. 12"] {
|
editor.drawsBackground = false
|
||||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
MarkdownHighlighting.apply(to: editor)
|
window.contentView = editor
|
||||||
editor.refreshTasks()
|
defer { window.orderOut(nil) }
|
||||||
|
for source in ["1. 1", "123. 123", " 12. 12"] {
|
||||||
|
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||||
let layout = try #require(editor.layoutManager)
|
let layout = try #require(editor.layoutManager)
|
||||||
let container = try #require(editor.textContainer)
|
let container = try #require(editor.textContainer)
|
||||||
layout.ensureLayout(for: container)
|
layout.ensureLayout(for: container)
|
||||||
let text = editor.string as NSString
|
let glyph = layout.glyphIndexForCharacter(at: 0)
|
||||||
let marker = text.range(of: "\u{fffc}")
|
let fragment = layout.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
|
||||||
let body = NSRange(location: NSMaxRange(marker) + 1, length: text.length - NSMaxRange(marker) - 1)
|
let bodyX = editor.textContainerOrigin.x + fragment.minX + layout.location(forGlyphAt: glyph).x
|
||||||
var bottoms: [Int] = []
|
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 400, pixelsHigh: 200,
|
||||||
for range in [marker, body] {
|
|
||||||
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 400, pixelsHigh: 80,
|
|
||||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
|
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
|
||||||
let bitmapContext = try #require(NSGraphicsContext(bitmapImageRep: bitmap))
|
let bitmapContext = try #require(NSGraphicsContext(bitmapImageRep: bitmap))
|
||||||
bitmapContext.cgContext.translateBy(x: 0, y: 80)
|
bitmapContext.cgContext.translateBy(x: 0, y: 200)
|
||||||
bitmapContext.cgContext.scaleBy(x: 1, y: -1)
|
bitmapContext.cgContext.scaleBy(x: 1, y: -1)
|
||||||
let context = NSGraphicsContext(cgContext: bitmapContext.cgContext, flipped: true)
|
let context = NSGraphicsContext(cgContext: bitmapContext.cgContext, flipped: true)
|
||||||
NSGraphicsContext.saveGraphicsState()
|
NSGraphicsContext.saveGraphicsState()
|
||||||
NSGraphicsContext.current = context
|
NSGraphicsContext.current = context
|
||||||
context.cgContext.clear(CGRect(x: 0, y: 0, width: 400, height: 80))
|
context.cgContext.clear(CGRect(x: 0, y: 0, width: 400, height: 200))
|
||||||
layout.drawGlyphs(forGlyphRange: layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil), at: .zero)
|
editor.draw(editor.bounds)
|
||||||
NSGraphicsContext.restoreGraphicsState()
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
var bottom = -1
|
var markerBottom = -1, bodyBottom = -1
|
||||||
for y in 0..<80 {
|
for y in 0..<200 {
|
||||||
for x in 0..<400 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.2 { bottom = y }
|
for x in 0..<400 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.2 {
|
||||||
|
if CGFloat(x) < bodyX - 1 { markerBottom = y }
|
||||||
|
else { bodyBottom = y }
|
||||||
}
|
}
|
||||||
try #require(bottom >= 0)
|
|
||||||
bottoms.append(bottom)
|
|
||||||
}
|
}
|
||||||
#expect(abs(bottoms[0] - bottoms[1]) <= 1, "Marker/body ink baseline for \(source)")
|
try #require(markerBottom >= 0 && bodyBottom >= 0)
|
||||||
|
#expect(abs(markerBottom - bodyBottom) <= 1, "Marker/body ink baseline for \(source)")
|
||||||
|
#expect(!editor.string.contains("\u{fffc}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user