Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5665a96
Keep Read Mode read-only and fix table editing interactions
mfauzaan Sep 28, 2026
1664d40
Render editable task checkboxes and exit empty task lists
mfauzaan Sep 28, 2026
160cdd9
Preserve blank lines when starting a new list item
mfauzaan Sep 28, 2026
46d32fd
Match completed task styling in Edit Mode
mfauzaan Sep 28, 2026
43227a8
Tighten checkbox label spacing in Edit Mode
mfauzaan Sep 28, 2026
c9f21bd
Match checkbox geometry between Read and Edit modes
mfauzaan Sep 28, 2026
5ac7cc1
Guard table formatting targets and verify native reader copying
mfauzaan Sep 28, 2026
db37ab0
Merge branch 'fix/read-only-mode-table-editing' into fix/task-checkbo…
mfauzaan Sep 28, 2026
4cc486f
Wait for native clipboard text before asserting copy output
mfauzaan Sep 28, 2026
625a66e
Merge branch 'fix/read-only-mode-table-editing' into fix/task-checkbo…
mfauzaan Sep 28, 2026
bfc58bc
Keep task marker editable until its closing bracket is typed
mfauzaan Sep 28, 2026
9236dab
Preserve checkbox focus and scope completed task styling
mfauzaan Sep 28, 2026
7189828
Update mixed layout tests for rendered table formatting
mfauzaan Sep 28, 2026
a1bcad5
Merge branch 'fix/read-only-mode-table-editing' into fix/task-checkbo…
mfauzaan Sep 28, 2026
05cd25e
Assert rendered checkboxes in mixed layout fixtures
mfauzaan Sep 28, 2026
6b89f67
Describe task marker editing accurately before checkbox support
mfauzaan Sep 28, 2026
4f634fa
Merge branch 'fix/read-only-mode-table-editing' into fix/task-checkbo…
mfauzaan Sep 28, 2026
90b80bd
Verify task labels stay visible in mixed layout fixtures
mfauzaan Sep 28, 2026
a24554c
Preserve active list separators without widening inactive lists
mfauzaan Sep 28, 2026
1567cdb
Merge remote-tracking branch 'origin/main' into fix/task-checkbox-edi…
mfauzaan Sep 28, 2026
f8e3457
Preserve list separator while editing continuation lines
mfauzaan Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ Or grab the latest signed and notarized DMG from the [Releases](https://github.c
## Features

- **Native rendering** — `WKWebView` pipeline backed by [swift-markdown](https://github.com/swiftlang/swift-markdown), with heading anchors and link handling. Bare `http://` and `https://` URLs are clickable in the app and Quick Look previews.
- **Edit Mode** — edit Markdown in place with a formatting toolbar for headings, emphasis, lists, quotes, code, and links. Toggle it from the toolbar or with <kbd>⌘E</kbd>, then save with <kbd>⌘S</kbd>.
- **Read Mode** — select and copy text, follow links, and browse tables without changing the document. Switch to Edit Mode to edit tables or toggle task checkboxes.
- **Edit Mode** — edit Markdown in place with a formatting toolbar for headings, emphasis, lists, quotes, code, and links. Table cells show inline formatting until focused, then reveal their Markdown syntax for editing. Task markers become checkboxes once you finish typing the closing bracket; Enter continues a task list, and Enter on an empty task exits it. Toggle it from the toolbar or with <kbd>⌘E</kbd>, then save with <kbd>⌘S</kbd>.
- **Mermaid diagrams** — fenced `mermaid` code blocks render as diagrams in both the app and Quick Look previews, using a bundled renderer so previews work offline without a CDN request.
- **Math equations** — LaTeX inline (`$x_1 + x_2$`), display (`$$\int_0^1 x^2\,dx$$`), and fenced `math` blocks render with a bundled KaTeX. Selecting a rendered formula and copying yields the original LaTeX source (via the official `copy-tex` extension).
- **Document outline** — sidebar TOC that mirrors your headings; click to jump.
Expand Down
8 changes: 0 additions & 8 deletions md-preview/Document/ContentViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ final class ContentViewController: NSViewController {

var activeHeadingDidChange: ((Int?) -> Void)?
var zoomDidChange: ((CGFloat) -> Void)?
var taskCheckboxToggled: ((Int, Bool) -> Void)?
var tableEditRequested: ((MarkdownTableEditRequest) -> Void)?
var localMarkdownLinkActivated: ((URL) -> Void)?
/// Fires once after a pending source scroll anchor (prepared via
/// `prepareToRestoreSourceScrollAnchor`) has been applied to a fresh
Expand Down Expand Up @@ -123,12 +121,6 @@ final class ContentViewController: NSViewController {
webView.localMarkdownLinkActivated = { [weak self] url in
self?.localMarkdownLinkActivated?(url)
}
webView.taskCheckboxToggled = { [weak self] line, checked in
self?.taskCheckboxToggled?(line, checked)
}
webView.tableEditRequested = { [weak self] request in
self?.tableEditRequested?(request)
}
webView.zoomDidChange = { [weak self] zoom in
self?.webViewCenteredLeadingConstraint?.constant =
-MarkdownHTML.preferredPageWidth * zoom / 2
Expand Down
165 changes: 0 additions & 165 deletions md-preview/Document/DocumentWindowController+EditSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -524,171 +524,6 @@ extension DocumentWindowController {
}
}

func toggleTaskCheckbox(onLine sourceLine: Int, checked: Bool) {
guard !isEditing,
let baseline = currentMarkdown,
let updated = TaskCheckboxSource.settingChecked(
checked, onLine: sourceLine, in: baseline
),
updated != baseline else {
rerenderCurrentPreview()
return
}

let diskState = diskFileState(for: currentFileURL, expectedMarkdown: baseline)
saveEditedMarkdown(updated, diskState: diskState) { [weak self] result in
guard let self else { return }
switch result {
case .saved:
self.currentMarkdown = updated
if let url = self.currentFileURL {
self.markdownDocument?.replaceContents(markdown: updated, fileURL: url)
self.renderCurrentDocument(text: updated, fileURL: url)
}
case let .reloaded(externalMarkdown):
self.currentMarkdown = externalMarkdown
if let url = self.currentFileURL {
self.markdownDocument?.replaceContents(markdown: externalMarkdown, fileURL: url)
self.renderCurrentDocument(text: externalMarkdown, fileURL: url)
}
case .cancelled:
self.rerenderCurrentPreview()
}
}
}

func applyTableEdit(_ request: MarkdownTableEditRequest) {
guard !isEditing,
let baseline = currentMarkdown else {
rerenderCurrentPreview()
return
}
let updated = request.edits.reduce(Optional(baseline)) { markdown, edit in
markdown.flatMap {
MarkdownTableSource.applying(
edit,
fromLine: request.startLine,
throughLine: request.endLine,
in: $0
)
}
}
guard let updated,
updated != baseline else {
rerenderCurrentPreview()
return
}

let diskState = diskFileState(for: currentFileURL, expectedMarkdown: baseline)
let actionName = tableUndoActionName(for: request.edits)
saveEditedMarkdown(updated, diskState: diskState) { [weak self] result in
guard let self else { return }
switch result {
case .saved:
self.currentMarkdown = updated
if let url = self.currentFileURL {
self.markdownDocument?.replaceContents(markdown: updated, fileURL: url)
self.renderCurrentDocument(text: updated, fileURL: url)
self.registerTableUndo(
restoring: baseline,
expectedCurrentMarkdown: updated,
fileURL: url,
actionName: actionName
)
}
case let .reloaded(externalMarkdown):
self.tableUndoManager.removeAllActions()
self.currentMarkdown = externalMarkdown
if let url = self.currentFileURL {
self.markdownDocument?.replaceContents(markdown: externalMarkdown, fileURL: url)
self.renderCurrentDocument(text: externalMarkdown, fileURL: url)
}
case .cancelled:
self.rerenderCurrentPreview()
}
}
}

private func tableUndoActionName(for edits: [MarkdownTableEdit]) -> String {
for edit in edits.reversed() {
switch edit {
case .setCell:
return NSLocalizedString("Edit Table Cell", comment: "Table edit undo action")
case .insertRowBefore, .insertRowAfter:
return NSLocalizedString("Add Row", comment: "Table edit undo action")
case .deleteRow:
return NSLocalizedString("Delete Row", comment: "Table edit undo action")
case .insertColumnBefore, .insertColumnAfter:
return NSLocalizedString("Add Column", comment: "Table edit undo action")
case .deleteColumn:
return NSLocalizedString("Delete Column", comment: "Table edit undo action")
}
}
return NSLocalizedString("Edit Table", comment: "Table edit undo action")
}

private func registerTableUndo(restoring markdown: String,
expectedCurrentMarkdown: String,
fileURL: URL,
actionName: String) {
tableUndoManager.registerUndo(withTarget: self) { target in
target.restoreTableMarkdown(
markdown,
expectedCurrentMarkdown: expectedCurrentMarkdown,
fileURL: fileURL,
actionName: actionName
)
}
tableUndoManager.setActionName(actionName)
}

private func restoreTableMarkdown(_ markdown: String,
expectedCurrentMarkdown: String,
fileURL: URL,
actionName: String) {
guard !isEditing,
!isTableUndoSaveInFlight,
currentFileURL?.standardizedFileURL == fileURL.standardizedFileURL,
currentMarkdown == expectedCurrentMarkdown else {
tableUndoManager.removeAllActions()
return
}

// Register while UndoManager is actively undoing or redoing so AppKit
// puts this inverse operation on the opposite stack immediately. The
// file write can become asynchronous if sandbox permission is needed.
registerTableUndo(
restoring: expectedCurrentMarkdown,
expectedCurrentMarkdown: markdown,
fileURL: fileURL,
actionName: actionName
)
isTableUndoSaveInFlight = true
let diskState = diskFileState(for: currentFileURL,
expectedMarkdown: expectedCurrentMarkdown)
saveEditedMarkdown(markdown, diskState: diskState) { [weak self] result in
guard let self else { return }
self.isTableUndoSaveInFlight = false
switch result {
case .saved:
self.currentMarkdown = markdown
self.markdownDocument?.replaceContents(markdown: markdown, fileURL: fileURL)
self.renderCurrentDocument(text: markdown, fileURL: fileURL)
case let .reloaded(externalMarkdown):
self.tableUndoManager.removeAllActions()
self.currentMarkdown = externalMarkdown
self.markdownDocument?.replaceContents(
markdown: externalMarkdown,
fileURL: fileURL
)
self.renderCurrentDocument(text: externalMarkdown, fileURL: fileURL)
case .cancelled:
self.tableUndoManager.removeAllActions()
self.rerenderCurrentPreview()
}
}
}

func rerenderCurrentPreview() {
guard let url = currentFileURL, let markdown = currentMarkdown else { return }
renderCurrentDocument(text: markdown, fileURL: url)
Expand Down
14 changes: 0 additions & 14 deletions md-preview/Document/DocumentWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,6 @@ final class DocumentWindowController: NSWindowController, NSWindowDelegate, NSTo
var searchMode: SearchMode = .contains
var pendingFindWork: DispatchWorkItem?
static let findDebounceDelay: TimeInterval = 0.10
let tableUndoManager = UndoManager()
var isTableUndoSaveInFlight = false

var documentWindow: NSWindow {
guard let window else {
Expand Down Expand Up @@ -206,12 +204,6 @@ final class DocumentWindowController: NSWindowController, NSWindowDelegate, NSTo
self?.present(url: url)
}
}
split.onToggleTaskCheckbox = { [weak self] line, checked in
self?.toggleTaskCheckbox(onLine: line, checked: checked)
}
split.onEditTable = { [weak self] request in
self?.applyTableEdit(request)
}
documentWindow.contentViewController = split
documentWindow.setContentSize(NSSize(width: 1100, height: 720))
documentWindow.center()
Expand Down Expand Up @@ -360,15 +352,10 @@ final class DocumentWindowController: NSWindowController, NSWindowDelegate, NSTo
applyWindowBackgroundTheme()
}

func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
isEditing ? nil : tableUndoManager
}

/// A saved image of the document, decoding while the window is built.
var pendingSnapshot: DocumentSnapshotCache.Prefetch?

func display(markdown: String, fileURL: URL?) {
tableUndoManager.removeAllActions()
currentFileURL = fileURL
currentMarkdown = markdown
resetAutoSaveFeedback()
Expand Down Expand Up @@ -444,7 +431,6 @@ final class DocumentWindowController: NSWindowController, NSWindowDelegate, NSTo
return
}
let restoredScrollPosition = commitNavigation(to: url, intent: intent)
tableUndoManager.removeAllActions()

// Switching to a different file blanks the preview so the previous
// doc doesn't linger on screen during sheet dismissal + load.
Expand Down
8 changes: 0 additions & 8 deletions md-preview/Document/MainSplitViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ final class MainSplitViewController: NSSplitViewController {

var onSelectFile: ((URL) -> Void)?
var onOpenMarkdownLink: ((URL) -> Void)?
var onToggleTaskCheckbox: ((Int, Bool) -> Void)?
var onEditTable: ((MarkdownTableEditRequest) -> Void)?

override func viewDidLoad() {
super.viewDidLoad()
Expand Down Expand Up @@ -79,12 +77,6 @@ final class MainSplitViewController: NSSplitViewController {
contentViewController?.activeHeadingDidChange = { [weak self] headingID in
self?.sidebarViewController?.setActiveHeading(headingID)
}
contentViewController?.taskCheckboxToggled = { [weak self] line, checked in
self?.onToggleTaskCheckbox?(line, checked)
}
contentViewController?.tableEditRequested = { [weak self] request in
self?.onEditTable?(request)
}
contentViewController?.localMarkdownLinkActivated = { [weak self] url in
self?.onOpenMarkdownLink?(url)
}
Expand Down
68 changes: 43 additions & 25 deletions md-preview/Features/Editor/EditorHTML.swift
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,45 @@ nonisolated enum EditorHTML {
padding-inline-start: 2.1em;
text-indent: -2.1em;
}
.cm-md-task-completed {
color: var(--secondary);
text-decoration: line-through;
}
#editor .cm-md-task-line {
text-indent: 0 !important;
}
.cm-md-task-marker {
display: inline;
}
.cm-md-task-marker input {
appearance: none;
-webkit-appearance: none;
font: inherit;
width: 0.9em;
height: 0.9em;
margin: 0;
margin-inline-start: calc(-0.9em - 0.75em);
margin-inline-end: 0.75em;
vertical-align: calc(0.5cap - 0.45em);
border: 1.5px solid var(--grid);
border-radius: 25%;
background: transparent;
position: relative;
cursor: pointer;
}
.cm-md-task-marker input:checked {
border-color: var(--accent);
background: var(--accent);
}
.cm-md-task-marker input:checked::after {
content: "";
position: absolute;
inset: 0;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M4.4 8.4 L7 11 L11.6 5.4" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>');
background-repeat: no-repeat;
background-position: center;
background-size: 100% 100%;
}
.cm-md-bullet {
display: inline-block;
width: 2.1em;
Expand Down Expand Up @@ -528,7 +567,7 @@ nonisolated enum EditorHTML {
position: relative;
width: fit-content;
/* Outer spacing comes from the block separator lines, matching
the preview's .md-table-scroll margin. */
the preview's table margin. */
margin: 0;
max-width: 100%;
overflow: visible;
Expand Down Expand Up @@ -575,34 +614,13 @@ nonisolated enum EditorHTML {
opacity: 0.72;
pointer-events: none;
}
/* Match the document's quiet, text-first editing surface. The caret
identifies the insertion point; a tint marks the active cell. */
.cm-md-table-cell:focus {
outline: 2px solid var(--accent);
outline-offset: -2px;
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.cm-md-table-cell.is-table-part-selected {
--table-selection-top-edge: 0 0 transparent;
--table-selection-right-edge: 0 0 transparent;
--table-selection-bottom-edge: 0 0 transparent;
--table-selection-left-edge: 0 0 transparent;
background: color-mix(in srgb, var(--accent) 14%, Canvas);
box-shadow:
var(--table-selection-top-edge),
var(--table-selection-right-edge),
var(--table-selection-bottom-edge),
var(--table-selection-left-edge);
}
.cm-md-table-cell.is-table-selection-top {
--table-selection-top-edge: inset 0 1px color-mix(in srgb, var(--accent) 52%, transparent);
}
.cm-md-table-cell.is-table-selection-right {
--table-selection-right-edge: inset -1px 0 color-mix(in srgb, var(--accent) 52%, transparent);
}
.cm-md-table-cell.is-table-selection-bottom {
--table-selection-bottom-edge: inset 0 -1px color-mix(in srgb, var(--accent) 52%, transparent);
}
.cm-md-table-cell.is-table-selection-left {
--table-selection-left-edge: inset 1px 0 color-mix(in srgb, var(--accent) 52%, transparent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
}
/* Page scrolling lets WebKit own the native toolbar backdrop.
The macOS 15 editor keeps its internal scroller. */
Expand Down
Loading
Loading