Merge pull request 'feat: Comments, drafts/publish, code block tooling, and editing preferences' (#13) from feature/document-editing into main

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-08-21 00:04:52 +01:00
committed by PSM Git
185 changed files with 29177 additions and 152 deletions
-3
View File
@@ -1,6 +1,3 @@
[submodule "docs/reference/outline-openapi"]
path = docs/reference/outline-openapi
url = https://github.com/outline/openapi.git
[submodule "Vendor/swift-markdown-engine"]
path = Vendor/swift-markdown-engine
url = https://github.com/nodes-app/swift-markdown-engine.git
@@ -92,6 +92,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
}
}
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)") {
try await self.live.listDrafts(request)
}
}
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await cachedFetch(key: "collections:\(offset):\(limit)") {
try await self.live.listCollections(offset: offset, limit: limit)
@@ -303,6 +309,34 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
try await live.listViews(request)
}
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
try await live.listComments(request)
}
public func commentInfo(id: String) async throws -> OutlineComment {
try await live.commentInfo(id: id)
}
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
try await live.createComment(request)
}
public func resolveComment(id: String) async throws -> OutlineComment {
try await live.resolveComment(id: id)
}
public func unresolveComment(id: String) async throws -> OutlineComment {
try await live.unresolveComment(id: id)
}
public func addReaction(commentId: String, emoji: String) async throws {
try await live.addReaction(commentId: commentId, emoji: emoji)
}
public func removeReaction(commentId: String, emoji: String) async throws {
try await live.removeReaction(commentId: commentId, emoji: emoji)
}
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await live.addDocumentUser(request)
}
@@ -343,6 +377,10 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
try await live.uploadAttachmentFile(result, fileData: fileData)
}
public func fetchAuthenticatedFile(path: String) async throws -> Data {
try await live.fetchAuthenticatedFile(path: path)
}
public func deleteAttachment(id: String) async throws {
try await live.deleteAttachment(id: id)
}
@@ -433,6 +471,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
/// existing cached-read methods already do the caching as a side effect,
/// this just has to drive the walk and separately cache each document by
/// id (`listDocuments`'s cache key is the list, not the individual doc).
///
/// Recurses into every document's children, not just collections' own
/// root-level documents a document with sub-documents used to leave
/// them uncached entirely (only reachable if something else happened to
/// open them individually first). Also caches each collection under its
/// own `"collection:<id>"` key (previously only cached as part of the
/// paginated list blob), so both are individually enumerable afterward
/// via `OfflineCacheStore.loadAll(keyPrefix:)` see
/// `cachedDocumentsIndex()`/`cachedCollectionsIndex()`.
public func performFullSync() async -> FullSyncSummary {
var documentsCount = 0
var errors: [String] = []
@@ -457,28 +504,64 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
}
for collection in collections {
var offset = 0
let limit = 100
while true {
let documents: [OutlineDocument]
do {
documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit)
} catch {
errors.append("\(collection.name): \(errorDescription(error))")
break
}
for document in documents {
await cacheDocument(document)
}
documentsCount += documents.count
guard documents.count == limit else { break }
offset += limit
}
await cacheCollection(collection)
let result = await cacheDocumentTree(collectionId: collection.id, parentDocumentId: nil, collectionName: collection.name)
documentsCount += result.count
errors.append(contentsOf: result.errors)
}
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
}
/// Caches every document under `parentDocumentId` (`nil` = a
/// collection's root level) and recurses into each one's own children,
/// depth-first, until a branch runs out of sub-documents. Returns a
/// plain `(count, errors)` pair rather than mutating shared state across
/// `await` boundaries, since this calls itself recursively.
private func cacheDocumentTree(
collectionId: String,
parentDocumentId: String?,
collectionName: String
) async -> (count: Int, errors: [String]) {
var count = 0
var errors: [String] = []
var offset = 0
let limit = 100
while true {
let documents: [OutlineDocument]
do {
documents = try await listDocuments(collectionId: collectionId, parentDocumentId: parentDocumentId, offset: offset, limit: limit)
} catch {
errors.append("\(collectionName): \(errorDescription(error))")
break
}
for document in documents {
await cacheDocument(document)
count += 1
let childResult = await cacheDocumentTree(collectionId: collectionId, parentDocumentId: document.id, collectionName: collectionName)
count += childResult.count
errors.append(contentsOf: childResult.errors)
}
guard documents.count == limit else { break }
offset += limit
}
return (count, errors)
}
/// Every individually cached document from the last Full Local Sync
/// empty if a sync has never run (or found nothing). Purely a local
/// SwiftData read, no network involved.
public func cachedDocumentsIndex() async -> [OutlineDocument] {
let payloads = await cache.loadAll(keyPrefix: "document:")
return payloads.compactMap { try? decoder.decode(OutlineDocument.self, from: $0) }
}
/// Every individually cached collection from the last Full Local Sync.
public func cachedCollectionsIndex() async -> [OutlineCollection] {
let payloads = await cache.loadAll(keyPrefix: "collection:")
return payloads.compactMap { try? decoder.decode(OutlineCollection.self, from: $0) }
}
// MARK: - Helpers
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
@@ -31,6 +31,15 @@ public actor OfflineCacheStore {
return try? modelContext.fetch(descriptor).first?.payload
}
/// Everything cached under a key prefix e.g. every individually
/// cached document (`"document:<id>"`) or collection
/// (`"collection:<id>"`) after a Full Local Sync, for building a local
/// search index without a per-item exact-key lookup.
public func loadAll(keyPrefix: String) -> [Data] {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key.starts(with: keyPrefix) })
return ((try? modelContext.fetch(descriptor)) ?? []).map(\.payload)
}
/// Used to drop a temporary `pending-*` document's cache entry once a
/// queued create syncs and the server hands back the real id the
/// placeholder key would otherwise sit around as a dead orphan forever.
@@ -13,6 +13,9 @@ public protocol OutlineAPIClient: Sendable {
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument]
/// Draft (unpublished) documents belonging to the current user. Backed
/// by `documents.drafts`.
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument]
/// Full-text search with snippets/ranking. Backed by `documents.search`.
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
/// Title-only search faster, no snippets. Backed by `documents.search_titles`.
@@ -48,6 +51,20 @@ public protocol OutlineAPIClient: Sendable {
/// Historical view records, not live presence. Backed by `views.list`.
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
/// See `OutlineComment`. `resolve`/`unresolve`/`add_reaction`/
/// `remove_reaction` are confirmed real against Outline's own server
/// source but aren't in the vendored spec.
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment]
func commentInfo(id: String) async throws -> OutlineComment
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment
func resolveComment(id: String) async throws -> OutlineComment
func unresolveComment(id: String) async throws -> OutlineComment
/// Reaction endpoints return `{success: true}`, not the updated
/// comment callers refetch via `commentInfo` for the fresh
/// `reactions` array.
func addReaction(commentId: String, emoji: String) async throws
func removeReaction(commentId: String, emoji: String) async throws
/// See `OutlineMembership`/`OutlineDocumentMember` `add` is confirmed
/// from Outline's official docs, the rest are best-effort.
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
@@ -75,6 +92,14 @@ public protocol OutlineAPIClient: Sendable {
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
/// Fetches raw bytes from an authenticated, server-relative GET path
/// e.g. `/api/attachments.redirect?id=<uuid>`, the reference Outline's
/// own editor embeds for uploaded images in document Markdown. Unlike
/// `post`'s RPC endpoints, this is a GET that 302-redirects to the
/// actual (often presigned, cross-host) storage URL; `path` is resolved
/// against the client's base URL, same as `uploadAttachmentFile`'s
/// `uploadUrl` handling.
func fetchAuthenticatedFile(path: String) async throws -> Data
/// Best-effort matches the shape every other simple `id`-only delete
/// in this API uses (`pins.delete`, `stars.delete`, ), not confirmed
/// against a live server specifically for attachments yet.
@@ -59,6 +59,10 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit))
}
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
try await post("documents.drafts", body: request)
}
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
try await post("documents.search", body: request)
}
@@ -175,6 +179,34 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("views.list", body: request)
}
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
try await post("comments.list", body: request)
}
public func commentInfo(id: String) async throws -> OutlineComment {
try await post("comments.info", body: StarIDParams(id: id))
}
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
try await post("comments.create", body: request)
}
public func resolveComment(id: String) async throws -> OutlineComment {
try await post("comments.resolve", body: StarIDParams(id: id))
}
public func unresolveComment(id: String) async throws -> OutlineComment {
try await post("comments.unresolve", body: StarIDParams(id: id))
}
public func addReaction(commentId: String, emoji: String) async throws {
try await postForSuccess("comments.add_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
}
public func removeReaction(commentId: String, emoji: String) async throws {
try await postForSuccess("comments.remove_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
}
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await post("documents.add_user", body: request)
}
@@ -278,6 +310,45 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
}
}
public func fetchAuthenticatedFile(path: String) async throws -> Data {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
}
// Same host-relative-or-absolute resolution as uploadAttachmentFile's uploadUrl.
guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
throw OutlineAPIError.transport(URLError(.badURL))
}
var request = URLRequest(url: url)
// URLSession's default redirect handling drops Authorization on a
// cross-host redirect (same as a browser dropping cookies on one)
// exactly what's wanted here: authorize the request to Outline's own
// `attachments.redirect`, not the presigned storage URL it 302s to.
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
switch response.statusCode {
case 401:
throw OutlineAPIError.unauthorized
case 404:
throw OutlineAPIError.notFound
default:
throw OutlineAPIError.server(status: response.statusCode, message: nil)
}
}
return data
}
public func deleteAttachment(id: String) async throws {
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
}
@@ -485,6 +556,11 @@ private struct StarIDParams: Encodable {
let id: String
}
private struct CommentReactionParams: Encodable {
let id: String
let emoji: String
}
private struct MoveDocumentResponse: Decodable {
let documents: [OutlineDocument]?
let collections: [OutlineCollection]?
@@ -0,0 +1,69 @@
import Foundation
/// Minimal recursive JSON tree for fields the API returns as genuinely
/// arbitrary/untyped JSON (`type: object` with no fixed schema in the
/// OpenAPI spec) currently just `Comment.data`, a ProseMirror document.
/// Decode-only: nothing in this codebase writes comment bodies back yet.
public enum JSONValue: Decodable, Sendable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([String: JSONValue].self) {
self = .object(value)
} else if let value = try? container.decode([JSONValue].self) {
self = .array(value)
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
}
}
}
extension JSONValue {
/// Best-effort plain-text extraction from a ProseMirror-shaped document
/// walks `content` arrays, concatenates `text` node strings, and adds a
/// trailing newline after known block-level node types so paragraphs
/// don't run together. Good enough for a read-only comment list; not a
/// real ProseMirror renderer (no marks, no lists/tables structure).
public func plainText() -> String {
switch self {
case .object(let fields):
if case .string(let text)? = fields["text"] {
return text
}
let childText: String
if case .array(let items)? = fields["content"] {
childText = items.map { $0.plainText() }.joined()
} else {
childText = ""
}
if case .string(let type)? = fields["type"], Self.blockTypes.contains(type) {
return childText + "\n"
}
return childText
case .array(let items):
return items.map { $0.plainText() }.joined()
case .string(let value):
return value
case .number, .bool, .null:
return ""
}
}
private static let blockTypes: Set<String> = [
"paragraph", "heading", "listItem", "blockquote", "codeBlock", "list_item", "code_block"
]
}
@@ -0,0 +1,46 @@
import Foundation
/// A comment (or reply, via `parentCommentId`) on a document. Backed by
/// `comments.*` `create`/`info`/`update`/`delete`/`list` are in the
/// vendored OpenAPI spec; `resolve`/`unresolve`/`add_reaction`/
/// `remove_reaction` are not (confirmed against Outline's own server
/// source instead same "spec mirror is incomplete" lesson as
/// `OutlinePin`). `data` is the comment body as a ProseMirror document,
/// not plain text see `JSONValue.plainText()`.
public struct OutlineComment: Decodable, Identifiable, Sendable {
public let id: String
public let data: JSONValue
public let documentId: String
public let parentCommentId: String?
public let createdAt: Date
public let createdBy: OutlineUser?
public let updatedAt: Date?
public let resolvedAt: Date?
public let resolvedBy: OutlineUser?
public let reactions: [ReactionSummary]
/// The document text this comment is anchored to only populated when
/// the request set `includeAnchorText: true`; `nil` for a document-level
/// (non-anchored) comment either way. No prefix/suffix comes back on
/// read (only accepted as create-time disambiguation input), so
/// re-finding this text in the rendered document is inherently
/// best-effort first occurrence wins, same as Outline's own create
/// behavior when nothing else disambiguates.
public let anchorText: String?
public var isResolved: Bool { resolvedAt != nil }
public var bodyText: String {
data.plainText().trimmingCharacters(in: .whitespacesAndNewlines)
}
}
/// One emoji's worth of reactions on a comment grouped by emoji server-side
/// (`ReactionSummary` in Outline's own source), not one object per reaction.
public struct ReactionSummary: Codable, Sendable, Equatable {
public let emoji: String
public let userIds: [String]
public init(emoji: String, userIds: [String]) {
self.emoji = emoji
self.userIds = userIds
}
}
@@ -0,0 +1,26 @@
import Foundation
/// `parentCommentId: nil` creates a document-level (top-level) comment;
/// non-nil creates a reply (Outline supports one level of nesting a
/// reply's own `parentCommentId` should always be a top-level comment's
/// id, never another reply's). `anchorText` creates an inline (anchored)
/// comment instead of a document-level one the first occurrence of that
/// exact substring in the document's plain text is used, same as Outline's
/// own web editor; `anchorPrefix`/`anchorSuffix` aren't sent (this app has
/// no UI for choosing between multiple identical occurrences). `text` is
/// the documented markdown convenience field for `data` (a ProseMirror
/// document) simplest path for plain-text comment bodies, no need to
/// construct ProseMirror JSON by hand.
public struct CreateCommentRequest: Encodable, Sendable {
public let documentId: String
public let parentCommentId: String?
public let text: String
public let anchorText: String?
public init(documentId: String, parentCommentId: String? = nil, text: String, anchorText: String? = nil) {
self.documentId = documentId
self.parentCommentId = parentCommentId
self.text = text
self.anchorText = anchorText
}
}
@@ -3,14 +3,16 @@ import Foundation
public struct CreateDocumentRequest: Codable, Sendable {
public let title: String
public let text: String
public let collectionId: String
/// `nil` (with `parentDocumentId` also `nil`) creates a draft Outline
/// requires one of the two to publish at all, regardless of `publish`.
public let collectionId: String?
public let parentDocumentId: String?
public let publish: Bool
public init(
title: String,
text: String,
collectionId: String,
collectionId: String? = nil,
parentDocumentId: String? = nil,
publish: Bool = true
) {
@@ -0,0 +1,18 @@
import Foundation
public struct ListCommentsRequest: Encodable, Sendable {
public let documentId: String
public let offset: Int
public let limit: Int
/// Include each anchored comment's `anchorText` (the document text it's
/// attached to) in the response. Off by default it's extra payload
/// only needed when actually positioning inline markers.
public let includeAnchorText: Bool
public init(documentId: String, offset: Int = 0, limit: Int = 100, includeAnchorText: Bool = false) {
self.documentId = documentId
self.offset = offset
self.limit = limit
self.includeAnchorText = includeAnchorText
}
}
@@ -0,0 +1,11 @@
import Foundation
public struct ListDraftsRequest: Encodable, Sendable {
public let offset: Int
public let limit: Int
public init(offset: Int = 0, limit: Int = 25) {
self.offset = offset
self.limit = limit
}
}
@@ -7,6 +7,13 @@ public struct UpdateDocumentRequest: Codable, Sendable {
public let append: Bool?
public let fullWidth: Bool?
public let insightsEnabled: Bool?
/// Moves the document to this collection. Combined with `publish: true`,
/// this is how a draft (no `collectionId`, or one it just hasn't left
/// yet) gets published into a specific collection in one call.
public let collectionId: String?
/// Publishes a draft, making it visible to other workspace members.
/// Documented as a no-op if the document is already published.
public let publish: Bool?
public init(
id: String,
@@ -14,7 +21,9 @@ public struct UpdateDocumentRequest: Codable, Sendable {
text: String? = nil,
append: Bool? = nil,
fullWidth: Bool? = nil,
insightsEnabled: Bool? = nil
insightsEnabled: Bool? = nil,
collectionId: String? = nil,
publish: Bool? = nil
) {
self.id = id
self.title = title
@@ -22,5 +31,7 @@ public struct UpdateDocumentRequest: Codable, Sendable {
self.append = append
self.fullWidth = fullWidth
self.insightsEnabled = insightsEnabled
self.collectionId = collectionId
self.publish = publish
}
}
@@ -29,6 +29,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
@@ -71,6 +72,13 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
func deleteSubscription(id: String) async throws { throw NotStubbed() }
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() }
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] { throw NotStubbed() }
func commentInfo(id: String) async throws -> OutlineComment { throw NotStubbed() }
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment { throw NotStubbed() }
func resolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
func unresolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
func addReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
func removeReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
@@ -91,6 +99,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
func fetchAuthenticatedFile(path: String) async throws -> Data { throw NotStubbed() }
func deleteAttachment(id: String) async throws { throw NotStubbed() }
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
@@ -421,8 +430,13 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
stub.listDocumentsHandler = { _, _, offset, _ in
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
// Must return empty for any non-nil parentDocumentId (no children)
// performFullSync now recurses into every document's own children,
// so a stub that ignores parentDocumentId and always returns the
// same root documents regardless would recurse into itself forever.
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
guard parentDocumentId == nil else { return [] }
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
@@ -437,6 +451,34 @@ final class CachingOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(cachedDoc.id, "doc-2")
}
func testPerformFullSyncRecursesIntoNestedDocuments() async throws {
let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
// doc-1 (root) -> doc-2 (child of doc-1) -> doc-3 (grandchild)
// regression test for the real gap this fixed: only root-level
// documents were ever cached before, so a document's own
// sub-documents were never reachable offline at all unless
// something else happened to open them individually first.
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
guard offset == 0 else { return [] }
switch parentDocumentId {
case nil: return [self.makeDocument(id: "doc-1")]
case "doc-1": return [self.makeDocument(id: "doc-2")]
case "doc-2": return [self.makeDocument(id: "doc-3")]
default: return []
}
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 3)
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedGrandchild = try await sut.documentInfo(id: "doc-3")
XCTAssertEqual(cachedGrandchild.id, "doc-3")
}
// MARK: - Offline document creation
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
@@ -846,6 +846,137 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list")
}
func testListCommentsDecodesCommentsAndExtractsPlainTextFromProseMirrorData() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "comment-1",
"documentId": "doc-1",
"parentCommentId": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"createdBy": { "id": "user-1", "name": "Jane Doe" },
"updatedAt": "2026-01-01T00:00:00.000Z",
"resolvedAt": null,
"resolvedBy": null,
"reactions": [
{ "emoji": "\u{1F44D}", "userIds": ["user-2", "user-3"] }
],
"data": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Sounds great" }
]
}
]
}
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let comments = try await client.listComments(ListCommentsRequest(documentId: "doc-1"))
XCTAssertEqual(comments.first?.id, "comment-1")
XCTAssertEqual(comments.first?.bodyText, "Sounds great")
XCTAssertFalse(comments.first?.isResolved ?? true)
XCTAssertEqual(comments.first?.reactions.first?.emoji, "\u{1F44D}")
XCTAssertEqual(comments.first?.reactions.first?.userIds, ["user-2", "user-3"])
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.list")
}
func testResolveCommentDecodesResolvedComment() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "comment-1",
"documentId": "doc-1",
"parentCommentId": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"createdBy": { "id": "user-1", "name": "Jane Doe" },
"updatedAt": "2026-01-01T00:00:00.000Z",
"resolvedAt": "2026-01-02T00:00:00.000Z",
"resolvedBy": { "id": "user-2", "name": "John Roe" },
"reactions": [],
"data": { "type": "doc", "content": [] }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let comment = try await client.resolveComment(id: "comment-1")
XCTAssertTrue(comment.isResolved)
XCTAssertEqual(comment.resolvedBy?.name, "John Roe")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.resolve")
}
func testCreateCommentSendsParentCommentIdForAReply() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "comment-2",
"documentId": "doc-1",
"parentCommentId": "comment-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"createdBy": { "id": "user-1", "name": "Jane Doe" },
"updatedAt": "2026-01-01T00:00:00.000Z",
"resolvedAt": null,
"resolvedBy": null,
"reactions": [],
"data": { "type": "doc", "content": [] }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let reply = try await client.createComment(
CreateCommentRequest(documentId: "doc-1", parentCommentId: "comment-1", text: "Agreed")
)
XCTAssertEqual(reply.parentCommentId, "comment-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.create")
}
func testAddReactionPostsIdAndEmoji() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.addReaction(commentId: "comment-1", emoji: "\u{1F44D}")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.add_reaction")
}
func testCreatePinDecodesPin() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
+18 -4
View File
@@ -10,6 +10,7 @@
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B130366A1D0000167E /* MarkdownEngine */; };
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */; };
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B530366A1D0000167E /* MarkdownEngineLatex */; };
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FADBE086303734FE001E69F0 /* ImagePlayground.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99C43302CF1BD00C9949F /* OutlineKit */; };
/* End PBXBuildFile section */
@@ -31,6 +32,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
FADBE086303734FE001E69F0 /* ImagePlayground.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ImagePlayground.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS27.0.sdk/System/Library/Frameworks/ImagePlayground.framework; sourceTree = DEVELOPER_DIR; };
FAF99C18302CE96100C9949F /* Outpost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Outpost.app; sourceTree = BUILT_PRODUCTS_DIR; };
FAF99C27302CE96200C9949F /* OutpostTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
FAF99C31302CE96200C9949F /* OutpostUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -61,6 +63,7 @@
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */,
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */,
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */,
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */,
);
};
@@ -77,12 +80,21 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
FADBE085303734FE001E69F0 /* Frameworks */ = {
isa = PBXGroup;
children = (
FADBE086303734FE001E69F0 /* ImagePlayground.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
FAF99C0F302CE96100C9949F = {
isa = PBXGroup;
children = (
FAF99C1A302CE96100C9949F /* Outpost */,
FAF99C2A302CE96200C9949F /* OutpostTests */,
FAF99C34302CE96200C9949F /* OutpostUITests */,
FADBE085303734FE001E69F0 /* Frameworks */,
FAF99C19302CE96100C9949F /* Products */,
);
sourceTree = "<group>";
@@ -420,13 +432,14 @@
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 27.0;
};
name = Debug;
@@ -471,13 +484,14 @@
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 27.0;
};
name = Release;
+116
View File
@@ -14,6 +14,13 @@ struct SettingsView: View {
@Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
@AppStorage("outpost.pointerCursorEnabled") private var isPointerCursorEnabled = true
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false
@@ -92,6 +99,8 @@ struct SettingsView: View {
private var sectionDetail: some View {
switch section {
case .appearance: appearanceDetail
case .editor: editorDetail
case .navigation: navigationDetail
case .profile: profileDetail
case .preferences: preferencesDetail
case .notifications: notificationsDetail
@@ -139,6 +148,113 @@ struct SettingsView: View {
}
}
// MARK: - Editor
/// Local-only, device-side settings for how this app's own editor
/// behaves not synced to Outline (unlike Preferences, which mirrors
/// server-side settings the web app also reads/writes). Same category
/// `.general`/"Outpost" as Appearance, for the same reason.
private var editorDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader
Text("Settings for how documents are edited in this app.")
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
Toggle("Split View", isOn: $isSplitViewEnabled)
Text("Edit raw Markdown on the left with a live-updating preview on the right, instead of a single editable view.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Autocomplete", isOn: $isAutocompleteEnabled)
Text("Show inline predictive-text suggestions while typing, same as Notes and TextEdit. Accept with Tab or →.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Writing Tools", isOn: $isWritingToolsEnabled)
Text("Proofread, rewrite, summarize, or compose text using system-provided Apple Intelligence tools. Available on supported Macs and macOS versions.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Image Playground", isOn: $isImagePlaygroundEnabled)
Text("Adds a \"Create Image with Image Playground\" button to the reader toolbar — generates an image from a text description (or your current selection, if any) and inserts it into the document. Requires macOS 15.1+ and a supported Mac — the toggle has no effect where it isn't available.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Pointer Cursor", isOn: $isPointerCursorEnabled)
Text("Show a pointing-hand cursor when hovering sidebar rows and links in a document, instead of the default arrow or I-beam.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
}
}
// MARK: - Navigation
/// Local-only settings for finding your way around the app separate
/// from Editor, which is scoped to how documents are actually edited.
private var navigationDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader
Text("Settings for finding documents and collections.")
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
Toggle("Command Palette", isOn: $isCommandPaletteEnabled)
Text("Press ⌘K to quickly jump to a document or collection. Always searches locally on your device — never a network request while typing.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
VStack(alignment: .leading, spacing: 6) {
Toggle("Search Entire Workspace", isOn: $isCommandPaletteFullWorkspaceSearch)
.disabled(!isCommandPaletteEnabled || !isFullLocalSyncEnabled)
Text(fullWorkspaceSearchDescription)
.font(.caption)
.foregroundStyle(isFullLocalSyncEnabled ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.orange))
}
.frame(maxWidth: 480, alignment: .leading)
.opacity(isCommandPaletteEnabled ? 1 : 0.4)
}
}
/// Full Workspace mode reads Full Local Sync's own SwiftData cache
/// directly zero network calls, and it's the only way to get nested
/// sub-documents included (the live per-collection fetch this used to
/// do could only ever see collection-root documents). Requires that
/// cache to actually exist first, so the toggle above stays disabled,
/// and this explains why, until Offline & Sync Full Local Sync is on.
private var fullWorkspaceSearchDescription: String {
guard isFullLocalSyncEnabled else {
return "Requires Full Local Sync (Offline & Sync) — turn that on first so there's a local copy of your workspace to search."
}
return "Off (default): only your collections and recently viewed documents — near-instant. On: every document and collection from Full Local Sync's local copy, including nested sub-documents — entirely offline, no network request at all."
}
// MARK: - Profile
private var profileDetail: some View {
@@ -157,6 +157,7 @@ private struct DocumentNodeRow: View {
.frame(width: 12)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
Button {
@@ -186,6 +187,7 @@ private struct DocumentNodeRow: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
.padding(.vertical, 6)
.padding(.horizontal, 4)
@@ -34,6 +34,7 @@ struct CollectionListContent: View {
CollectionRowView(collection: collection)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
@@ -1,6 +1,7 @@
#if os(macOS)
import SwiftUI
import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit
/// Read-only for now document/overview editing isn't wired up yet. Uses
@@ -9,21 +10,34 @@ import OutlineKit
/// `isSelectable` and link-opening both still need to work.
struct CollectionOverviewContent: View {
@State private var markdown: String
@State private var imageProvider: OutlineImageProvider
/// See `DocumentReaderView`'s `imageReloadTick`.
@State private var imageReloadTick = 0
init(collection: OutlineCollection) {
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
_markdown = State(initialValue: collection.description ?? "")
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
}
var body: some View {
ScrollView {
NativeTextViewWrapper(
text: $markdown,
configuration: .init(heightBehavior: .fitsContent),
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false
)
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.animation(nil, value: imageReloadTick)
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
}
}
#endif
@@ -3,6 +3,7 @@ import SwiftUI
import OutlineKit
struct CollectionOverviewView: View {
let apiClient: OutlineAPIClient
let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel
@State private var selectedTab: CollectionTab = .overview
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
searchQuery: Binding<String>,
onOpenDocument: @escaping (OutlineDocument) -> Void
) {
self.apiClient = apiClient
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
@@ -59,7 +61,7 @@ struct CollectionOverviewView: View {
if !trimmedSearchQuery.isEmpty {
searchResultsList
} else if selectedTab == .overview {
CollectionOverviewContent(collection: collection)
CollectionOverviewContent(apiClient: apiClient, collection: collection)
} else {
documentList
}
@@ -114,6 +116,7 @@ struct CollectionOverviewView: View {
)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
Spacer(minLength: 0)
}
@@ -150,6 +153,7 @@ struct CollectionOverviewView: View {
DocumentRowView(document: document)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
@@ -182,6 +186,7 @@ struct CollectionOverviewView: View {
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
@@ -56,6 +56,7 @@ struct CollectionTreeRow: View {
)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
.animation(.easeInOut(duration: 0.15), value: isExpanded)
.contextMenu { contextMenuContent }
@@ -0,0 +1,248 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// K. Settings Editor Command Palette. Always searches locally, never a
/// per-keystroke network request. Two data-source modes:
///
/// - Lightweight (default): a live `listCollections` + `listViewedDocuments`
/// fetch once when the palette opens two small requests, near-instant,
/// works with no setup.
/// - Full Workspace: reads `CachingOutlineAPIClient`'s local SwiftData cache
/// directly (`cachedDocumentsIndex()`/`cachedCollectionsIndex()`) zero
/// network calls at all, and includes every nested sub-document, not just
/// collection roots. Requires Full Local Sync to actually have populated
/// that cache first (gated in Settings the toggle here is disabled
/// without it); this view doesn't trigger a sync itself.
struct CommandPaletteView: View {
let apiClient: OutlineAPIClient
let cachingClient: CachingOutlineAPIClient?
let fullWorkspaceSearch: Bool
let onSelectDocument: (OutlineDocument) -> Void
let onSelectCollection: (OutlineCollection) -> Void
let onDismiss: () -> Void
@State private var query = ""
@State private var collections: [OutlineCollection] = []
@State private var documents: [OutlineDocument] = []
@State private var isLoading = true
@State private var selectedIndex = 0
@FocusState private var isSearchFieldFocused: Bool
private enum Result: Identifiable {
case collection(OutlineCollection)
case document(OutlineDocument)
var id: String {
switch self {
case .collection(let collection): return "collection-\(collection.id)"
case .document(let document): return "document-\(document.id)"
}
}
}
private var results: [Result] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
// No query yet: surface collections first, then the most
// recent/full-workspace documents as-is, capped so the panel
// doesn't dump the entire workspace with nothing typed.
return (collections.map(Result.collection) + documents.map(Result.document))
.prefix(20)
.map { $0 }
}
let scored: [(Result, Int)] = collections.compactMap { collection in
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
} + documents.compactMap { document in
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
}
return scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
}
/// Lower is better exact match, then prefix match, then earliest
/// contiguous-substring position, then (for multi-word queries) every
/// word present somewhere in the title in any order. That last tier is
/// what makes "test document" find a title like "Test Plan Document"
/// requiring the exact phrase contiguously (the previous behavior)
/// meant a title with anything between the words never matched at all,
/// which looked like "documents never show up, only collections" any
/// time the real title didn't happen to contain the typed phrase
/// verbatim. `nil` means no match at all. Still deliberately not a full
/// fuzzy/Levenshtein algorithm good enough for document/collection
/// titles without the unpredictability that brings.
private func matchScore(_ title: String, query: String) -> Int? {
let haystack = title.lowercased()
let needle = query.lowercased()
if haystack == needle { return 0 }
if haystack.hasPrefix(needle) { return 1 }
if let range = haystack.range(of: needle) {
return 2 + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
}
let words = needle.split(separator: " ").map(String.init)
guard words.count > 1, words.allSatisfy({ haystack.contains($0) }) else { return nil }
let totalPosition = words.reduce(0) { partial, word in
guard let range = haystack.range(of: word) else { return partial }
return partial + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
}
return 100 + totalPosition
}
var body: some View {
ZStack {
Color.black.opacity(0.001) // catches clicks outside the card to dismiss
.onTapGesture { onDismiss() }
VStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search documents and collections…", text: $query)
.textFieldStyle(.plain)
.font(.title3)
.focused($isSearchFieldFocused)
.onChange(of: query) { selectedIndex = 0 }
.onSubmit { selectCurrent() }
// Attached directly on the field itself, not an
// ancestor confirmed live that .onKeyPress on the
// outer card never saw arrow-key events at all while
// this TextField actually held focus, the up/down
// presses just went nowhere. Escape still needs its
// own handler below since this one only covers
// whichever view is actually focused.
.onKeyPress(.downArrow) { moveSelection(by: 1); return .handled }
.onKeyPress(.upArrow) { moveSelection(by: -1); return .handled }
.onKeyPress(.escape) { onDismiss(); return .handled }
if isLoading {
ProgressView().controlSize(.small)
}
}
.padding(14)
Divider()
if results.isEmpty {
ContentUnavailableView(
isLoading ? "Loading…" : "No Results",
systemImage: isLoading ? "ellipsis" : "magnifyingglass"
)
.frame(height: 160)
} else {
ScrollViewReader { scrollProxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(results.enumerated()), id: \.element.id) { index, result in
resultRow(result, isSelected: index == selectedIndex)
.id(index)
.contentShape(Rectangle())
.pointerCursorOnHover()
.onTapGesture {
selectedIndex = index
selectCurrent()
}
}
}
.padding(6)
}
.frame(maxHeight: 360)
.onChange(of: selectedIndex) { _, newValue in
scrollProxy.scrollTo(newValue, anchor: .center)
}
}
}
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(.separator))
.frame(width: 560)
.shadow(color: .black.opacity(0.3), radius: 24, y: 12)
}
.task {
// The window/responder chain isn't always ready to accept a
// first-responder change in the same instant this view is
// inserted confirmed live: setting this synchronously on
// appear left the field unfocused until manually clicked
// (also the likely source of several "entangle context after
// pre-commit" / CA-transaction warnings in the console, which
// are exactly what fighting AppKit for first-responder status
// mid-commit looks like). A one-frame-ish delay is enough for
// the overlay's insertion to settle first.
try? await Task.sleep(for: .milliseconds(50))
isSearchFieldFocused = true
await loadResults()
}
}
private func resultRow(_ result: Result, isSelected: Bool) -> some View {
HStack(spacing: 10) {
switch result {
case .collection(let collection):
// Reuses the sidebar's own icon logic (emoji vs Outline's
// icon-key-to-SF-Symbol mapping vs fallback) instead of
// guessing `collection.icon` isn't a raw SF Symbol name.
CollectionRowView(collection: collection)
.labelStyle(.iconOnly)
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(collection.name)
.lineLimit(1)
Text("Collection")
.font(.caption2)
.foregroundStyle(.secondary)
}
case .document(let document):
if let emoji = document.emoji {
Text(emoji).frame(width: 20)
} else {
Image(systemName: "doc.text")
.foregroundStyle(.secondary)
.frame(width: 20)
}
VStack(alignment: .leading, spacing: 1) {
Text(document.title.isEmpty ? "Untitled" : document.title)
.lineLimit(1)
Text("Document")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
private func moveSelection(by delta: Int) {
guard !results.isEmpty else { return }
selectedIndex = max(0, min(results.count - 1, selectedIndex + delta))
}
private func selectCurrent() {
guard results.indices.contains(selectedIndex) else { return }
switch results[selectedIndex] {
case .collection(let collection): onSelectCollection(collection)
case .document(let document): onSelectDocument(document)
}
onDismiss()
}
private func loadResults() async {
isLoading = true
defer { isLoading = false }
if fullWorkspaceSearch {
// Purely local SwiftData reads no network at all, and (since
// Full Local Sync now recurses into every document's children)
// this includes nested sub-documents the live per-collection
// fetch never could. Empty if a sync has never actually run.
collections = await cachingClient?.cachedCollectionsIndex() ?? []
documents = await cachingClient?.cachedDocumentsIndex() ?? []
return
}
async let fetchedCollections = (try? apiClient.listCollections(offset: 0, limit: 250)) ?? []
async let fetchedRecent = (try? apiClient.listViewedDocuments(offset: 0, limit: 30)) ?? []
collections = await fetchedCollections
documents = await fetchedRecent
}
}
#endif
@@ -6,6 +6,7 @@ struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
/// The landing state no collection selected yet is what Home actually
/// means, so this starts `true` rather than auto-selecting the first
/// collection the way this used to work.
@@ -23,6 +24,11 @@ struct ContentView_macOS: View {
/// Home's "New Document" buttons) every expanded sidebar row reloads
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
@State private var documentsChangedToken = 0
/// Guards the restore-on-launch attempt to exactly once per app launch
/// without this, `mainContent`'s `.task` would re-run (and
/// re-navigate out from under the user) every time it reappears, e.g.
/// after a trip through Settings.
@State private var hasAttemptedLocationRestore = false
private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -147,6 +153,38 @@ struct ContentView_macOS: View {
if newValue != nil {
isShowingHome = false
}
persistLastLocationIfEnabled()
}
.onChange(of: documentPath) { _, _ in
persistLastLocationIfEnabled()
}
.onChange(of: isShowingHome) { _, _ in
persistLastLocationIfEnabled()
}
// Once per launch, before the user has a chance to navigate
// manually restores whatever `restoreLastLocationIfEnabled`
// finds, or leaves today's Home default alone if there's nothing
// to restore (preference off, nothing stored yet, or resolution
// fails e.g. a deleted document/collection or being offline).
.task {
guard !hasAttemptedLocationRestore else { return }
hasAttemptedLocationRestore = true
await restoreLastLocationIfEnabled()
}
.overlay {
if navigation.isShowingCommandPalette, let apiClient = session.apiClient {
CommandPaletteView(
apiClient: apiClient,
cachingClient: session.cachingClient,
fullWorkspaceSearch: isCommandPaletteFullWorkspaceSearch,
onSelectDocument: openDocument,
onSelectCollection: { collection in
selectedCollection = collection
replaceDocumentPath(with: [])
},
onDismiss: { navigation.isShowingCommandPalette = false }
)
}
}
}
@@ -160,6 +198,63 @@ struct ContentView_macOS: View {
replaceDocumentPath(with: [])
}
// MARK: - Remember previous location (Preferences Remember previous location)
private static let lastLocationDefaultsKey = "outline.lastLocation"
/// What gets persisted `isHome` disambiguates "was on Home" from "no
/// collection selected yet" (the latter only otherwise happens on the
/// brief `ContentUnavailableView` placeholder state), since both would
/// otherwise look identical (`collectionId == nil`).
private struct LastLocation: Codable {
var isHome: Bool
var collectionId: String?
var documentIds: [String]
}
/// Called from every navigation-changing `.onChange` cheap to persist
/// on every change rather than debouncing, this is just a small JSON
/// blob in `UserDefaults`, not a network call.
private func persistLastLocationIfEnabled() {
guard session.userPreferences?.rememberLastPath == true else { return }
let location = LastLocation(isHome: isShowingHome, collectionId: selectedCollection?.id, documentIds: documentPath.map(\.id))
guard let data = try? JSONEncoder().encode(location) else { return }
UserDefaults.standard.set(data, forKey: Self.lastLocationDefaultsKey)
}
/// Resolves IDs back into real `OutlineCollection`/`OutlineDocument`
/// objects via the API stored IDs alone aren't enough to populate
/// `selectedCollection`/`documentPath` directly. Resolves the document
/// chain in order and stops at the first failure (deleted document,
/// offline, etc.) rather than aborting the whole restore whatever
/// prefix of the chain resolved successfully is still a better landing
/// spot than falling all the way back to Home.
private func restoreLastLocationIfEnabled() async {
guard session.userPreferences?.rememberLastPath == true,
let apiClient = session.apiClient,
let data = UserDefaults.standard.data(forKey: Self.lastLocationDefaultsKey),
let location = try? JSONDecoder().decode(LastLocation.self, from: data)
else { return }
// A pure "was on Home, nothing pushed" location needs no action
// Home is already the default state before this ever runs.
guard location.collectionId != nil || !location.documentIds.isEmpty else { return }
if let collectionId = location.collectionId {
guard let collection = try? await apiClient.collectionInfo(id: collectionId) else { return }
selectedCollection = collection
isShowingHome = false
}
var resolvedChain: [OutlineDocument] = []
for documentId in location.documentIds {
guard let document = try? await apiClient.documentInfo(id: documentId) else { break }
resolvedChain.append(document)
}
if !resolvedChain.isEmpty {
replaceDocumentPath(with: resolvedChain)
}
}
@ViewBuilder
private var contextualSearchField: some View {
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
@@ -350,6 +445,13 @@ struct ContentView_macOS: View {
if !documentPath.isEmpty {
documentPath.removeLast()
}
// Delete/Archive/Unpublish/Move all change what
// should show in the sidebar tree this only
// popped the reader before, leaving the sidebar
// showing the document until some unrelated
// trigger (the 45s poll, navigating away and
// back) happened to refresh it.
documentsChangedToken += 1
},
onDocumentCreated: { documentsChangedToken += 1 }
)
@@ -0,0 +1,425 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Document-level and anchored comments + single-level replies + emoji
/// reactions + resolve/unresolve. Composing new comments/replies is still
/// plain text only (no bold/italic/lists/etc.) sent through the `text`
/// (markdown) convenience field, not a hand-built ProseMirror `data`
/// document.
@MainActor
struct DocumentCommentsSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let document: OutlineDocument
/// Set when opened by tapping an inline anchor marker scrolls to and
/// briefly highlights that comment/reply. `nil` opens unfocused (the
/// plain toolbar marker).
var focusedCommentId: String? = nil
/// Set when opened via "Comment on Selection" from the editor's
/// right-click menu the selected text to anchor a NEW comment to.
/// The first occurrence of this exact substring in the document is
/// what the server (and later, this app's own inline marker) anchors
/// to; no prefix/suffix disambiguation UI for multiple identical
/// occurrences.
var pendingAnchorText: String? = nil
/// Called after any successful create/reply/resolve/reaction lets the
/// reader refresh its own lightweight copy (inline anchor markers, the
/// toolbar badge count) without waiting for the document to be reopened.
var onCommentsChanged: (() -> Void)? = nil
/// A small curated set rather than the full system emoji picker quick
/// taps for the common reactions, matching how most chat apps default.
private static let quickReactions = ["👍", "❤️", "😂", "🎉", "😮", "😢"]
@State private var comments: [OutlineComment] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var actionErrorMessage: String?
@State private var currentUserId: String?
@State private var newCommentText = ""
@State private var isPostingNewComment = false
/// Mutable mirror of `pendingAnchorText` lets the user clear it (fall
/// back to a plain document-level comment) without touching the init
/// param itself.
@State private var composingAnchorText: String?
@State private var replyingToThreadId: String?
@State private var replyText = ""
@State private var isPostingReply = false
@State private var resolvingCommentIds: Set<String> = []
@State private var reactingCommentIds: Set<String> = []
@State private var reactionPickerCommentId: String?
private struct CommentThread: Identifiable {
let top: OutlineComment
let replies: [OutlineComment]
var id: String { top.id }
}
private var threads: [CommentThread] {
let topLevel = comments.filter { $0.parentCommentId == nil }.sorted { $0.createdAt < $1.createdAt }
return topLevel.map { top in
let replies = comments
.filter { $0.parentCommentId == top.id }
.sorted { $0.createdAt < $1.createdAt }
return CommentThread(top: top, replies: replies)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Comments")
.font(.headline)
Spacer()
Button("Done") { dismiss() }
}
.padding()
Divider()
Group {
if isLoading && comments.isEmpty {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage {
ContentUnavailableView {
Label("Couldn't Load Comments", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") { Task { await load() } }
}
} else if threads.isEmpty {
ContentUnavailableView {
Label("No Comments", systemImage: "bubble.left.and.bubble.right")
} description: {
Text("This document has no comments yet.")
}
} else {
ScrollViewReader { proxy in
List(threads) { thread in
threadSection(thread)
}
.task {
guard let focusedCommentId else { return }
// The list needs a beat to lay out before a
// scrollTo lands correctly on first appear.
try? await Task.sleep(for: .milliseconds(50))
withAnimation {
proxy.scrollTo(focusedCommentId, anchor: .center)
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
newCommentComposer
}
.frame(width: 480, height: 560)
.task { await load() }
.task { currentUserId = try? await apiClient.currentUser().id }
.task { composingAnchorText = pendingAnchorText }
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
Button("OK") { actionErrorMessage = nil }
} message: {
Text(actionErrorMessage ?? "")
}
}
// MARK: - Thread
private func threadSection(_ thread: CommentThread) -> some View {
VStack(alignment: .leading, spacing: 8) {
commentRow(thread.top, isReply: false)
ForEach(thread.replies) { reply in
commentRow(reply, isReply: true)
.padding(.leading, 20)
}
if replyingToThreadId == thread.id {
replyComposer(for: thread)
.padding(.leading, 20)
} else {
Button("Reply") {
replyingToThreadId = thread.id
replyText = ""
}
.buttonStyle(.plain)
.font(.caption.weight(.semibold))
.foregroundStyle(.blue)
.padding(.leading, 20)
}
}
.padding(.vertical, 6)
}
private func commentRow(_ comment: OutlineComment, isReply: Bool) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text(comment.createdBy?.name ?? "Unknown")
.font(.callout.weight(.semibold))
Spacer()
Text(comment.createdAt, format: .relative(presentation: .named))
.font(.caption)
.foregroundStyle(.secondary)
}
Text(comment.bodyText.isEmpty ? "(empty)" : comment.bodyText)
.font(.callout)
.foregroundStyle(comment.bodyText.isEmpty ? .secondary : .primary)
if !comment.reactions.isEmpty {
reactionChips(comment)
}
HStack(spacing: 12) {
Button {
reactionPickerCommentId = comment.id
} label: {
Image(systemName: "face.smiling")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.popover(isPresented: Binding(
get: { reactionPickerCommentId == comment.id },
set: { if !$0 { reactionPickerCommentId = nil } }
)) {
quickReactionPicker(comment)
}
if !isReply {
if comment.isResolved {
Label("Resolved", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
if resolvingCommentIds.contains(comment.id) {
ProgressView().controlSize(.small)
} else {
Button(comment.isResolved ? "Unresolve" : "Resolve") {
Task { await toggleResolved(comment) }
}
.buttonStyle(.plain)
.font(.caption.weight(.semibold))
.foregroundStyle(.blue)
}
} else {
Spacer()
}
}
}
.padding(6)
.background(
comment.id == focusedCommentId ? Color.accentColor.opacity(0.12) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
.id(comment.id)
}
private func reactionChips(_ comment: OutlineComment) -> some View {
HStack(spacing: 4) {
ForEach(comment.reactions, id: \.emoji) { reaction in
let mine = currentUserId.map(reaction.userIds.contains) ?? false
Button {
Task { await toggleReaction(comment, emoji: reaction.emoji, currentlyReacted: mine) }
} label: {
Text("\(reaction.emoji) \(reaction.userIds.count)")
.font(.caption)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(mine ? Color.accentColor.opacity(0.2) : Color.gray.opacity(0.15), in: Capsule())
}
.buttonStyle(.plain)
.disabled(reactingCommentIds.contains(comment.id))
}
}
}
private func quickReactionPicker(_ comment: OutlineComment) -> some View {
HStack(spacing: 8) {
ForEach(Self.quickReactions, id: \.self) { emoji in
let mine = currentUserId.map { userId in
comment.reactions.first { $0.emoji == emoji }?.userIds.contains(userId) ?? false
} ?? false
Button {
reactionPickerCommentId = nil
Task { await toggleReaction(comment, emoji: emoji, currentlyReacted: mine) }
} label: {
Text(emoji)
.font(.title2)
.opacity(mine ? 1 : 0.5)
}
.buttonStyle(.plain)
}
}
.padding(10)
}
// MARK: - Composers
private var newCommentComposer: some View {
VStack(alignment: .leading, spacing: 6) {
if let composingAnchorText {
HStack(spacing: 6) {
Image(systemName: "text.quote")
.foregroundStyle(.blue)
Text(composingAnchorText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
Spacer()
Button {
self.composingAnchorText = nil
} label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help("Remove — this will post as a document-level comment instead")
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
}
HStack(alignment: .bottom, spacing: 8) {
TextField(
composingAnchorText == nil ? "Add a comment…" : "Comment on this text…",
text: $newCommentText,
axis: .vertical
)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.onSubmit { Task { await postNewComment() } }
if isPostingNewComment {
ProgressView().controlSize(.small)
} else {
Button("Post") {
Task { await postNewComment() }
}
.disabled(newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
.padding()
}
private func replyComposer(for thread: CommentThread) -> some View {
HStack(alignment: .bottom, spacing: 8) {
TextField("Reply…", text: $replyText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.onSubmit { Task { await postReply(to: thread) } }
if isPostingReply {
ProgressView().controlSize(.small)
} else {
Button("Send") {
Task { await postReply(to: thread) }
}
.disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button("Cancel") {
replyingToThreadId = nil
replyText = ""
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Actions
private func load() async {
isLoading = true
defer { isLoading = false }
do {
comments = try await apiClient.listComments(ListCommentsRequest(documentId: document.id))
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load comments for this document.")
}
}
private func postNewComment() async {
let text = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
isPostingNewComment = true
defer { isPostingNewComment = false }
do {
let created = try await apiClient.createComment(
CreateCommentRequest(documentId: document.id, text: text, anchorText: composingAnchorText)
)
comments.append(created)
composingAnchorText = nil
newCommentText = ""
onCommentsChanged?()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this comment.")
}
}
private func postReply(to thread: CommentThread) async {
let text = replyText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
isPostingReply = true
defer { isPostingReply = false }
do {
let created = try await apiClient.createComment(
CreateCommentRequest(documentId: document.id, parentCommentId: thread.id, text: text)
)
comments.append(created)
replyText = ""
replyingToThreadId = nil
onCommentsChanged?()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this reply.")
}
}
private func toggleResolved(_ comment: OutlineComment) async {
resolvingCommentIds.insert(comment.id)
defer { resolvingCommentIds.remove(comment.id) }
do {
let updated = comment.isResolved
? try await apiClient.unresolveComment(id: comment.id)
: try await apiClient.resolveComment(id: comment.id)
replace(updated)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this comment.")
}
}
/// Reaction endpoints return `{success: true}`, not the updated comment
/// (see `OutlineAPIClient.addReaction`) refetch via `commentInfo` for
/// the real post-toggle `reactions` array instead of guessing the merge
/// locally (another user reacting concurrently would make a guess wrong).
private func toggleReaction(_ comment: OutlineComment, emoji: String, currentlyReacted: Bool) async {
reactingCommentIds.insert(comment.id)
defer { reactingCommentIds.remove(comment.id) }
do {
if currentlyReacted {
try await apiClient.removeReaction(commentId: comment.id, emoji: emoji)
} else {
try await apiClient.addReaction(commentId: comment.id, emoji: emoji)
}
let refreshed = try await apiClient.commentInfo(id: comment.id)
replace(refreshed)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update that reaction.")
}
}
private func replace(_ updated: OutlineComment) {
if let index = comments.firstIndex(where: { $0.id == updated.id }) {
comments[index] = updated
}
}
}
#endif
@@ -1,6 +1,7 @@
#if os(macOS)
import SwiftUI
import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit
/// Distraction-free reading view no toolbar/sidebar chrome, larger type.
@@ -16,11 +17,17 @@ struct DocumentPresentSheet: View {
@State private var text: String
@State private var isLoading = false
@State private var errorMessage: String?
@State private var imageProvider: OutlineImageProvider
/// See `DocumentReaderView`'s `imageReloadTick` same "force an
/// `updateNSView` re-pass so the engine notices `fingerprint()` changed"
/// mechanism, needed here too since this sheet renders its own images.
@State private var imageReloadTick = 0
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
self.apiClient = apiClient
self.document = document
_text = State(initialValue: document.text)
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
}
var body: some View {
@@ -44,7 +51,10 @@ struct DocumentPresentSheet: View {
.padding(.bottom, 8)
NativeTextViewWrapper(
text: $text,
configuration: .init(heightBehavior: .fitsContent),
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false
)
.font(.system(size: 18))
@@ -67,6 +77,12 @@ struct DocumentPresentSheet: View {
}
.frame(minWidth: 800, minHeight: 600)
.background(.background)
.animation(nil, value: imageReloadTick)
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
.task { await load() }
}
@@ -3,7 +3,11 @@ import AppKit
import SwiftUI
import UniformTypeIdentifiers
import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit
#if canImport(ImagePlayground)
import ImagePlayground
#endif
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
/// below must run on the main thread see the identical note on
@@ -13,6 +17,55 @@ struct DocumentReaderView: View {
@Environment(SessionStore.self) private var session
@Environment(StarStore.self) private var starStore
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Local-only Outpost setting (Settings Editor), not synced to
/// Outline see `SettingsView.editorDetail`.
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
@AppStorage("outpost.pointerCursorEnabled") private var isPointerCursorEnabled = true
/// `ImagePlaygroundViewController.isAvailable` gates on both OS version
/// (macOS 15.1+) and actual device/region support (Apple Intelligence
/// eligibility) a supported OS with an unsupported Mac still reports
/// `false`, so this is the one check that matters, not just `#available`.
private var isImagePlaygroundSupported: Bool {
#if canImport(ImagePlayground)
if #available(macOS 15.1, *) {
return ImagePlaygroundViewController.isAvailable
}
#endif
return false
}
/// Outline's own "Show line numbers" preference (synced, read via
/// `session.userPreferences`, not `@AppStorage` this one's the
/// server's, not a local-only Outpost setting). No `@Environment`-in-`init`
/// problem here since this is read directly in the view, not the
/// view model.
private var showCodeBlockLineNumbers: Bool {
session.userPreferences?.codeBlockLineNumbers ?? false
}
/// Widened left indent reserved for the number gutter when line numbers
/// are on (default is 12pt, just enough margin, no room for digits).
private static let lineNumberGutterWidth: CGFloat = 32
private var editorCodeBlockStyle: CodeBlockStyle {
showCodeBlockLineNumbers ? .init(horizontalIndent: Self.lineNumberGutterWidth) : .default
}
/// Outline's own "Smart text replacements" preference (synced) smart
/// quotes/dashes while typing. Only meaningful on the editable pane.
private var editorTextSubstitution: TextSubstitutionPolicy {
let enabled = session.userPreferences?.smartText ?? false
return .init(quoteSubstitution: enabled, dashSubstitution: enabled)
}
/// Local-only Outpost settings (Settings Editor) not synced to
/// Outline, same as Split View above.
private var editorTextCompletion: TextCompletionPolicy {
.init(isEnabled: isAutocompleteEnabled)
}
private var editorWritingTools: WritingToolsPolicy {
.init(isEnabled: isWritingToolsEnabled)
}
@State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient
@@ -31,16 +84,73 @@ struct DocumentReaderView: View {
let onDocumentCreated: () -> Void
@State private var isShowingUnpublishConfirmation = false
@State private var isShowingPublishSheet = false
@State private var isShowingArchiveConfirmation = false
@State private var isShowingDeleteConfirmation = false
@State private var isShowingMoveSheet = false
@State private var isShowingHistorySheet = false
@State private var isShowingInsightsSheet = false
@State private var isShowingCommentsSheet = false
/// Fetched once on load, purely to drive the toolbar marker/badge and
/// the inline anchor bars below the sheet itself fetches its own copy
/// independently (see `DocumentCommentsSheet`), same as every other
/// self-contained sheet in this file.
@State private var loadedComments: [OutlineComment] = []
/// Comment id to scroll/focus to when the sheet opens set when an
/// inline anchor bar is tapped, `nil` for the plain toolbar marker.
@State private var focusedCommentId: String?
/// Resolved on-screen positions for each anchored comment's text, from
/// `NativeTextViewWrapper.onCommentAnchorRectsChange`.
@State private var commentAnchorRects: [CommentAnchorRect] = []
/// Set from "Comment on Selection" in the editor's right-click menu
/// the sheet opens straight into composing a new anchored comment.
@State private var pendingCommentAnchorText: String?
private var commentCount: Int? {
loadedComments.isEmpty ? nil : loadedComments.count
}
private var commentAnchorQueries: [CommentAnchorQuery] {
loadedComments.compactMap { comment in
guard let anchorText = comment.anchorText, !anchorText.isEmpty else { return nil }
return CommentAnchorQuery(id: comment.id, anchorText: anchorText)
}
}
@State private var isShowingPresentSheet = false
@State private var isShowingSearchSheet = false
@State private var isShowingShareSheet = false
@State private var isShowingNewDocumentSheet = false
@State private var isShowingImagePlayground = false
@State private var actionErrorMessage: String?
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
/// insert an Image Playground result's Markdown reference at the caret.
@State private var pendingTextInsertion: TextInsertionRequest?
/// Pushed by `CodeBlockLanguagePicker` to rewrite a code block's fence
/// line when the user switches its language from the picker.
@State private var pendingCodeBlockLanguageChange: TextRangeReplacementRequest?
/// Live-updated by `onSelectedTextChange` on the main editable pane;
/// `nil` when the selection is empty (caret only, nothing highlighted).
@State private var currentSelectedText: String?
/// Resolves `![alt](url)` images in this document shared by both
/// panes (main + split-view preview), since they render the same text.
@State private var imageProvider: OutlineImageProvider
/// Bumped by `imageProvider.onImageLoaded`. Not read for its value
/// just referenced via `.animation(nil, value:)` so SwiftUI re-evaluates
/// this view (and so `NativeTextViewWrapper.updateNSView` re-runs and
/// notices the provider's `fingerprint()` changed) once an async image
/// load completes. The engine has no polling of its own for this.
@State private var imageReloadTick = 0
/// Snapshot of `currentSelectedText` taken the moment the Image
/// Playground button is pressed the sheet's seed shouldn't shift if
/// the user's selection happens to change while it's open.
@State private var imagePlaygroundSeedText: String?
/// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange`
/// one array per instance (main pane, split-view preview pane), since
/// each lays the same text out at a different width and gets different
/// rects. Only non-empty when `showCodeBlockLineNumbers` is on (see its
/// doc comment for why the gutter needs `codeBlock.horizontalIndent`
/// widened, which is gated on the same flag).
@State private var readerCodeBlocks: [CodeBlockSelection] = []
@State private var previewCodeBlocks: [CodeBlockSelection] = []
init(
apiClient: OutlineAPIClient,
@@ -51,7 +161,13 @@ struct DocumentReaderView: View {
) {
self.apiClient = apiClient
self.document = document
// `separateEditingEnabled` can't be read from `@Environment` here
// environment values aren't populated yet inside a view's `init`,
// only from `body` onward. Defaults to `true` (today's only
// behavior) and gets set for real in `.task` below once `session`
// is actually available.
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
self.onOpenChild = onOpenChild
self.onDeleted = onDeleted
self.onDocumentCreated = onDocumentCreated
@@ -66,44 +182,26 @@ struct DocumentReaderView: View {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
/// Split View needs the full window height (each pane scrolls itself),
/// which an unbounded page-level `ScrollView` can't give it a
/// `minHeight` inside one just resolves to exactly that minimum, not
/// "fill available space", since there's no bounded space to fill.
/// Only switches over once there's real content to show; loading/error
/// states still go through the normal scrolling layout.
private var canShowSplitView: Bool {
isSplitViewEnabled
&& viewModel.isEffectivelyEditable
&& viewModel.errorMessage == nil
&& !(viewModel.isLoading && viewModel.text.isEmpty)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if viewModel.isEditing {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
}
if viewModel.isLoading && viewModel.text.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
} else if let errorMessage = viewModel.errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.loadFullContent() }
}
}
} else {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: viewModel.isEditing
)
if !viewModel.children.isEmpty {
childrenSection
}
}
Group {
if canShowSplitView {
splitViewContent
} else {
scrollingReaderContent
}
.padding()
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
.frame(maxWidth: .infinity)
}
.overlay(alignment: .topTrailing) {
if viewModel.isLoading && !viewModel.text.isEmpty {
@@ -127,16 +225,62 @@ struct DocumentReaderView: View {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
}
Button {
Task { await viewModel.toggleEditing() }
} label: {
if viewModel.isSaving {
ProgressView().controlSize(.small)
} else {
Text(viewModel.isEditing ? "Done" : "Edit")
// Toolbar marker only shows once there's actually
// something to point at. Anchored comments additionally get
// an inline vertical bar next to their text (see
// `commentAnchorMarkers`/`onCommentAnchorRectsChange`
// below) this button opens the sheet unfocused, showing
// every comment/thread.
if let commentCount, commentCount > 0 {
Button {
focusedCommentId = nil
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
} label: {
Image(systemName: "bubble.left.and.bubble.right")
}
.help("\(commentCount) Comment\(commentCount == 1 ? "" : "s")")
.disabled(!isEffectivelyOnline)
.overlay(alignment: .topTrailing) {
Text(commentCount > 10 ? "10+" : "\(commentCount)")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.padding(2)
.frame(minWidth: 12, minHeight: 12)
.background(.blue, in: Circle())
.offset(x: 0, y: -1)
}
}
.disabled(viewModel.isSaving)
if isImagePlaygroundEnabled && isImagePlaygroundSupported {
Button {
imagePlaygroundSeedText = currentSelectedText
isShowingImagePlayground = true
} label: {
Image(systemName: "sparkles")
}
.help("Create Image with Image Playground")
.disabled(!viewModel.isEffectivelyEditable)
}
if viewModel.separateEditingEnabled {
Button {
Task { await viewModel.toggleEditing() }
} label: {
if viewModel.isSaving {
ProgressView().controlSize(.small)
} else {
Text(viewModel.isEditing ? "Done" : "Edit")
}
}
.disabled(viewModel.isSaving)
} else if viewModel.isSaving {
// No Edit/Done affordance when documents are always
// editable this is the only feedback that an autosave
// is actually happening.
ProgressView().controlSize(.small)
.help("Saving…")
}
Button {
isShowingNewDocumentSheet = true
@@ -159,13 +303,35 @@ struct DocumentReaderView: View {
.id(menuIdentity)
}
}
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
.animation(nil, value: imageReloadTick)
.task { await viewModel.loadFullContent() }
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
// for why this can't just be read at `init` time.
.task { viewModel.separateEditingEnabled = session.userPreferences?.separateEditing ?? true }
.onChange(of: viewModel.text) {
guard !viewModel.separateEditingEnabled else { return }
viewModel.scheduleAutosave()
}
.onChange(of: viewModel.title) {
guard !viewModel.separateEditingEnabled else { return }
viewModel.scheduleAutosave()
}
.task {
await viewModel.loadPinAndSubscriptionState()
}
.task {
await viewModel.loadInsightsEnabledState()
}
.task {
loadedComments = (try? await apiClient.listComments(
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
)) ?? []
}
.task {
while !Task.isCancelled {
await viewModel.loadViewers()
@@ -213,6 +379,36 @@ struct DocumentReaderView: View {
} message: {
Text(actionErrorMessage ?? "")
}
.sheet(isPresented: $isShowingCommentsSheet) {
DocumentCommentsSheet(
apiClient: apiClient,
document: document,
focusedCommentId: focusedCommentId,
pendingAnchorText: pendingCommentAnchorText,
onCommentsChanged: {
Task {
loadedComments = (try? await apiClient.listComments(
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
)) ?? loadedComments
}
}
)
}
.sheet(isPresented: $isShowingPublishSheet) {
PublishDocumentSheet(apiClient: apiClient, document: document) {
// Stays in the reader unlike Move/Unpublish, publishing
// doesn't make the document any less visible from here, so
// there's no reason to pop back like onDeleted() does
// elsewhere. Refresh from the server rather than guessing
// the new collectionId locally (moveDocument may have run
// as a second step inside the sheet).
await viewModel.loadFullContent()
// The doc previously had no collectionId (or a different
// one) the sidebar tree for its new collection needs to
// know it exists now, same signal "New Document" sends.
onDocumentCreated()
}
}
.sheet(isPresented: $isShowingMoveSheet) {
MoveDocumentSheet(apiClient: apiClient, document: document) {
onDeleted()
@@ -236,6 +432,59 @@ struct DocumentReaderView: View {
onOpenChild(child)
}
}
.modifier(ImagePlaygroundPresenter(
isPresented: $isShowingImagePlayground,
seedText: imagePlaygroundSeedText,
seedTitle: viewModel.title.isEmpty ? "Untitled" : viewModel.title,
onCompletion: { url in handleGeneratedImage(url) }
))
}
/// Adds "Comment on Selection" to the right-click menu when there's an
/// actual (non-empty) selection to anchor to `currentSelectedText` is
/// kept live by `onSelectedTextChange` on the same pane. Inserted at the
/// top since it's the primary reason to right-click selected text here,
/// matching Outline's own web editor surfacing comment as the first
/// selection action.
private func addCommentMenuItem(to menu: NSMenu) -> NSMenu {
guard let selection = currentSelectedText, !selection.isEmpty else { return menu }
let item = ClosureMenuItem(title: "Comment on Selection…") {
pendingCommentAnchorText = selection
focusedCommentId = nil
isShowingCommentsSheet = true
}
menu.insertItem(item, at: 0)
menu.insertItem(.separator(), at: 1)
return menu
}
/// Uploads an Image Playground result the same way the reader would any
/// other attachment (`attachments.create` presigned target, then the
/// direct file POST see `OutlineAPIClient.uploadAttachmentFile`), then
/// inserts the hosted image's Markdown reference at the caret.
private func handleGeneratedImage(_ localURL: URL) {
Task {
do {
let data = try Data(contentsOf: localURL)
let created = try await apiClient.createAttachment(.init(
name: localURL.lastPathComponent,
contentType: "image/png",
size: data.count,
documentId: viewModel.documentId
))
try await apiClient.uploadAttachmentFile(created, fileData: data)
// Outline's own editor never embeds the raw (presigned/storage)
// upload URL in document Markdown it writes this stable
// redirect-by-id reference instead, which keeps resolving
// correctly even if the underlying storage URL rotates/expires.
pendingTextInsertion = TextInsertionRequest(
documentId: viewModel.documentId,
text: "\n\n![](/api/attachments.redirect?id=\(created.attachment.id))\n\n"
)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add the generated image.")
}
}
}
/// Every toggle-backed piece of state shown as a checkmark inside
@@ -272,31 +521,166 @@ struct DocumentReaderView: View {
}
}
private var childrenSection: some View {
VStack(alignment: .leading, spacing: 8) {
Divider()
.padding(.vertical, 4)
Text("Sub-documents")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(viewModel.children) { child in
Button {
onOpenChild(child)
} label: {
DocumentRowView(document: child)
/// Today's single-pane layout page-level `ScrollView` wrapping title +
/// content, used for the normal reading/editing view, and for every
/// loading/error state regardless of Split View.
private var scrollingReaderContent: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if viewModel.isEffectivelyEditable {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
}
.buttonStyle(.plain)
.padding(.vertical, 4)
if child.id != viewModel.children.last?.id {
Divider()
if viewModel.isLoading && viewModel.text.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
} else if let errorMessage = viewModel.errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.loadFullContent() }
}
}
} else {
ZStack(alignment: .topLeading) {
NativeTextViewWrapper(
text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
pendingTextRangeReplacement: $pendingCodeBlockLanguageChange,
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
textSubstitution: editorTextSubstitution,
textCompletion: editorTextCompletion,
writingTools: editorWritingTools,
heightBehavior: .fitsContent,
pointerCursorOverLinksWhileEditing: isPointerCursorEnabled
),
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 },
commentAnchorQueries: commentAnchorQueries,
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
)
if showCodeBlockLineNumbers {
ForEach(readerCodeBlocks) { selection in
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
}
}
if viewModel.isEffectivelyEditable {
ForEach(readerCodeBlocks) { selection in
CodeBlockLanguagePicker(selection: selection, documentId: viewModel.documentId) { request in
pendingCodeBlockLanguageChange = request
}
}
}
ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
}
}
}
}
}
.padding()
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
.frame(maxWidth: .infinity)
}
}
/// Split View's layout title fixed at the top (not part of either
/// scrolling pane), `splitEditorView` filling every remaining pixel of
/// the window below it. No outer `ScrollView` here on purpose: each
/// pane already scrolls itself, and nesting that inside another
/// unbounded scroll container is exactly what was capping both panes
/// at a fixed height instead of spanning the window.
private var splitViewContent: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
.padding([.horizontal, .top])
splitEditorView
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// Left is the literal Markdown source in `rawSourceMode` (no syntax
/// hiding/styling, but still the real engine needed so selection
/// tracking and caret-position insertion, e.g. from the Image Playground
/// button, work here the same as everywhere else); right is the same
/// rich rendering used everywhere else in the app, read-only, bound to
/// the same `viewModel.text` so it updates live as the left side is
/// typed into.
///
/// Scroll position between the two panes is **not** synchronized the
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
/// private internal view hierarchy to find its scroll view (the package
/// exposes no scroll position/delegate hook at all), which is fragile
/// enough to break silently on a package update. Flagged as a known
/// follow-up, not attempted here.
private var splitEditorView: some View {
HSplitView {
NativeTextViewWrapper(
text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
configuration: .init(rawSourceMode: true),
fontName: "SFMono-Regular",
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
onSelectedTextChange: { currentSelectedText = $0 }
)
.padding(8)
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
ScrollView {
ZStack(alignment: .topLeading) {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
heightBehavior: .fitsContent
),
documentId: viewModel.documentId,
isEditable: false,
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 },
commentAnchorQueries: commentAnchorQueries,
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
)
if showCodeBlockLineNumbers {
ForEach(previewCodeBlocks) { selection in
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
}
}
ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
}
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var menuContent: some View {
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
@@ -309,8 +693,10 @@ struct DocumentReaderView: View {
Divider()
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
Task { await viewModel.toggleEditing() }
if viewModel.separateEditingEnabled {
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
Task { await viewModel.toggleEditing() }
}
}
// Membership management now lives in DocumentShareSheet's "People
// with access" section, alongside the share link same sheet,
@@ -330,10 +716,17 @@ struct DocumentReaderView: View {
Task { await duplicate() }
}
.disabled(!isEffectivelyOnline)
Button("Unpublish") {
isShowingUnpublishConfirmation = true
if viewModel.publishedAt == nil {
Button("Publish…") {
isShowingPublishSheet = true
}
.disabled(!isEffectivelyOnline)
} else {
Button("Unpublish") {
isShowingUnpublishConfirmation = true
}
.disabled(!isEffectivelyOnline)
}
.disabled(!isEffectivelyOnline)
Button("Archive…") {
isShowingArchiveConfirmation = true
}
@@ -531,4 +924,202 @@ struct DocumentReaderView: View {
operation.run()
}
}
/// One code block's number gutter, positioned absolutely over a
/// `NativeTextViewWrapper` via `CodeBlockSelection.rect` same overlay
/// pattern MarkdownEngine's own `CodeBlockButton` uses.
///
/// `selection.rect` spans the WHOLE fenced block (open fence line + content
/// + close fence line), matching what the engine actually lays out the
/// fence lines render with invisible (`.clear`) text once the caret leaves
/// the block, but they don't collapse to zero height, so the block is
/// always exactly `content line count + 2` rows tall. `selection.code` is
/// content only, so the row height and number positions below both account
/// for that phantom top/bottom row explicitly instead of dividing by the
/// content line count alone (which would drift the numbers upward, more so
/// per line, the taller the block).
///
/// Known limitation, accepted rather than fixable app-side: a content line
/// that soft-wraps onto a second visual row (MarkdownEngine always
/// char-wraps code blocks, no way to opt out without forking the package)
/// throws this off every row below it reads one line low. Documented in
/// TODO.local.md alongside the same package's other gaps.
/// Thin vertical bar next to an anchored comment's text same visual
/// language as Word/Google Docs' margin comment indicators. Tapping opens
/// the comments sheet focused to that thread.
/// Top-left pill overlay on each code block opposite corner from the
/// engine's own copy button (top-right), same `Color.clear` + `.position()`
/// sizing trick `CodeBlockButton` uses. Tapping opens a curated language
/// list (not the full ~190-language highlight.js catalog same "small
/// curated set, not the whole thing" call as the emoji reaction picker);
/// picking one rewrites just the fence line via
/// `CodeBlockSelection.fenceRange`, leaving the block's content untouched.
private struct CodeBlockLanguagePicker: View {
let selection: CodeBlockSelection
let documentId: String
let onRequest: (TextRangeReplacementRequest) -> Void
private static let languages: [(label: String, tag: String?)] = [
("Plain Text", nil),
("Swift", "swift"),
("Python", "python"),
("JavaScript", "javascript"),
("TypeScript", "typescript"),
("Bash", "bash"),
("JSON", "json"),
("YAML", "yaml"),
("HTML", "html"),
("CSS", "css"),
("SQL", "sql"),
("Java", "java"),
("Kotlin", "kotlin"),
("C", "c"),
("C++", "cpp"),
("C#", "csharp"),
("Go", "go"),
("Rust", "rust"),
("Ruby", "ruby"),
("PHP", "php"),
("Markdown", "markdown"),
]
private var currentLabel: String {
guard let language = selection.language, !language.isEmpty else { return "Plain Text" }
return Self.languages.first { $0.tag == language }?.label ?? language.uppercased()
}
var body: some View {
Color.clear
.frame(width: selection.rect.width, height: selection.rect.height)
.overlay(alignment: .topLeading) {
Menu {
ForEach(Self.languages, id: \.label) { entry in
Button(entry.label) {
onRequest(TextRangeReplacementRequest(
documentId: documentId,
range: selection.fenceRange,
replacement: "```\(entry.tag ?? "")\n"
))
}
}
} label: {
Text(currentLabel.uppercased())
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))
}
.menuStyle(.borderlessButton)
.fixedSize()
.padding(.top, 6)
.padding(.leading, 8)
}
.position(
x: selection.rect.midX,
y: selection.rect.midY
)
}
}
private struct CommentAnchorMarker: View {
let rect: CGRect
let onTap: () -> Void
private static let width: CGFloat = 3
private static let gap: CGFloat = 4
private static let hitTargetWidth: CGFloat = 16
var body: some View {
Color.clear
.frame(width: Self.hitTargetWidth, height: max(rect.height, 4))
.contentShape(Rectangle())
.overlay {
RoundedRectangle(cornerRadius: 1.5)
.fill(Color.blue.opacity(0.6))
.frame(width: Self.width)
}
.position(
x: rect.minX - Self.gap - Self.width / 2,
y: rect.minY + rect.height / 2
)
.onTapGesture(perform: onTap)
.help("View comment")
}
}
private struct CodeBlockLineNumberGutter: View {
let selection: CodeBlockSelection
let gutterWidth: CGFloat
/// `selection.code` (`token.contentRange`) always ends with exactly one
/// trailing `\n` per content line the range runs right up to the
/// start of the closing fence's own line, so the newline that ends the
/// last content line is included, but there's never an unterminated
/// final line to add one more for. Counting `\n` characters directly
/// (not `.components(separatedBy:).count`, which is one too many
/// whenever the string ends in the separator) is what makes a
/// single-line block read "1", not "2".
private var contentLineCount: Int {
max(1, selection.code.reduce(into: 0) { count, char in if char == "\n" { count += 1 } })
}
var body: some View {
let totalRows = CGFloat(contentLineCount + 2)
let rowHeight = selection.rect.height / totalRows
ForEach(0..<contentLineCount, id: \.self) { line in
Text("\(line + 1)")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: gutterWidth - 6, alignment: .trailing)
.position(
x: selection.rect.minX + (gutterWidth - 6) / 2,
// +1.5 rows: skip the invisible open-fence row, then
// center within this content row.
y: selection.rect.minY + rowHeight * (CGFloat(line) + 1.5)
)
}
.allowsHitTesting(false)
}
}
/// Applies `.imagePlaygroundSheet` only where it exists (macOS 15.1+, and
/// only once the `ImagePlayground` framework is actually linked in Xcode
/// see `SETUP.md`). A no-op modifier everywhere else, so this file stays
/// valid to build before that link-up happens.
private struct ImagePlaygroundPresenter: ViewModifier {
@Binding var isPresented: Bool
/// Highlighted document text at the moment the button was pressed, if
/// any seeds Image Playground's prompt instead of opening blank.
let seedText: String?
/// Document title, used as the concept's title when `seedText` is used.
let seedTitle: String
let onCompletion: (URL) -> Void
func body(content: Content) -> some View {
#if canImport(ImagePlayground)
if #available(macOS 15.1, *) {
let concepts: [ImagePlaygroundConcept] = {
guard let seedText, !seedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return []
}
return [ImagePlaygroundConcept.extracted(from: seedText, title: seedTitle)]
}()
content.imagePlaygroundSheet(
isPresented: $isPresented,
concepts: concepts,
onCompletion: { url in
isPresented = false
onCompletion(url)
},
onCancellation: { isPresented = false }
)
} else {
content
}
#else
content
#endif
}
}
#endif
@@ -9,8 +9,10 @@ final class DocumentReaderViewModel {
var emoji: String?
var text: String
var collectionId: String?
var parentDocumentId: String?
/// `nil` = draft (not published/visible to other workspace members).
var publishedAt: Date?
var isFullWidth = false
var children: [OutlineDocument] = []
var isLoading = false
var errorMessage: String?
@@ -18,6 +20,38 @@ final class DocumentReaderViewModel {
var isSaving = false
var saveErrorMessage: String?
/// Snapshot of the preference, set once via `.task` right after the
/// view appears (can't be read from `@Environment` inside the view's
/// own `init`) rather than a live binding to `SessionStore` matches
/// how `isFullWidth` etc. are already seeded from the document at init
/// rather than observed reactively. A change made in Settings while a
/// document is already open takes effect the next document opened, not
/// mid-session; an acceptable tradeoff for how rarely this gets
/// toggled versus the complexity of threading a live preference
/// reference through every reader instance.
var separateEditingEnabled: Bool
/// The single source of truth the view reads for both "show the title
/// field" and "is the text view editable" when separate editing is
/// off there's no Edit/Done mode at all, the document is just always
/// editable (assuming permission; there's no per-document permission
/// field to pre-check against, so an unauthorized edit simply fails to
/// save rather than being blocked client-side up front).
var isEffectivelyEditable: Bool {
separateEditingEnabled ? isEditing : true
}
private var autosaveTask: Task<Void, Never>?
/// Tracks the last known-synced-with-the-server values so
/// `scheduleAutosave()` can no-op when called just because `text`/
/// `title` were reassigned *from* a server response (initial load, or
/// a completed save) rather than actually edited without this, every
/// document open in the always-editable mode would fire one pointless
/// autosave round-trip immediately, re-sending exactly what was just
/// received.
private var lastSyncedText: String
private var lastSyncedTitle: String
/// Recent viewers, `views.list` filtered to entries that actually have a
/// `lastViewedAt` this is historical/aggregated view data, not live
/// "viewing right now" presence (that needs the Hocuspocus collaboration
@@ -38,14 +72,19 @@ final class DocumentReaderViewModel {
let documentId: String
private let apiClient: OutlineAPIClient
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
self.apiClient = apiClient
self.documentId = document.id
self.title = document.title
self.emoji = document.emoji
self.text = document.text
self.collectionId = document.collectionId
self.parentDocumentId = document.parentDocumentId
self.publishedAt = document.publishedAt
self.isFullWidth = document.fullWidth ?? false
self.separateEditingEnabled = separateEditingEnabled
self.lastSyncedText = document.text
self.lastSyncedTitle = document.title
}
/// The list endpoint's copy of a document isn't guaranteed to be the full,
@@ -61,17 +100,14 @@ final class DocumentReaderViewModel {
emoji = full.emoji
text = full.text
collectionId = full.collectionId
parentDocumentId = full.parentDocumentId
publishedAt = full.publishedAt
isFullWidth = full.fullWidth ?? false
lastSyncedText = full.text
lastSyncedTitle = full.title
} catch {
errorMessage = "Couldn't load this document. Check your connection and try again."
}
children = (try? await apiClient.listDocuments(
collectionId: nil,
parentDocumentId: documentId,
offset: 0,
limit: 100
)) ?? []
}
func loadViewers() async {
@@ -146,20 +182,55 @@ final class DocumentReaderViewModel {
}
}
/// Turning editing off saves; turning it on is just a mode switch.
/// Turning editing off saves; turning it on is just a mode switch. Only
/// meaningful when `separateEditingEnabled` the always-editable path
/// uses `scheduleAutosave()` instead.
func toggleEditing() async {
guard isEditing else {
isEditing = true
return
}
await save()
if saveErrorMessage == nil {
isEditing = false
}
}
/// Debounced save for the always-editable (separate editing off) path
/// cancels any pending save and starts a fresh countdown on every call,
/// so a save only actually fires once typing pauses, not on every
/// keystroke. Goes through the same `updateDocument` call the explicit
/// Done-button save uses, which is already offline-queue-aware
/// (`CachingOutlineAPIClient`), so autosave while offline just queues
/// like any other edit instead of needing separate handling here.
func scheduleAutosave() {
guard text != lastSyncedText || title != lastSyncedTitle else { return }
autosaveTask?.cancel()
autosaveTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1.5))
guard let self, !Task.isCancelled else { return }
await self.save()
}
}
private func save() async {
isSaving = true
saveErrorMessage = nil
defer { isSaving = false }
let sentTitle = title
let sentText = text
do {
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text))
title = updated.title
text = updated.text
isEditing = false
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
// Only reconcile with the server's response if nothing changed
// locally while the request was in flight otherwise this
// would clobber keystrokes typed during a debounced autosave's
// round trip. Whatever's newer goes out on the next autosave
// cycle regardless, since `scheduleAutosave()` keeps getting
// re-triggered by continued typing.
if title == sentTitle { title = updated.title }
if text == sentText { text = updated.text }
lastSyncedTitle = sentTitle
lastSyncedText = sentText
} catch {
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
}
@@ -71,7 +71,7 @@ struct NewDocumentSheet: View {
ProgressView().frame(maxWidth: .infinity)
} else {
Picker("Collection", selection: $selectedCollectionID) {
Text("Choose a collection").tag(String?.none)
Text("Draft (not published)").tag(String?.none)
ForEach(collections) { collection in
Text(collection.name).tag(Optional(collection.id))
}
@@ -86,6 +86,12 @@ struct NewDocumentSheet: View {
}
.labelsHidden()
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
if selectedCollectionID != nil {
Label("This will publish the document immediately, making it visible to your workspace.", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
if let errorMessage {
@@ -97,18 +103,24 @@ struct NewDocumentSheet: View {
HStack {
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
Button("Create") {
Button(selectedCollectionID == nil ? "Save as Draft" : "Create & Publish") {
Task { await create() }
}
.keyboardShortcut(.defaultAction)
.disabled(selectedCollectionID == nil || isCreating)
.disabled(isCreating)
}
}
.padding(20)
.frame(width: 380)
.task {
await loadCollections()
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id
// "By default" means a bare New Document (Home, reader
// toolbar) starts as a draft no `collections.first` fallback
// like there used to be. A contextual entry point (right-click
// a specific collection/document) still pre-fills that
// location, since that already carries clear placement intent;
// the publish warning above still applies to it either way.
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId
selectedParentID = initialParentDocument?.id
isTitleFocused = true
}
@@ -147,7 +159,6 @@ struct NewDocumentSheet: View {
}
private func create() async {
guard let selectedCollectionID else { return }
isCreating = true
defer { isCreating = false }
do {
@@ -156,7 +167,12 @@ struct NewDocumentSheet: View {
title: title.isEmpty ? "Untitled" : title,
text: "",
collectionId: selectedCollectionID,
parentDocumentId: selectedParentID
parentDocumentId: selectedParentID,
// No collection/parent selected = draft; Outline can't
// publish without one of the two regardless of this
// flag, but setting it explicitly rather than relying
// on that keeps intent obvious at the call site.
publish: selectedCollectionID != nil
)
)
onCreated(document)
@@ -0,0 +1,144 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Destination picker for publishing a draft same collection-then-
/// optional-parent-document shape as `MoveDocumentSheet`, since Outline's
/// own web app treats "where does this go" identically for both actions.
/// Pre-selects the document's own `collectionId`/`parentDocumentId` when it
/// already has one (a draft can already belong to a collection without
/// being published) rather than starting blank, per explicit instruction.
///
/// Publishing itself is two calls, not one: `documents.update(publish:
/// true, collectionId:)` does the actual publish and top-level collection
/// placement in one round-trip (confirmed from a live network capture);
/// nesting under a specific parent document isn't a documented
/// `documents.update` field, so that part reuses `documents.move` the
/// same already-working mechanism `MoveDocumentSheet` uses as a second
/// step, only when a parent was actually chosen.
@MainActor
struct PublishDocumentSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let document: OutlineDocument
let onPublished: () async -> Void
@State private var collections: [OutlineCollection] = []
@State private var selectedCollectionID: String?
@State private var rootDocuments: [OutlineDocument] = []
@State private var selectedParentID: String?
@State private var isLoadingCollections = false
@State private var isLoadingDestinationDocuments = false
@State private var isPublishing = false
@State private var errorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Publish \"\(document.title.isEmpty ? "Untitled" : document.title)\"")
.font(.headline)
Text("Choose where this document should live once published.")
.font(.callout)
.foregroundStyle(.secondary)
if isLoadingCollections {
ProgressView().frame(maxWidth: .infinity)
} else {
Picker("Collection", selection: $selectedCollectionID) {
Text("Choose a collection").tag(String?.none)
ForEach(collections) { collection in
Text(collection.name).tag(Optional(collection.id))
}
}
.labelsHidden()
Picker("Location", selection: $selectedParentID) {
Text("Collection root").tag(String?.none)
ForEach(rootDocuments.filter { $0.id != document.id }) { candidate in
Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id))
}
}
.labelsHidden()
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
}
if let errorMessage {
Text(errorMessage)
.font(.callout)
.foregroundStyle(.red)
}
HStack {
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
Button("Publish") {
Task { await publish() }
}
.disabled(selectedCollectionID == nil || isPublishing)
}
}
.padding(20)
.frame(width: 360)
.task {
await loadCollections()
selectedCollectionID = document.collectionId
selectedParentID = document.parentDocumentId
}
.task(id: selectedCollectionID) {
await loadRootDocuments()
}
}
private func loadCollections() async {
isLoadingCollections = true
defer { isLoadingCollections = false }
do {
collections = try await apiClient.listCollections(offset: 0, limit: 100)
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.")
}
}
private func loadRootDocuments() async {
guard let selectedCollectionID else {
rootDocuments = []
return
}
isLoadingDestinationDocuments = true
defer { isLoadingDestinationDocuments = false }
do {
rootDocuments = try await apiClient.listDocuments(
collectionId: selectedCollectionID,
parentDocumentId: nil,
offset: 0,
limit: 100
)
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.")
}
}
private func publish() async {
guard let selectedCollectionID else { return }
isPublishing = true
defer { isPublishing = false }
do {
_ = try await apiClient.updateDocument(
UpdateDocumentRequest(id: document.id, collectionId: selectedCollectionID, publish: true)
)
// Only a documents.move call actually supports parentDocumentId
// skip it entirely when publishing straight to the collection root,
// the update above already placed it there.
if let selectedParentID {
try await apiClient.moveDocument(
MoveDocumentRequest(id: document.id, collectionId: selectedCollectionID, parentDocumentId: selectedParentID)
)
}
await onPublished()
dismiss()
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't publish this document.")
}
}
}
#endif
+1
View File
@@ -5,6 +5,7 @@ enum HomeTab: String, CaseIterable, Identifiable {
case popular = "Popular"
case recentlyUpdated = "Recently Updated"
case createdByMe = "Created by Me"
case drafts = "Drafts"
var id: String { rawValue }
}
+1
View File
@@ -101,6 +101,7 @@ struct HomeView: View {
PinnedDocumentCard(document: document)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
.padding(.horizontal, 24)
@@ -10,6 +10,7 @@ final class HomeViewModel {
private(set) var popular: [OutlineDocument] = []
private(set) var recentlyUpdated: [OutlineDocument] = []
private(set) var createdByMe: [OutlineDocument] = []
private(set) var drafts: [OutlineDocument] = []
var isLoadingPinned = false
var isLoadingTab = false
@@ -32,6 +33,7 @@ final class HomeViewModel {
case .popular: popular
case .recentlyUpdated: recentlyUpdated
case .createdByMe: createdByMe
case .drafts: drafts
}
}
@@ -115,6 +117,8 @@ final class HomeViewModel {
return try await apiClient.documentsList(
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
)
case .drafts:
return try await apiClient.listDrafts(ListDraftsRequest(limit: 25))
}
}
@@ -124,6 +128,7 @@ final class HomeViewModel {
case .popular: popular = documents
case .recentlyUpdated: recentlyUpdated = documents
case .createdByMe: createdByMe = documents
case .drafts: drafts = documents
}
}
+40 -28
View File
@@ -2,45 +2,57 @@
import SwiftUI
import OutlineKit
/// Deliberately distinct from `DocumentCardView` the pinned section is for
/// a quick scan of a small curated set, not browsing, so this is a dense
/// single-line row rather than a tall card, with an explicit pin glyph so
/// it doesn't read the same as the tab grids below it.
/// Same tall-card shape as `DocumentCardView` (the tab grids below it) so the
/// pinned section reads as a set of cards, not a row of tab-like chips the
/// accent-filled pin badge is the one thing that marks these as pinned.
struct PinnedDocumentCard: View {
@Environment(StarStore.self) private var starStore
let document: OutlineDocument
var body: some View {
HStack(spacing: 10) {
if let emoji = document.emoji {
Text(emoji)
.font(.title3)
} else {
Image(systemName: "doc.text")
.font(.body)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
if let emoji = document.emoji {
Text(emoji)
.font(.title2)
} else {
Image(systemName: "doc.text")
.font(.title3)
.foregroundStyle(.secondary)
}
Spacer()
if starStore.isStarred(documentId: document.id) {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(.yellow)
}
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundStyle(.white)
.padding(5)
.background(Color.accentColor, in: Circle())
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.callout.weight(.medium))
.lineLimit(1)
.font(.headline)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0)
if starStore.isStarred(documentId: document.id) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
Image(systemName: "pin.fill")
.font(.caption2)
Text(document.updatedAt, format: .relative(presentation: .named))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
.padding(14)
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
)
}
}
#endif
@@ -134,6 +134,7 @@ struct GlobalSearchResultsView: View {
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
+37
View File
@@ -19,6 +19,7 @@ struct OutpostApp: App {
#if os(macOS)
@State private var isShowingLogoutConfirmation = false
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
#endif
var body: some Scene {
@@ -33,6 +34,7 @@ struct OutpostApp: App {
.onAppear { applyMacAppearance() }
.onChange(of: appearance) { _, _ in applyMacAppearance() }
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
.background(TransparentTitlebarWindowAccessor())
#endif
}
#if os(macOS)
@@ -60,6 +62,17 @@ struct OutpostApp: App {
}
.disabled(!session.isSignedIn)
}
// Settings Editor Command Palette gates this disabled
// (not just a no-op) when the user's turned it off, matching
// how Settings/Log Out already disable rather than silently
// do nothing.
CommandGroup(after: .newItem) {
Button("Command Palette…") {
navigation.isShowingCommandPalette = true
}
.keyboardShortcut("k")
.disabled(!session.isSignedIn || !isCommandPaletteEnabled)
}
}
#endif
@@ -89,3 +102,27 @@ struct OutpostApp: App {
}
#endif
}
#if os(macOS)
/// Makes the titlebar/toolbar strip blend into the sidebar's own background
/// instead of reading as a separate bar same look as Mail/Notes/Finder.
/// SwiftUI's `WindowGroup` exposes no direct hook for this, so this reaches
/// into the underlying `NSWindow` the way `applyMacAppearance()` above
/// reaches into `NSApp` for the same reason (no SwiftUI-level API exists).
/// A `View` (not the window itself) is what actually needs to extend under
/// the now-transparent titlebar `.fullSizeContentView` just makes room;
/// the sidebar's `.background` already does the rest with no other change.
private struct TransparentTitlebarWindowAccessor: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
guard let window = view.window else { return }
window.titlebarAppearsTransparent = true
window.styleMask.insert(.fullSizeContentView)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
#endif
+9 -3
View File
@@ -33,7 +33,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
/// explicitly built yet. Content lands section by section.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// General (ours)
case appearance, offlineSync, advanced, about
case appearance, editor, navigation, offlineSync, advanced, about
// Account
case profile, preferences, notifications, passkeys, apiAccess
@@ -45,7 +45,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
var category: SettingsCategory {
switch self {
case .appearance, .offlineSync, .advanced, .about:
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about:
return .general
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
return .account
@@ -57,6 +57,8 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
var title: String {
switch self {
case .appearance: return "Appearance"
case .editor: return "Editor"
case .navigation: return "Navigation"
case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced"
case .about: return "About"
@@ -85,6 +87,8 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
var icon: String {
switch self {
case .appearance: return "paintbrush"
case .editor: return "square.split.2x1"
case .navigation: return "command"
case .offlineSync: return "arrow.triangle.2.circlepath"
case .advanced: return "wrench.and.screwdriver"
case .about: return "info.circle"
@@ -115,7 +119,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
/// specified and built.
var isImplemented: Bool {
switch self {
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
return true
default:
return false
@@ -134,4 +138,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
final class AppNavigation {
var isShowingSettings = false
var selectedSettingsSection: SettingsSection? = .appearance
/// K, see `OutpostApp`'s `CommandGroup` and `CommandPaletteView`.
var isShowingCommandPalette = false
}
+33
View File
@@ -6,6 +6,13 @@ import OutlineKit
@Observable
final class SessionStore {
private static let serverURLDefaultsKey = "outline.serverURL"
/// Preferences now drive real editor behavior (separate editing, etc.),
/// not just a settings screen they need to survive a cold launch with
/// no network, not just live in memory from the last successful fetch.
/// Still read-only while offline (Settings already gates every toggle
/// on `isEffectivelyOnline`) this only makes the *last known* values
/// available, never lets them be changed without a server round-trip.
private static let userPreferencesDefaultsKey = "outline.userPreferences"
private let tokenStore: TokenStoring
private let defaults: UserDefaults
@@ -47,6 +54,7 @@ final class SessionStore {
if hasToken, let storedServerURL {
isSignedIn = true
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
userPreferences = Self.loadCachedPreferences(defaults: defaults)
} else {
// Keychain and the sandboxed UserDefaults container don't
// always survive together a Keychain item written by an
@@ -70,6 +78,24 @@ final class SessionStore {
isSignedIn = true
}
/// `static` (not an instance method) so `init` can call it before every
/// stored property has a value same reason `makeAPIClient` is static.
private static func loadCachedPreferences(defaults: UserDefaults) -> OutlineUserPreferences? {
guard let data = defaults.data(forKey: userPreferencesDefaultsKey) else { return nil }
return try? JSONDecoder().decode(OutlineUserPreferences.self, from: data)
}
/// `nil` clears the cache instead of writing a `null` happens whenever
/// a fresh fetch legitimately comes back with no preferences set, so a
/// stale cached value from a previous account/state can't linger.
private func cachePreferences(_ preferences: OutlineUserPreferences?) {
guard let preferences, let data = try? JSONEncoder().encode(preferences) else {
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
return
}
defaults.set(data, forKey: Self.userPreferencesDefaultsKey)
}
private static func makeAPIClient(
serverURL: URL,
tokenStore: TokenStoring,
@@ -99,6 +125,11 @@ final class SessionStore {
teamAvatarURL = nil
apiClient = nil
cachingClient = nil
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
// Same key `ContentView_macOS` persists "Remember previous
// location" under cleared here too so switching accounts/servers
// can't restore a stale location that belongs to a different sign-in.
defaults.removeObject(forKey: "outline.lastLocation")
}
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
@@ -119,6 +150,7 @@ final class SessionStore {
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language
userPreferences = user.preferences
cachePreferences(user.preferences)
userNotificationSettings = user.notificationSettings
}
@@ -132,6 +164,7 @@ final class SessionStore {
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language
userPreferences = user.preferences
cachePreferences(user.preferences)
userNotificationSettings = user.notificationSettings
teamName = team.name
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
+25
View File
@@ -0,0 +1,25 @@
#if os(macOS)
import AppKit
/// `NSMenuItem` has no closure-based initializer the standard AppKit
/// pattern is a small subclass that's its own target/action, so callers can
/// just pass a Swift closure instead of wiring up a selector by hand.
final class ClosureMenuItem: NSMenuItem {
private let handler: () -> Void
init(title: String, handler: @escaping () -> Void) {
self.handler = handler
super.init(title: title, action: #selector(invokeHandler), keyEquivalent: "")
self.target = self
}
@available(*, unavailable)
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func invokeHandler() {
handler()
}
}
#endif
@@ -0,0 +1,11 @@
#if os(macOS)
import MarkdownEngineCodeBlocks
/// One `HighlighterSwiftBridge` for the whole app. It owns a JavaScriptCore
/// context (expensive to spin up) plus its own highlight cache, so every
/// `NativeTextViewWrapper` should share this instance rather than each
/// constructing its own.
enum CodeSyntaxHighlighting {
static let shared = HighlighterSwiftBridge()
}
#endif
@@ -0,0 +1,84 @@
#if os(macOS)
import AppKit
import MarkdownEngine
import OutlineKit
/// Resolves `![alt](url)` Markdown image references for the editor.
///
/// `EmbeddedImageProvider.image(for:)` is called synchronously from the
/// styling pipeline and must return immediately it can't `await` a
/// network fetch inline. So a miss kicks off an async load in the
/// background, caches the result, and calls `onImageLoaded` (on the main
/// thread) once it lands; the embedder is responsible for turning that into
/// a real SwiftUI update (see `DocumentReaderView`'s `imageReloadTick`) so
/// `updateNSView` runs again, notices `fingerprint()` changed, and
/// restyles the engine has no polling of its own.
///
/// `url` is usually a server-relative path like
/// `/api/attachments.redirect?id=<uuid>` Outline's own stable reference
/// for an uploaded attachment, which needs the same Bearer auth as every
/// other OutlineKit request (`OutlineAPIClient.fetchAuthenticatedFile`).
/// A plain absolute `http(s)://` URL (an external image someone pasted) is
/// fetched directly instead, no auth attached.
///
/// Thread-safety mirrors `HighlighterSwiftBridge`: `NSCache` for the image
/// store (inherently thread-safe), a lock for the small bit of state that
/// isn't (`version`, `inFlight`) no actor isolation, since the engine may
/// call `image(for:)`/`fingerprint()` from whatever thread is styling.
final class OutlineImageProvider: EmbeddedImageProvider, @unchecked Sendable {
private let apiClient: OutlineAPIClient
private let cache = NSCache<NSString, NSImage>()
private let lock = NSLock()
private var inFlight: Set<String> = []
private var version = 0
/// Set by the embedder; called on the main thread whenever a load
/// completes and `fingerprint()` has changed.
var onImageLoaded: (@Sendable () -> Void)?
init(apiClient: OutlineAPIClient) {
self.apiClient = apiClient
}
func image(for reference: EmbeddedImageRequest) -> NSImage? {
let url = reference.name
if let cached = cache.object(forKey: url as NSString) {
return cached
}
beginLoad(url)
return nil
}
func fingerprint() -> AnyHashable {
lock.withLock { version }
}
private func beginLoad(_ url: String) {
let alreadyLoading: Bool = lock.withLock {
if inFlight.contains(url) { return true }
inFlight.insert(url)
return false
}
guard !alreadyLoading else { return }
Task {
defer { lock.withLock { inFlight.remove(url) } }
do {
let data: Data
if url.hasPrefix("http://") || url.hasPrefix("https://"), let externalURL = URL(string: url) {
(data, _) = try await URLSession.shared.data(from: externalURL)
} else {
data = try await apiClient.fetchAuthenticatedFile(path: url)
}
guard let image = NSImage(data: data) else { return }
cache.setObject(image, forKey: url as NSString)
lock.withLock { version += 1 }
onImageLoaded?()
} catch {
// Best-effort: a failed load just leaves the Markdown source
// visible (the engine's existing fallback for `image(for:)
// == nil`), no separate error UI for an inline image fetch.
}
}
}
}
#endif
+38
View File
@@ -0,0 +1,38 @@
import SwiftUI
#if os(macOS)
import AppKit
/// Pointing-hand cursor while the mouse is over a clickable sidebar/list row,
/// gated by the "Pointer Cursor" setting (Settings Editor Pointer Cursor).
/// `didPush` tracks whether this instance actually pushed a cursor, so a
/// mid-hover toggle of the preference can never leave `NSCursor`'s push/pop
/// stack unbalanced pop only fires for a push this same hover made.
private struct PointerCursorOnHover: ViewModifier {
@AppStorage("outpost.pointerCursorEnabled") private var isEnabled = true
@State private var didPush = false
func body(content: Content) -> some View {
content.onHover { hovering in
if hovering {
guard isEnabled else { return }
NSCursor.pointingHand.push()
didPush = true
} else if didPush {
NSCursor.pop()
didPush = false
}
}
}
}
extension View {
func pointerCursorOnHover() -> some View {
modifier(PointerCursorOnHover())
}
}
#else
extension View {
func pointerCursorOnHover() -> some View { self }
}
#endif
+4
View File
@@ -46,6 +46,10 @@ Outpost itself is licensed under the [Business Source License 1.1](./LICENSE)
This project is a client only — it does not include, vendor, or redistribute any of Outline's own (also BSL 1.1 licensed) server source.
## Credits
The native markdown editor is built on [swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine) by Luca Chen, licensed under Apache License 2.0. It's vendored directly in [`Vendor/swift-markdown-engine`](./Vendor/swift-markdown-engine) (see its [`LICENSE`](./Vendor/swift-markdown-engine/LICENSE)); local fixes made in that copy haven't been upstreamed yet.
## Privacy
Outpost collects nothing about you — no analytics, no telemetry, no crash reporting of its own, no age or demographic data, nothing. The only thing stored locally is your Outline server URL and API token (in the device Keychain) and, optionally, a local offline cache of what you've viewed. Everything else goes straight from your device to whatever Outline server you configure — there's no backend in between, and the developer has no access to your data or your server.
+32
View File
@@ -0,0 +1,32 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Build & Test (macOS)
runs-on: macos-15
steps:
- name: Check out
uses: actions/checkout@v4
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Show Swift version
run: swift --version
- name: Build
run: swift build -v
- name: Test
run: swift test --parallel
+17
View File
@@ -0,0 +1,17 @@
# macOS
.DS_Store
# Swift Package Manager
/.build
/.swiftpm/xcode
Package.resolved
# Xcode
xcuserdata/
DerivedData/
*.xcodeproj/project.xcworkspace/xcuserdata/
*.xcodeproj/xcuserdata/
# Generated documentation
/docs
*.doccarchive
+4
View File
@@ -0,0 +1,4 @@
version: 1
builder:
configs:
- documentation_targets: [MarkdownEngine]
+183
View File
@@ -0,0 +1,183 @@
# Architecture
## Source layout
```bash
Sources/
├── MarkdownEngine/ # core target — zero deps
│ ├── Configuration/ # MarkdownEditorConfiguration + MarkdownEditorTheme
│ ├── Extensions/ # the extension seam: MarkdownExtension + bundled opt-ins
│ ├── Services/ # 4 protocols, no-op defaults, WikiLinkService
│ ├── Parser/ # two-phase AST: BlockParser → InlineParser → DocumentAST (+ token projection)
│ ├── Styling/ # MarkdownASTStyler (AST walk) + MarkdownStyler facade for NSImage passes
│ ├── Renderer/ # LayoutBridge, MarkdownTextLayoutFragment, EmbeddedImageCache
│ ├── Input/ # MarkdownInputHandler + MarkdownListHandler
│ ├── TextView/
│ │ ├── NativeTextViewWrapper.swift # SwiftUI entry point (NSViewRepresentable)
│ │ ├── NativeTextViewContainer.swift # the scroll view's documentView: header band + text column stacking
│ │ ├── ScrollingHeaderController.swift # scroll-away header: hosting, collapse/expand, teardown
│ │ ├── ClampedScrollView.swift # scroll range clamped to real content height
│ │ ├── NativeTextView/ # AppKit subclass + UX extensions (paste, drag-select, …)
│ │ └── Coordinator/ # NSTextViewDelegate split by concern (restyling, find, …)
│ └── MarkdownEngine.docc/ # DocC catalog
├── MarkdownEngineCodeBlocks/ # opt-in SPM product — pulls in HighlighterSwift
│ └── HighlighterSwiftBridge.swift # SyntaxHighlighter conformance
└── MarkdownEngineLatex/ # opt-in SPM product — pulls in SwiftMath
└── SwiftMathBridge.swift # LatexRenderer conformance
```
The rest of this file is a per-directory tour, in the order text flows
through the engine.
## [`Parser/`](Sources/MarkdownEngine/Parser): text → AST → tokens
A two-phase AST pipeline following CommonMark's model — block structure first,
inline content second. There is no regex tokenizer anymore; the structural
regexes are gone, replaced by hand-written scanners and a real syntax tree.
1. **`BlockParser`** splits the document into a flat, gap-free (tiling)
sequence of `Block`s: `heading`, `paragraph`, `blockquote`, `list`,
`fencedCode`, `blockLatex`, `table`, `thematicBreak`, `blank`. Hand-written
line scanners. It memoizes the last parse (UTF-16 buffer cache) so the
per-keystroke callers share one line-scan.
2. **`InlineParser`** turns a single inline-bearing block's text into an inline
AST (`[InlineNode]`) with correct CommonMark precedence: code spans →
escapes → link family (`![[…]]`, `[[…]]`, `![…](…)`, `[…](…)`, `~~…~~`,
`$…$`) → emphasis (`*`/`_` delimiter runs) → `buildTree`. Each pass claims
spans only in regions not already claimed, so there are never partial
overlaps and the tree is a clean containment tree. That invariant is also
what keeps the pass linear in span count: claimed ranges are consulted
through a cursor rather than rescanned, and `buildTree` derives containment
from a sort instead of comparing spans pairwise.
3. **`MarkdownAST` / `DocumentAST.parse`** combines the two into the semantic
document AST — `[BlockNode]`, each inline-bearing block carrying its parsed
`[InlineNode]` children in absolute document coordinates. `BlockNode`,
`InlineNode`, and `ListItem` are defined here.
**Tokens are now a projection of the AST, not the source of truth.**
`MarkdownTokenizer` is just a namespace; its entry point `parseTokensViaAST`
(implemented in **`BlockScopedTokenizer`** — the live tokenization pipeline)
walks each `BlockParser` block and emits the legacy flat `[MarkdownToken]`
shape: block-level tokens (heading, blockquote, fenced code, table, block
LaTeX) come from **`BlockLevelTokenizer`** (hand scanners), inline tokens from
the AST via **`InlineASTAdapter`** (`[InlineNode]``[MarkdownToken]`). Token
shapes are reproduced 1:1 from the old regex tokenizer (parity-checked), so the
consumers that still read tokens — the NSImage render passes, code-block
handling, `MarkdownInputHandler`, `ContextMenu`, and `MarkdownDetection`
(caret-aware active-token indices) — keep working unchanged.
**Invariant:** Ranges everywhere are absolute UTF-16 `NSRange`s into the source
(the editor is TextKit-2 / `NSTextView`-based, so UTF-16 offsets are the native
currency).
**Invariant:** Parsing is incremental. With `scopedRanges`, `DocumentAST.parse`
parses inlines only for blocks intersecting the edit, and `BlockScopedTokenizer`
memoizes per-block tokens (substring → tokens, FIFO-capped) — so a keystroke
re-parses one block, not the whole document (≈ O(edit)).
## [`Extensions/`](Sources/MarkdownEngine/Extensions): opt-in constructs beyond pure markdown
`MarkdownExtension` contributes an inline span form (`InlineSyntax`, e.g.
`==highlight==`), a fenced block form (`BlockSyntax`, e.g. `::: … :::`), or
both — plus content attributes and an HTML wrapper for the clean-copy path.
Registered via `MarkdownEditorConfiguration.extensions`; unregistered syntax
stays literal text. Extensions never emit ranges — the parser derives all
geometry — and every parse cache keys on the registry fingerprint, so the
registered set can change at runtime.
**Invariant:** built-in constructs always classify first; an extension can
never take text away from core markdown.
## [`Services/`](Sources/MarkdownEngine/Services): how does the engine talk to your app?
`MarkdownEditorServices.swift` declares the four service protocols. Each is
called synchronously when its construct is styled or rendered: `WikiLinkResolver`
while styling wiki-links, `EmbeddedImageProvider` from the image-embed render
pass, `SyntaxHighlighter` from code styling, `LatexRenderer` from the LaTeX
render passes.
`WikiLinkService.swift` handles the dual-form storage / display transform —
storage is `[[Name|<id>]]`, display is `[[Name]]`. The coordinator runs it both
ways every time `rebuildTextStorageAndStyle()` fires.
**Invariant:** Service callbacks are synchronous. If an embedder's
implementation is slow, it caches (both bundled bridges do); the engine never
async-renders.
**Invariant:** Wiki-link storage and display are different strings. Display IDs
never leak into the binding.
## [`Styling/`](Sources/MarkdownEngine/Styling): how does the AST become attributes?
`MarkdownASTStyler.styleAttributes()` is the live styler. It walks the document
AST and emits `[StyledRange]`, **composing** attributes on descent: a heading
sets a large bold font, descending into bold adds the bold trait (keeping the
size), into italic adds italic — so nested / combined inline styles stack
instead of overwriting each other. (Composition is what the old flat pass
pipeline got wrong, e.g. the shrinking bold in `# **n*o*des**`.)
`MarkdownStyler.styleAttributes()` (`MarkdownStyler.swift:43`) is now a thin
facade: it builds the `StylingContext`, runs the AST styler for all text
styling, then appends the passes that still render **NSImages** and therefore
still consume tokens — block / inline LaTeX (`+Latex`), image embeds and image
links (`+Images`), and rendered tables (`+Tables`). `MarkdownStyler+TaskCheckboxes`
and `+BulletMarkers` no longer style (the AST styler does); they keep only the
caret / selection range helpers (`taskSyntaxRange`, `bulletSyntaxRange`,
`hrLineRange`) the text-view delegate uses.
If the coordinator passes `scopedRanges`, only the intersecting blocks are
re-styled — the optimization that keeps per-keystroke restyling cheap.
**Invariant:** Markers shrink, they don't disappear. Inactive markers render at
`hiddenMarkerFontSize`; they're never removed from text storage. Every
selection / copy / find / undo bug downstream traces back to violating this.
## [`Renderer/`](Sources/MarkdownEngine/Renderer): TextKit 2 layout
Thin wrappers around `NSTextLayoutManager` (`LayoutBridge.swift`), a custom
`MarkdownTextLayoutFragment` for precise positioning, and `EmbeddedImageCache`
keyed by an embedder-supplied fingerprint so images and LaTeX results
invalidate when the embedder says so.
## [`Input/`](Sources/MarkdownEngine/Input): typing-time helpers
`MarkdownInputHandler.swift` handles auto-wrap for `$…$` / `$$…$$` / `![[…]]`.
`MarkdownListHandler.swift` handles list continuation, indent / outdent, and
task-checkbox toggling on Enter / Tab / Backspace. Both run synchronously inside
the text-view delegate.
## [`TextView/`](Sources/MarkdownEngine/TextView): NSTextView + SwiftUI bridge
The entry point is `NativeTextViewWrapper.swift` — an `NSViewRepresentable` that
owns the coordinator and the configured text view.
The scroll view's `documentView` is **always** `NativeTextViewContainer`, never
the text view itself. The container stacks up to three kinds of siblings in a
flipped coordinate space: the optional scroll-away header band at the top (a
clipped `NSHostingView` managed by `ScrollingHeaderController`, reserved height
mirrored into `container.headerHeight`), the `NativeTextView` at
`y = headerHeight` (centered at a fixed width when
`configuration.readingWidth` is set), and — in reading-column mode — the
full-width wide-table breakout overlays. Anything that converts between
text-view-local rects and scroll/document space must lift by the text view's
origin inside the container (`convert(_:to:)` or `frame.origin`); see
`viewRect(forCharacterRange:)` and the find-in-document paths for the pattern.
Two sub-folders matter:
- `NativeTextView/` — extensions on the AppKit subclass (paste, drag-select
boost, spell policy, caret workarounds, frame/overscroll management)
- `Coordinator/``NSTextViewDelegate` glue, split by concern (restyling,
writing-tools, find, code-blocks, inline selection, autocorrect)
Application of `[StyledRange]` to text storage happens in
`Coordinator/NativeTextViewCoordinator+Restyling.swift`
`rebuildTextStorageAndStyle()`, which tokenizes via `parseTokensViaAST` and
calls `MarkdownStyler.styleAttributes()`.
## [`Configuration/`](Sources/MarkdownEngine/Configuration): the tunables
`MarkdownEditorConfiguration` is a struct of structs — one nested group per
concern (headings, codeBlock, blockLatex, overscroll, markers, lists, …) —
passed by reference into the styler via the `StylingContext`.
`MarkdownEditorTheme` is its colour sub-field.
+377
View File
@@ -0,0 +1,377 @@
# Changelog
All notable changes to swift-markdown-engine are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.12.0] - 2026-08-10
### Added
- `onPersistScrollOffset` / `restoreScrollOffset` on `NativeTextViewWrapper`
scroll memory an embedder can keep somewhere that outlives the editor. The
engine's own per-document offsets live on the coordinator, so an embedder that
routes to a different screen and back lost them: nothing recorded the offset on
the way out (there was no `dismantleNSView` at all), and the restore was gated
on a document switch, which a remount is not — `makeCoordinator` seeds
`documentId`, so the first update pass never looks like one. Teardown now hands
the offset over, and the restore is latched instead of gated, retrying for a
bounded few passes because the first pass after a remount still carries the
embedder's empty buffer. Both closures are asked at call time, so the
embedder's own retention rules can see changes made on the way out. Passing
neither leaves behavior unchanged.
- `NSAttributedString.Key.markdownBlockBackground` — a background painted
across the whole line box by `MarkdownTextLayoutFragment` instead of the
glyph box AppKit's `.backgroundColor` covers. Embedder extensions can use it
wherever a fill should read as a block.
### Changed
- **Inline parse cost is linear in the spans per region, not quadratic.** Every
pass after the first consulted the claimed ranges by scanning the whole array
— once per character in `scanEscapes` and `collectDelimiterRuns`, once per
candidate in `scanLinkFamily` — and `buildTree` decided containment by testing
each span against every other. The passes walk the string left to right and
claimed ranges never partially overlap, so a cursor over the sorted ranges answers both
questions in amortised constant time, and sorting spans by start ascending /
length descending turns containment into a single ordered walk. A paragraph of
240 code spans parses in 0.5ms rather than 33ms; 6x the spans now costs 6x the
parse instead of ~30x. Affects every claimed-span construct — code, escapes,
links, images, wiki links, inline LaTeX, emphasis, and extension spans. No
parse result changes.
- `==highlight==` fills the line box. AppKit paints `.backgroundColor` over
ascent + descent only, so the marker fell short of the line height by the
leading plus `paragraph.lineHeightExtraSpacing`, and a highlight that wrapped
came out as a stack of bands. `HighlightExtension` returns
`.markdownBlockBackground` now, so the block is continuous at any font size.
Table cells rasterize their own text and keep the glyph-box fill.
### Fixed
- Bare URLs and emails survive rich copy as real links. The editor styler
linkifies them with `NSDataDetector`, but the HTML renderer emitted them as
plain text, so the pasteboard's HTML/RTF/web-archive flavors carried no anchor
at all, and whether a copied URL arrived clickable was left to the receiving
app — Apple Mail runs its own detection and linkifies anyway, a consumer that
takes the rich flavor verbatim pastes dead text. `MarkdownHTMLRenderer` now
wraps detector matches in `<a href>` (emails as `mailto:`) using the same
system detector as the styler; the RTF and web-archive flavors are derived
from that HTML, so all three inherit the link. Explicit `[title](url)` links
were already correct; a URL-shaped run inside a link's own title stays plain
so anchors never nest, and code spans remain excluded, matching the styler.
Table cells are unaffected: they render no inline markup on the copy path.
- Markdown link labels may hold inline code and escaped punctuation —
``[`App`](/tmp/App.swift:56)`` stayed literal. Code spans and escapes are
claimed before links so they stay opaque, and the link pass rejected every
candidate overlapping a claimed span, including one lying entirely inside the
label. Spans contained in the label are permitted now, links act as
containers when the tree is built, and partial overlaps or spans crossing the
label boundary are still rejected.
- Initially narrow tables reflow when the editor width shrinks instead of
retaining stale image geometry until an unrelated full restyle.
## [0.11.0] - 2026-07-31
### Added
- **Ordered lists render their position.** An item's number is computed from its
place in the run and painted over the source digits, so typing, deleting,
merging and pasting renumber live. The `.md` file is never rewritten — the
source stays valid CommonMark whatever it says. The whole source marker is
hidden as one unit and the slot is kerned to the display width, so the dot
travels with the digits at any digit count; the raw digits are revealed while
they are edited.
- `MarkdownEditorConfiguration.cursorFollowsSpanInk` (opt-in, off by default):
the caret and the I-beam take the ink of the extension span they sit in. It
matters for an extension that INVERTS its content — dark ink on a light block
— where both cursors are otherwise drawn in the block's own color and
disappear inside it. `InvertedIBeamCursor` recolors the live `NSCursor.iBeam`
image, which keeps the system shape and the user's pointer size.
### Fixed
- Find-in-document no longer erases other backgrounds. Clearing its highlights
removed `.backgroundColor` across the whole document, which blanked extension
spans, code fences and table cells until some unrelated restyle repainted
them. Find now marks its own backgrounds and restyles only the paragraphs it
touched.
### Performance
- **Large notes open ~14× faster.** Measured on a 346k-char / 5,241-block note
(Release): first open 19.5 s → 1.35 s, warm open 1.1 s → 590 ms, switching
away 440 ms → 115 ms. Styling is built on a detached string and transferred
with one `setAttributedString` — per-key `addAttribute` on live TextKit-2
storage left weak tombstones in Foundation's attribute-intern table, which
turned quadratic. The restyle apply uses a paragraph overlap index above 32
paragraphs, the redundant second full-document parse and the re-entrant
full-document layout during rebuild are gone, and the SwiftMath render cache
persists to disk (717 ms → 35 ms on relaunch, byte-identical geometry).
- **Editing long ordered lists.** One Return in an 800-item loose list:
944 ms → 67 ms (was quadratic in list length). Typing in a 1,600-item ordered
list: 89 ms → 41 ms per key. Resolving the caret ink across 1,600 spans:
0.134 ms → 0.002 ms per keystroke.
### Known limitations
- A list item's continuation line is a paragraph and ends the run, so a
multi-line item switches numbering off below it.
- A loose list keeps stale numbers after a pure digit edit (a digit edit is not
classified as structural).
- Ordered task items (`1. [ ] x`) consume a number but render none.
## [0.10.1] - 2026-07-22
### Added
- Custom SF Symbols for task checkboxes: `MarkdownEditorConfiguration` accepts
custom unchecked and checked symbols for `- [ ]` / `- [x]` task-list items
(opt-in; the defaults are unchanged).
### Fixed
- List markers no longer disappear while a selection covers them, and selecting
a list item now reveals its raw marker syntax like other inline constructs.
- An unclosed ``` fence no longer swallows the rest of the document: typing an
opening fence above existing content left every block below it (tables,
block LaTeX, thematic breaks, links) unrendered until the closing fence was
typed. A fence now forms a code block only once its closing fence exists.
## [0.10.0] - 2026-07-15
### Added
- **Extension seam**: opt-in constructs beyond pure markdown. A
`MarkdownExtension` contributes an inline form (`==highlight==`), a fenced
block form (`::: … :::`), or both — plus content attributes and an HTML
wrapper for the clean-copy path; register instances via
`MarkdownEditorConfiguration.extensions`. Extensions never emit ranges — the
parser derives all geometry, so a misbehaving extension can at worst restyle
its own construct. Marker/fence hiding, caret reveal, incremental restyle,
table cells, and rich copy are handled generically. Registered extensions can
change at runtime; all parse caches key on the registry.
- `HighlightExtension` (`==text==`) and `StrikethroughExtension` (`~~text~~`),
the former built-ins repackaged as extensions, and `ContainerExtension`
(`::: … :::`), the first fenced block extension.
### Changed
- **Breaking**: `==highlight==` and `~~strikethrough~~` are no longer part of
the core grammar. Unregistered, the syntax stays literal text. To keep the
previous behavior:
`configuration.extensions = [HighlightExtension(), StrikethroughExtension()]`.
The formatting actions (context menu, `applyHighlightRequest` /
`applyStrikethroughRequest`) still insert/remove the markers either way;
construct detection (toggle-off, selection state) requires the extension.
### Fixed
- A pre-existing incremental-parse gap surfaced by the seam review:
backspace-joining two paragraphs could leave transiently wrong styling
(extra spacing or a stray emphasis/code span across the join) until the next
edit re-parsed the region.
## [0.9.0] - 2026-07-13
### Added
- `MarkdownEditorConfiguration.rawSourceMode`: present the document as raw
Markdown source — no syntax hiding, no markdown styling, and no wiki-link
display transform (`[[Name|UUID]]` shows verbatim). The editor keeps base
font/paragraph styling and stays fully editable; smart Markdown input
handling (list continuation, `$$`/`![[` auto-wrap, ⇧⇥ outdent) is disabled
while raw. Runtime switching is supported and rebuilds the document
immediately; the current document's undo stack is dropped on a switch
because undo actions recorded against the other mode's display text would
replay at stale ranges. Default `false` — existing embedders are unaffected.
- Find & replace: two optional bus notifications, `replaceCurrent` (replace the
focused match and advance) and `replaceAll` (replace every match in one undo
step, back-to-front so ranges stay valid). Both edit the engine's displayed
text with proper `shouldChangeText`/`didChangeText` undo registration and
report the remaining count via `findResults`. Purely additive — embedders that
don't set the bus names are unaffected.
- Clean clipboard: ⌘C copies the selection as rich text (RTF + `com.apple.webarchive`)
built from the AST rather than the raw storage form, and paste converts HTML to
Markdown. Wiki-link `[[Name|UUID]]` side-channels no longer leak into copied text.
### Fixed
- Find/jump scroll now works without a reading column. The TextKit 2
fragment-enumeration scroll path (with `.ensuresLayout`) runs universally
instead of only when `readingWidth` was set; the unreliable
`NSTextView.scrollRangeToVisible` (which routes through the absent TextKit 1
layout manager for off-screen content) is now only the last-resort fallback.
- Inline syntax markers (`**`, `*`, `~~`, `==`) now use `mutedText` foreground
color while the caret is inside the corresponding span, matching the existing
behavior of inline code backticks and link/wiki-link brackets. This makes
highlight `==` markers visually distinct from body text in edit state.
- Web links `[text](url)` now share the wiki-link "edit zone": clicking the outer
~30% of the link's first/last visible character places the caret just outside the
markers (before `[` / after `)`) and reveals the source for editing instead of
navigating, matching `[[…]]` behavior. Previously the edit zone only resolved
`.wikiLink` tokens, so a web link dropped the caret between its brackets and did
not reveal. Middle-of-link clicks still navigate; read-only links stay navigable.
- Auto-linking (`NSDataDetector`) no longer linkifies a URL that sits inside a
markdown or wiki link's own range. A link's `(url)` previously got its own
competing `.link` attribute on top of the link — making the raw URL independently
navigable and offsetting the click edit zone. Bare URLs outside links still
autolink; URLs inside code were already excluded.
- Wiki links: UUID-robust labels/embeds and keyboard navigation in the inline
autocomplete list.
### Contributors
- Find/jump scroll fix and find & replace by @ChristineTham
- Inline syntax-marker color fix by @sospartan
- rawSourceMode, clean clipboard, web-link edit zone, and wiki-link robustness by @luca-chen198
## [0.8.0] - 2026-06-28
### Added
- `MarkdownEditorBus.findQuery` / `findResults`: query-based in-document find. The host posts a
search string (+ current index) and the engine matches against its OWN displayed text,
highlighting in display coordinates and posting the match count back. This is correct where the
displayed text differs from the source — e.g. node links rendered shorter than `[[Name|UUID]]`,
LaTeX, or images — which the legacy `findScrollToRange` (host-computed source-coordinate ranges)
highlighted at the wrong offset. Opt-in; `findScrollToRange` is unchanged for existing embedders.
- `NativeTextView.isCursorExcluded: ((CGPoint) -> Bool)?` — embedder-supplied
predicate that suppresses the edit-mode I-beam cursor when the mouse is inside
a defined exclusion zone (e.g. a formatting toolbar). When the closure returns
`true`, `mouseMoved:` skips calling `super.mouseMoved` to avoid NSTextView's
built-in I-beam cursor, setting the arrow cursor instead. Exposed through
`NativeTextViewWrapper.isCursorExcluded`.
- `NativeTextViewWrapper.onBuildContextMenu: ((NSMenu, NSRange) -> NSMenu)?`
embedder hook to build the editor's right-click menu. The engine hands over the
default `NSMenu` + the current selection; the embedder returns the menu to show
(driving the `didMarkdown*` actions through the bus). Keeps the engine UI-free.
- `==highlight==` inline markup: double-equals markers around text apply a
background color (configurable via `MarkdownEditorTheme.highlightColor`,
default `.systemOrange.withAlphaComponent(0.4)`). Content is recursively parsed so nested emphasis,
code, etc. work inside highlights.
- `MarkdownEditorBus.applyHighlightRequest` /
`selectionHighlightDidChange`: bus notification names for driving a
highlight toolbar button from host UI.
- `MarkdownEditorBus` extended with nine new notification types for
formatting toolbar integration: `applyStrikethroughRequest`,
`applyInlineCodeRequest`, `applyBlockquoteRequest`,
`applyUnorderedListRequest`, `applyOrderedListRequest`,
`applyLinkRequest`, `applyCodeBlockRequest`,
`applyHorizontalRuleRequest`, `applyImageRequest`. Embedders wire
these into `NotificationCenter` to trigger formatting from
external UI (toolbars, menus) without reaching into the editor's
view hierarchy.
- New formatting actions on the coordinator (callable directly or
via the bus above): `didMarkdownStrikethrough`, `didMarkdownInlineCode`,
`didMarkdownBlockquote`, `didMarkdownLink`, `didMarkdownCodeBlock`,
`didMarkdownHorizontalRule`, `didMarkdownImage`.
- Word-boundary detection in inline formatting: when the cursor is
placed inside an English word with no active text selection, bold,
italic, strikethrough, and inline-code actions now auto-select the
containing word before wrapping. If no word character is adjacent
to the cursor, empty markers are inserted as before. The cursor's
relative offset within the word is preserved after wrapping
(e.g. `wo|rd``**wo|rd**`).
- Headless test suite for formatting actions (`FormattingActionTests`
— 21 tests covering bold, strikethrough, inline code, blockquote,
link, code block, horizontal rule, and image insertion).
### Changed
- The engine no longer ships a built-in right-click "Format" context menu — menus
are now embedder-supplied via `onBuildContextMenu` (above). The system rich-text
"Font" submenu (Bold/Italic/Show Colors…) is stripped from the default menu, since
those font traits don't apply to Markdown.
### Fixed
- Blockquote removal no longer doubles trailing newlines when the
original line already carries one.
## [0.7.1] - 2026-06-20
### Added
- `MarkdownEditorConfiguration.heightBehavior` (`.scrolls` default / `.fitsContent`):
in `.fitsContent` the editor grows to its content height and reports it to
SwiftUI, so an enclosing `ScrollView` scrolls the page instead of a nested
internal scroller. Opt-in, off by default — no change for existing embedders. (#75)
- `BlockquoteStyle` configuration struct with `extraLineHeight` to control line
spacing inside blockquotes, following the `ListStyle.extraLineHeight` /
`ParagraphStyle.lineHeightExtraSpacing` pattern. Defaults to `0` (no extra
spacing), preserving existing rendering. (#76)
### Fixed
- Mouse-wheel / trackball scrolling no longer clamps back at the bottom past a
stale-small content-height measurement. (#71)
- Inspector clip mask and caret reveal at the document end. (#73)
- Scroll position is remembered per document across switches, and Writing Tools
results stay styled and visible after accept. (#70)
- Empty-file placeholder no longer clips to one line after a view rebuild. (#69)
### Added
- Scroll-away header: `NativeTextViewWrapper` gains `header: AnyView?`,
`headerCollapsedHeight: CGFloat`, and `headerExpanded: Bool`. The engine
hosts the supplied SwiftUI view above the document body, scrolling with
it; collapsing animates the reserved band down to `headerCollapsedHeight`
(the top row stays visible, lower rows clip away). The hosted content
refreshes on every SwiftUI update and stays fully interactive. Composes
with `readingWidth`. See the README's *Scrolling Header* section.
### Changed
- The scroll view's `documentView` is now always an engine-internal
container view (hosting the text view, the optional scroll-away header,
and the reading column's breakout overlays) rather than sometimes the
`NSTextView` itself. Embedders that reached into
`scrollView.documentView` expecting an `NSTextView` must adapt — the
document view's class was never API.
- **Breaking**: The editor's enclosing scroll view no longer applies a
hard-coded `top: 55.4` content inset. The default is now `0` on every
edge, matching the most common embedding case where the editor fills
its container exactly. Embedders that previously relied on the engine
reserving header space (e.g. for a translucent toolbar) must opt in
explicitly:
```swift
var config = MarkdownEditorConfiguration.default
config.safeAreaInsets = SafeAreaInsets(top: 55.4)
```
### Added
- `SafeAreaInsets` struct exposing `top` / `leading` / `trailing` / `bottom`
inset knobs for the editor's enclosing scroll view, configurable via
`MarkdownEditorConfiguration.safeAreaInsets`.
- `MarkdownASTStyler` now stamps `.spellingState: 0` on fenced code blocks
and inline `` `code` `` spans, completing the engine's existing
spell-check suppression convention (links, wiki-links, LaTeX, and tables
already carry the same attribute). The system spell-checker no longer
underlines tokens inside code regions even when continuous spell
checking is enabled.
### Fixed
- Undo is now kept per `documentId`, so Cmd+Z keeps working after switching
files. The single reused `NSTextView` previously wiped its undo manager on
every document switch; the editor now vends a per-document `UndoManager`
(via the new `undoManager(for:)` delegate method) whose undo/redo stack
survives switching away and back. (#77)
- A document's surviving undo stack is dropped when its text is reloaded
*changed* while it was switched away (e.g. renaming a node rewrites the
`[[label]]` in every file that links it), so Cmd+Z can no longer replay
stale ranges against the rewritten content. (#78)
- `NativeTextViewWrapper` keeps links clickable and text selectable
when `isEditable: false`; `isSelectable` is no longer coupled to
`isEditable`. (#31)
- `NativeTextViewWrapper` now applies its initial styling pass even when
the bound text starts at its final value (e.g. supplied as a SwiftUI
`@State` initializer). Previously the editor would render the raw
Markdown source until the user clicked into the document, because the
coordinator's `lastSyncedText` already matched the bound text at first
`updateNSView`. The early-return now also requires `didInitialFormatting`
to be true, which only flips after the first styling pass completes.
### Added
- Initial public API surface:
- `NativeTextViewWrapper` — SwiftUI bridge for the AppKit-backed editor
- `MarkdownEditorConfiguration` — every spacing / sizing / behavior knob
- `MarkdownEditorTheme` — color palette, defaults to system colors
- `MarkdownEditorServices` — container for the four service protocols
- Service protocols: `WikiLinkResolver`, `EmbeddedImageProvider`,
`SyntaxHighlighter`, `LatexRenderer`
- No-op default implementations: `NoOpWikiLinkResolver`,
`NoOpEmbeddedImageProvider`, `PlainTextSyntaxHighlighter`,
`NoOpLatexRenderer`
- `WikiLinkService` — bidirectional storage / display roundtrip helper
- `PasteboardImageReader` — pasteboard image inspection helpers
- Selection / replacement value types: `WikiLinkSelection`,
`InlineSelectionState`, `InlineReplacementRequest`, `CodeBlockSelection`
- `CodeBlockButton` — drop-in copy button overlay
- DocC documentation catalog with landing page and topic groups
- Triple-slash documentation comments on the full public API surface
[Unreleased]: https://github.com/nodes-app/swift-markdown-engine/compare/0.7.1...HEAD
[0.7.1]: https://github.com/nodes-app/swift-markdown-engine/compare/0.7.0...0.7.1
+92
View File
@@ -0,0 +1,92 @@
# Contributing to MarkdownEngine
Thanks for your interest. **MarkdownEngine is maintained by one person —
expect 12 weeks for review.** A pull request is the normal way in, for
fixes, documentation and new extensions alike. If a change is large or
architectural, open it as a draft PR with the design sketched in the
description — that gets you an answer faster than describing it in prose.
> **New here?** Start with [ARCHITECTURE.md](ARCHITECTURE.md) — a
> codemap that walks each directory in the order text flows through
> the engine.
## Development setup
```bash
git clone https://github.com/nodes-app/swift-markdown-engine.git
cd swift-markdown-engine
swift build
swift test
```
Open `Package.swift` in Xcode for a graphical environment. The runnable
demo is in `Demo/MarkdownEngineDemo.xcodeproj` — open and **Run** to see
your changes against a real app target.
### Local DocC preview
Temporarily add the [swift-docc-plugin](https://github.com/swiftlang/swift-docc-plugin)
to `Package.swift`, then `swift package --disable-sandbox preview-documentation
--target MarkdownEngine`. It's intentionally not a permanent dependency — the
core product stays free of optional tooling.
## Reporting bugs
Include:
- A minimal reproducer (the smallest Markdown input + code that triggers
it)
- macOS, Xcode, and Swift versions
- Expected vs. actual behavior
Screen recordings welcome.
## Pull requests
- One logical change per PR, branched from `main`
- Tests for new tokenizer / styler / service / extension behavior in
`Tests/MarkdownEngineTests/`
- DocC comments for any public-API change; update `Demo/` if relevant
- One-line entry in `CHANGELOG.md` under `[Unreleased]`
- `swift build` and `swift test` must be green; CI runs the same checks
## Design constraints
Non-negotiable for the core `MarkdownEngine` target:
- **Don't add external dependencies to the core `MarkdownEngine`
target.** App-specific behaviors plug in through the four service
protocols (`WikiLinkResolver`, `EmbeddedImageProvider`,
`SyntaxHighlighter`, `LatexRenderer`) instead. The two existing
bridge products (`MarkdownEngineCodeBlocks` → HighlighterSwift,
`MarkdownEngineLatex` → SwiftMath) are the deliberate exception so
consumers can opt in. A new bridge or a new core dependency is a bigger
call — make the case in the PR description.
- **New constructs are extensions, not core grammar.** A construct like
`==highlight==` (inline) or a `::: … :::` fenced block belongs in
`Sources/MarkdownEngine/Extensions/` as a `MarkdownExtension` — see
`HighlightExtension` / `ContainerExtension` as templates — never a new case
threaded through the parser, styler, and renderer. This keeps the core pure
markdown and each construct isolated. Image/overlay-rendered constructs
(tables, math) are the exception — they still need core work.
- **Public surface stays small.** Favor `internal`; new public symbols
need a DocC comment.
## Commit messages
Imperative subject, blank line, then a paragraph explaining *why*:
```
Tokenize escaped backticks inside fenced code blocks
The previous tokenizer treated `\`` inside ``` … ``` as a token
delimiter, which broke any code block containing escaped backtick
examples. The new behavior matches CommonMark.
```
The "what" is in the diff.
## License
By contributing, you agree that your contributions are licensed under
the [Apache 2.0 License](LICENSE).
@@ -0,0 +1,364 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
1A2B3C0000000000000004BB /* MarkdownEngineDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */; };
1A2B3C0000000000000005BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000005AA /* ContentView.swift */; };
1A2B3C0000000000000006BB /* MDE.icon in Resources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000006AA /* MDE.icon */; };
1A2B3C0000000000000007CC /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C0000000000000007BB /* MarkdownEngine */; };
1A2B3C0000000000000008BB /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C0000000000000008AA /* MarkdownEngineCodeBlocks */; };
1A2B3C0000000000000009BB /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C0000000000000009AA /* MarkdownEngineLatex */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
1A2B3C0000000000000003DD /* MarkdownEngineDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MarkdownEngineDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEngineDemoApp.swift; sourceTree = "<group>"; };
1A2B3C0000000000000005AA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
1A2B3C0000000000000006AA /* MDE.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = MDE.icon; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
1A2B3C0000000000000003BB /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
1A2B3C0000000000000007CC /* MarkdownEngine in Frameworks */,
1A2B3C0000000000000008BB /* MarkdownEngineCodeBlocks in Frameworks */,
1A2B3C0000000000000009BB /* MarkdownEngineLatex in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1A2B3C0000000000000001BB = {
isa = PBXGroup;
children = (
1A2B3C0000000000000001DD /* MarkdownEngineDemo */,
1A2B3C0000000000000001CC /* Products */,
);
sourceTree = "<group>";
};
1A2B3C0000000000000001CC /* Products */ = {
isa = PBXGroup;
children = (
1A2B3C0000000000000003DD /* MarkdownEngineDemo.app */,
);
name = Products;
sourceTree = "<group>";
};
1A2B3C0000000000000001DD /* MarkdownEngineDemo */ = {
isa = PBXGroup;
children = (
1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */,
1A2B3C0000000000000005AA /* ContentView.swift */,
1A2B3C0000000000000006AA /* MDE.icon */,
);
path = MarkdownEngineDemo;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1A2B3C0000000000000001EE /* MarkdownEngineDemo */ = {
isa = PBXNativeTarget;
buildConfigurationList = 1A2B3C0000000000000001FF /* Build configuration list for PBXNativeTarget "MarkdownEngineDemo" */;
buildPhases = (
1A2B3C0000000000000003AA /* Sources */,
1A2B3C0000000000000003BB /* Frameworks */,
1A2B3C0000000000000003CC /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = MarkdownEngineDemo;
packageProductDependencies = (
1A2B3C0000000000000007BB /* MarkdownEngine */,
1A2B3C0000000000000008AA /* MarkdownEngineCodeBlocks */,
1A2B3C0000000000000009AA /* MarkdownEngineLatex */,
);
productName = MarkdownEngineDemo;
productReference = 1A2B3C0000000000000003DD /* MarkdownEngineDemo.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
1A2B3C0000000000000001AA /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
1A2B3C0000000000000001EE = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = 1A2B3C0000000000000002AA /* Build configuration list for PBXProject "MarkdownEngineDemo" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 1A2B3C0000000000000001BB;
packageReferences = (
1A2B3C0000000000000007AA /* XCLocalSwiftPackageReference ".." */,
);
productRefGroup = 1A2B3C0000000000000001CC /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
1A2B3C0000000000000001EE /* MarkdownEngineDemo */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
1A2B3C0000000000000003CC /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1A2B3C0000000000000006BB /* MDE.icon in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1A2B3C0000000000000003AA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1A2B3C0000000000000004BB /* MarkdownEngineDemoApp.swift in Sources */,
1A2B3C0000000000000005BB /* ContentView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
1A2B3C0000000000000002BB /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = MDE;
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "MarkdownEngine Demo";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.nodes-app.MarkdownEngineDemo";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
1A2B3C0000000000000002CC /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = MDE;
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "MarkdownEngine Demo";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.nodes-app.MarkdownEngineDemo";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
1A2B3C0000000000000002DD /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
1A2B3C0000000000000002EE /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
1A2B3C0000000000000001FF /* Build configuration list for PBXNativeTarget "MarkdownEngineDemo" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1A2B3C0000000000000002BB /* Debug */,
1A2B3C0000000000000002CC /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
1A2B3C0000000000000002AA /* Build configuration list for PBXProject "MarkdownEngineDemo" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1A2B3C0000000000000002DD /* Debug */,
1A2B3C0000000000000002EE /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
1A2B3C0000000000000007AA /* XCLocalSwiftPackageReference ".." */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ..;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
1A2B3C0000000000000007BB /* MarkdownEngine */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngine;
};
1A2B3C0000000000000008AA /* MarkdownEngineCodeBlocks */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngineCodeBlocks;
};
1A2B3C0000000000000009AA /* MarkdownEngineLatex */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngineLatex;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 1A2B3C0000000000000001AA /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1A2B3C0000000000000001EE"
BuildableName = "MarkdownEngineDemo.app"
BlueprintName = "MarkdownEngineDemo"
ReferencedContainer = "container:MarkdownEngineDemo.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1A2B3C0000000000000001EE"
BuildableName = "MarkdownEngineDemo.app"
BlueprintName = "MarkdownEngineDemo"
ReferencedContainer = "container:MarkdownEngineDemo.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1A2B3C0000000000000001EE"
BuildableName = "MarkdownEngineDemo.app"
BlueprintName = "MarkdownEngineDemo"
ReferencedContainer = "container:MarkdownEngineDemo.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,351 @@
//
// ContentView.swift
// MarkdownEngine
//
// Created by Nicolas von Mallinckrodt on 29.04.26.
//
import SwiftUI
import MarkdownEngine
// Optional bridge products. Each is independent drop either of these
// `#if` blocks (or remove the matching Swift Package product dependency
// from the Xcode project) and the demo still compiles. Code blocks fall
// back to plain monospace; LaTeX falls back to its raw `$$` source.
#if canImport(MarkdownEngineCodeBlocks)
import MarkdownEngineCodeBlocks
#endif
#if canImport(MarkdownEngineLatex)
import MarkdownEngineLatex
#endif
struct ContentView: View {
@State private var text: String = sampleMarkdown
// Engine modes, flipped live from the toolbar.
@State private var isReadOnly = false
@State private var showRawSource = false
@State private var useReadingColumn = false
// Base font size; all relative sizing (headings, code, math) tracks it.
@State private var fontSize: CGFloat = 16
// Scroll-away header demo.
@State private var showHeader = false
@State private var headerExpanded = true
var body: some View {
NativeTextViewWrapper(
text: $text,
configuration: configuration,
fontSize: fontSize,
isEditable: !isReadOnly,
placeholder: NSAttributedString(
string: "Empty document — start typing, markdown styles live…",
attributes: [
.font: NSFont.systemFont(ofSize: fontSize),
.foregroundColor: NSColor.secondaryLabelColor,
]
),
header: showHeader ? AnyView(demoHeader) : nil,
headerCollapsedHeight: 40,
headerExpanded: headerExpanded
)
// `readingWidth` is applied when the underlying NSView is built, so
// flipping the reading column recreates the editor via `.id`. The
// `text` binding survives; scroll position resets fine for a demo.
.id(useReadingColumn)
.toolbar {
ToolbarItemGroup {
Toggle(isOn: $isReadOnly) {
Label("Read-only", systemImage: isReadOnly ? "lock" : "lock.open")
}
.help("Read-only: the styled document stays scrollable and selectable, editing is off")
Toggle(isOn: $showRawSource) {
Label("Raw source", systemImage: "chevron.left.forwardslash.chevron.right")
}
.help("Raw markdown source: no styling, no syntax hiding")
Toggle(isOn: $useReadingColumn) {
Label("Reading column", systemImage: "arrow.right.and.line.vertical.and.arrow.left")
}
.help("Centered fixed-width reading column — wide tables still break out to full width")
ControlGroup {
Button {
fontSize = max(10, fontSize - 2)
} label: {
Label("Smaller text", systemImage: "textformat.size.smaller")
}
.disabled(fontSize <= 10)
Button {
fontSize = min(28, fontSize + 2)
} label: {
Label("Larger text", systemImage: "textformat.size.larger")
}
.disabled(fontSize >= 28)
}
.help("Base font size — headings, code, and math scale relative to it")
Menu {
Toggle("Show header", isOn: $showHeader)
Toggle("Expanded", isOn: $headerExpanded)
.disabled(!showHeader)
} label: {
Label("Header", systemImage: "rectangle.topthird.inset.filled")
}
.help("Scroll-away header: an embedder-supplied SwiftUI view hosted above the document")
}
}
}
/// Sample scroll-away header: a fixed top row (kept visible when collapsed)
/// plus detail rows that reveal/hide with the `headerExpanded` toggle.
private var demoHeader: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Scroll-away header").font(.headline)
Spacer()
}
.frame(height: 40) // == headerCollapsedHeight: the always-visible row
VStack(alignment: .leading, spacing: 6) {
Text("These rows clip away when the header collapses.")
Text("The header scrolls with the document body and stays fully interactive.")
.foregroundStyle(.secondary)
}
.font(.callout)
.padding(.bottom, 12)
}
.padding(.horizontal, 16)
}
/// The engine talks to your app through service protocols. Two of them
/// `SyntaxHighlighter` and `LatexRenderer` render the code-block and
/// LaTeX visuals. The base `MarkdownEngine` ships no-op defaults
/// (plain monospace, raw `$$`); the optional `MarkdownEngineCodeBlocks`
/// and `MarkdownEngineLatex` products ship ready-made bridges backed by
/// HighlighterSwift and SwiftMath respectively.
///
/// This demo opportunistically plugs in whichever bridges are linked,
/// so you can see exactly what each one adds.
private var configuration: MarkdownEditorConfiguration {
var config = MarkdownEditorConfiguration.default
#if canImport(MarkdownEngineCodeBlocks)
// Syntax highlighting for fenced code blocks. Auto-switches between
// `atom-one-light` and `atom-one-dark` with system appearance.
config.services.syntaxHighlighter = HighlighterSwiftBridge()
#endif
#if canImport(MarkdownEngineLatex)
// LaTeX rendering for `$inline$` and `$$block$$` math. Uses the
// Latin Modern math font and tints formulas to match the theme.
config.services.latex = SwiftMathBridge()
#endif
// Opt-in constructs beyond pure markdown. The core engine no longer
// knows `==highlight==` or `~~strikethrough~~` they are extensions
// you register. Unregistered syntax stays literal text.
config.extensions = [HighlightExtension(), StrikethroughExtension()]
// Toolbar-driven modes.
config.rawSourceMode = showRawSource
config.readingWidth = useReadingColumn ? 620 : nil
return config
}
}
/// Builds the demo markdown shown when the editor first loads.
///
/// The text is composed from a fixed header/footer plus feature sections.
/// Three of them inline formatting, block math, and code swap between
/// a full showcase and a short "feature unavailable" note depending on
/// which optional bridge products are linked.
///
/// When a bridge is missing, the fallback links to the README section
/// that explains how to enable that feature in your own app.
private var sampleMarkdown: String {
[
markdownHeader,
inlineFormattingSection,
blocksSection,
taskListSection,
extensionSection,
tableSection,
latexSection,
codeSection,
markdownFooter,
].joined(separator: "\n\n")
}
/// Blockquote + list demo: quotes keep inline styling; lists auto-continue
/// on Return, renumber, and change nesting with Tab / Shift-Tab.
private let blocksSection = """
## Blockquotes & lists
> Blockquotes keep full **inline** styling and quote markers hide like every other marker.
Lists auto-continue on Return; Tab and Shift-Tab move the nesting level:
- Unordered lists
- nest two spaces per level
- up to three levels deep
1. Ordered lists renumber as you edit
2. and auto-continue too
"""
/// Task-list demo: click a checkbox to toggle it. The glyphs are SF Symbols;
/// embedders can swap them via `TaskCheckboxStyle` (`config.taskCheckbox`).
private let taskListSection = """
## Task lists
- [x] Draw checkboxes as SF Symbols
- [ ] Click a box to toggle it
- [ ] Ship it
"""
/// Extension seam demo: `==highlight==` and `~~strikethrough~~` are NOT part
/// of the core grammar anymore they're supplied by the opt-in
/// `HighlightExtension` and `StrikethroughExtension` registered above.
private let extensionSection = """
## Extensions
The engine core parses pure markdown; extra constructs are opt-in extensions. \
This ==highlighted text== comes from `HighlightExtension`, and this \
~~struck-through text~~ from `StrikethroughExtension`. Unregistered, the exact \
same characters would stay literal markdown. Nesting works too: \
==with *italic* inside== and ~~also *nested*~~.
"""
/// Table layout demo: the first table's cells WRAP to the available width
/// (CSS auto-layout style); the second has so many columns that even the
/// longest-word minimums don't fit it stays wide and scrolls horizontally.
private let tableSection = """
## Tables
Cells wrap to the available width:
| Novel | Opening line |
|---|---|
| Der Zauberberg (1924) | "Ein einfacher junger Mensch reiste im Hochsommer von Hamburg, seiner Vaterstadt, nach Davos-Platz im Graubündischen." |
| The Master and Margarita (196667) | "At the sunset hour of one warm spring day two men were to be seen at Patriarch's Ponds." (trans. Michael Glenny) |
| The Picture of Dorian Gray (1890) | "The studio was filled with the rich odour of roses, and when the light summer wind stirred amidst the trees of the garden, there came through the open door the heavy scent of the lilac, or the more delicate perfume of the pink-flowering thorn." |
Too many columns horizontal scroll instead of crushed cells:
| Movement | Landmark novel | Narrative signature | Characteristic preoccupations | Philosophical undercurrents | Contemporaneous reception | Posthumous reputation | Author | Structural device | Central symbol | Typical setting | Enduring influence |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Modernism | Der Zauberberg | Essayistic time-dilation | Sanatorium cosmopolitanism | Schopenhauer-inflected pessimism | Immediate bestseller | Cornerstone of literary modernism | Thomas Mann | Bildungsroman inversion | The mountain as timeless enclosure | Alpine sanatorium | Shaped the European novel of ideas |
| Menippean satire | The Master and Margarita | Novel-within-a-novel | Cowardice and censorship | Faustian epigraph | Suppressed, samizdat-circulated | Perennial Russian favorite | Mikhail Bulgakov | Interleaved dual narratives | The devil as satirical mirror | Soviet Moscow and biblical Jerusalem | Model for satire under censorship |
| Aestheticism | The Picture of Dorian Gray | Epigrammatic wit | Portrait-as-conscience | Paterian hedonism | Scandalized reviewers | Perpetually adapted | Oscar Wilde | Portrait as moral ledger | The aging portrait | Fin de siècle London | Touchstone for art for arts sake |
"""
private let markdownHeader = """
# MarkdownEngine
A native macOS Markdown editor built on **TextKit 2**, bridged to SwiftUI brought to you by [nodes-web.com](https://nodes-web.com).
Edit this text live. Formatting updates as you type and the toolbar flips engine modes at runtime: read-only, raw markdown source, and a centered reading column.
---
"""
/// Inline formatting demo. Drops the inline-LaTeX example sentence when
/// the LaTeX bridge isn't linked, so the reader doesn't see raw `$$`.
private var inlineFormattingSection: String {
#if canImport(MarkdownEngineLatex)
return #"""
## Inline formatting
Mix **bold**, *italic*, and ***both at once***. Reach for `inline code` when a short snippet helps. Inline math fits naturally in prose the Pythagorean identity says $a^2 + b^2 = c^2$, and Euler's identity famously claims $e^{i\pi} + 1 = 0$.
"""#
#else
return """
## Inline formatting
Mix **bold**, *italic*, and ***both at once***. Reach for `inline code` when a short snippet helps.
"""
#endif
}
/// Block LaTeX demo when the `MarkdownEngineLatex` bridge is linked;
/// otherwise a short note pointing to the README section that explains
/// how to enable LaTeX rendering.
private var latexSection: String {
#if canImport(MarkdownEngineLatex)
return #"""
## Block math
$$
\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
$$
$$
\frac{\partial}{\partial t}\Psi(\mathbf{r}, t) = -\frac{i}{\hbar}\hat{H}\,\Psi(\mathbf{r}, t)
$$
"""#
#else
return """
## LaTeX
LaTeX (`$inline$` and `$$block$$`) is parsed but not rendered without the optional `MarkdownEngineLatex` product. See [LaTeX Rendering](https://github.com/nodes-app/swift-markdown-engine#latex-rendering) in the README to wire it up.
"""
#endif
}
/// Fenced code-block demo when the `MarkdownEngineCodeBlocks` bridge is
/// linked; otherwise a plain monospace example and a link to the
/// README's Code Blocks section.
private var codeSection: String {
#if canImport(MarkdownEngineCodeBlocks)
return #"""
## Code
Swift, with syntax highlighting:
```swift
import SwiftUI
import MarkdownEngine
struct Editor: View {
@State private var text = "# Hello"
var body: some View {
NativeTextViewWrapper(text: $text)
.frame(minWidth: 640, minHeight: 480)
}
}
```
And a little JSON:
```json
{
"engine": "MarkdownEngine",
"features": ["latex", "code", "wiki-links"],
"version": 1.0
}
```
"""#
#else
return #"""
## Code
Fenced code blocks render as plain monospace without the optional `MarkdownEngineCodeBlocks` product. See [Code Blocks](https://github.com/nodes-app/swift-markdown-engine#code-blocks) in the README for syntax-highlighted output:
```swift
let greeting = "Hello, world!"
```
"""#
#endif
}
private let markdownFooter = """
---
"""
@@ -0,0 +1,10 @@
<svg width="173" height="185" viewBox="0 0 173 185" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1869_2042)">
<path d="M84.1452 185H85.3003C97.6509 185 102.36 181.114 102.36 168.928V109.41L90.8093 115.68L142.878 145.616C146.788 147.646 149.897 148.971 152.741 148.971C157.628 148.971 161.004 145.616 165.091 139.523L165.714 138.552C168.38 134.577 169.712 130.957 169.712 127.955C169.712 123.009 166.247 119.301 160.116 115.945L107.07 85.4796V98.1072L160.027 69.0549C166.247 65.7875 169.445 62.167 169.445 57.2219C169.445 54.2195 168.113 50.7756 165.891 46.8019L165.27 45.6539C161.271 39.2076 157.628 35.9403 152.83 35.9403C149.987 35.9403 146.698 37.2649 142.79 39.2959L90.8981 69.2315L102.36 75.5011V16.0716C102.36 3.88544 97.6509 0 85.3003 0H84.1452C71.7944 0 67.0852 3.88544 67.0852 16.0716V75.5011L78.814 69.2315L26.7452 39.2959C22.8356 37.0883 19.1926 35.6754 16.1715 35.6754C11.2845 35.6754 7.28607 39.031 4.17616 45.8305L3.64304 46.8019C1.68824 50.8639 0.444272 54.3961 0.444272 57.3986C0.444272 62.0787 3.37647 65.6992 9.59631 69.1431L62.6425 98.1072V85.4796L9.50741 115.945C3.37647 119.301 0 122.922 0 127.778C0 130.78 1.42167 134.401 3.73189 138.463L4.26502 139.258C7.9969 145.704 11.5511 149.06 16.6158 149.06C19.4591 149.06 22.8356 147.646 26.6564 145.616L78.814 115.68L67.0852 109.41V168.928C67.0852 181.114 71.7944 185 84.1452 185Z" fill="#E0A347"/>
</g>
<defs>
<clipPath id="clip0_1869_2042">
<rect width="173" height="185" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,10 @@
<svg width="173" height="185" viewBox="0 0 173 185" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1869_2051)">
<path d="M84.1452 185H85.3003C97.6509 185 102.36 181.114 102.36 168.928V109.41L90.8093 115.68L142.878 145.616C146.788 147.646 149.897 148.971 152.741 148.971C157.628 148.971 161.004 145.616 165.091 139.523L165.714 138.552C168.38 134.577 169.712 130.957 169.712 127.955C169.712 123.009 166.247 119.301 160.116 115.945L107.07 85.4796V98.1072L160.027 69.0549C166.247 65.7875 169.445 62.167 169.445 57.2219C169.445 54.2195 168.113 50.7756 165.891 46.8019L165.27 45.6539C161.271 39.2076 157.628 35.9403 152.83 35.9403C149.987 35.9403 146.698 37.2649 142.79 39.2959L90.8981 69.2315L102.36 75.5011V16.0716C102.36 3.88544 97.6509 0 85.3003 0H84.1452C71.7944 0 67.0852 3.88544 67.0852 16.0716V75.5011L78.814 69.2315L26.7452 39.2959C22.8356 37.0883 19.1926 35.6754 16.1715 35.6754C11.2845 35.6754 7.28607 39.031 4.17616 45.8305L3.64304 46.8019C1.68824 50.8639 0.444272 54.3961 0.444272 57.3986C0.444272 62.0787 3.37647 65.6992 9.59631 69.1431L62.6425 98.1072V85.4796L9.50741 115.945C3.37647 119.301 0 122.922 0 127.778C0 130.78 1.42167 134.401 3.73189 138.463L4.26502 139.258C7.9969 145.704 11.5511 149.06 16.6158 149.06C19.4591 149.06 22.8356 147.646 26.6564 145.616L78.814 115.68L67.0852 109.41V168.928C67.0852 181.114 71.7944 185 84.1452 185Z" fill="#D1403F"/>
</g>
<defs>
<clipPath id="clip0_1869_2051">
<rect width="173" height="185" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,10 @@
<svg width="173" height="185" viewBox="0 0 173 185" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1869_2047)">
<path d="M84.1452 185H85.3003C97.6509 185 102.36 181.114 102.36 168.928V109.41L90.8093 115.68L142.878 145.616C146.788 147.646 149.897 148.971 152.741 148.971C157.628 148.971 161.004 145.616 165.091 139.523L165.714 138.552C168.38 134.577 169.712 130.957 169.712 127.955C169.712 123.009 166.247 119.301 160.116 115.945L107.07 85.4796V98.1072L160.027 69.0549C166.247 65.7875 169.445 62.167 169.445 57.2219C169.445 54.2195 168.113 50.7756 165.891 46.8019L165.27 45.6539C161.271 39.2076 157.628 35.9403 152.83 35.9403C149.987 35.9403 146.698 37.2649 142.79 39.2959L90.8981 69.2315L102.36 75.5011V16.0716C102.36 3.88544 97.6509 0 85.3003 0H84.1452C71.7944 0 67.0852 3.88544 67.0852 16.0716V75.5011L78.814 69.2315L26.7452 39.2959C22.8356 37.0883 19.1926 35.6754 16.1715 35.6754C11.2845 35.6754 7.28607 39.031 4.17616 45.8305L3.64304 46.8019C1.68824 50.8639 0.444272 54.3961 0.444272 57.3986C0.444272 62.0787 3.37647 65.6992 9.59631 69.1431L62.6425 98.1072V85.4796L9.50741 115.945C3.37647 119.301 0 122.922 0 127.778C0 130.78 1.42167 134.401 3.73189 138.463L4.26502 139.258C7.9969 145.704 11.5511 149.06 16.6158 149.06C19.4591 149.06 22.8356 147.646 26.6564 145.616L78.814 115.68L67.0852 109.41V168.928C67.0852 181.114 71.7944 185 84.1452 185Z" fill="#056CC1"/>
</g>
<defs>
<clipPath id="clip0_1869_2047">
<rect width="173" height="185" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,62 @@
{
"fill" : "automatic",
"groups" : [
{
"blend-mode" : "normal",
"blur-material" : 0.5,
"hidden" : false,
"layers" : [
{
"fill" : "automatic",
"image-name" : "staroflife.fill 2-1.svg",
"name" : "staroflife.fill 2-1",
"position" : {
"scale" : 3,
"translation-in-points" : [
0,
90
]
}
},
{
"image-name" : "staroflife.fill 2.svg",
"name" : "staroflife.fill 2",
"position" : {
"scale" : 3,
"translation-in-points" : [
0,
0
]
}
},
{
"image-name" : "staroflife.fill 1.svg",
"name" : "staroflife.fill 1",
"position" : {
"scale" : 3,
"translation-in-points" : [
0,
-90
]
}
}
],
"lighting" : "individual",
"shadow" : {
"kind" : "layer-color",
"opacity" : 0.5
},
"specular" : false,
"translucency" : {
"enabled" : true,
"value" : 0.45
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
@@ -0,0 +1,18 @@
//
// MarkdownEngineDemoApp.swift
// MarkdownEngine
//
// Created by Nicolas von Mallinckrodt on 29.04.26.
//
import SwiftUI
@main
struct MarkdownEngineDemoApp: App {
var body: some Scene {
WindowGroup("MarkdownEngine Demo") {
ContentView()
.frame(minWidth: 640, minHeight: 480)
}
}
}
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2026] [Luca Chen]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+51
View File
@@ -0,0 +1,51 @@
// swift-tools-version: 5.9
import PackageDescription
// MarkdownEngine a TextKit-2 backed Markdown editor view for macOS.
//
// Embedders import `MarkdownEngine` and supply their own adapters that
// conform to the engine's service protocols (`WikiLinkResolver`,
// `EmbeddedImageProvider`, `SyntaxHighlighter`, `LatexRenderer`). The engine
// itself has zero external dependencies.
//
// Users who want turnkey adapters for the two highest-friction protocols
// (code-block styling/highlighting, LaTeX rendering) can additionally
// depend on the `MarkdownEngineCodeBlocks` and/or `MarkdownEngineLatex`
// products, which ship pre-built bridges backed by HighlighterSwift and
// SwiftMath respectively. Both products are opt-in: the core
// `MarkdownEngine` library stays free of those transitive dependencies
// at link time.
let package = Package(
name: "MarkdownEngine",
platforms: [.macOS(.v14)],
products: [
.library(name: "MarkdownEngine", targets: ["MarkdownEngine"]),
.library(name: "MarkdownEngineCodeBlocks", targets: ["MarkdownEngineCodeBlocks"]),
.library(name: "MarkdownEngineLatex", targets: ["MarkdownEngineLatex"]),
],
dependencies: [
.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.0.0"),
.package(url: "https://github.com/mgriebling/SwiftMath", from: "1.7.0"),
],
targets: [
.target(name: "MarkdownEngine"),
.target(
name: "MarkdownEngineCodeBlocks",
dependencies: [
"MarkdownEngine",
.product(name: "Highlighter", package: "HighlighterSwift"),
]
),
.target(
name: "MarkdownEngineLatex",
dependencies: [
"MarkdownEngine",
.product(name: "SwiftMath", package: "SwiftMath"),
]
),
.testTarget(
name: "MarkdownEngineTests",
dependencies: ["MarkdownEngine"]
)
]
)
+350
View File
@@ -0,0 +1,350 @@
<p align="center">
<img width="128" alt="SwiftMarkdownEngine logo" src="media/logo.png" />
</p>
<h1 align="center">SwiftMarkdownEngine</h1>
<p align="center">
<a href="https://swift.org"><img src="https://img.shields.io/badge/Swift-5.9+-F05138?logo=swift&logoColor=white" alt="Swift 5.9+" /></a>
<a href="https://developer.apple.com/macos/"><img src="https://img.shields.io/badge/Platforms-macOS%2014+-lightgrey" alt="Platforms macOS 14+" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-yellow.svg" alt="License: Apache 2.0" /></a>
<a href="https://github.com/nodes-app/swift-markdown-engine/actions/workflows/ci.yml"><img src="https://github.com/nodes-app/swift-markdown-engine/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
</p>
<video src="https://github.com/user-attachments/assets/b61ed622-0e9a-4e91-9de5-9cd6c53752e5"
autoplay loop muted playsinline
width="100%">
</video>
A native AppKit Markdown editor for macOS, built on TextKit 2 and bridged to SwiftUI. It is the editor inside **[Nodes](https://apps.apple.com/app/apple-store/id6745401961?pt=127809373&ct=github&mt=8)**, a macOS notes app. Live styling, wiki-link support, fenced code blocks with syntax highlighting, LaTeX rendering, embedded images, and GitHub-style task
checkboxes.
## Features
- **Live Markdown styling** — bold, italic, headings, lists, blockquotes, GFM tables, code, links, task checkboxes, horizontal rules
- **Extensions** — opt-in constructs beyond CommonMark (`==highlight==`, `~~strikethrough~~`, …); add your own via [`MarkdownExtension`](#extensions)
- **Wiki-style linking** with two-form storage / display roundtripping
(`[[Name|<id>]]``[[Name]]`)
- **Image embeds** — both `![[Name]]` (Obsidian-style, embedder supplies the
bytes) and standard Markdown `![alt](url)`
- **LaTeX** — both block (`$$ … $$`) and inline (`$…$`), embedder supplies
the renderer
- **Code blocks** with embedder-supplied syntax highlighting and overlayable
copy buttons
- **Reading column** — opt-in fixed-width centered column, wide tables
break out to the full window width (`readingWidth`)
- **Scroll-away header** — host your own SwiftUI view above the document;
it scrolls with the content and collapses to a pinned top row
- **TextKit 2** layout for accurate, modern text rendering
- **Writing Tools** integration on macOS 15.1+
- **Comfortable bottom overscroll** so the caret never pins to the viewport
edge while typing
- **Drag-select autoscroll boost** for long documents
- **Spelling & grammar** with code/LaTeX/wiki-link suppression
## Installation
```swift
dependencies: [
.package(url: "https://github.com/nodes-app/swift-markdown-engine", from: "0.1.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "MarkdownEngine", package: "swift-markdown-engine"),
]
)
]
```
Or in Xcode: **File → Add Package Dependencies…** and paste the repo URL.
The package ships three library products — add only what you need:
| Product | Use when |
|---|---|
| `MarkdownEngine` | You want the editor only. Zero external dependencies. |
| `MarkdownEngineCodeBlocks` | You want the full visual code-block experience — background fill, monospace font, and syntax highlighting — without writing your own bridge. Pulls in [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) transitively. See [Customization → Code Blocks](#code-blocks). |
| `MarkdownEngineLatex` | You want LaTeX formula rendering without writing your own bridge. Pulls in [SwiftMath](https://github.com/mgriebling/SwiftMath) transitively. See [Customization → LaTeX Rendering](#latex-rendering). |
## Quick Start
```swift
import SwiftUI
import MarkdownEngine
struct EditorScreen: View {
@State private var text: String = "# Hello, *world*"
var body: some View {
NativeTextViewWrapper(text: $text)
}
}
```
That's it. See [Customization](#customization) below for syntax
highlighting, themes, wiki-link state, and more.
> **Displaying multiple editors?** Pass a stable, unique
> `documentId: "your-doc-id"` so undo history and pending replacements
> stay scoped to each editor instance.
## Customization
### Service Protocols
The engine talks to your app through four service protocols, each with
a no-op default so you only implement what you actually need:
| Protocol | What you supply | Ready-made bridge / suggested library |
|---|---|---|
| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | (your data model) |
| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | (your asset store) |
| `SyntaxHighlighter` | Highlight code blocks for a given language | **`HighlighterSwiftBridge`** ([recommended](#code-blocks)) — built on [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) |
| `LatexRenderer` | Render a LaTeX string to an `NSImage` | **`SwiftMathBridge`** ([recommended](#latex-rendering)) — built on [SwiftMath](https://github.com/mgriebling/SwiftMath) |
Implement what you need and pass it through `MarkdownEditorServices`:
```swift
struct MyResolver: WikiLinkResolver {
func resolve(displayName: String, range: NSRange) -> WikiLinkResolution? {
myIndex[displayName].map { WikiLinkResolution(id: $0, exists: true) }
}
}
configuration.services = MarkdownEditorServices(
wikiLinks: MyResolver()
// images, syntaxHighlighter, latex omitted → no-op defaults
)
```
Each protocol and its no-op default are documented in DocC.
### Extensions
The core engine parses pure markdown. Extra constructs like `==highlight==`,
`~~strikethrough~~`, and `::: … :::` container blocks are opt-in extensions:
```swift
var config = MarkdownEditorConfiguration()
config.extensions = [HighlightExtension(), StrikethroughExtension(), ContainerExtension()]
```
Unregistered syntax stays literal text. An extension contributes an inline
form (`InlineSyntax`), a fenced block form (`BlockSyntax`), or both — plus the
attributes for its content and an HTML wrapper for rich copy. The parser owns
all geometry, marker/fence hiding, caret reveal, and incremental restyling, so
extensions behave identically to built-ins and cannot affect neighboring
constructs. Conform to `MarkdownExtension` to add your own.
### Code Blocks
**Recommended path: depend on the `MarkdownEngineCodeBlocks` product
and use the bundled `HighlighterSwiftBridge`.** Rolling your own
`SyntaxHighlighter` has subtle footguns the bridge already handles —
line-height metrics across light/dark themes, appearance-change
observation, layout-pass timing, font name extraction from the theme,
and CSS-theme-derived background colors. Use the bundle unless you
specifically need a non-HighlighterSwift library.
```swift
import MarkdownEngineCodeBlocks
var configuration = MarkdownEditorConfiguration.default
configuration.services = MarkdownEditorServices(
syntaxHighlighter: HighlighterSwiftBridge()
)
```
The bridge auto-switches between `atom-one-light` and `atom-one-dark`
with system appearance. Different theme names or a pinned single theme
are configurable via init params — see DocC.
Need a different highlighter library entirely? Implement
`SyntaxHighlighter` yourself (see [Service Protocols](#service-protocols)
above for the declaration) and reference the bundled bridge in
`Sources/MarkdownEngineCodeBlocks/` as a working example.
### LaTeX Rendering
**Recommended path: depend on the `MarkdownEngineLatex` product and use
the bundled `SwiftMathBridge`.** Hand-rolling a `LatexRenderer` has
real footguns the bridge already handles — appearance-aware text color,
zero-sized output guards (`lockFocus` crashes on 0×0 images),
window-vs-NSApp appearance distinction, single-letter padding, and an
internal cache keyed by (latex, font size, appearance, theme color).
```swift
import MarkdownEngineLatex
var configuration = MarkdownEditorConfiguration.default
configuration.services = MarkdownEditorServices(
latex: SwiftMathBridge()
)
```
The bridge uses the Latin Modern math font and tints formulas with
`MarkdownEditorTheme.latexLightModeText` / `latexDarkModeText`. Pass
`singleLetterPaddingBottom:` to override the engine's matching default.
### Theming
Every color the editor puts on screen reads from `MarkdownEditorTheme`:
```swift
var theme = MarkdownEditorTheme.default
theme.bodyText = .labelColor
theme.findMatchHighlight = NSColor(named: "MyAccent")!
var configuration = MarkdownEditorConfiguration.default
configuration.theme = theme
```
Defaults map to `NSColor` dynamic system colors, so light/dark mode
keeps working without extra code.
### Tuning
`MarkdownEditorConfiguration` exposes every spacing / sizing / behavior
knob the engine has, grouped by concern:
```swift
var configuration = MarkdownEditorConfiguration.default
configuration.codeBlock.fontSizeScale = 0.9
configuration.headings.fontMultipliers = [2.4, 1.8, 1.4, 1.1, 0.9, 0.75]
configuration.overscroll.percent = 0.4
configuration.lists.helpersEnabled = false
configuration.safeAreaInsets = SafeAreaInsets(top: 56) // headroom under a translucent toolbar
```
### Wiki-Links & Replacement State
Two optional bindings on `NativeTextViewWrapper` let you observe
wiki-link state and push inline replacements programmatically. Pass
only what you need — each is independent and defaults to a no-op:
```swift
NativeTextViewWrapper(
text: $text,
isWikiLinkActive: $isWikiLinkActive,
pendingInlineReplacement: $pendingReplacement
)
```
- `isWikiLinkActive` — the wrapper sets this to `true` while the caret
sits inside a `[[Name]]` link, so you can present a contextual UI.
- `pendingInlineReplacement` — assign a non-nil value to push a
replacement (e.g. an autocomplete result); the engine consumes it
and clears the binding.
### Height Behavior
By default the editor scrolls internally. Set `heightBehavior` to
`.fitsContent` to make it grow to fit its content and report that height to
SwiftUI, so an enclosing `ScrollView` scrolls the page instead:
```swift
ScrollView {
NativeTextViewWrapper(text: $text, configuration: .init(heightBehavior: .fitsContent))
}
```
Composes with `readingWidth` and the scrolling header, and is switchable at
runtime. `.fitsContent` lays out the whole document (no viewport
virtualization), so prefer it for small-to-medium content. See
``HeightBehavior`` in DocC for the full behavior.
### Reading Column
Give long documents a fixed-width centered column; wide GFM tables break out
to the full window width, Google-Docs-style:
```swift
configuration.readingWidth = 650
```
Text wraps at `readingWidth` and never re-wraps on resize (only the column's
position moves), keeping live resize smooth. Leave it `nil` (default) to fill
the container edge-to-edge.
### Scrolling Header
Host a SwiftUI view above the document body that scrolls away with it —
metadata, a property table, a contextual toolbar:
```swift
NativeTextViewWrapper(
text: $text,
header: AnyView(MyDocumentHeader(document: document)),
headerCollapsedHeight: 40,
headerExpanded: isHeaderExpanded
)
```
The engine hosts it in an `NSHostingView`, reserves its intrinsic height, and
keeps it fully interactive. `headerExpanded: false` collapses to
`headerCollapsedHeight` (top row stays, rows below clip away, animated). Inject
any required environment *before* wrapping in `AnyView`, and give wrapping
content an explicit height so it doesn't clip at the band's bottom. Composes
with `readingWidth`; an optional `placeholder:` shows ghost text while empty;
`header: nil` (default) adds nothing. The demo's **Header** toggle shows it.
## Demo
A runnable SwiftUI demo lives in [`Demo/`](Demo/MarkdownEngineDemo.xcodeproj).
Open it in Xcode and hit **Run** — the demo references the package via
a local path, so any engine edit rebuilds into the demo on the next run.
> If you're seeing a "missing package product" error, it's almost always
> stale package cache. Use **File → Packages → Reset Package Caches**
> once and rebuild.
## Documentation
Full API docs ship as DocC. In Xcode: **Product → Build Documentation**
(`⇧⌃⌘D`); for local CLI preview see [CONTRIBUTING.md](CONTRIBUTING.md). Once
hosted on Swift Package Index, docs will live at
`https://swiftpackageindex.com/nodes-app/swift-markdown-engine/documentation`.
## Requirements & Status
- macOS 14 or later (15.1+ for Apple Writing Tools integration)
- Swift 5.9 / Xcode 15 or later
MarkdownEngine is currently **pre-1.0**. The public API may change between
minor releases as it stabilizes. Production use is fine — pin a specific
version (`0.x.y`) in your `Package.swift`.
## Who makes it
<a href="https://apps.apple.com/app/apple-store/id6745401961?pt=127809373&ct=github&mt=8">
<img align="right" width="96" alt="Nodes" src="media/nodes-app-icon.png" />
</a>
MarkdownEngine is the editor inside **[Nodes](https://apps.apple.com/app/apple-store/id6745401961?pt=127809373&ct=github&mt=8)**,
a macOS app for writing, linking and exploring notes. This is not a side project
we open-sourced and walked away from — it is the editor our own users type in
every day, and every fix here ships in a real app first.
If it is useful to you, telling someone about it is all we would ask for.
## Contributing
Bug reports, ideas, and pull requests are welcome.
- [ARCHITECTURE.md](ARCHITECTURE.md) — codemap and pipeline guide for
contributors
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, PR process, and design
constraints
## License
MarkdownEngine is released under the Apache 2.0 License. See [LICENSE](LICENSE)
for the full text.
---
Built by a small team in Munich and Zurich. Day-to-day on [Instagram](https://www.instagram.com/nodes.app).
@@ -0,0 +1,737 @@
//
// MarkdownEditorConfiguration.swift
// MarkdownEngine
//
// Created by Luca Chen on 16.03.26.
//
// Centralized configuration for the Markdown editor engine.
//
// This struct exposes every spacing, sizing, and behavior knob that is
// shared across the engine. The defaults reproduce the historical
// Nodes-app behavior, so passing `.default` keeps existing rendering
// pixel-identical. Embedders that want a different look-and-feel can
// override individual fields without forking the engine.
//
import AppKit
import Foundation
// MARK: - Top-level Configuration
/// All tunable values for the Markdown editor engine grouped by concern.
///
/// The struct is deliberately flat-with-nested-groups: top level holds
/// orthogonal feature areas (markers, code blocks, lists, ), each group
/// owns the values that belong together. Default values are the production
/// defaults used by the Nodes app and have been chosen empirically.
public struct MarkdownEditorConfiguration: Sendable {
public var theme: MarkdownEditorTheme
public var services: MarkdownEditorServices
public var markers: MarkerStyle
public var codeBlock: CodeBlockStyle
public var inlineCode: InlineCodeStyle
public var lists: ListStyle
public var taskCheckbox: TaskCheckboxStyle
public var headings: HeadingStyle
public var imageEmbed: ImageEmbedStyle
public var blockLatex: BlockLatexStyle
public var inlineLatex: InlineLatexStyle
public var blockquote: BlockquoteStyle
public var link: LinkStyle
public var paragraph: ParagraphStyle
public var overscroll: OverscrollPolicy
public var dragSelection: DragSelectionPolicy
public var safeAreaInsets: SafeAreaInsets
public var scrollers: ScrollersPolicy
public var textInsets: TextInsets
/// Centered reading-column width; wide tables break out to full width. nil = full width (default).
public var readingWidth: CGFloat?
public var spellChecking: SpellCheckingPolicy
/// Smart quote/dash substitution while typing. Independent of `spellChecking`
/// AppKit tracks these as separate `NSTextView` flags.
public var textSubstitution: TextSubstitutionPolicy
/// Inline predictive-text completion (the ghost-text suggestion AppKit
/// shows as you type, same feature as Notes/TextEdit). Mirrors
/// `NSTextView.isAutomaticTextCompletionEnabled`.
public var textCompletion: TextCompletionPolicy
/// System Writing Tools (proofread/rewrite/summarize). Mirrors
/// `NSTextView.writingToolsBehavior` `.none` when disabled.
public var writingTools: WritingToolsPolicy
/// How the editor resolves its own height.
///
/// - `.scrolls` (default): the editor scrolls internally within whatever
/// height SwiftUI gives it. This is the historical behavior.
/// - `.fitsContent`: the editor grows to fit its content and reports that
/// height to SwiftUI, so an enclosing `ScrollView` scrolls the page
/// instead of a nested internal scroller. The editor re-reports its
/// height per keystroke as well as after async content changes (image
/// loads, font-size changes, header band resizes).
///
/// Switching at runtime is supported; the editor reconfigures immediately
/// (scroller visibility, overscroll, inflation, and intrinsic size all
/// update in the same SwiftUI update cycle).
///
/// - SeeAlso: ``HeightBehavior``
public var heightBehavior: HeightBehavior
/// Present the document as raw Markdown source: no syntax hiding, no
/// styling, no wiki-link display transform (`[[Name|UUID]]` shows verbatim).
/// Stays editable, but smart input (list continuation, auto-wrap, ) is off.
/// Runtime-switchable; a flip rebuilds immediately and drops the document's
/// undo stack (actions from the other mode would replay at stale ranges).
public var rawSourceMode: Bool
/// Opt-in constructs beyond pure markdown (e.g. `==highlight==`). Empty by
/// default: unregistered syntax stays literal text. Order defines match
/// precedence among extensions; built-in constructs always win first.
public var extensions: [any MarkdownExtension]
/// Let the caret and the I-beam take the ink of the extension span they sit
/// in, instead of `theme.bodyText` and the plain system pointer.
///
/// Off by default. It only matters for an extension that INVERTS its
/// content (dark ink on a light block), where both cursors would otherwise
/// be drawn in the block's own color and disappear inside it. An extension
/// whose `contentAttributes` set no foreground is unaffected either way
/// so this stays the embedder's explicit decision rather than something the
/// engine infers from a color it happens to see.
public var cursorFollowsSpanInk: Bool
/// Show a pointing-hand cursor over links while editing (not just in
/// read-only mode, where it always shows regardless of this flag).
///
/// On by default. The embedder's "pointer cursor" preference maps
/// straight to this off just means the I-beam stays over links like
/// any other text, since a link's edge zone still repositions the caret
/// for editing rather than navigating (see `clickedOnLink`).
public var pointerCursorOverLinksWhileEditing: Bool
public init(
theme: MarkdownEditorTheme = .default,
services: MarkdownEditorServices = .default,
markers: MarkerStyle = .default,
codeBlock: CodeBlockStyle = .default,
inlineCode: InlineCodeStyle = .default,
lists: ListStyle = .default,
taskCheckbox: TaskCheckboxStyle = .default,
headings: HeadingStyle = .default,
imageEmbed: ImageEmbedStyle = .default,
blockLatex: BlockLatexStyle = .default,
inlineLatex: InlineLatexStyle = .default,
blockquote: BlockquoteStyle = .default,
link: LinkStyle = .default,
paragraph: ParagraphStyle = .default,
overscroll: OverscrollPolicy = .default,
dragSelection: DragSelectionPolicy = .default,
safeAreaInsets: SafeAreaInsets = .default,
scrollers: ScrollersPolicy = .default,
textInsets: TextInsets = .default,
readingWidth: CGFloat? = nil,
spellChecking: SpellCheckingPolicy = .default,
textSubstitution: TextSubstitutionPolicy = .default,
textCompletion: TextCompletionPolicy = .default,
writingTools: WritingToolsPolicy = .default,
heightBehavior: HeightBehavior = .scrolls,
rawSourceMode: Bool = false,
extensions: [any MarkdownExtension] = [],
cursorFollowsSpanInk: Bool = false,
pointerCursorOverLinksWhileEditing: Bool = true
) {
self.theme = theme
self.services = services
self.markers = markers
self.codeBlock = codeBlock
self.inlineCode = inlineCode
self.lists = lists
self.taskCheckbox = taskCheckbox
self.headings = headings
self.imageEmbed = imageEmbed
self.blockLatex = blockLatex
self.inlineLatex = inlineLatex
self.blockquote = blockquote
self.link = link
self.paragraph = paragraph
self.overscroll = overscroll
self.dragSelection = dragSelection
self.safeAreaInsets = safeAreaInsets
self.scrollers = scrollers
self.textInsets = textInsets
self.readingWidth = readingWidth
self.spellChecking = spellChecking
self.textSubstitution = textSubstitution
self.textCompletion = textCompletion
self.writingTools = writingTools
self.heightBehavior = heightBehavior
self.rawSourceMode = rawSourceMode
self.extensions = extensions
self.cursorFollowsSpanInk = cursorFollowsSpanInk
self.pointerCursorOverLinksWhileEditing = pointerCursorOverLinksWhileEditing
}
public static let `default` = MarkdownEditorConfiguration()
}
// MARK: - Spell checking
/// Initial state for the three "Spelling and Grammar" toggles. Only consulted
/// at `makeNSView` time; afterwards the user's context-menu choices take
/// precedence and are surfaced via ``NativeTextViewWrapper/onSpellCheckingPolicyChanged``.
public struct SpellCheckingPolicy: Sendable {
/// Mirrors `NSTextView.isContinuousSpellCheckingEnabled`.
public var continuousSpellChecking: Bool
/// Mirrors `NSTextView.isGrammarCheckingEnabled`.
public var grammarChecking: Bool
/// Mirrors `NSTextView.isAutomaticSpellingCorrectionEnabled`.
public var automaticSpellingCorrection: Bool
public init(
continuousSpellChecking: Bool = true,
grammarChecking: Bool = true,
automaticSpellingCorrection: Bool = true
) {
self.continuousSpellChecking = continuousSpellChecking
self.grammarChecking = grammarChecking
self.automaticSpellingCorrection = automaticSpellingCorrection
}
public static let `default` = SpellCheckingPolicy()
}
// MARK: - Text substitution
/// Smart quote/dash substitution while typing. Mirrors the historical
/// hardcoded `NativeTextViewWrapper` behavior (quotes on, dashes off) as the
/// default, now exposed as a config knob instead of fixed AppKit calls.
public struct TextSubstitutionPolicy: Sendable {
/// Mirrors `NSTextView.isAutomaticQuoteSubstitutionEnabled`.
public var quoteSubstitution: Bool
/// Mirrors `NSTextView.isAutomaticDashSubstitutionEnabled`.
public var dashSubstitution: Bool
public init(
quoteSubstitution: Bool = true,
dashSubstitution: Bool = false
) {
self.quoteSubstitution = quoteSubstitution
self.dashSubstitution = dashSubstitution
}
public static let `default` = TextSubstitutionPolicy()
}
// MARK: - Text completion
/// Inline predictive-text completion while typing (ghost-text suggestion,
/// accepted with Tab/ same feature as Notes/TextEdit on macOS 14+).
public struct TextCompletionPolicy: Sendable {
/// Mirrors `NSTextView.isAutomaticTextCompletionEnabled`.
public var isEnabled: Bool
public init(isEnabled: Bool = true) {
self.isEnabled = isEnabled
}
public static let `default` = TextCompletionPolicy()
}
// MARK: - Writing Tools
/// System Writing Tools (proofread / rewrite / summarize / compose), backed
/// by Apple Intelligence where available.
public struct WritingToolsPolicy: Sendable {
/// When `false`, `NSTextView.writingToolsBehavior` is set to `.none`
/// instead of `.limited` hides Writing Tools entirely for this view.
public var isEnabled: Bool
public init(isEnabled: Bool = true) {
self.isEnabled = isEnabled
}
public static let `default` = WritingToolsPolicy()
}
// MARK: - Scroll bars
/// Scroll bar visibility. Default: vertical only, autohide on.
public struct ScrollersPolicy: Sendable {
public var hasVerticalScroller: Bool
public var hasHorizontalScroller: Bool
public var autohidesScrollers: Bool
public init(
hasVerticalScroller: Bool = true,
hasHorizontalScroller: Bool = false,
autohidesScrollers: Bool = true
) {
self.hasVerticalScroller = hasVerticalScroller
self.hasHorizontalScroller = hasHorizontalScroller
self.autohidesScrollers = autohidesScrollers
}
public static let `default` = ScrollersPolicy()
/// No scrollers (use with a custom scroll overlay).
public static let hidden = ScrollersPolicy(hasVerticalScroller: false, hasHorizontalScroller: false)
/// Vertical only same as `.default`.
public static let vertical = ScrollersPolicy(hasVerticalScroller: true, hasHorizontalScroller: false)
/// Both axes (code-heavy / wide content).
public static let both = ScrollersPolicy(hasVerticalScroller: true, hasHorizontalScroller: true)
/// Vertical, no auto-hide.
public static let alwaysVisible = ScrollersPolicy(hasVerticalScroller: true, autohidesScrollers: false)
}
// MARK: - Text insets
/// Margins inside the text view (`NSTextView.textContainerInset`). Scroll bar stays at the outer edge.
public struct TextInsets: Sendable {
public var horizontal: CGFloat
public var vertical: CGFloat
public init(horizontal: CGFloat = 0, vertical: CGFloat = 0) {
self.horizontal = horizontal
self.vertical = vertical
}
public static let `default` = TextInsets()
}
// MARK: - Marker visibility
/// How Markdown syntax markers (e.g. `**`, `*`, `$`) are visualized when
/// the cursor is not inside the corresponding token.
///
/// The engine's default approach is to keep markers in the text storage but
/// shrink them to a near-zero font size (`hiddenMarkerFontSize`). This avoids
/// any range translation between displayed and stored text cursor movement,
/// find/replace, selection, and copy/paste all stay trivially correct.
/// The trade-off is a sub-pixel residue at extreme zoom levels.
public struct MarkerStyle: Sendable {
/// Font size used for "hidden" inline markers. Effectively invisible at
/// normal zoom while keeping displayed-range == stored-range.
public var hiddenMarkerFontSize: CGFloat
/// Alpha applied to inline-code's secondary marker color.
public var inlineCodeMarkerAlpha: CGFloat
/// Alpha applied to non-focused find matches when in-document search
/// highlights are visible. The focused match is drawn at full opacity.
public var findMatchHighlightAlpha: CGFloat
public init(
hiddenMarkerFontSize: CGFloat = 0.1,
inlineCodeMarkerAlpha: CGFloat = 0.5,
findMatchHighlightAlpha: CGFloat = 0.65
) {
self.hiddenMarkerFontSize = hiddenMarkerFontSize
self.inlineCodeMarkerAlpha = inlineCodeMarkerAlpha
self.findMatchHighlightAlpha = findMatchHighlightAlpha
}
public static let `default` = MarkerStyle()
}
// MARK: - Code blocks
/// Styling for fenced code blocks (```language ... ```).
public struct CodeBlockStyle: Sendable {
/// Code-block font size as a fraction of the document base font size.
public var fontSizeScale: CGFloat
/// Vertical paragraph spacing applied above and below the code block.
public var paragraphSpacing: CGFloat
/// Left/right indent (in points) so code blocks don't run into the gutter.
public var horizontalIndent: CGFloat
public init(
fontSizeScale: CGFloat = 0.85,
paragraphSpacing: CGFloat = 2.0,
horizontalIndent: CGFloat = 12.0
) {
self.fontSizeScale = fontSizeScale
self.paragraphSpacing = paragraphSpacing
self.horizontalIndent = horizontalIndent
}
public static let `default` = CodeBlockStyle()
}
// MARK: - Inline code
/// Styling for inline `` `code` `` spans.
public struct InlineCodeStyle: Sendable {
/// Inline-code reuses the code block font size scale by default.
public var fontSizeScale: CGFloat
public init(fontSizeScale: CGFloat = 0.85) {
self.fontSizeScale = fontSizeScale
}
public static let `default` = InlineCodeStyle()
}
// MARK: - Lists
/// Behavior toggles and metrics for ordered / unordered list editing.
public struct ListStyle: Sendable {
/// Master switch for list-related editing helpers (auto-continue,
/// auto-indent, marker conversion). When `false`, lists are still
/// rendered, but typing-time conveniences are skipped.
public var helpersEnabled: Bool
/// Master switch for auto-closing pairs `()`, `{}`, `[]` while typing.
public var autoClosePairsEnabled: Bool
/// Indent (in points) that one nesting level adds to the list item.
public var indentPerLevel: CGFloat
/// Maximum nesting level reachable by pressing Tab inside a list.
public var maximumNestingLevel: Int
/// Extra line height added on top of the default to give list items room.
public var extraLineHeight: CGFloat
public init(
helpersEnabled: Bool = true,
autoClosePairsEnabled: Bool = true,
indentPerLevel: CGFloat = 27.5,
maximumNestingLevel: Int = 3,
extraLineHeight: CGFloat = 2
) {
self.helpersEnabled = helpersEnabled
self.autoClosePairsEnabled = autoClosePairsEnabled
self.indentPerLevel = indentPerLevel
self.maximumNestingLevel = maximumNestingLevel
self.extraLineHeight = extraLineHeight
}
public static let `default` = ListStyle()
}
// MARK: - Task checkboxes
/// SF Symbol names used to draw task-list checkboxes (`- [ ]` / `- [x]`).
///
/// Any SF Symbol available on the deployment target can be substituted, for
/// example `"circle"` / `"checkmark.circle.fill"`. A name that doesn't
/// resolve falls back to the corresponding default symbol at draw time, so a
/// typo degrades to the stock look instead of drawing nothing. Tint colors
/// stay theme-driven (`MarkdownEditorTheme/mutedText` unchecked,
/// `MarkdownEditorTheme/bodyText` checked).
public struct TaskCheckboxStyle: Sendable {
/// SF Symbol drawn for an unchecked task item (`[ ]`).
public var uncheckedSymbolName: String
/// SF Symbol drawn for a checked task item (`[x]`).
public var checkedSymbolName: String
public init(
uncheckedSymbolName: String = "square",
checkedSymbolName: String = "checkmark.square.fill"
) {
self.uncheckedSymbolName = uncheckedSymbolName
self.checkedSymbolName = checkedSymbolName
}
public static let `default` = TaskCheckboxStyle()
}
// MARK: - Headings
/// Per-level heading metrics. Defaults follow the historical Nodes ratios,
/// which are loosely based on browser default heading sizes.
public struct HeadingStyle: Sendable {
/// Font-size multiplier per heading level (1...6).
public var fontMultipliers: [CGFloat]
/// Top spacing in `em` units per heading level (1...6).
public var topSpacingEm: [CGFloat]
public init(
fontMultipliers: [CGFloat] = [2.0, 1.5, 1.17, 1.0, 0.83, 0.67],
topSpacingEm: [CGFloat] = [0.35, 0.30, 0.25, 0.20, 0.15, 0.10]
) {
self.fontMultipliers = fontMultipliers
self.topSpacingEm = topSpacingEm
}
public func fontMultiplier(for level: Int) -> CGFloat {
let index = max(1, min(level, fontMultipliers.count)) - 1
return fontMultipliers[index]
}
public func topSpacingEm(for level: Int) -> CGFloat {
let index = max(1, min(level, topSpacingEm.count)) - 1
return topSpacingEm[index]
}
public static let `default` = HeadingStyle()
}
// MARK: - Image embeds (![[...]])
/// Sizing and spacing rules for `![[Name]]` image embeds.
public struct ImageEmbedStyle: Sendable {
/// Minimum allowed display width (points) for an embedded image.
public var minimumWidth: CGFloat
/// Fallback maximum width if no usable text container width is available.
public var fallbackMaxWidth: CGFloat
/// Sanity bound container widths above this are treated as invalid.
public var unreasonableMaxWidth: CGFloat
/// Vertical paragraph spacing above/below the image paragraph.
public var paragraphSpacing: CGFloat
/// Gap between the source line and the rendered image (visibleSource mode).
public var imageGap: CGFloat
public init(
minimumWidth: CGFloat = 50,
fallbackMaxWidth: CGFloat = 650,
unreasonableMaxWidth: CGFloat = 1_000_000,
paragraphSpacing: CGFloat = 8,
imageGap: CGFloat = 8
) {
self.minimumWidth = minimumWidth
self.fallbackMaxWidth = fallbackMaxWidth
self.unreasonableMaxWidth = unreasonableMaxWidth
self.paragraphSpacing = paragraphSpacing
self.imageGap = imageGap
}
public static let `default` = ImageEmbedStyle()
}
// MARK: - LaTeX
/// Vertical spacing for block-LaTeX `$$...$$` paragraphs.
public struct BlockLatexStyle: Sendable {
/// Top spacing for $$...$$ block paragraphs.
public var paragraphSpacingBefore: CGFloat
/// Bottom spacing for $$...$$ block paragraphs.
public var paragraphSpacing: CGFloat
/// Extra bottom padding added to single-letter formulas to avoid clipping.
public var singleLetterPaddingBottom: CGFloat
public init(
paragraphSpacingBefore: CGFloat = 16,
paragraphSpacing: CGFloat = 20,
singleLetterPaddingBottom: CGFloat = 1.0
) {
self.paragraphSpacingBefore = paragraphSpacingBefore
self.paragraphSpacing = paragraphSpacing
self.singleLetterPaddingBottom = singleLetterPaddingBottom
}
public static let `default` = BlockLatexStyle()
}
/// Reserved for future inline-LaTeX (`$...$`) tuning. Currently has no
/// effect; inline LaTeX inherits font size from the surrounding context.
public struct InlineLatexStyle: Sendable {
/// Reserved for future inline-LaTeX tuning currently the engine inherits
/// font size from the surrounding heading context.
public var placeholder: Void
public init() { self.placeholder = () }
public static let `default` = InlineLatexStyle()
}
// MARK: - Blockquote
/// Extra line height added to blockquote lines.
///
/// By default blockquote lines use the font's natural line height with no
/// extra spacing. Set `extraLineHeight` to add breathing room, matching
/// the pattern used by `ListStyle.extraLineHeight` and
/// `ParagraphStyle.lineHeightExtraSpacing`.
public struct BlockquoteStyle: Sendable {
/// Extra height (points) added to the default line height for blockquote lines.
public var extraLineHeight: CGFloat
public init(extraLineHeight: CGFloat = 0) {
self.extraLineHeight = extraLineHeight
}
public static let `default` = BlockquoteStyle()
}
// MARK: - Links
/// Foreground alpha values applied to link content in different states.
public struct LinkStyle: Sendable {
/// Foreground alpha for the visible label of an active markdown link.
public var activeLinkAlpha: CGFloat
/// Foreground alpha applied to "incomplete" link content (e.g. `[text]`
/// without a target).
public var incompleteLinkAlpha: CGFloat
public init(activeLinkAlpha: CGFloat = 0.55, incompleteLinkAlpha: CGFloat = 0.7) {
self.activeLinkAlpha = activeLinkAlpha
self.incompleteLinkAlpha = incompleteLinkAlpha
}
public static let `default` = LinkStyle()
}
// MARK: - Paragraphs
/// Default paragraph spacing and line height applied to body text.
public struct ParagraphStyle: Sendable {
/// Extra paragraph spacing as a fraction of the document's default line height.
public var spacingFactor: CGFloat
/// Extra height (points) added to the default paragraph line height.
public var lineHeightExtraSpacing: CGFloat
public init(spacingFactor: CGFloat = 0.3, lineHeightExtraSpacing: CGFloat = 2) {
self.spacingFactor = spacingFactor
self.lineHeightExtraSpacing = lineHeightExtraSpacing
}
public static let `default` = ParagraphStyle()
}
// MARK: - Bottom overscroll
/// Controls the empty space below the last line so that typing at the bottom
/// of a long document remains comfortable instead of pinning to the viewport
/// bottom edge.
public struct OverscrollPolicy: Sendable {
/// Desired overscroll as a fraction of the visible viewport height.
public var percent: CGFloat
/// Hard upper bound for the overscroll in points.
public var maxPoints: CGFloat
/// Hard lower bound for the overscroll in points.
public var minPoints: CGFloat
/// Fraction of the viewport above which overscroll starts ramping up.
public var activationStartFraction: CGFloat
/// Fraction of the viewport over which overscroll fully ramps in.
public var activationRangeFraction: CGFloat
public init(
percent: CGFloat = 0.5,
maxPoints: CGFloat = 450,
minPoints: CGFloat = 40,
activationStartFraction: CGFloat = 0.15,
activationRangeFraction: CGFloat = 0.85
) {
self.percent = percent
self.maxPoints = maxPoints
self.minPoints = minPoints
self.activationStartFraction = activationStartFraction
self.activationRangeFraction = activationRangeFraction
}
public static let `default` = OverscrollPolicy()
}
// MARK: - Drag selection
/// Tuning for the auto-scroll boost that engages while the user drags a
/// selection past the visible viewport edges.
public struct DragSelectionPolicy: Sendable {
/// Movement threshold (points) before the auto-scroll boost engages.
public var movementThreshold: CGFloat
/// Distance from the window edge that triggers the boost.
public var edgeTriggerDistance: CGFloat
/// Pixels per tick scrolled while the boost is active.
public var scrollStepPerTick: CGFloat
/// Boost timer frequency (ticks per second).
public var ticksPerSecond: Double
public init(
movementThreshold: CGFloat = 5.0,
edgeTriggerDistance: CGFloat = 5.0,
scrollStepPerTick: CGFloat = 12.0,
ticksPerSecond: Double = 60.0
) {
self.movementThreshold = movementThreshold
self.edgeTriggerDistance = edgeTriggerDistance
self.scrollStepPerTick = scrollStepPerTick
self.ticksPerSecond = ticksPerSecond
}
public static let `default` = DragSelectionPolicy()
}
// MARK: - Safe-area insets
/// Reserves space on the scroll view for system overlays (e.g. a translucent toolbar to scroll underneath). Maps to `NSScrollView.contentInsets`; scroll bar follows the inset.
public struct SafeAreaInsets: Sendable {
public var top: CGFloat
public var leading: CGFloat
public var trailing: CGFloat
public var bottom: CGFloat
public init(
top: CGFloat = 0,
leading: CGFloat = 0,
trailing: CGFloat = 0,
bottom: CGFloat = 0
) {
self.top = top
self.leading = leading
self.trailing = trailing
self.bottom = bottom
}
public static let `default` = SafeAreaInsets()
}
// MARK: - Height behavior
extension MarkdownEditorConfiguration {
/// How the editor resolves its own height.
///
/// ## Usage
///
/// ```swift
/// // Inline editor inside a page scroll view:
/// ScrollView {
/// NativeTextViewWrapper(
/// text: $text,
/// configuration: .init(heightBehavior: .fitsContent)
/// )
/// }
/// ```
///
/// ## Behavior
///
/// In `.fitsContent` mode:
/// - The editor reports `headerHeight + text content height` to SwiftUI.
/// - Typing grows/shrinks the block per keystroke; SwiftUI re-lays-out.
/// - An empty document shows at least one body line of height.
/// - Scroll-wheel events pass through to the enclosing scroll view.
/// - Caret visibility propagates to the enclosing (page-level) scroll
/// view so editing at the bottom of a tall block keeps the caret
/// on-screen.
/// - Async content changes (image/LaTeX finishing layout, font-size
/// change) re-report size via `invalidateIntrinsicContentSize`.
/// - Switching between `.scrolls` and `.fitsContent` at runtime is
/// supported; the editor reconfigures immediately.
///
/// ## Composition
///
/// - **Reading column** (`readingWidth`): the centered fixed-width column
/// is preserved; height grows to the column's content height.
/// - **Scroll-away header**: a static header's band is included in the
/// reported height. The collapse-on-scroll animation is driven by the
/// inner scroll offset, which is always zero in `.fitsContent`, so the
/// collapse never triggers. Combining a collapsing header with
/// `.fitsContent` is allowed but the collapse behavior is not meaningful.
///
/// ## Trade-offs
///
/// `.fitsContent` forces full-document layout so the total height is known.
/// For small-to-medium documents this is fine; for very large documents it
/// forgoes TextKit-2 viewport virtualization.
public enum HeightBehavior: Sendable {
/// The editor scrolls internally within the height SwiftUI gives it.
/// This is the historical behavior and the default.
case scrolls
/// The editor grows to fit its content and reports that height back to
/// SwiftUI, so an enclosing scroll view / page scrolls instead of a
/// nested scroll view. Internal scrolling and bottom-overscroll slack
/// are disabled in this mode.
case fitsContent
/// Whether the vertical scroller should be shown for this height
/// behavior and scroller policy combination.
///
/// In `.fitsContent` the editor never scrolls internally, so the
/// vertical scroller is always hidden regardless of the policy.
/// In `.scrolls` the policy's `hasVerticalScroller` is respected.
public func wantsVerticalScroller(for scrollers: ScrollersPolicy) -> Bool {
switch self {
case .fitsContent: return false
case .scrolls: return scrollers.hasVerticalScroller
}
}
}
}
@@ -0,0 +1,117 @@
//
// MarkdownEditorTheme.swift
// MarkdownEngine
//
// Created by Luca Chen on 16.03.26.
//
// Color palette for the Markdown editor engine.
//
// All user-visible colors used by the engine are routed through this
// struct. Defaults map to system colors so the editor adapts to light/
// dark mode automatically. Embedders that want a custom palette (for
// example, a sepia or high-contrast preset) can replace any subset of
// the colors without touching engine source files.
//
import AppKit
import Foundation
// MARK: - Theme
/// Color palette consumed by the Markdown editor engine.
///
/// Every color the engine puts on screen is read from this struct, so a
/// single override is enough to retheme the entire editor. The defaults
/// reproduce a system-native macOS look using `NSColor` dynamic system
/// colors, so light/dark-mode switching keeps working without extra code.
public struct MarkdownEditorTheme: Sendable {
// MARK: Text colors
/// Foreground color for plain body text and the typing caret.
public var bodyText: NSColor
/// Foreground color for de-emphasized text and most syntax markers.
/// Defaults to `secondaryLabelColor` so it tracks the system style.
public var mutedText: NSColor
/// Foreground color for content the engine wants to deemphasize further
/// than `mutedText` for example, broken wiki-links.
public var disabledText: NSColor
/// Foreground color for heading marker glyphs (`#`, `##`, ).
public var headingMarker: NSColor
// MARK: Links
/// Foreground color for hyperlinks that resolve to an URL.
public var link: NSColor
/// Foreground color for incomplete `[text]` patterns (no URL yet).
public var incompleteLink: NSColor
// MARK: Find / search highlights
/// Background color used to highlight all matches when the user is
/// running an in-document search.
///
/// The default is `.systemYellow` so embedders that don't customize
/// this still get a sensible result. Apps with their own brand color
/// (for example, the Nodes app uses its custom yellow) should override
/// this to match their palette.
public var findMatchHighlight: NSColor
/// Background color used to highlight the currently-focused match
/// during in-document search. Typically a stronger version of
/// ``findMatchHighlight``.
public var findCurrentMatchHighlight: NSColor
// MARK: LaTeX rendering
/// Foreground color used when rendering LaTeX formulas in light mode.
public var latexLightModeText: NSColor
/// Foreground color used when rendering LaTeX formulas in dark mode.
public var latexDarkModeText: NSColor
// MARK: Strikethrough / decoration
/// Stroke color used for strikethrough decorations
/// (e.g. completed task list items, horizontal rules).
public var strikethroughColor: NSColor
// MARK: Highlight
/// Background color used for `==highlight==` inline markup.
public var highlightColor: NSColor
// MARK: Init
public init(
bodyText: NSColor = .labelColor,
mutedText: NSColor = .secondaryLabelColor,
disabledText: NSColor = .tertiaryLabelColor,
headingMarker: NSColor = .gray,
link: NSColor = .linkColor,
incompleteLink: NSColor = .systemBlue,
findMatchHighlight: NSColor = .systemYellow,
findCurrentMatchHighlight: NSColor = .systemYellow,
latexLightModeText: NSColor = .black,
latexDarkModeText: NSColor = .white,
strikethroughColor: NSColor = .labelColor,
highlightColor: NSColor = .systemOrange.withAlphaComponent(0.4)
) {
self.bodyText = bodyText
self.mutedText = mutedText
self.disabledText = disabledText
self.headingMarker = headingMarker
self.link = link
self.incompleteLink = incompleteLink
self.findMatchHighlight = findMatchHighlight
self.findCurrentMatchHighlight = findCurrentMatchHighlight
self.latexLightModeText = latexLightModeText
self.latexDarkModeText = latexDarkModeText
self.strikethroughColor = strikethroughColor
self.highlightColor = highlightColor
}
/// System-native palette built from `NSColor` dynamic system colors.
///
/// Use this if you want the engine to look like a stock macOS
/// `NSTextView`. It's also the default when no theme is supplied.
public static let `default` = MarkdownEditorTheme()
}
@@ -0,0 +1,135 @@
//
// PerfTrace.swift
// MarkdownEngine
//
// Created by Luca Chen on 07.07.26.
//
// TEMP diagnostics (typing performance). Prints one compact line per keystroke
// with a per-phase breakdown plus the current document length, so we can see
// which costs grow with file size instead of staying constant. The whole point:
// type in a short file, then a long one, and compare `total` for the same edit.
//
// Toggle: set the env var MD_PERF=0 in the run scheme to silence.
// Debug-only the whole thing compiles out in Release.
// Remove before shipping (this file + the `PerfTrace.` call sites).
//
import Foundation
enum PerfTrace {
#if DEBUG
static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] != "0"
/// Opt-in for the sampled full-rebuild verifier asserts (wiki splice,
/// backtick census, parse buffer). They run 3× O(doc) work synchronously
/// on every 64th keystroke periodic spikes that pollute the PERF
/// numbers so they stay off unless explicitly requested.
static let verifyEnabled = ProcessInfo.processInfo.environment["MD_PERF_VERIFY"] == "1"
#else
static let enabled = false
static let verifyEnabled = false
#endif
// All call sites run on the main thread (the coordinator + text view are
// main-actor), so plain static state is safe under the package's Swift 5 mode.
private static var active = false
private static var frameStart: UInt64 = 0
private static var docLength = 0
private static var phases: [(String, Double)] = []
private static var notes: [String] = []
/// Summed costs for code that runs MANY times per frame or from inside
/// AppKit callbacks (caret reveal, spell-checker callbacks) printed as
/// `+label=(×n)` after the sequential phases.
private static var accumulated: [(String, Double, Int)] = []
private static func nowMs() -> Double {
Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000
}
/// Open a per-keystroke frame. Every `measure`/`note` until `end()` attaches to it.
/// A frame already opened this keystroke is CONTINUED, not reset:
/// shouldChangeTextIn opens the frame (so the pre-edit parse and the
/// smart-input interceptors are counted they used to run before the
/// frame and were invisible), the mid-edit selection change and
/// textDidChange attach to it. A frame left open by an edit that never
/// reached textDidChange is considered stale after 1s and reset.
static func begin(docLength len: Int) {
guard enabled else { return }
let now = DispatchTime.now().uptimeNanoseconds
docLength = len
if active, Double(now - frameStart) / 1_000_000 < 1_000 { return }
active = true
phases.removeAll(keepingCapacity: true)
notes.removeAll(keepingCapacity: true)
accumulated.removeAll(keepingCapacity: true)
frameStart = now
}
/// Like `measure`, but SUMS repeated calls under one label instead of
/// appending a phase per call for work triggered from inside AppKit
/// (caret reveal, spell-checker callbacks) that can fire several times
/// per keystroke and would otherwise stay invisible in the frame.
@discardableResult
static func accumulate<T>(_ label: String, _ body: () -> T) -> T {
guard enabled, active else { return body() }
let t0 = nowMs()
let result = body()
let dt = nowMs() - t0
if let i = accumulated.firstIndex(where: { $0.0 == label }) {
accumulated[i].1 += dt
accumulated[i].2 += 1
} else {
accumulated.append((label, dt, 1))
}
return result
}
/// Time one sequential top-level phase of the current frame.
@discardableResult
static func measure<T>(_ label: String, _ body: () -> T) -> T {
guard enabled, active else { return body() }
let t0 = nowMs()
let result = body()
phases.append((label, nowMs() - t0))
return result
}
/// Attach a free-form detail line (e.g. how many tables were re-rendered).
/// The closure only runs when tracing is active, so it costs nothing when off.
static func note(_ make: () -> String) {
guard enabled, active else { return }
notes.append(make())
}
/// Record a named timestamp (offset from frame start) inline in the
/// breakdown, printed as `@label=12.34`. The gaps BETWEEN checkpoints and
/// the measured spans locate work the spans don't cover (AppKit edit
/// application, layout, notification dispatch between our callbacks).
static func checkpoint(_ label: String) {
guard enabled, active else { return }
phases.append(("@" + label, Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000))
}
/// Close the frame and print total + per-phase breakdown + notes.
/// `other` = total Σ(phases + accumulated): time inside the frame that
/// no span covers (AppKit edit processing, layout, unmeasured code).
static func end() {
guard enabled, active else { return }
active = false
let total = Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000
var breakdown = phases.map { String(format: "%@=%.2f", $0.0, $0.1) }.joined(separator: " ")
if !accumulated.isEmpty {
breakdown += " " + accumulated.map { String(format: "+%@=%.2f(×%d)", $0.0, $0.1, $0.2) }.joined(separator: " ")
}
let covered = phases.filter { !$0.0.hasPrefix("@") }.reduce(0) { $0 + $1.1 }
+ accumulated.reduce(0) { $0 + $1.1 }
print(String(format: "⌨️ PERF doc=%dch total=%.2fms | %@ other=%.2f", docLength, total, breakdown, total - covered))
for note in notes { print(" └─ \(note)") }
}
/// Standalone timing print for a cost that runs *outside* the keystroke frame
/// (e.g. the async wide-table overlay reconcile fired after the edit settles).
static func stamp(_ label: String, _ ms: Double, _ detail: @autoclosure () -> String = "") {
guard enabled else { return }
print(String(format: "⏱️ PERF %@ %.2fms %@", label, ms, detail()))
}
}
@@ -0,0 +1,63 @@
//
// ContainerExtension.swift
// MarkdownEngine
//
// Created by Luca Chen on 15.07.26.
//
// `:::` fenced container as the first block extension. Not registered by
// default the core engine parses pure markdown; embedders opt in via:
//
// configuration.extensions = [ContainerExtension()]
//
// Syntax:
//
// ::: note
// Body text with **inline** markdown.
// :::
//
// The fence lines hide while the caret is outside the block and reveal
// muted while editing (mirroring code fences); the body keeps full inline
// styling plus the container background. An unclosed container runs to the end
// of the document (unlike an unclosed ``` fence, which stays literal text
// until its closing fence exists).
//
import AppKit
import Foundation
public struct ContainerExtension: MarkdownExtension {
/// Well-known id.
public static let identifier = "container"
/// Background tint applied to the container block.
public var backgroundColor: NSColor
/// The default resolves per appearance: `withAlphaComponent` on a dynamic
/// system color FREEZES it (same gotcha the table renderer documents), so
/// the tint is rebuilt inside a dynamic provider and keeps tracking
/// light/dark mode.
public init(backgroundColor: NSColor = NSColor(name: nil) { appearance in
var resolved = NSColor.systemBlue
appearance.performAsCurrentDrawingAppearance {
resolved = NSColor.systemBlue.usingColorSpace(.sRGB) ?? .systemBlue
}
return resolved.withAlphaComponent(0.12)
}) {
self.backgroundColor = backgroundColor
}
public var id: String { Self.identifier }
public var block: BlockSyntax? {
BlockSyntax(fence: ":::")
}
public func contentAttributes(theme: MarkdownEditorTheme) -> [NSAttributedString.Key: Any] {
[.backgroundColor: backgroundColor]
}
public func html(childrenHTML: String) -> String {
"<blockquote>\(childrenHTML)</blockquote>"
}
}
@@ -0,0 +1,46 @@
//
// HighlightExtension.swift
// MarkdownEngine
//
// Created by Luca Chen on 15.07.26.
//
// `==text==` highlight (Obsidian/CriticMarkup flavor) as the first inline
// span extension. Not registered by default the core engine parses pure
// markdown; embedders opt in via:
//
// configuration.extensions = [HighlightExtension()]
//
// Behavior is identical to the formerly built-in construct: content gets the
// theme's highlight background, content is re-parsed (emphasis etc. nest),
// markers mute while the caret is inside and shrink away otherwise, and the
// clean-copy path emits `<mark>`.
//
import AppKit
import Foundation
public struct HighlightExtension: MarkdownExtension {
/// Well-known id, referenced by the engine's formatting actions
/// (context menu / `applyHighlightRequest` toggle).
public static let identifier = "highlight"
public init() {}
public var id: String { Self.identifier }
public var inline: InlineSyntax? {
InlineSyntax(open: "==", close: "==")
}
/// `.markdownBlockBackground`, not `.backgroundColor`: the fill covers the
/// whole line box, so a highlight that wraps over several lines reads as
/// one block instead of a band per line (see the key's own note).
public func contentAttributes(theme: MarkdownEditorTheme) -> [NSAttributedString.Key: Any] {
[.markdownBlockBackground: theme.highlightColor]
}
public func html(childrenHTML: String) -> String {
"<mark>\(childrenHTML)</mark>"
}
}
@@ -0,0 +1,237 @@
//
// MarkdownExtension.swift
// MarkdownEngine
//
// Created by Luca Chen on 15.07.26.
//
// The extension seam: a construct beyond pure markdown an inline span
// (`==text==`, `%%text%%`, ) and/or a fenced block (`::: :::`) can be
// supplied by an extension instead of being hard-coded into the parser. The core stays pure
// markdown; extensions are opt-in per editor instance via
// `MarkdownEditorConfiguration.extensions`.
//
// Isolation contract: an extension supplies SYNTAX (the delimiters) and
// ATTRIBUTES (how the content looks). It never emits ranges the parser
// derives content/marker ranges itself, so a buggy extension can restyle its
// own span at worst, never a neighbor. Marker mute/shrink, caret reveal,
// incremental restyle, and copy behavior are handled generically by the
// engine, identical for every extension.
//
import AppKit
import Foundation
// MARK: - Syntax rule
/// The syntax of a delimited span, mirroring the semantics of the engine's
/// built-in span scanners:
///
/// * The span opens where `open` matches and closes at the FIRST exact `close`
/// match on the same line.
/// * A lone occurrence of `close`'s first character inside the content aborts
/// the match (the candidate stays literal) `==a=b==` is not a span.
/// * A newline before the close aborts the match (spans are single-line).
public struct InlineSyntax: Sendable, Equatable {
/// Opening delimiter, e.g. `"=="`.
public var open: String
/// Closing delimiter, e.g. `"=="`.
public var close: String
/// Whether the content is re-parsed as markdown (container, like
/// `==bold **inside**==`) or kept opaque (leaf, like a comment).
///
/// Note: an opaque span's content is still VISIBLE text carrying the
/// extension's `contentAttributes` the engine does not yet offer a
/// caret-aware hide/reveal affordance for content (markers shrink
/// generically, content does not). A comment-style extension that wants
/// to fully hide its content needs that future affordance.
public var parsesContent: Bool
/// Reject an empty span (`====`). Default `true`.
public var requiresNonEmptyContent: Bool
/// Reject when the character before `open` equals `open`'s first character
/// (the span must not extend a longer delimiter run). Default `true`,
/// matching `~~`/`==` built-in behavior.
public var rejectsOpenerRun: Bool
/// Reject when the character after `close` equals `close`'s last character.
/// `~~` uses this (strict GFM-ish run handling); `==` does not. Default `false`.
public var rejectsCloserRun: Bool
public init(
open: String,
close: String,
parsesContent: Bool = true,
requiresNonEmptyContent: Bool = true,
rejectsOpenerRun: Bool = true,
rejectsCloserRun: Bool = false
) {
self.open = open
self.close = close
self.parsesContent = parsesContent
self.requiresNonEmptyContent = requiresNonEmptyContent
self.rejectsOpenerRun = rejectsOpenerRun
self.rejectsCloserRun = rejectsCloserRun
}
}
// MARK: - Block syntax rule
/// The syntax of a fenced block, mirroring the engine's built-in fence
/// semantics (``` code fences):
///
/// * A line starting with `fence` at column 0 OPENS the block; the rest of
/// that line is the info string (e.g. `::: warning`).
/// * The next line starting with `fence` at column 0 CLOSES it.
/// * An unclosed block runs to the end of the document.
///
/// Built-in constructs always classify first a fence that collides with a
/// built-in line form (```, `$$`, `#`, `>`, list markers, `||`) never fires.
public struct BlockSyntax: Sendable, Equatable {
/// Fence prefix that opens and closes the block, e.g. `":::"`.
public var fence: String
public init(fence: String) {
self.fence = fence
}
}
// MARK: - Extension protocol
/// An opt-in construct beyond pure markdown. Register instances via
/// `MarkdownEditorConfiguration.extensions`; an unregistered construct's
/// syntax stays literal text.
///
/// An extension contributes one or both syntax forms:
/// * ``inline`` a delimited span on a single line (`==text==`).
/// * ``block`` a fenced multi-line block (`:::` `:::`).
///
/// It supplies only SYNTAX (delimiters) and ATTRIBUTES (how its content
/// looks). It never emits ranges the parser derives all geometry so a
/// misbehaving extension can at worst restyle its own construct, never a
/// neighbor. Marker mute/hide, caret reveal, incremental restyle, table
/// cells, and rich copy are handled generically by the engine, identical
/// for every extension.
public protocol MarkdownExtension: Sendable {
/// Stable identifier, unique per extension (e.g. `"highlight"`). Used for
/// dispatch and cache keying never shown to users.
var id: String { get }
/// Inline span form; `nil` when the extension has none (default).
var inline: InlineSyntax? { get }
/// Fenced block form; `nil` when the extension has none (default).
var block: BlockSyntax? { get }
/// Attributes applied to the construct's CONTENT range (between the
/// markers/fences). Called during styling; must be cheap and synchronous.
func contentAttributes(theme: MarkdownEditorTheme) -> [NSAttributedString.Key: Any]
/// Wrap the rendered inner HTML for the clean-copy path
/// (`childrenHTML` is already escaped / recursively rendered).
func html(childrenHTML: String) -> String
}
public extension MarkdownExtension {
// NOTE: because these have nil defaults, a conformance that misspells the
// property name (`var inlin: `) compiles fine and silently yields an
// inert extension. If your construct never fires, check these two names
// first.
var inline: InlineSyntax? { nil }
var block: BlockSyntax? { nil }
}
// MARK: - Parser-facing registry (internal)
/// Precompiled, purely syntactic view of the registered extensions the only
/// thing the parser sees. Built once per parse entry from the configuration.
struct ExtensionRegistry {
struct Entry {
let id: String
let open: [unichar]
let close: [unichar]
let syntax: InlineSyntax
}
struct BlockEntry {
let id: String
let fence: String
let fenceChars: [unichar]
}
/// Inline span rules, in registration order.
let entries: [Entry]
/// Fenced block rules, in registration order.
let blockEntries: [BlockEntry]
/// Stable fingerprint for cache keying ("" when empty). Two registries with
/// the same fingerprint produce identical parses for identical text.
let fingerprint: String
static let empty = ExtensionRegistry(entries: [], blockEntries: [], fingerprint: "")
private init(entries: [Entry], blockEntries: [BlockEntry], fingerprint: String) {
self.entries = entries
self.blockEntries = blockEntries
self.fingerprint = fingerprint
}
init(extensions: [any MarkdownExtension]) {
guard !extensions.isEmpty else {
self = .empty
return
}
self.entries = extensions.compactMap { ext in
guard let syntax = ext.inline else { return nil }
return Entry(
id: ext.id,
open: Array(syntax.open.utf16),
close: Array(syntax.close.utf16),
syntax: syntax
)
}
self.blockEntries = extensions.compactMap { ext in
guard let block = ext.block, !block.fence.isEmpty else { return nil }
return BlockEntry(id: ext.id, fence: block.fence, fenceChars: Array(block.fence.utf16))
}
// Every syntax field participates: registries that differ in ANY flag
// must never share cached parse results. Free-text fields (id, open,
// close, fence) are length-prefixed so the concatenation is injective
// an id containing the separator characters cannot alias another
// registry.
func framed(_ str: String) -> String { "\(str.utf16.count).\(str)" }
self.fingerprint = extensions
.map { ext in
var parts = [framed(ext.id)]
if let s = ext.inline {
parts += ["i", framed(s.open), framed(s.close),
"\(s.parsesContent)", "\(s.requiresNonEmptyContent)",
"\(s.rejectsOpenerRun)", "\(s.rejectsCloserRun)"]
}
if let b = ext.block {
parts += ["b", framed(b.fence)]
}
return parts.joined(separator: ",")
}
.joined(separator: "|")
}
var isEmpty: Bool { entries.isEmpty && blockEntries.isEmpty }
/// The first registered block rule whose fence opens `line` (column 0),
/// or nil. Registration order is precedence, matching the inline rules.
func blockEntry(opening line: String) -> BlockEntry? {
blockEntries.first { line.hasPrefix($0.fence) }
}
/// The block rule with the given extension id, or nil.
func blockEntry(for id: String) -> BlockEntry? {
blockEntries.first { $0.id == id }
}
}
extension MarkdownEditorConfiguration {
/// The parser-facing registry derived from `extensions`.
var extensionRegistry: ExtensionRegistry {
ExtensionRegistry(extensions: extensions)
}
/// Styler-facing lookup: extension behavior by id.
var extensionsByID: [String: any MarkdownExtension] {
var out: [String: any MarkdownExtension] = [:]
for ext in extensions { out[ext.id] = ext }
return out
}
}
@@ -0,0 +1,45 @@
//
// StrikethroughExtension.swift
// MarkdownEngine
//
// Created by Luca Chen on 15.07.26.
//
// `~~text~~` strikethrough (GFM flavor) as an inline span extension. Not
// registered by default the core engine parses pure markdown; embedders
// opt in via:
//
// configuration.extensions = [StrikethroughExtension()]
//
// Matches the formerly built-in semantics exactly, including the stricter
// GFM-ish run handling: `~~a~~~` stays literal (the closer must not extend
// into a longer `~` run), unlike highlight's tolerant `==abc===`.
//
import AppKit
import Foundation
public struct StrikethroughExtension: MarkdownExtension {
/// Well-known id, referenced by the engine's formatting actions
/// (context menu / `applyStrikethroughRequest` toggle).
public static let identifier = "strikethrough"
public init() {}
public var id: String { Self.identifier }
public var inline: InlineSyntax? {
InlineSyntax(open: "~~", close: "~~", rejectsCloserRun: true)
}
public func contentAttributes(theme: MarkdownEditorTheme) -> [NSAttributedString.Key: Any] {
[
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
.strikethroughColor: theme.strikethroughColor,
]
}
public func html(childrenHTML: String) -> String {
"<del>\(childrenHTML)</del>"
}
}
@@ -0,0 +1,130 @@
//
// MarkdownInputHandler.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Handles Markdown typing shortcuts, like continuing lists and keeping block
// LaTeX on its own line while you type.
import AppKit
enum MarkdownInputHandler {
/// `codeTokens` (codeBlock + inlineCode, from the keystroke's existing
/// parse) answers "is the caret in code?" without the O(doc) document
/// scan the handler otherwise runs on every space/Enter/Tab.
static func handleListInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?, codeTokens: [MarkdownToken]? = nil) -> Bool {
let isInsideCodeBlock = codeTokens.map {
MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, codeTokens: $0)
}
return MarkdownLists.handleInsertion(textView: textView, affectedCharRange: affectedCharRange,
replacementString: replacementString, isInsideCodeBlock: isInsideCodeBlock)
}
// MARK: - Block LaTeX Auto-Wrap
private static func insertTextProgrammatically(_ textView: NSTextView, text: String, at range: NSRange, cursorAfter: Int) {
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator {
coord.isProgrammaticEdit = true
// Replaces a suppressed keystroke that never applied reset its
// pending count so this edit registers as the cycle's single
// tracked edit and textDidChange keeps the trusted fast paths.
coord.pendingEditCount = 0
}
textView.insertText(text, replacementRange: range)
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator {
coord.isProgrammaticEdit = false
}
textView.setSelectedRange(NSRange(location: cursorAfter, length: 0))
}
/// Keeps block LaTeX ($$...$$) on its own line by inserting newlines; returns true if handled.
static func handleBlockLatexAutoWrap(
textView: NSTextView,
affectedCharRange: NSRange,
replacementString: String?,
blockLatexTokens: [MarkdownToken]? = nil
) -> Bool {
let resolvedTokens: [MarkdownToken]
if let blockLatexTokens {
resolvedTokens = blockLatexTokens
} else {
resolvedTokens = MarkdownTokenizer.parseTokensViaAST(
in: textView.string,
registry: (textView as? NativeTextView)?.configuration.extensionRegistry ?? .empty
).filter { $0.kind == .blockLatex }
}
return handleBlockAutoWrap(textView: textView, affectedCharRange: affectedCharRange,
replacementString: replacementString, tokens: resolvedTokens)
}
/// Ensures image embeds (![[...]]) stay on their own line by automatically inserting newlines.
static func handleImageEmbedAutoWrap(
textView: NSTextView,
affectedCharRange: NSRange,
replacementString: String?,
imageEmbedTokens: [MarkdownToken]? = nil
) -> Bool {
let resolvedTokens: [MarkdownToken]
if let imageEmbedTokens {
resolvedTokens = imageEmbedTokens
} else {
resolvedTokens = MarkdownTokenizer.parseTokensViaAST(
in: textView.string,
registry: (textView as? NativeTextView)?.configuration.extensionRegistry ?? .empty
).filter { $0.kind == .imageEmbed }
}
return handleBlockAutoWrap(textView: textView, affectedCharRange: affectedCharRange,
replacementString: replacementString, tokens: resolvedTokens)
}
/// Shared auto-wrap logic: ensures a block-level token stays on its own line.
private static func handleBlockAutoWrap(
textView: NSTextView,
affectedCharRange: NSRange,
replacementString: String?,
tokens: [MarkdownToken]
) -> Bool {
guard let replacement = replacementString,
!replacement.isEmpty,
replacement != "\n" else { return false }
let text = textView.string as NSString
let newlineChar = UInt16(("\n" as Character).asciiValue!)
for token in tokens {
let tokenEnd = NSMaxRange(token.range)
// Typing right after closing marker
if affectedCharRange.location == tokenEnd {
if tokenEnd < text.length && text.character(at: tokenEnd) == newlineChar {
insertTextProgrammatically(textView, text: replacement,
at: NSRange(location: tokenEnd + 1, length: 0),
cursorAfter: tokenEnd + 1 + replacement.utf16.count)
} else {
insertTextProgrammatically(textView, text: "\n" + replacement,
at: affectedCharRange,
cursorAfter: affectedCharRange.location + 1 + replacement.utf16.count)
}
return true
}
// Typing right before opening marker
if affectedCharRange.location == token.range.location {
if token.range.location > 0 && text.character(at: token.range.location - 1) == newlineChar {
insertTextProgrammatically(textView, text: replacement,
at: NSRange(location: token.range.location - 1, length: 0),
cursorAfter: token.range.location - 1 + replacement.utf16.count)
} else {
insertTextProgrammatically(textView, text: replacement + "\n",
at: affectedCharRange,
cursorAfter: affectedCharRange.location + replacement.utf16.count)
}
return true
}
}
return false
}
}
@@ -0,0 +1,327 @@
//
// MarkdownListHandler.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Makes list editing feel natural by continuing items, handling indentation,
// and applying spacing/alignment that keeps lists easy to read.
import AppKit
struct MarkdownLists {
static func performEdit(_ textView: NSTextView, replace range: NSRange, with string: String) {
let ns = textView.string as NSString
let loc = min(range.location, ns.length)
let maxLen = ns.length - loc
let len = min(range.length, max(0, maxLen))
let safeRange = NSRange(location: loc, length: len)
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator {
coord.isProgrammaticEdit = true
// This edit REPLACES a suppressed keystroke that never applied.
// Dropping its pending count lets the shouldChangeText below
// re-register as the cycle's single tracked edit, so textDidChange
// keeps the trusted fast paths (the descriptor is refreshed for
// every proposed edit and describes THIS transition exactly).
coord.pendingEditCount = 0
}
defer {
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { coord.isProgrammaticEdit = false }
}
guard textView.shouldChangeText(in: safeRange, replacementString: string) else { return }
textView.textStorage?.replaceCharacters(in: safeRange, with: string)
textView.didChangeText()
}
// Markers: `-`/`*`/`+` (raw Markdown) + legacy `` (rendered, never typed).
static let listRegex = try! NSRegularExpression(
pattern: #"^\s*((?:(\d+)\.|[-•*+])(?:\s+\[[ xX]\])?\s+)"#
)
/// Blockquote line: 3 indent + `>` marker run; group 1 = whitespace, group 2 = markers.
// Trailing `[ \t]*` so the prefix length covers the space(s) the continuation
// inserts (`markers + " "`) otherwise exiting an empty quote leaves a stray
// space (greedy like listRegex's `\s+`).
static let blockquoteRegex = try! NSRegularExpression(
pattern: #"^( {0,3})(>+(?:[ \t]+>+)*)[ \t]*"#
)
static let dashNoSpaceRegex = try! NSRegularExpression(pattern: #"^\s*-(?!\s)"#)
static let leadingWhitespaceRegex = try! NSRegularExpression(pattern: #"^\s*"#)
static func indentLevel(from leadingWhitespace: String) -> Int {
let tabCount = leadingWhitespace.filter { $0 == "\t" }.count
let spaceCount = leadingWhitespace.filter { $0 == " " }.count
return tabCount + (spaceCount / 2)
}
/// Remove the current line's leading marker and put the caret at line start (exit empty block on Enter).
private static func removeLinePrefixAndExit(
textView: NSTextView,
currentLineRange: NSRange,
prefixLength: Int
) -> Bool {
let lineEnd = currentLineRange.location + currentLineRange.length
let hasNewline = currentLineRange.length > 0
&& (textView.string as NSString)
.substring(with: NSRange(location: lineEnd - 1, length: 1)) == "\n"
let maxBodyLen = hasNewline ? currentLineRange.length - 1 : currentLineRange.length
let removalLength = min(prefixLength, maxBodyLen)
let removalRange = NSRange(location: currentLineRange.location, length: removalLength)
performEdit(textView, replace: removalRange, with: "")
textView.setSelectedRange(NSRange(location: currentLineRange.location, length: 0))
return false
}
/// Mirror Enter-key quote continuation for multi-line pastes: when `location`
/// sits on a blockquote line, prefix every line after the first with that
/// line's `>` marker run so the whole paste stays inside the quote. Returns
/// `pasted` unchanged when it has no newline or the caret isn't in a quote.
static func blockquoteContinuedPaste(_ pasted: String, at location: Int, in document: String) -> String {
guard pasted.contains("\n") else { return pasted }
let ns = document as NSString
guard location >= 0, location <= ns.length else { return pasted }
let lineRange = ns.lineRange(for: NSRange(location: location, length: 0))
let nsLine = ns.substring(with: lineRange) as NSString
guard let match = blockquoteRegex.firstMatch(
in: nsLine as String,
range: NSRange(location: 0, length: nsLine.length)
) else { return pasted }
let ws = nsLine.substring(with: match.range(at: 1))
let markers = nsLine.substring(with: match.range(at: 2))
let prefix = ws + markers + " "
return pasted.replacingOccurrences(of: "\n", with: "\n" + prefix)
}
// MARK: - Input Handling
/// `isInsideCodeBlock` is the caller's pre-parsed answer for
/// `affectedCharRange.location` (the coordinator derives it from the
/// keystroke's existing parse). `nil` direct callers without a parse
/// falls back to deriving it here, which walks the whole document.
static func handleInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?, isInsideCodeBlock: Bool? = nil) -> Bool {
guard let replacementString = replacementString else { return true }
// Fast path: plain characters never trigger list/pair/arrow handling.
if replacementString.count == 1,
let ch = replacementString.first,
ch != ">" && ch != "[" && ch != "(" && ch != "{" &&
ch != "\t" && ch != " " && ch != "\n" {
return true
}
let activeConfig = (textView as? NativeTextView)?.configuration ?? .default
let listsEnabled = activeConfig.lists.helpersEnabled
let autoClosePairsEnabled = activeConfig.lists.autoClosePairsEnabled
func insertAutoPair(open openChar: String, close closeChar: String) -> Bool {
let insertionLocation = affectedCharRange.location
MarkdownLists.performEdit(textView, replace: affectedCharRange, with: "\(openChar)\(closeChar)")
textView.setSelectedRange(NSRange(location: insertionLocation + openChar.count, length: 0))
return false
}
let isInCodeBlock = isInsideCodeBlock ?? (
textView.string.contains("`")
? MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, in: textView.string)
: false
)
if replacementString == ">" && affectedCharRange.length == 0 && !isInCodeBlock {
let insertionLocation = affectedCharRange.location
guard insertionLocation > 0 else { return true }
let nsText = textView.string as NSString
let previousCharRange = NSRange(location: insertionLocation - 1, length: 1)
let previousChar = nsText.substring(with: previousCharRange)
if previousChar == "-" {
MarkdownLists.performEdit(textView, replace: previousCharRange, with: "")
textView.setSelectedRange(NSRange(location: insertionLocation, length: 0))
return false
}
}
// Autocomplete Obsidian-style node brackets and single square brackets
if replacementString == "[" {
let nsText = textView.string as NSString
let insertionLocation = affectedCharRange.location
if insertionLocation > 0 {
let prevChar = nsText.substring(with: NSRange(location: insertionLocation - 1, length: 1))
if prevChar == "[" {
let hasAutoCloseBracket = insertionLocation < nsText.length
&& nsText.substring(with: NSRange(location: insertionLocation, length: 1)) == "]"
if hasAutoCloseBracket {
// Collapse auto-paired "[]" into "[[]]" without changing surrounding text.
MarkdownLists.performEdit(
textView,
replace: NSRange(location: insertionLocation - 1, length: 2),
with: "[[]]"
)
} else {
// If the char to the right is not "]" (e.g. newline), do not delete it.
MarkdownLists.performEdit(textView, replace: affectedCharRange, with: "[]]")
}
textView.setSelectedRange(NSRange(location: insertionLocation + 1, length: 0))
return false
}
}
guard autoClosePairsEnabled else { return true }
return insertAutoPair(open: "[", close: "]")
}
// Autocomplete parentheses / braces
if replacementString == "(" || replacementString == "{" {
guard autoClosePairsEnabled else { return true }
let closeChar = (replacementString == "(") ? ")" : "}"
return insertAutoPair(open: replacementString, close: closeChar)
}
// TAB: indent list items (skip in code blocks)
if replacementString == "\t" && !isInCodeBlock {
guard listsEnabled else { return true }
let nsText = textView.string as NSString
let insertionLocation = affectedCharRange.location
let safeLocTAB = min(affectedCharRange.location, nsText.length)
let currentLineRange = nsText.lineRange(for: NSRange(location: safeLocTAB, length: 0))
let currentLine = nsText.substring(with: currentLineRange)
if MarkdownLists.listRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) != nil {
if let wsMatch = MarkdownLists.leadingWhitespaceRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) {
let ws = (currentLine as NSString).substring(with: wsMatch.range)
let level = MarkdownLists.indentLevel(from: ws)
if level >= MarkdownEditorConfiguration.default.lists.maximumNestingLevel {
return false
}
}
MarkdownLists.performEdit(textView, replace: NSRange(location: currentLineRange.location, length: 0), with: "\t")
textView.setSelectedRange(NSRange(location: insertionLocation + 1, length: 0))
return false
}
if MarkdownLists.dashNoSpaceRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) != nil {
if let wsMatch = MarkdownLists.leadingWhitespaceRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) {
let ws = (currentLine as NSString).substring(with: wsMatch.range)
let level = MarkdownLists.indentLevel(from: ws)
if level >= MarkdownEditorConfiguration.default.lists.maximumNestingLevel { return false }
}
MarkdownLists.performEdit(textView, replace: NSRange(location: currentLineRange.location, length: 0), with: "\t")
textView.setSelectedRange(NSRange(location: insertionLocation + 1, length: 0))
return false
}
return true
}
// ENTER: list continuation/outdent
if replacementString == "\n" {
let nsText = textView.string as NSString
let safeLocENTER = min(affectedCharRange.location, nsText.length)
let currentLineRange = nsText.lineRange(for: NSRange(location: safeLocENTER, length: 0))
let currentLine = nsText.substring(with: currentLineRange).trimmingCharacters(in: .whitespacesAndNewlines)
// Horizontal rules render via the styler; source stays literal `---` so files round-trip.
if currentLine.range(of: "^```\\w*$", options: .regularExpression) != nil {
// Non-overlapping ``` count before the line (what
// components(separatedBy:).count-1 computed, without
// materializing an O(doc) substring array).
var openingCount = 0
var searchLocation = 0
while searchLocation < currentLineRange.location {
let found = nsText.range(of: "```", options: [],
range: NSRange(location: searchLocation,
length: currentLineRange.location - searchLocation))
if found.location == NSNotFound { break }
openingCount += 1
searchLocation = NSMaxRange(found)
}
let afterLineStart = currentLineRange.location + currentLineRange.length
let hasClosingAfter: Bool = {
guard afterLineStart < nsText.length else { return false }
let after = NSRange(location: afterLineStart, length: nsText.length - afterLineStart)
return nsText.range(of: "```", options: [], range: after).location != NSNotFound
}()
let lineEnd = currentLineRange.location + max(0, currentLineRange.length - 1)
let cursorAtLineEnd = affectedCharRange.location >= lineEnd
if openingCount.isMultiple(of: 2) && cursorAtLineEnd && !hasClosingAfter {
let insertionLocation = affectedCharRange.location
let completion = "\n\n```"
MarkdownLists.performEdit(textView, replace: affectedCharRange, with: completion)
textView.setSelectedRange(NSRange(location: insertionLocation + 1, length: 0))
return false
}
}
// Skip list / blockquote continuation in code blocks.
guard listsEnabled && !isInCodeBlock else { return true }
// Blockquote continuation: `> foo` `\n> `, `>>>` stays `>>>`, empty marker exit.
let quoteLine = nsText.substring(with: currentLineRange)
if let quoteMatch = MarkdownLists.blockquoteRegex.firstMatch(
in: quoteLine,
range: NSRange(location: 0, length: quoteLine.utf16.count)
) {
let ws = (quoteLine as NSString).substring(with: quoteMatch.range(at: 1))
let markers = (quoteLine as NSString).substring(with: quoteMatch.range(at: 2))
let prefixLength = quoteMatch.range.length
let contentStart = quoteMatch.range.location + prefixLength
let contentLength = quoteLine.utf16.count - contentStart
let contentText = (quoteLine as NSString)
.substring(with: NSRange(location: contentStart, length: contentLength))
.trimmingCharacters(in: .whitespacesAndNewlines)
if contentText.isEmpty {
return removeLinePrefixAndExit(
textView: textView,
currentLineRange: currentLineRange,
prefixLength: prefixLength
)
}
MarkdownLists.performEdit(textView, replace: affectedCharRange, with: "\n" + ws + markers + " ")
return false
}
let listLine = nsText.substring(with: currentLineRange)
if let match = MarkdownLists.listRegex.firstMatch(in: listLine, range: NSRange(location: 0, length: listLine.utf16.count)) {
let contentStart = match.range.location + match.range.length
let contentLength = listLine.utf16.count - contentStart
let contentRangeLocal = NSRange(location: contentStart, length: contentLength)
let contentText = (listLine as NSString).substring(with: contentRangeLocal).trimmingCharacters(in: .whitespacesAndNewlines)
if contentText.isEmpty {
return removeLinePrefixAndExit(
textView: textView,
currentLineRange: currentLineRange,
prefixLength: match.range.location + match.range.length
)
}
let leadingWhitespace: String
if let wsMatch = MarkdownLists.leadingWhitespaceRegex.firstMatch(in: listLine, range: NSRange(location: 0, length: listLine.utf16.count)) {
leadingWhitespace = (listLine as NSString).substring(with: wsMatch.range)
} else {
leadingWhitespace = ""
}
let markerRaw = (listLine as NSString).substring(with: match.range(at: 1))
let marker = markerRaw.trimmingCharacters(in: .whitespaces)
let hasCheckbox = marker.range(of: #"\[[ xX]\]"#, options: .regularExpression) != nil
let newListItem: String
if match.range(at: 2).location != NSNotFound,
let number = Int((listLine as NSString).substring(with: match.range(at: 2))) {
if hasCheckbox {
newListItem = "\n" + leadingWhitespace + "\(number + 1). [ ] "
} else {
newListItem = "\n" + leadingWhitespace + "\(number + 1). "
}
} else {
// Continue with the user's marker char (legacy `` `-`), keeping leading whitespace.
let bulletChar = (marker.first == "") ? "-" : String(marker.prefix(1))
if hasCheckbox {
newListItem = "\n" + leadingWhitespace + bulletChar + " [ ] "
} else {
newListItem = "\n" + leadingWhitespace + bulletChar + " "
}
}
MarkdownLists.performEdit(textView, replace: affectedCharRange, with: newListItem)
return false
}
}
return true
}
}
@@ -0,0 +1,113 @@
# ``MarkdownEngine``
A TextKit 2-backed Markdown editor view for macOS, bridged to SwiftUI.
## Overview
MarkdownEngine provides a native AppKit Markdown editor with live styling,
wiki-style ``[[Name]]`` linking, fenced code blocks with syntax highlighting,
LaTeX rendering, embedded images, and GitHub-style task checkboxes.
The engine itself has **zero external dependencies**. Everything app-specific
is injected through small service protocols, so embedders stay in control of
where wiki-links resolve, where embedded images live, how code is highlighted,
and how LaTeX is rendered.
### Quick Start
```swift
import SwiftUI
import MarkdownEngine
struct EditorScreen: View {
@State private var text: String = "# Hello, *world*"
@State private var isLinkActive: Bool = false
@State private var pendingReplacement: InlineReplacementRequest?
var body: some View {
NativeTextViewWrapper(
text: $text,
isWikiLinkActive: $isLinkActive,
pendingInlineReplacement: $pendingReplacement,
configuration: .default,
fontName: "SF Pro",
documentId: "doc-1"
)
}
}
```
The default ``MarkdownEditorConfiguration`` ships with no-op service
implementations, so the editor renders plain Markdown out of the box. Add
real services as you need them.
### Customizing Appearance
```swift
var theme = MarkdownEditorTheme.default
theme.bodyText = .labelColor
theme.headingMarker = .secondaryLabelColor
var configuration = MarkdownEditorConfiguration.default
configuration.theme = theme
```
### Wiring Up Services
```swift
let services = MarkdownEditorServices(
wikiLinks: MyWikiLinkResolver(),
images: MyImageProvider(),
syntaxHighlighter: MySyntaxHighlighter(),
latex: MyLatexRenderer()
)
var configuration = MarkdownEditorConfiguration.default
configuration.services = services
```
## Topics
### Editor View
- ``NativeTextViewWrapper``
### Configuration
- ``MarkdownEditorConfiguration``
- ``MarkdownEditorTheme``
### Service Protocols
- ``WikiLinkResolver``
- ``EmbeddedImageProvider``
- ``SyntaxHighlighter``
- ``LatexRenderer``
### Services Container
- ``MarkdownEditorServices``
- ``MarkdownEditorBus``
### Default No-Op Implementations
- ``NoOpWikiLinkResolver``
- ``NoOpEmbeddedImageProvider``
- ``PlainTextSyntaxHighlighter``
- ``NoOpLatexRenderer``
### Selection & Replacement
- ``InlineSelectionState``
- ``InlineSelectionKind``
- ``WikiLinkSelection``
- ``InlineReplacementRequest``
- ``CodeBlockSelection``
### Wiki-Link Roundtripping
- ``WikiLinkService``
### Pasteboard Helpers
- ``PasteboardImageReader``
@@ -0,0 +1,269 @@
//
// BlockLevelTokenizer.swift
// MarkdownEngine
//
// Builds the block-level MarkdownTokens (heading, blockquote, fenced code,
// table, block LaTeX) directly from already-classified block substrings
// replacing the legacy `parseTokens` regexes. Inline tokens come from the AST
// (`InlineParser` `InlineASTAdapter`); this only covers block-level kinds.
//
// Token shapes are reproduced 1:1 from the old regex tokenizer so every
// downstream consumer (ContextMenu, code/LaTeX detection, the NSImage render
// passes) sees identical tokens. A parity check pins that during the swap.
//
import Foundation
enum BlockLevelTokenizer {
private static let backtick: unichar = 0x60
private static let dollar: unichar = 0x24
private static let hash: unichar = 0x23
private static let pipe: unichar = 0x7C
private static let gt: unichar = 0x3E
private static let dash: unichar = 0x2D
private static let colon: unichar = 0x3A
private static let space: unichar = 0x20
private static let tab: unichar = 0x09
private static let lf: unichar = 0x0A
private static let cr: unichar = 0x0D
private static func isWS(_ c: unichar) -> Bool { c == space || c == tab }
/// Content end (excludes trailing CR/LF) and next-line start for the line at `start`.
private static func line(in s: NSString, from start: Int) -> (contentEnd: Int, nextStart: Int) {
let len = s.length
var i = start
while i < len, s.character(at: i) != lf, s.character(at: i) != cr { i += 1 }
let contentEnd = i
if i < len, s.character(at: i) == cr { i += 1 }
if i < len, s.character(at: i) == lf { i += 1 }
return (contentEnd, i)
}
/// Block-level tokens for one block substring, dispatched by its kind.
static func tokens(for kind: BlockKind, in sub: NSString, registry: ExtensionRegistry = .empty) -> [MarkdownToken] {
switch kind {
case .fencedCode: return codeBlock(in: sub)
case .heading: return heading(in: sub)
case .blockquote: return blockquote(in: sub)
case .table: return table(in: sub)
case .blockLatex: return blockLatex(in: sub)
case .ext(let id): return extensionBlock(in: sub, id: id,
fence: registry.blockEntry(for: id)?.fence ?? "")
case .paragraph, .list, .thematicBreak, .blank:
// Safety-net table scan; tables/block LaTeX are their own blocks now, inline `$$$$` stays plain.
return table(in: sub)
}
}
// MARK: - Extension fenced block (open fence line closing fence line / EOF)
private static func extensionBlock(in s: NSString, id: String, fence: String) -> [MarkdownToken] {
let len = s.length
guard len > 0 else { return [] }
let afterOpenLine = line(in: s, from: 0).nextStart
// Closing fence: the LAST line, when it starts with the fence (the
// block parser guarantees no interior fence line).
var closeStart = -1
var closeEnd = -1
if afterOpenLine < len, !fence.isEmpty {
var lineStart = afterOpenLine
while lineStart < len {
let (contentEnd, next) = line(in: s, from: lineStart)
if next >= len {
let text = s.substring(with: NSRange(location: lineStart, length: contentEnd - lineStart))
if text.hasPrefix(fence) { closeStart = lineStart; closeEnd = contentEnd }
break
}
if next <= lineStart { break }
lineStart = next
}
}
let contentEnd = closeStart >= 0 ? closeStart : len
var markers = [NSRange(location: 0, length: afterOpenLine)]
if closeStart >= 0 { markers.append(NSRange(location: closeStart, length: closeEnd - closeStart)) }
return [MarkdownToken(
kind: .extensionBlock(id),
range: NSRange(location: 0, length: len),
contentRange: NSRange(location: afterOpenLine, length: max(0, contentEnd - afterOpenLine)),
markerRanges: markers)]
}
// MARK: - Heading (legacy `^\s*(#{1,6}) +(.*)$`)
private static func heading(in s: NSString) -> [MarkdownToken] {
let len = s.length
var i = 0
while i < len, isWS(s.character(at: i)) { i += 1 }
let hashStart = i
while i < len, s.character(at: i) == hash { i += 1 }
let hashEnd = i
guard hashEnd > hashStart, hashEnd - hashStart <= 6,
hashEnd < len, s.character(at: hashEnd) == space else { return [] }
var contentStart = hashEnd
while contentStart < len, s.character(at: contentStart) == space { contentStart += 1 }
let lineEnd = line(in: s, from: 0).contentEnd
let tokenRange = NSRange(location: hashStart, length: lineEnd - hashStart)
let markers = [NSRange(location: hashStart, length: hashEnd - hashStart),
NSRange(location: hashEnd, length: 1)]
let content = NSRange(location: contentStart, length: max(0, lineEnd - contentStart))
return [MarkdownToken(kind: .heading, range: tokenRange, contentRange: content, markerRanges: markers)]
}
// MARK: - Blockquote (legacy `^[ \t]{0,3}((?:>[ \t]?)+)(.*)$`, one token per line)
private static func blockquote(in s: NSString) -> [MarkdownToken] {
let len = s.length
var tokens: [MarkdownToken] = []
var lineStart = 0
while lineStart < len {
let (contentEnd, nextStart) = line(in: s, from: lineStart)
var i = lineStart
var indent = 0
while i < contentEnd, indent < 3, isWS(s.character(at: i)) { i += 1; indent += 1 }
let markerStart = i
if i < contentEnd, s.character(at: i) == gt {
while i < contentEnd, s.character(at: i) == gt {
i += 1
if i < contentEnd, isWS(s.character(at: i)) { i += 1 }
}
let markerEnd = i
tokens.append(MarkdownToken(
kind: .blockquote,
range: NSRange(location: lineStart, length: contentEnd - lineStart),
contentRange: NSRange(location: markerEnd, length: contentEnd - markerEnd),
markerRanges: [NSRange(location: markerStart, length: markerEnd - markerStart)]))
}
if nextStart <= lineStart { break }
lineStart = nextStart
}
return tokens
}
// MARK: - Fenced code (legacy ```lang\n\n```)
private static func codeBlock(in s: NSString) -> [MarkdownToken] {
let len = s.length
guard len >= 3 else { return [] }
let afterOpenLine = line(in: s, from: 0).nextStart
var lineStart = afterOpenLine
var closingStart = -1
while lineStart < len {
if lineStart + 3 <= len,
s.character(at: lineStart) == backtick,
s.character(at: lineStart + 1) == backtick,
s.character(at: lineStart + 2) == backtick {
closingStart = lineStart
break
}
let next = line(in: s, from: lineStart).nextStart
if next <= lineStart { break }
lineStart = next
}
guard closingStart >= 0 else { return [] } // no closing fence legacy didn't match
return [MarkdownToken(
kind: .codeBlock,
range: NSRange(location: 0, length: closingStart + 3),
contentRange: NSRange(location: afterOpenLine, length: closingStart - afterOpenLine),
markerRanges: [NSRange(location: 0, length: afterOpenLine),
NSRange(location: closingStart, length: 3)])]
}
// MARK: - Table (legacy header `||` + separator `|--|` + data rows)
private static func table(in s: NSString) -> [MarkdownToken] {
let len = s.length
var tokens: [MarkdownToken] = []
var lineStart = 0
while lineStart < len {
let (contentEnd, nextStart) = line(in: s, from: lineStart)
if isTableRow(s, lineStart, contentEnd), nextStart < len {
let (sepEnd, afterSep) = line(in: s, from: nextStart)
if isTableSeparator(s, nextStart, sepEnd) {
var rowEnd = sepEnd
var cursor = afterSep
while cursor < len {
let (cEnd, cNext) = line(in: s, from: cursor)
guard isTableRow(s, cursor, cEnd) else { break }
rowEnd = cEnd
if cNext <= cursor { cursor = cEnd; break }
cursor = cNext
}
tokens.append(MarkdownToken(
kind: .table,
range: NSRange(location: lineStart, length: rowEnd - lineStart),
contentRange: NSRange(location: lineStart, length: rowEnd - lineStart),
markerRanges: []))
lineStart = cursor
continue
}
}
if nextStart <= lineStart { break }
lineStart = nextStart
}
return tokens
}
/// `^[ \t]*\|.+\|[ \t]*$` a pipe, 1 char, a pipe (trailing ws allowed).
private static func isTableRow(_ s: NSString, _ start: Int, _ end: Int) -> Bool {
var i = start
while i < end, isWS(s.character(at: i)) { i += 1 }
guard i < end, s.character(at: i) == pipe else { return false }
var j = end
while j > i, isWS(s.character(at: j - 1)) { j -= 1 }
guard j - 1 > i, s.character(at: j - 1) == pipe else { return false }
return (j - 1) - (i + 1) >= 1
}
/// `^[ \t]*\|[- \t:|]+\|[ \t]*$` outer pipes, inner only `- : | space tab`.
private static func isTableSeparator(_ s: NSString, _ start: Int, _ end: Int) -> Bool {
var i = start
while i < end, isWS(s.character(at: i)) { i += 1 }
guard i < end, s.character(at: i) == pipe else { return false }
var j = end
while j > i, isWS(s.character(at: j - 1)) { j -= 1 }
guard j - 1 > i, s.character(at: j - 1) == pipe else { return false }
var k = i + 1
var count = 0
while k < j - 1 {
let c = s.character(at: k)
guard c == dash || c == space || c == tab || c == colon || c == pipe else { return false }
count += 1; k += 1
}
return count >= 1
}
// MARK: - Block LaTeX (legacy `(?s)(?<!\$)\$\$(.+?)\$\$`)
private static func blockLatex(in s: NSString) -> [MarkdownToken] {
let len = s.length
var tokens: [MarkdownToken] = []
var i = 0
while i + 1 < len {
if s.character(at: i) == dollar, s.character(at: i + 1) == dollar {
if i > 0, s.character(at: i - 1) == dollar { i += 1; continue } // (?<!\$)
var j = i + 2
var closeAt = -1
while j + 1 < len {
if s.character(at: j) == dollar, s.character(at: j + 1) == dollar, j > i + 2 {
closeAt = j; break
}
j += 1
}
if closeAt >= 0 {
tokens.append(MarkdownToken(
kind: .blockLatex,
range: NSRange(location: i, length: (closeAt + 2) - i),
contentRange: NSRange(location: i + 2, length: closeAt - (i + 2)),
markerRanges: [NSRange(location: i, length: 2),
NSRange(location: closeAt, length: 2)]))
i = closeAt + 2
continue
}
}
i += 1
}
return tokens
}
}
@@ -0,0 +1,472 @@
//
// BlockParser.swift
// MarkdownEngine
//
// Phase 1 of the regexAST refactor: the block-structure pass. Splits the
// document into a flat, gap-free (tiling) sequence of blocks following the
// CommonMark two-phase model block structure first, inline content later.
// Inline parsing happens per inline-bearing block in a separate step.
//
// Ranges are absolute UTF-16 NSRanges into the source (the editor is
// NSTextView / TextKit-2 based, so UTF-16 offsets are the native currency).
// Storing relative widths (green-tree style, for cheap incremental reparse)
// is a deliberate Phase 3 concern and intentionally deferred here.
//
// Line classification mirrors the recognition the current regex tokenizer /
// styler perform, so block ranges line up with today's tokens:
// heading headingRegex `^\s*#{1,6} +`
// thematic break styler HR pattern `^\s*(-{3,}|\*{3,}|_{3,})\s*$`
// fenced code codeBlockRegex opening/closing ``` line
// blockquote blockquoteRegex `^[ \t]{0,3}(>)`
//
import Foundation
/// The block-level classification of a run of lines.
enum BlockKind: Equatable {
case paragraph // inline-bearing
case heading // single ATX line (`# `), inline-bearing content
case blockquote // consecutive `>` lines, inline-bearing per line
case list // consecutive list-item lines (`-`/`*`/`+` or `1.`/`1)`)
case fencedCode // `````` opaque (no inline parsing inside)
case blockLatex // $$$$ opaque
case table // GFM table opaque (rendered as a unit)
case thematicBreak // `---` / `***` / `___` produces no token today
case blank // blank / whitespace-only line(s) separator
case ext(String) // extension-supplied fenced block (id), inline-bearing content
}
/// One block; `range` is the absolute UTF-16 span of its lines, tiling with no gaps.
struct Block: Equatable {
let kind: BlockKind
let range: NSRange
}
/// A resolved contiguous change between two buffer states, in UTF-16 units.
/// `changeStart ..< changeEndOld` in the old buffer was replaced by
/// `changeStart ..< changeEndNew` in the new one. The region may be wider
/// than the minimal diff splice logic only requires containment.
struct BufferDiff {
let changeStart: Int
let changeEndOld: Int
let changeEndNew: Int
let delta: Int
}
enum BlockParser {
private static let cacheLock = NSLock()
private static var cachedChars: [unichar]? // UTF-16 buffer of the last parse
private static var cachedBlocks: [Block]?
/// Registry fingerprint the memo was computed under extension fences
/// change the block structure of identical text.
private static var cachedFingerprint: String = ""
/// Splits `text` into gap-free tiling blocks; memoizes the last parse so both per-keystroke callers share one line-scan.
/// Pass `utf16Chars` when the caller already extracted the buffer (must match `text`).
static func parse(_ text: String, utf16Chars: [unichar]? = nil, registry: ExtensionRegistry = .empty) -> [Block] {
let textNS = text as NSString
let newLen = textNS.length
let newChars: [unichar]
if let utf16Chars, utf16Chars.count == newLen {
newChars = utf16Chars
} else {
var buffer = [unichar](repeating: 0, count: newLen)
if newLen > 0 { textNS.getCharacters(&buffer, range: NSRange(location: 0, length: newLen)) }
newChars = buffer
}
cacheLock.lock()
let prevChars = cachedFingerprint == registry.fingerprint ? cachedChars : nil
let prevBlocks = cachedFingerprint == registry.fingerprint ? cachedBlocks : nil
cacheLock.unlock()
if let prevChars, let prevBlocks {
// Identical text memcmp hit (the scan below would walk O(doc)).
if equalBuffers(prevChars, newChars) { return prevBlocks }
if let diff = scanDiff(old: prevChars, new: newChars),
let (incr, _) = incrementalParse(oldChars: prevChars, oldBlocks: prevBlocks, newChars: newChars, newNS: textNS, diff: diff, registry: registry) {
cacheLock.lock(); cachedChars = newChars; cachedBlocks = incr; cachedFingerprint = registry.fingerprint; cacheLock.unlock()
return incr
}
}
let blocks = computeBlocks(text, registry: registry)
cacheLock.lock(); cachedChars = newChars; cachedBlocks = blocks; cachedFingerprint = registry.fingerprint; cacheLock.unlock()
return blocks
}
/// Adopt an externally computed parse (DocumentParseState publishes its
/// per-keystroke result) so static-path callers the restyle's
/// DocumentAST.parse above all take the memcmp hit instead of
/// re-splicing against a one-keystroke-stale cache.
static func seedCache(chars: [unichar], blocks: [Block], fingerprint: String = "") {
cacheLock.lock(); cachedChars = chars; cachedBlocks = blocks; cachedFingerprint = fingerprint; cacheLock.unlock()
}
private static func equalBuffers(_ a: [unichar], _ b: [unichar]) -> Bool {
guard a.count == b.count else { return false }
if a.isEmpty { return true }
return a.withUnsafeBytes { ap in
b.withUnsafeBytes { bp in memcmp(ap.baseAddress!, bp.baseAddress!, ap.count) == 0 }
}
}
/// Common prefix/suffix scan; nil when the buffers are identical.
static func scanDiff(old: [unichar], new: [unichar]) -> BufferDiff? {
let oldLen = old.count, newLen = new.count
var p = 0
let maxPre = min(oldLen, newLen)
while p < maxPre, old[p] == new[p] { p += 1 }
if p == oldLen, oldLen == newLen { return nil }
var s = 0
let maxSuf = maxPre - p
while s < maxSuf, old[oldLen - 1 - s] == new[newLen - 1 - s] { s += 1 }
return BufferDiff(changeStart: p, changeEndOld: oldLen - s, changeEndNew: newLen - s, delta: newLen - oldLen)
}
/// Does any LINE touched by `[lo, hi)` contain a `$$` or ``` that can ripple?
/// Line-expanded, not just ±3 around the edit: block delimiters are
/// line-classified with a TRIMMED prefix (`isBlockLatexOpen`), so editing
/// the leading whitespace of an indented `$$` opener flips the pairing
/// from arbitrarily far away from the literal `$$`. The boundary walk is
/// capped; hitting the cap reports a delimiter (conservative full parse).
static func hasBlockDelimiter(_ buf: [unichar], _ lo: Int, _ hi: Int, fences: [[unichar]] = []) -> Bool {
let cap = 4096
var start = max(0, lo - 3)
var steps = 0
while start > 0, buf[start - 1] != 0x0A, buf[start - 1] != 0x0D {
start -= 1
steps += 1
if steps > cap { return true }
}
var end = min(buf.count, hi + 3)
steps = 0
while end < buf.count, buf[end] != 0x0A, buf[end] != 0x0D {
end += 1
steps += 1
if steps > cap { return true }
}
var i = start
while i < end {
if buf[i] == 0x24 { // $
if i + 1 < end, buf[i + 1] == 0x24 { return true } // $$
} else if buf[i] == 0x60, i + 2 < end, buf[i + 1] == 0x60, buf[i + 2] == 0x60 {
return true // ```
}
// Extension fences pair with a distant partner exactly like ```
// an edit touching one must force the full reparse too.
for fence in fences where !fence.isEmpty && buf[i] == fence[0] {
if i + fence.count <= end {
var match = true
for (k, u) in fence.enumerated() where buf[i + k] != u { match = false; break }
if match { return true }
}
}
i += 1
}
return false
}
/// Splice-parse against a precomputed change region (descriptor- or scan-derived):
/// reparse the affected block window, splice between untouched prefix/suffix; nil to fall back to full.
static func incrementalParse(oldChars o: [unichar], oldBlocks: [Block], newChars n: [unichar], newNS: NSString, diff: BufferDiff, registry: ExtensionRegistry = .empty) -> (blocks: [Block], window: Int)? {
guard !oldBlocks.isEmpty else { return nil }
let oldLen = o.count, newLen = n.count
guard oldLen > 0, newLen > 0 else { return nil }
let delta = diff.delta
let changeStart = diff.changeStart
let changeEnd = diff.changeEndOld // [changeStart, changeEnd) in old
guard changeStart >= 0, changeEnd <= oldLen, diff.changeEndNew <= newLen,
changeStart <= changeEnd, changeStart <= diff.changeEndNew else { return nil }
// A fence/block-LaTeX/extension delimiter in the edit can pair with a distant partner full reparse.
let fences = registry.blockEntries.map(\.fenceChars)
if hasBlockDelimiter(o, changeStart, changeEnd, fences: fences)
|| hasBlockDelimiter(n, changeStart, diff.changeEndNew, fences: fences) {
return nil
}
// 2. Affected old-block window (±1 block margin for merges/splits).
// Blocks tile the document in order binary search instead of the
// linear walks that cost O(#blocks) per keystroke in large documents.
var lo = 0, hi = oldBlocks.count - 1
while lo < hi { // last block starting <= changeStart
let m = (lo + hi + 1) / 2
if oldBlocks[m].range.location <= changeStart { lo = m } else { hi = m - 1 }
}
let firstIdx = lo
lo = 0; hi = oldBlocks.count - 1
while lo < hi { // first block ending >= changeEnd
let m = (lo + hi) / 2
if NSMaxRange(oldBlocks[m].range) >= changeEnd { hi = m } else { lo = m + 1 }
}
let lastIdx = lo
let winFirst = max(0, min(firstIdx, lastIdx) - 1)
let winLast = min(oldBlocks.count - 1, max(firstIdx, lastIdx) + 1)
// 3. Opaque multi-line blocks (fences / block LaTeX) in the window are
// fine for INTERIOR edits: the window contains each block wholly, the
// ±3 delimiter guard above already bailed on any edit that creates,
// destroys, or touches a ``` / $$ pairing, and an edit that UN-closes
// a block (trailing chars on its closer line) makes the reparsed block
// reach the window end caught by the trailing guard below. Typing
// inside a code block used to fall back to a full O(doc) reparse on
// every keystroke because of an unconditional bail here.
// 4. Window new-text range (window start is before the edit unchanged).
let winStart = oldBlocks[winFirst].range.location
let winEndNew = NSMaxRange(oldBlocks[winLast].range) + delta
guard winStart >= 0, winEndNew >= winStart, winEndNew <= newLen else { return nil }
// 5. Reparse just the window substring, shift to absolute new coords.
let windowText = newNS.substring(with: NSRange(location: winStart, length: winEndNew - winStart))
let reparsed = computeBlocks(windowText, registry: registry).map { $0.shifted(by: winStart) }
// A trailing fence/latex/extension block reaching the window end might continue past it.
if let last = reparsed.last, NSMaxRange(last.range) >= winEndNew {
switch last.kind {
case .fencedCode, .blockLatex, .ext: return nil
case .paragraph:
// The edit may have dissolved the separator that used to end
// this paragraph (backspace-joining two paragraphs): if the
// suffix ALSO starts with a paragraph, the two would need to
// MERGE a full parse never yields adjacent paragraphs. The
// splice can't merge across the cut, so fall back.
if winLast + 1 < oldBlocks.count, oldBlocks[winLast + 1].kind == .paragraph {
return nil
}
default: break
}
}
// 6. Splice: prefix (unchanged) + reparsed window + suffix (shifted).
var result: [Block] = []
result.append(contentsOf: oldBlocks[0..<winFirst])
result.append(contentsOf: reparsed)
if winLast + 1 < oldBlocks.count {
result.append(contentsOf: oldBlocks[(winLast + 1)...].map { $0.shifted(by: delta) })
}
// 7. Validate gap-free tiling of [0, newLen); else full reparse.
var cursor = 0
for b in result {
if b.range.location != cursor { return nil }
cursor = NSMaxRange(b.range)
}
guard cursor == newLen else { return nil }
return (result, reparsed.count)
}
static func computeBlocks(_ text: String, registry: ExtensionRegistry = .empty) -> [Block] {
let nsText = text as NSString
let length = nsText.length
guard length > 0 else { return [] }
// 1. Slice into physical lines (each includes its trailing newline).
var lines: [NSRange] = []
var cursor = 0
while cursor < length {
let r = nsText.lineRange(for: NSRange(location: cursor, length: 0))
lines.append(r)
cursor = NSMaxRange(r)
}
func lineText(_ i: Int) -> String { nsText.substring(with: lines[i]) }
/// Line index of the fence closing a code block opened at `start`; nil when unclosed.
func fenceCloseIndex(from start: Int) -> Int? {
var scan = start + 1
while scan < lines.count {
if isFence(lineText(scan)) { return scan }
scan += 1
}
return nil
}
/// Line index of the `$$` closing a block-LaTeX run opened at `start`; nil if none.
func blockLatexCloseIndex(from start: Int) -> Int? {
let open = lineText(start).trimmingCharacters(in: .whitespacesAndNewlines)
if open.dropFirst(2).contains("$$") { return start }
var j = start + 1
while j < lines.count { if lineText(j).contains("$$") { return j }; j += 1 }
return nil
}
// 2. Classify + group.
var blocks: [Block] = []
var i = 0
while i < lines.count {
let line = lineText(i)
if isBlank(line) {
var end = i
while end + 1 < lines.count, isBlank(lineText(end + 1)) { end += 1 }
blocks.append(Block(kind: .blank, range: union(lines[i...end])))
i = end + 1
} else if isFence(line), let end = fenceCloseIndex(from: i) {
blocks.append(Block(kind: .fencedCode, range: union(lines[i...end])))
i = end + 1
} else if isThematicBreak(line) {
blocks.append(Block(kind: .thematicBreak, range: lines[i]))
i += 1
} else if isHeading(line) {
blocks.append(Block(kind: .heading, range: lines[i]))
i += 1
} else if isBlockquote(line) {
var end = i
while end + 1 < lines.count, isBlockquote(lineText(end + 1)) { end += 1 }
blocks.append(Block(kind: .blockquote, range: union(lines[i...end])))
i = end + 1
} else if isListItem(line) {
// Consecutive list-item lines form one list block; per-item detail is parsed in DocumentAST.
var end = i
while end + 1 < lines.count, isListItem(lineText(end + 1)) { end += 1 }
blocks.append(Block(kind: .list, range: union(lines[i...end])))
i = end + 1
} else if isTableRow(line), i + 1 < lines.count, isTableSeparator(lineText(i + 1)) {
// GFM table: a `||` header, a `|--|` separator, then data rows.
var end = i + 1
while end + 1 < lines.count, isTableRow(lineText(end + 1)) { end += 1 }
blocks.append(Block(kind: .table, range: union(lines[i...end])))
i = end + 1
} else if isBlockLatexOpen(line), let end = blockLatexCloseIndex(from: i) {
// Block LaTeX `$$$$` a single line or a `$$`-delimited run.
blocks.append(Block(kind: .blockLatex, range: union(lines[i...end])))
i = end + 1
} else if let entry = registry.blockEntry(opening: line) {
// Extension fenced block: consume through the closing fence
// line (or to EOF if none) mirrors ``` semantics. Built-ins
// classify first, so a fence colliding with a built-in line
// form never reaches here.
var end = lines.count - 1
var scan = i + 1
while scan < lines.count {
if lineText(scan).hasPrefix(entry.fence) { end = scan; break }
scan += 1
}
blocks.append(Block(kind: .ext(entry.id), range: union(lines[i...end])))
i = end + 1
} else {
// Paragraph: merge consecutive plain (non-blank, non-special) lines.
var end = i
while end + 1 < lines.count {
let next = lineText(end + 1)
if isBlank(next) || isThematicBreak(next)
|| isHeading(next) || isBlockquote(next) || isListItem(next) { break }
// A table (row + separator), a CLOSED code fence, a
// block-LaTeX run, or an extension fence interrupts it
// an unclosed opener stays part of the paragraph.
if isFence(next), fenceCloseIndex(from: end + 1) != nil { break }
if isTableRow(next), end + 2 < lines.count, isTableSeparator(lineText(end + 2)) { break }
if isBlockLatexOpen(next), blockLatexCloseIndex(from: end + 1) != nil { break }
if registry.blockEntry(opening: next) != nil { break }
end += 1
}
blocks.append(Block(kind: .paragraph, range: union(lines[i...end])))
i = end + 1
}
}
return blocks
}
// MARK: - Line classification
private static func isBlank(_ line: String) -> Bool {
line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// An opening or closing fence line: starts with three backticks.
private static func isFence(_ line: String) -> Bool {
line.hasPrefix("```")
}
/// `^\s*(-{3,}|\*{3,}|_{3,})\s*$` a solid run of 3+ of one of `- * _`.
private static func isThematicBreak(_ line: String) -> Bool {
let t = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard t.count >= 3, let first = t.first,
first == "-" || first == "*" || first == "_" else { return false }
return t.allSatisfy { $0 == first }
}
/// `^\s*#{1,6} +` 16 hashes after optional indent, then at least one space.
private static func isHeading(_ line: String) -> Bool {
var rest = Substring(line).drop { $0 == " " || $0 == "\t" }
var hashes = 0
while let c = rest.first, c == "#" { hashes += 1; rest = rest.dropFirst() }
guard (1...6).contains(hashes) else { return false }
return rest.first == " "
}
/// `^[ \t]{0,3}>` up to 3 leading spaces/tabs, then a `>`.
private static func isBlockquote(_ line: String) -> Bool {
var rest = Substring(line)
var indent = 0
while indent < 3, let c = rest.first, c == " " || c == "\t" {
rest = rest.dropFirst(); indent += 1
}
return rest.first == ">"
}
/// A list-item line: optional indent, a bullet (`-`/`*`/`+`) or ordered marker (`1.`/`1)`), then a space/tab.
static func isListItem(_ line: String) -> Bool {
var rest = Substring(line).drop { $0 == " " || $0 == "\t" }
guard let first = rest.first else { return false }
if first == "-" || first == "*" || first == "+" {
rest = rest.dropFirst()
} else if first.isNumber {
var digits = 0
while let c = rest.first, c.isNumber, digits < 9 { rest = rest.dropFirst(); digits += 1 }
guard let d = rest.first, d == "." || d == ")" else { return false }
rest = rest.dropFirst()
} else {
return false
}
// A space/tab must follow the marker a bare `-`/`*`/`1.` stays literal (pre-AST bullet behavior).
guard let after = rest.first else { return false }
return after == " " || after == "\t"
}
/// A GFM table row: `^[ \t]*\|.+\|[ \t]*$` outer pipes, content between.
private static func isTableRow(_ line: String) -> Bool {
let t = line.trimmingCharacters(in: .whitespacesAndNewlines)
return t.count >= 3 && t.hasPrefix("|") && t.hasSuffix("|")
}
/// A GFM table separator: `^[ \t]*\|[- \t:|]+\|[ \t]*$` only `- : |` + ws inside.
private static func isTableSeparator(_ line: String) -> Bool {
let t = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard t.count >= 3, t.hasPrefix("|"), t.hasSuffix("|") else { return false }
let middle = t.dropFirst().dropLast()
return !middle.isEmpty && middle.allSatisfy {
$0 == "-" || $0 == ":" || $0 == "|" || $0 == " " || $0 == "\t"
}
}
/// A block-LaTeX opener: a line whose content starts with `$$`.
private static func isBlockLatexOpen(_ line: String) -> Bool {
line.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("$$")
}
private static func union(_ ranges: ArraySlice<NSRange>) -> NSRange {
let lo = ranges.first!.location
let hi = NSMaxRange(ranges.last!)
return NSRange(location: lo, length: hi - lo)
}
}
private extension Block {
/// A copy with the range moved by `d` UTF-16 units.
func shifted(by d: Int) -> Block {
Block(kind: kind, range: NSRange(location: range.location + d, length: range.length))
}
}
@@ -0,0 +1,210 @@
//
// BlockScopedTokenizer.swift
// MarkdownEngine
//
// The live tokenization pipeline. For each block from `BlockParser`:
// block-level tokens (heading, blockquote, table, block LaTeX, code) come from
// `BlockLevelTokenizer` (hand scanners, no regex), while ALL inline tokens come
// from the AST (`InlineParser` `InlineASTAdapter`). Results are offset back
// into document coordinates. Fenced-code blocks emit only their code-block
// token (no inline markup inside).
//
import Foundation
extension MarkdownTokenizer {
/// Per-block memo (substring block-relative tokens): only the edited block re-parses, O(change). FIFO-capped, locked.
private static let blockTokenLock = NSLock()
private static var blockTokenCache: [String: [MarkdownToken]] = [:]
private static var blockTokenOrder: [String] = []
private static let blockTokenCacheCap = 4096
// Document-level token memo: re-tokenize only the touched blocks; the rest shift by the delta.
private static let tokensLock = NSLock()
private static var cachedTokenChars: [unichar]?
private static var cachedTokens: [MarkdownToken]?
/// Registry fingerprint the memo was computed under a different set of
/// registered extensions yields different tokens for identical text.
private static var cachedTokenFingerprint: String = ""
/// The live tokenizer: block-level tokens + inline AST tokens; fenced code emits only its code-block token.
static func parseTokensViaAST(in text: String, registry: ExtensionRegistry = .empty) -> [MarkdownToken] {
let t0 = DispatchTime.now().uptimeNanoseconds
defer {
PerfTrace.note { "🗜️ tokenizer.static \(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" }
}
let ns = text as NSString
let newLen = ns.length
var newChars = [unichar](repeating: 0, count: newLen)
if newLen > 0 { ns.getCharacters(&newChars, range: NSRange(location: 0, length: newLen)) }
let blocks = BlockParser.parse(text, utf16Chars: newChars, registry: registry)
tokensLock.lock()
let prevChars = cachedTokenFingerprint == registry.fingerprint ? cachedTokenChars : nil
let prevTokens = cachedTokenFingerprint == registry.fingerprint ? cachedTokens : nil
tokensLock.unlock()
let result: [MarkdownToken]
if let prevChars, let prevTokens {
if let diff = BlockParser.scanDiff(old: prevChars, new: newChars) {
result = incrementalTokens(oldChars: prevChars, prevTokens: prevTokens, newChars: newChars, blocks: blocks, ns: ns, diff: diff, registry: registry)?.tokens
?? fullTokens(blocks: blocks, ns: ns, registry: registry)
} else {
result = prevTokens // identical text
}
} else {
result = fullTokens(blocks: blocks, ns: ns, registry: registry)
}
tokensLock.lock()
cachedTokenChars = newChars; cachedTokens = result; cachedTokenFingerprint = registry.fingerprint
tokensLock.unlock()
return result
}
/// Adopt an externally computed parse (DocumentParseState publishes its
/// per-keystroke result) so static-path callers hit instead of re-splicing
/// against a one-keystroke-stale cache.
static func seedCache(chars: [unichar], tokens: [MarkdownToken], fingerprint: String = "") {
tokensLock.lock()
cachedTokenChars = chars; cachedTokens = tokens; cachedTokenFingerprint = fingerprint
tokensLock.unlock()
}
static func fullTokens(blocks: [Block], ns: NSString, registry: ExtensionRegistry = .empty) -> [MarkdownToken] {
var result: [MarkdownToken] = []
for block in blocks {
let delta = block.range.location
let relTokens = cachedBlockTokens(kind: block.kind, sub: ns.substring(with: block.range), registry: registry)
result.append(contentsOf: relTokens.map { $0.shifted(by: delta) })
}
return result
}
/// Reuse prefix/suffix tokens (suffix shifted) and re-tokenize only touched blocks,
/// against a precomputed change region; nil to fall back to full.
static func incrementalTokens(oldChars o: [unichar], prevTokens: [MarkdownToken], newChars n: [unichar], blocks: [Block], ns: NSString, diff: BufferDiff, registry: ExtensionRegistry = .empty) -> (tokens: [MarkdownToken], retok: Int)? {
let oldLen = o.count, newLen = n.count
guard oldLen > 0, newLen > 0, !blocks.isEmpty else { return nil }
let delta = diff.delta
let changeStart = diff.changeStart, changeEndNew = diff.changeEndNew
guard changeStart >= 0, diff.changeEndOld <= oldLen, changeEndNew <= newLen,
changeStart <= diff.changeEndOld, changeStart <= changeEndNew else { return nil }
// A fence/block-LaTeX/extension delimiter can pair with a distant partner and ripple far full tokenization.
let fences = registry.blockEntries.map(\.fenceChars)
if BlockParser.hasBlockDelimiter(o, changeStart, diff.changeEndOld, fences: fences)
|| BlockParser.hasBlockDelimiter(n, changeStart, changeEndNew, fences: fences) { return nil }
// New blocks touching the changed char range [changeStart, changeEndNew].
// Blocks tile in order the touching set is one contiguous run;
// binary search replaces the O(#blocks) full scan per keystroke.
var lo = 0, hi = blocks.count - 1
while lo < hi { // first block ending >= changeStart
let m = (lo + hi) / 2
if NSMaxRange(blocks[m].range) >= changeStart { hi = m } else { lo = m + 1 }
}
let first = lo
lo = 0; hi = blocks.count - 1
while lo < hi { // last block starting <= changeEndNew
let m = (lo + hi + 1) / 2
if blocks[m].range.location <= changeEndNew { lo = m } else { hi = m - 1 }
}
let last = lo
// Validate the run actually touches (mirrors the old filter exactly).
if first > last || blocks[first].range.location > changeEndNew || NSMaxRange(blocks[last].range) < changeStart {
return delta == 0 ? (prevTokens, 0) : nil
}
lo = first
hi = last
// Widen the window until no previous token straddles either cut (a block's extent can change in place).
var expanded = true
while expanded {
expanded = false
for t in prevTokens {
let cutStart = blocks[lo].range.location
if t.range.location < cutStart, NSMaxRange(t.range) > cutStart {
while lo > 0, blocks[lo].range.location > t.range.location { lo -= 1; expanded = true }
}
let cutEndOld = NSMaxRange(blocks[hi].range) - delta
if t.range.location < cutEndOld, NSMaxRange(t.range) > cutEndOld {
while hi < blocks.count - 1, NSMaxRange(blocks[hi].range) - delta < NSMaxRange(t.range) { hi += 1; expanded = true }
}
}
}
let regionStart = blocks[lo].range.location
let regionEndOld = NSMaxRange(blocks[hi].range) - delta
var result: [MarkdownToken] = []
for t in prevTokens where NSMaxRange(t.range) <= regionStart { result.append(t) } // prefix, unchanged
for i in lo...hi { // changed window, retokenized
let off = blocks[i].range.location
let rel = cachedBlockTokens(kind: blocks[i].kind, sub: ns.substring(with: blocks[i].range), registry: registry)
result.append(contentsOf: rel.map { $0.shifted(by: off) })
}
for t in prevTokens where t.range.location >= regionEndOld { result.append(t.shifted(by: delta)) } // suffix, shifted
return (result, hi - lo + 1)
}
/// Cached block-relative tokens for `sub` (computed on miss); a pure memo over
/// the token logic. The key carries the registry fingerprint the same text
/// tokenizes differently under a different extension set.
static func cachedBlockTokens(kind: BlockKind, sub: String, registry: ExtensionRegistry = .empty) -> [MarkdownToken] {
let key = registry.fingerprint.isEmpty ? sub : registry.fingerprint + "\u{1F}" + sub
blockTokenLock.lock()
if let cached = blockTokenCache[key] {
blockTokenLock.unlock()
return cached
}
blockTokenLock.unlock()
let blockLevel = BlockLevelTokenizer.tokens(for: kind, in: sub as NSString, registry: registry)
// Fenced code is opaque no inline markup inside it. Extension blocks
// parse inlines over their CONTENT only (the fence lines are syntax
// a `$x$` in the info string must not become a latex token).
let inline: [MarkdownToken]
if kind == .fencedCode {
inline = []
} else if case .ext = kind, let block = blockLevel.first {
let ns = sub as NSString
let content = block.contentRange
inline = content.length > 0
? InlineASTAdapter.tokens(from: InlineParser.parse(ns, range: content, registry: registry))
: []
} else {
inline = InlineASTAdapter.tokens(from: InlineParser.parse(sub, registry: registry))
}
let computed = blockLevel + inline
blockTokenLock.lock()
if blockTokenCache[key] == nil {
blockTokenCache[key] = computed
blockTokenOrder.append(key)
if blockTokenOrder.count > blockTokenCacheCap {
blockTokenCache[blockTokenOrder.removeFirst()] = nil
}
}
blockTokenLock.unlock()
return computed
}
}
private extension MarkdownToken {
/// Returns a copy with every range moved forward by `delta` UTF-16 units.
func shifted(by delta: Int) -> MarkdownToken {
func move(_ r: NSRange) -> NSRange {
NSRange(location: r.location + delta, length: r.length)
}
return MarkdownToken(
kind: kind,
range: move(range),
contentRange: move(contentRange),
markerRanges: markerRanges.map(move)
)
}
}
@@ -0,0 +1,157 @@
//
// DocumentParseState.swift
// MarkdownEngine
//
// Created by Luca Chen on 11.07.26.
//
// Per-editor incremental parse state: one UTF-16 buffer, its block list, and
// its token list evolve together under a single edit descriptor. A keystroke
// then pays one O(edit) buffer splice and a block-window re-tokenize instead
// of a full-document re-extraction plus two independent O(doc) prefix/suffix
// diff scans (BlockParser and the tokenizer each ran their own).
//
import Foundation
/// A contiguous edit in NEW-text coordinates plus the length delta, as
/// delivered by shouldChangeTextIn/textDidChange. The described region may be
/// wider than the minimal diff splice logic only requires containment.
struct ParseEditDescriptor {
let editedRange: NSRange // post-edit coords: location + replacement length
let delta: Int
}
final class DocumentParseState {
private let lock = NSLock()
private var chars: [unichar] = []
private var blocks: [Block] = []
private var tokens: [MarkdownToken] = []
private var valid = false
/// Registry fingerprint the stored tokens were computed under; a change
/// (extension registered/unregistered at runtime) invalidates the splice
/// base old tokens must not be reused under a new grammar.
private var fingerprint = ""
#if DEBUG
private var verifyCounter: UInt = 0
#endif
/// The block list matching the most recent `tokens(for:edit:)` call
/// handed to the restyle so DocumentAST.parse skips the block parser.
var currentBlocks: [Block] {
lock.lock(); defer { lock.unlock() }
return blocks
}
/// Drop all state (document switch / full rebuild) the next parse
/// re-extracts and re-parses from scratch.
func invalidate() {
lock.lock()
valid = false
chars = []; blocks = []; tokens = []
lock.unlock()
}
/// Tokens for `text`. With a trustworthy `edit` the update is
/// O(edit + touched blocks + suffix shift); without one, a single shared
/// O(doc) diff scan replaces the two independent scans of the static path.
func tokens(for text: String, edit: ParseEditDescriptor?, registry: ExtensionRegistry = .empty) -> [MarkdownToken] {
let ns = text as NSString
let newLen = ns.length
let tStart = DispatchTime.now().uptimeNanoseconds
lock.lock()
let prevChars = chars
let prevBlocks = blocks
let prevTokens = tokens
let wasValid = valid && fingerprint == registry.fingerprint
lock.unlock()
// 1. New buffer + change region spliced O(edit) when the descriptor
// passes every sanity check, extracted O(doc) otherwise.
var newChars: [unichar]
var diff: BufferDiff?
if wasValid, let edit,
edit.delta != Int.min,
edit.editedRange.location != NSNotFound,
edit.editedRange.location >= 0, edit.editedRange.length >= 0,
NSMaxRange(edit.editedRange) <= newLen,
edit.editedRange.length - edit.delta >= 0,
prevChars.count == newLen - edit.delta {
let changeStart = edit.editedRange.location
let changeEndNew = NSMaxRange(edit.editedRange)
let changeEndOld = changeEndNew - edit.delta
var replacement = [unichar](repeating: 0, count: edit.editedRange.length)
if edit.editedRange.length > 0 { ns.getCharacters(&replacement, range: edit.editedRange) }
newChars = prevChars
newChars.replaceSubrange(changeStart..<changeEndOld, with: replacement)
diff = BufferDiff(changeStart: changeStart, changeEndOld: changeEndOld,
changeEndNew: changeEndNew, delta: edit.delta)
#if DEBUG
// Sampled safety net: the spliced buffer must equal the storage.
// Opt-in (MD_PERF_VERIFY=1) the fresh O(doc) extraction spikes
// every 64th keystroke and pollutes the PERF numbers.
verifyCounter &+= 1
if PerfTrace.verifyEnabled, verifyCounter % 64 == 0 {
var fresh = [unichar](repeating: 0, count: newLen)
if newLen > 0 { ns.getCharacters(&fresh, range: NSRange(location: 0, length: newLen)) }
assert(fresh == newChars, "spliced parse buffer diverged from the text storage")
}
#endif
} else {
var buffer = [unichar](repeating: 0, count: newLen)
if newLen > 0 { ns.getCharacters(&buffer, range: NSRange(location: 0, length: newLen)) }
newChars = buffer
if wasValid {
diff = BlockParser.scanDiff(old: prevChars, new: newChars)
if diff == nil, prevChars.count == newLen {
return prevTokens // identical text
}
}
}
let tBuffer = DispatchTime.now().uptimeNanoseconds
// 2. Blocks: window splice on the shared diff, full reparse fallback.
var newBlocks: [Block]?
if wasValid, let diff {
newBlocks = BlockParser.incrementalParse(
oldChars: prevChars, oldBlocks: prevBlocks,
newChars: newChars, newNS: ns, diff: diff, registry: registry
)?.blocks
}
let resolvedBlocks = newBlocks ?? BlockParser.computeBlocks(text, registry: registry)
let tBlocks = DispatchTime.now().uptimeNanoseconds
// 3. Tokens: prefix/suffix reuse on the same diff, full fallback.
var newTokens: [MarkdownToken]?
if wasValid, let diff, newBlocks != nil {
newTokens = MarkdownTokenizer.incrementalTokens(
oldChars: prevChars, prevTokens: prevTokens,
newChars: newChars, blocks: resolvedBlocks, ns: ns, diff: diff, registry: registry
)?.tokens
}
let resolvedTokens = newTokens ?? MarkdownTokenizer.fullTokens(blocks: resolvedBlocks, ns: ns, registry: registry)
let tTokens = DispatchTime.now().uptimeNanoseconds
PerfTrace.note {
let ms = { (a: UInt64, b: UInt64) in String(format: "%.2f", Double(b - a) / 1_000_000) }
let blockMode = newBlocks != nil ? "splice" : "FULL"
let tokenMode = newTokens != nil ? "incremental" : "FULL"
return "parseState split: buffer=\(ms(tStart, tBuffer))ms blocks(\(blockMode))=\(ms(tBuffer, tBlocks))ms tokens(\(tokenMode))=\(ms(tBlocks, tTokens))ms #blocks=\(resolvedBlocks.count) #tokens=\(resolvedTokens.count)"
}
lock.lock()
chars = newChars
blocks = resolvedBlocks
tokens = resolvedTokens
valid = true
fingerprint = registry.fingerprint
lock.unlock()
// Publish to the static memos so their callers (restyle's
// DocumentAST.parse, smart-input helpers) take the memcmp hit instead
// of splicing against a one-keystroke-stale cache every time.
BlockParser.seedCache(chars: newChars, blocks: resolvedBlocks, fingerprint: registry.fingerprint)
MarkdownTokenizer.seedCache(chars: newChars, tokens: resolvedTokens, fingerprint: registry.fingerprint)
return resolvedTokens
}
}
@@ -0,0 +1,69 @@
//
// InlineASTAdapter.swift
// MarkdownEngine
//
// Phase 2.5 bridge: flattens an inline AST (`[InlineNode]`) into the legacy
// `[MarkdownToken]` shape the existing styler consumes. Walking the tree and
// emitting one token per markup node (recursing into children) reproduces the
// legacy tokenizer's flat, overlapping token set so the new AST parser can
// feed the unchanged styler. `.text` nodes carry no token.
//
import Foundation
enum InlineASTAdapter {
static func tokens(from nodes: [InlineNode]) -> [MarkdownToken] {
var result: [MarkdownToken] = []
for node in nodes { append(node, to: &result) }
return result
}
private static func append(_ node: InlineNode, to result: inout [MarkdownToken]) {
switch node {
case .text:
break
case .code(let range, let content):
let open = NSRange(location: range.location, length: content.location - range.location)
let close = NSRange(location: NSMaxRange(content), length: NSMaxRange(range) - NSMaxRange(content))
result.append(MarkdownToken(kind: .inlineCode, range: range, contentRange: content, markerRanges: [open, close]))
case .emphasis(let kind, let range, let markers, let children):
let tokenKind: MarkdownTokenKind = kind == .italic ? .italic : (kind == .bold ? .bold : .boldItalic)
result.append(MarkdownToken(kind: tokenKind, range: range,
contentRange: between(markers), markerRanges: markers))
children.forEach { append($0, to: &result) }
case .link(let range, let textRange, _, let markers, let children):
result.append(MarkdownToken(kind: .link, range: range, contentRange: textRange, markerRanges: markers))
children.forEach { append($0, to: &result) }
case .image(let range, let alt, _, let markers):
result.append(MarkdownToken(kind: .imageLink, range: range, contentRange: alt, markerRanges: markers))
case .wikiLink(let range, let name, _, let markers):
result.append(MarkdownToken(kind: .wikiLink, range: range, contentRange: name, markerRanges: markers))
case .imageEmbed(let range, let target, let markers):
result.append(MarkdownToken(kind: .imageEmbed, range: range, contentRange: target, markerRanges: markers))
case .ext(let node):
result.append(MarkdownToken(kind: .extensionSpan(node.extensionID), range: node.range,
contentRange: node.contentRange, markerRanges: node.markers))
node.children.forEach { append($0, to: &result) }
case .inlineLatex(let range, let content, let markers):
result.append(MarkdownToken(kind: .inlineLatex, range: range, contentRange: content, markerRanges: markers))
case .escape(let range, let character, let marker):
result.append(MarkdownToken(kind: .backslashEscape, range: range, contentRange: character, markerRanges: [marker]))
}
}
/// Content range between a `[open, close]` marker pair.
private static func between(_ markers: [NSRange]) -> NSRange {
let start = NSMaxRange(markers[0])
return NSRange(location: start, length: markers[1].location - start)
}
}
@@ -0,0 +1,792 @@
//
// InlineParser.swift
// MarkdownEngine
//
// Phase 2 of the regexAST refactor: the inline-structure pass. Given the
// text of a single inline-bearing block, it produces an inline AST node tree
// with correct CommonMark precedence replacing the per-construct regex soup
// that the current tokenizer uses inside each block.
//
// Built construct by construct, test-first. Ranges in the returned tree are
// relative to the parsed string; callers offset to document coordinates.
//
// Pipeline (each pass claims spans only in regions not already claimed, so
// there are never partial overlaps and buildTree is a clean containment tree):
// 1. scanCodeSpans highest precedence, opaque interior.
// 2. scanEscapes `\x` becomes a claimed span, so the escaped char is
// automatically inert for every pass below.
// 3. scanLinkFamily ![[]], [[]], ![](), [](), $$ (+ registered
// extension spans) in precedence order. URLs allow
// balanced parens. A candidate overlapping a claimed
// span is rejected (kept literal), except for opaque
// spans wholly nested inside a Markdown link's label.
// This keeps `[x](y)` inert inside code while allowing
// valid labels such as [`x`](y).
// 4. resolveEmphasis `*`/`_` delimiter runs over text outside every
// claimed span; may wrap claimed spans.
// 5. buildTree containment tree. Emphasis nests already-collected
// spans; link/extension-span content is re-parsed
// recursively; code/image/wiki/embed/latex/escape are
// opaque leaves.
//
// Claimed spans are therefore either disjoint or properly NESTED a link
// label may hold one, nothing else may. That is load-bearing for cost as well
// as correctness: it's what lets `ClaimedIndex` answer "is this claimed?" with
// a cursor and `buildTree` derive containment from a sort. A pass that claimed
// a PARTIALLY overlapping span would break both, so keep claiming whole or not
// at all.
//
import Foundation
enum EmphasisKind: Equatable { case italic, bold, boldItalic }
/// A node in the inline AST.
indirect enum InlineNode: Equatable {
case text(NSRange)
/// `` `code` `` opaque; `range` covers the backticks, `content` strips single-space padding.
case code(range: NSRange, content: NSRange)
/// `*`/`_` emphasis. `markers` is `[openMarker, closeMarker]`.
case emphasis(EmphasisKind, range: NSRange, markers: [NSRange], children: [InlineNode])
/// `[text](url)`. `markers` is `[ "[", "]", "(", ")" ]`; text is recursively parsed.
case link(range: NSRange, textRange: NSRange, url: NSRange, markers: [NSRange], children: [InlineNode])
/// `![alt](url)`. `markers` is `[ "![", "]", "(", ")" ]`. Alt is opaque.
case image(range: NSRange, alt: NSRange, url: NSRange, markers: [NSRange])
/// `[[Name|id]]`. `markers` is `[ "[[", "]]" ]`; `id` is nil when no `|`.
case wikiLink(range: NSRange, name: NSRange, id: NSRange?, markers: [NSRange])
/// `![[target]]`. `markers` is `[ "![[", "]]" ]`.
case imageEmbed(range: NSRange, target: NSRange, markers: [NSRange])
/// `$math$` opaque. `markers` is `[ "$", "$" ]`.
case inlineLatex(range: NSRange, content: NSRange, markers: [NSRange])
/// Backslash escape `\x`; `marker` is the `\`, `character` the now-literal punctuation.
case escape(range: NSRange, character: NSRange, marker: NSRange)
/// A span contributed by a registered `MarkdownExtension`
/// (e.g. `==highlight==`). Pure data behavior lives in the extension,
/// looked up by `extensionID` at styling/render time.
case ext(ExtensionInlineNode)
}
/// An extension-contributed inline span. `children` is empty for opaque
/// (non-`parsesContent`) spans.
struct ExtensionInlineNode: Equatable {
let extensionID: String
let range: NSRange
let contentRange: NSRange
let markers: [NSRange] // [open, close]
let children: [InlineNode]
}
enum InlineParser {
private static let backtick: unichar = 0x60
private static let asterisk: unichar = 0x2A
private static let underscore: unichar = 0x5F
private static let newline: unichar = 0x0A
private static let bang: unichar = 0x21
private static let lbracket: unichar = 0x5B
private static let rbracket: unichar = 0x5D
private static let lparen: unichar = 0x28
private static let rparen: unichar = 0x29
private static let pipe: unichar = 0x7C
private static let backslash: unichar = 0x5C
private static let dollar: unichar = 0x24
// MARK: - Entry point
static func parse(_ text: String, registry: ExtensionRegistry = .empty) -> [InlineNode] {
let ns = text as NSString
let len = ns.length
guard len > 0 else { return [] }
var claimed = scanCodeSpans(ns, len: len)
claimed += scanEscapes(ns, len: len, claimed: ClaimedIndex(claimed))
claimed += scanLinkFamily(ns, len: len, claimed: ClaimedIndex(claimed), registry: registry)
let emphasis = resolveEmphasis(ns, len: len, claimed: ClaimedIndex(claimed))
return buildTree(region: NSRange(location: 0, length: len), spans: claimed + emphasis, ns: ns, registry: registry)
}
/// Parse the inline content of `range` within `ns`, returning nodes in absolute document coordinates.
static func parse(_ ns: NSString, range: NSRange, registry: ExtensionRegistry = .empty) -> [InlineNode] {
offsetNodes(parse(ns.substring(with: range), registry: registry), by: range.location)
}
// MARK: - Span model
private enum Span {
case code(range: NSRange, content: NSRange)
case emphasis(kind: EmphasisKind, range: NSRange, open: NSRange, close: NSRange)
case link(range: NSRange, textRange: NSRange, url: NSRange, markers: [NSRange])
case image(range: NSRange, alt: NSRange, url: NSRange, markers: [NSRange])
case wikiLink(range: NSRange, name: NSRange, id: NSRange?, markers: [NSRange])
case imageEmbed(range: NSRange, target: NSRange, markers: [NSRange])
case inlineLatex(range: NSRange, content: NSRange, markers: [NSRange])
case escape(range: NSRange, character: NSRange, marker: NSRange)
case ext(id: String, range: NSRange, contentRange: NSRange, markers: [NSRange], parsesContent: Bool)
var fullRange: NSRange {
switch self {
case .code(let r, _), .emphasis(_, let r, _, _), .link(let r, _, _, _),
.image(let r, _, _, _), .wikiLink(let r, _, _, _), .imageEmbed(let r, _, _),
.inlineLatex(let r, _, _), .escape(let r, _, _),
.ext(_, let r, _, _, _):
return r
}
}
}
/// The already-claimed ranges, in a form the later passes can consult in
/// amortised constant time.
///
/// Every pass that asks "is this claimed?" walks the string left to right
/// and never looks back, and claimed ranges never PARTIALLY overlap (each
/// pass only claims inside regions no earlier pass took). So a cursor over
/// the sorted ranges answers without rescanning: the answer for index `i`
/// only ever involves the first range that ends after `i`.
///
/// A nested range (a code span inside a link label) sorts after its
/// container, which already covers it, so `contains` stays correct without
/// looking past the cursor. `overlapping` is the one query that must, and
/// it peeks rather than advances.
///
/// Sortedness is established here rather than assumed of callers, so no
/// call site carries an ordering obligation.
private struct ClaimedIndex {
private let ranges: [NSRange]
private var cursor = 0
init(_ spans: [Span]) {
ranges = spans.map(\.fullRange).sorted { $0.location < $1.location }
}
/// Discard ranges that end at or before `idx`. `idx` must not move backwards.
private mutating func advance(to idx: Int) {
while cursor < ranges.count, NSMaxRange(ranges[cursor]) <= idx { cursor += 1 }
}
mutating func contains(_ idx: Int) -> Bool {
advance(to: idx)
return cursor < ranges.count && NSLocationInRange(idx, ranges[cursor])
}
mutating func overlaps(_ range: NSRange) -> Bool {
advance(to: range.location)
return cursor < ranges.count && ranges[cursor].location < NSMaxRange(range)
}
/// Every claimed range overlapping `range`. Peeks forward from the
/// cursor without consuming, so the caller's left-to-right walk is
/// unaffected.
mutating func overlapping(_ range: NSRange) -> [NSRange] {
advance(to: range.location)
var out: [NSRange] = []
var k = cursor
while k < ranges.count, ranges[k].location < NSMaxRange(range) {
if NSIntersectionRange(ranges[k], range).length > 0 { out.append(ranges[k]) }
k += 1
}
return out
}
}
// MARK: - 1. Code spans
private static func scanCodeSpans(_ ns: NSString, len: Int) -> [Span] {
var spans: [Span] = []
var i = 0
while i < len {
guard ns.character(at: i) == backtick, !isEscaped(i, ns) else { i += 1; continue }
let runStart = i
var j = i
while j < len, ns.character(at: j) == backtick { j += 1 }
let runLen = j - runStart
guard let close = closingBacktickRun(in: ns, from: j, length: len, runLen: runLen) else {
i = j; continue
}
let codeRange = NSRange(location: runStart, length: (close + runLen) - runStart)
let rawContent = NSRange(location: j, length: close - j)
spans.append(.code(range: codeRange, content: strippedCodeContent(rawContent, in: ns)))
i = close + runLen
}
return spans
}
private static func closingBacktickRun(in ns: NSString, from: Int, length len: Int, runLen: Int) -> Int? {
var k = from
while k < len {
guard ns.character(at: k) == backtick, !isEscaped(k, ns) else { k += 1; continue }
let start = k
while k < len, ns.character(at: k) == backtick { k += 1 }
if k - start == runLen { return start }
}
return nil
}
private static func strippedCodeContent(_ raw: NSRange, in ns: NSString) -> NSRange {
let space: unichar = 0x20
guard raw.length >= 2,
ns.character(at: raw.location) == space,
ns.character(at: NSMaxRange(raw) - 1) == space else { return raw }
var allSpaces = true
for k in raw.location..<NSMaxRange(raw) where ns.character(at: k) != space {
allSpaces = false; break
}
guard !allSpaces else { return raw }
return NSRange(location: raw.location + 1, length: raw.length - 2)
}
// MARK: - 2. Backslash escapes (claimed escaped chars are inert everywhere)
private static func scanEscapes(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [Span] {
var claimed = claimed
var spans: [Span] = []
var i = 0
while i < len - 1 {
if ns.character(at: i) == backslash, !claimed.contains(i), isAsciiPunctuationChar(ns.character(at: i + 1)) {
spans.append(.escape(
range: NSRange(location: i, length: 2),
character: NSRange(location: i + 1, length: 1),
marker: NSRange(location: i, length: 1)
))
i += 2 // the escaped char can't itself start a new escape (even/odd `\\`)
} else {
i += 1
}
}
return spans
}
// MARK: - 3. Link family / inline LaTeX / extension spans
private static func scanLinkFamily(_ ns: NSString, len: Int, claimed: ClaimedIndex, registry: ExtensionRegistry) -> [Span] {
var claimed = claimed
// A candidate overlapping a claimed span is rejected, except for spans
// wholly nested inside a Markdown link's label (#118). Only that case
// needs the full overlap list; everything else short-circuits on the
// first one.
func hasDisallowedClaimedOverlap(_ span: Span) -> Bool {
guard case .link(_, let textRange, _, _) = span else {
return claimed.overlaps(span.fullRange)
}
return claimed.overlapping(span.fullRange).contains { !rangeContains(textRange, $0) }
}
var spans: [Span] = []
var i = 0
while i < len {
if claimed.contains(i) { i += 1; continue }
if let span = matchClaimedSpan(ns, len, at: i, registry: registry),
!hasDisallowedClaimedOverlap(span) {
spans.append(span)
i = NSMaxRange(span.fullRange)
} else {
i += 1
}
}
return spans
}
private static func matchClaimedSpan(_ ns: NSString, _ len: Int, at i: Int, registry: ExtensionRegistry) -> Span? {
if let span = matchBuiltIn(ns, len, at: i) { return span }
// Extensions match after every built-in, in registration order. A
// built-in trigger that matched-and-FAILED (e.g. `$50$` rejected by
// the math heuristic) falls through here, so an extension sharing a
// built-in's first character is still reachable.
let c = ns.character(at: i)
for entry in registry.entries where entry.open.first == c {
if let span = matchExtensionSpan(ns, len, start: i, entry: entry) { return span }
}
return nil
}
/// The built-in constructs, tried exclusively in fixed precedence order
/// the first branch whose trigger matches decides (nil = stays literal
/// for built-ins), exactly the pre-extension behavior.
private static func matchBuiltIn(_ ns: NSString, _ len: Int, at i: Int) -> Span? {
let c = ns.character(at: i)
let c1 = peek(ns, i + 1, len)
let c2 = peek(ns, i + 2, len)
if c == bang, c1 == lbracket, c2 == lbracket { return matchImageEmbed(ns, len, start: i) }
if c == lbracket, c1 == lbracket { return matchWikiLink(ns, len, start: i) }
if c == bang, c1 == lbracket { return matchImage(ns, len, start: i) }
if c == lbracket { return matchLink(ns, len, start: i) }
if c == dollar, c1 != dollar { return matchInlineLatex(ns, len, start: i) }
return nil
}
/// Generic scanner for extension-contributed delimited spans. Mirrors the
/// built-in `~~`/`==` semantics: the span opens at an exact `open` match,
/// closes at the FIRST exact `close` match on the same line, and a lone
/// occurrence of `close`'s first character inside the content aborts the
/// candidate (it stays literal).
private static func matchExtensionSpan(_ ns: NSString, _ len: Int, start i: Int, entry: ExtensionRegistry.Entry) -> Span? {
let open = entry.open, close = entry.close
guard !open.isEmpty, !close.isEmpty else { return nil }
guard matches(ns, len, at: i, chars: open) else { return nil }
if entry.syntax.rejectsOpenerRun, i > 0, ns.character(at: i - 1) == open[0] { return nil }
let contentStart = i + open.count
let closeFirst = close[0]
var k = contentStart
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == closeFirst {
guard matches(ns, len, at: k, chars: close) else { return nil }
if entry.syntax.requiresNonEmptyContent, k == contentStart { return nil }
if entry.syntax.rejectsCloserRun,
let after = peek(ns, k + close.count, len), after == close[close.count - 1] { return nil }
return .ext(
id: entry.id,
range: NSRange(location: i, length: (k + close.count) - i),
contentRange: NSRange(location: contentStart, length: k - contentStart),
markers: [NSRange(location: i, length: open.count), NSRange(location: k, length: close.count)],
parsesContent: entry.syntax.parsesContent
)
}
k += 1
}
return nil
}
/// Exact UTF-16 sequence match at `i`.
private static func matches(_ ns: NSString, _ len: Int, at i: Int, chars: [unichar]) -> Bool {
guard i + chars.count <= len else { return false }
for (offset, u) in chars.enumerated() where ns.character(at: i + offset) != u { return false }
return true
}
private static func peek(_ ns: NSString, _ idx: Int, _ len: Int) -> unichar? {
(idx >= 0 && idx < len) ? ns.character(at: idx) : nil
}
/// `![[ target ]]`
private static func matchImageEmbed(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let contentStart = i + 3
guard let close = closeDoubleBracket(ns, len, from: contentStart) else { return nil }
return .imageEmbed(
range: NSRange(location: i, length: (close + 2) - i),
target: NSRange(location: contentStart, length: close - contentStart),
markers: [NSRange(location: i, length: 3), NSRange(location: close, length: 2)]
)
}
/// `[[ name (| id)? ]]`
private static func matchWikiLink(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let contentStart = i + 2
var k = contentStart
var pipeIdx = -1
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == pipe, pipeIdx == -1 { pipeIdx = k }
if ch == rbracket {
guard peek(ns, k + 1, len) == rbracket else { return nil }
let range = NSRange(location: i, length: (k + 2) - i)
let markers = [NSRange(location: i, length: 2), NSRange(location: k, length: 2)]
if pipeIdx >= 0 {
return .wikiLink(range: range,
name: NSRange(location: contentStart, length: pipeIdx - contentStart),
id: NSRange(location: pipeIdx + 1, length: k - (pipeIdx + 1)),
markers: markers)
}
return .wikiLink(range: range,
name: NSRange(location: contentStart, length: k - contentStart),
id: nil, markers: markers)
}
k += 1
}
return nil
}
/// `![ alt ]( url )`
private static func matchImage(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let altStart = i + 2
guard let closeBracket = findChar(ns, len, from: altStart, char: rbracket),
peek(ns, closeBracket + 1, len) == lparen,
let closeParen = balancedParen(ns, len, from: closeBracket + 2) else { return nil }
let urlStart = closeBracket + 2
guard closeParen > urlStart else { return nil }
return .image(
range: NSRange(location: i, length: (closeParen + 1) - i),
alt: NSRange(location: altStart, length: closeBracket - altStart),
url: NSRange(location: urlStart, length: closeParen - urlStart),
markers: [
NSRange(location: i, length: 2),
NSRange(location: closeBracket, length: 1),
NSRange(location: closeBracket + 1, length: 1),
NSRange(location: closeParen, length: 1),
]
)
}
/// `[ text ]( url )`
private static func matchLink(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let textStart = i + 1
guard let closeBracket = findChar(ns, len, from: textStart, char: rbracket),
closeBracket > textStart,
peek(ns, closeBracket + 1, len) == lparen,
let closeParen = balancedParen(ns, len, from: closeBracket + 2) else { return nil }
let urlStart = closeBracket + 2
guard closeParen > urlStart else { return nil }
return .link(
range: NSRange(location: i, length: (closeParen + 1) - i),
textRange: NSRange(location: textStart, length: closeBracket - textStart),
url: NSRange(location: urlStart, length: closeParen - urlStart),
markers: [
NSRange(location: i, length: 1),
NSRange(location: closeBracket, length: 1),
NSRange(location: closeBracket + 1, length: 1),
NSRange(location: closeParen, length: 1),
]
)
}
/// `$ math $` single dollars, content has no `$`, passes the math heuristic.
private static func matchInlineLatex(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
if i > 0, ns.character(at: i - 1) == dollar { return nil }
let contentStart = i + 1
var k = contentStart
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == dollar {
guard k > contentStart, peek(ns, k + 1, len) != dollar else { return nil }
let content = NSRange(location: contentStart, length: k - contentStart)
guard isInlineMathContent(ns.substring(with: content)) else { return nil }
return .inlineLatex(
range: NSRange(location: i, length: (k + 1) - i),
content: content,
markers: [NSRange(location: i, length: 1), NSRange(location: k, length: 1)]
)
}
k += 1
}
return nil
}
private static func closeDoubleBracket(_ ns: NSString, _ len: Int, from: Int) -> Int? {
var k = from
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == rbracket { return peek(ns, k + 1, len) == rbracket ? k : nil }
k += 1
}
return nil
}
private static func findChar(_ ns: NSString, _ len: Int, from: Int, char: unichar) -> Int? {
var k = from
while k < len {
let ch = ns.character(at: k)
if ch == char { return k }
if ch == newline { return nil }
k += 1
}
return nil
}
private static func balancedParen(_ ns: NSString, _ len: Int, from: Int) -> Int? {
var depth = 1
var k = from
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == lparen { depth += 1 }
else if ch == rparen { depth -= 1; if depth == 0 { return k } }
k += 1
}
return nil
}
/// Rejects currency-looking and trivially short non-mathy `$$` so prose isn't misread as math.
private static func isInlineMathContent(_ content: String) -> Bool {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if isCurrencyLike(trimmed) { return false }
let mathyMatches = mathyCharCount(trimmed)
if mathyMatches == 0 {
return trimmed.count <= 3 && isAllAsciiLetters(trimmed)
}
let tokenCount = trimmed.split(whereSeparator: { $0.isWhitespace }).count
if mathyMatches >= 3 { return tokenCount <= 120 }
if mathyMatches == 2 { return tokenCount <= 40 }
return tokenCount <= 6
}
/// A plain signed/thousands-grouped/decimal number (`50`, `1,000.50`, `-5`), regex-free, so currency isn't math.
private static func isCurrencyLike(_ s: String) -> Bool {
let u = Array(s.utf16)
let n = u.count
func digit(_ x: Int) -> Bool { x >= 0 && x < n && u[x] >= 0x30 && u[x] <= 0x39 }
var i = 0
if i < n, u[i] == 0x2B || u[i] == 0x2D { i += 1 } // + / -
guard digit(i) else { return false }
var sawDigit = false
while i < n {
if digit(i) { sawDigit = true; i += 1 }
else if u[i] == 0x2C, digit(i + 1), digit(i + 2), digit(i + 3), !digit(i + 4) {
i += 4 // a strict `,DDD` thousands group
} else { break }
}
guard sawDigit else { return false }
if i < n, u[i] == 0x2E { // optional `.DDD+`
i += 1
guard digit(i) else { return false }
while digit(i) { i += 1 }
}
return i == n
}
/// Count of "mathy" characters `\ ^ _ { } = + - * / < >`.
private static func mathyCharCount(_ s: String) -> Int {
let mathy: Set<unichar> = [0x5C, 0x5E, 0x5F, 0x7B, 0x7D, 0x3D, 0x2B, 0x2D, 0x2A, 0x2F, 0x3C, 0x3E]
var count = 0
for u in s.utf16 where mathy.contains(u) { count += 1 }
return count
}
/// True when `s` is one or more ASCII letters only.
private static func isAllAsciiLetters(_ s: String) -> Bool {
let u = Array(s.utf16)
guard !u.isEmpty else { return false }
for x in u where !((x >= 0x41 && x <= 0x5A) || (x >= 0x61 && x <= 0x7A)) { return false }
return true
}
// MARK: - 4. Emphasis (delimiter runs)
private struct DelimRun {
let char: unichar
let originalLength: Int
var leftEdge: Int
var rightEdge: Int
let canOpen: Bool
let canClose: Bool
let lineIdx: Int
var remaining: Int { rightEdge - leftEdge }
}
private static func resolveEmphasis(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [Span] {
var runs = collectDelimiterRuns(ns, len: len, claimed: claimed)
guard !runs.isEmpty else { return [] }
var stack: [Int] = []
var spans: [Span] = []
for idx in runs.indices {
if runs[idx].canClose {
closeAgainstStack(closerIdx: idx, runs: &runs, stack: &stack, spans: &spans)
}
if runs[idx].canOpen && runs[idx].remaining > 0 {
stack.append(idx)
}
}
return spans
}
private static func collectDelimiterRuns(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [DelimRun] {
var claimed = claimed
var runs: [DelimRun] = []
var lineIdx = 0
var i = 0
while i < len {
let c = ns.character(at: i)
if c == newline { lineIdx += 1; i += 1; continue }
guard c == asterisk || c == underscore, !claimed.contains(i) else { i += 1; continue }
var j = i
while j < len, ns.character(at: j) == c { j += 1 }
let before = i - 1, after = j
let beforeWs = isWhitespaceOrBoundary(before, ns, len)
let beforePunct = isAsciiPunctuation(before, ns, len)
let afterWs = isWhitespaceOrBoundary(after, ns, len)
let afterPunct = isAsciiPunctuation(after, ns, len)
let leftFlanking = !afterWs && (!afterPunct || beforeWs || beforePunct)
let rightFlanking = !beforeWs && (!beforePunct || afterWs || afterPunct)
let canOpen: Bool, canClose: Bool
if c == underscore {
canOpen = leftFlanking && (!rightFlanking || beforePunct)
canClose = rightFlanking && (!leftFlanking || afterPunct)
} else {
canOpen = leftFlanking
canClose = rightFlanking
}
runs.append(DelimRun(
char: c, originalLength: j - i, leftEdge: i, rightEdge: j,
canOpen: canOpen, canClose: canClose, lineIdx: lineIdx
))
i = j
}
return runs
}
private static func closeAgainstStack(
closerIdx: Int, runs: inout [DelimRun], stack: inout [Int], spans: inout [Span]
) {
var sp = stack.count - 1
while sp >= 0, runs[closerIdx].remaining > 0 {
let openerIdx = stack[sp]
if runs[openerIdx].char != runs[closerIdx].char { sp -= 1; continue }
if runs[openerIdx].lineIdx != runs[closerIdx].lineIdx {
stack.remove(at: sp); sp -= 1; continue
}
let avail = min(runs[openerIdx].remaining, runs[closerIdx].remaining)
if avail == 0 { stack.remove(at: sp); sp -= 1; continue }
let openerBoth = runs[openerIdx].canOpen && runs[openerIdx].canClose
let closerBoth = runs[closerIdx].canOpen && runs[closerIdx].canClose
if openerBoth || closerBoth {
let sum = runs[openerIdx].originalLength + runs[closerIdx].originalLength
let bothMod3 = runs[openerIdx].originalLength % 3 == 0 && runs[closerIdx].originalLength % 3 == 0
if sum % 3 == 0 && !bothMod3 { sp -= 1; continue }
}
let matchLen = avail >= 3 ? 3 : (avail >= 2 ? 2 : 1)
let openerMarkerStart = runs[openerIdx].rightEdge - matchLen
let closerMarkerStart = runs[closerIdx].leftEdge
let kind: EmphasisKind = matchLen == 3 ? .boldItalic : (matchLen == 2 ? .bold : .italic)
spans.append(.emphasis(
kind: kind,
range: NSRange(location: openerMarkerStart, length: (closerMarkerStart + matchLen) - openerMarkerStart),
open: NSRange(location: openerMarkerStart, length: matchLen),
close: NSRange(location: closerMarkerStart, length: matchLen)
))
runs[openerIdx].rightEdge -= matchLen
runs[closerIdx].leftEdge += matchLen
if runs[openerIdx].remaining == 0 { stack.remove(at: sp) }
sp -= 1
}
}
// MARK: - 5. Containment tree
private static func buildTree(region: NSRange, spans: [Span], ns: NSString, registry: ExtensionRegistry) -> [InlineNode] {
// Spans are non-overlapping or properly nested (each pass claims only
// inside regions no earlier pass took), so ordering by start ascending
// and length descending puts every span immediately after the one that
// contains it. Containment then falls out of a single ordered walk,
// instead of testing each span against every other span.
let ordered = spans
.filter { rangeContains(region, $0.fullRange) }
.sorted { a, b in
let (x, y) = (a.fullRange, b.fullRange)
return x.location == y.location ? x.length > y.length : x.location < y.location
}
var cursor = 0
return buildTree(region: region, ordered: ordered, cursor: &cursor, ns: ns, registry: registry)
}
/// Consumes spans from `cursor` for as long as they fall inside `region`,
/// leaving `cursor` on the first span that doesn't.
private static func buildTree(
region: NSRange, ordered: [Span], cursor: inout Int, ns: NSString, registry: ExtensionRegistry
) -> [InlineNode] {
var result: [InlineNode] = []
var textStart = region.location
while cursor < ordered.count {
let span = ordered[cursor]
let fr = span.fullRange
guard rangeContains(region, fr) else { break }
cursor += 1
if fr.location > textStart {
result.append(.text(NSRange(location: textStart, length: fr.location - textStart)))
}
switch span {
case .code(let range, let content):
result.append(.code(range: range, content: content))
case .emphasis(let kind, let range, let open, let close):
let content = NSRange(location: NSMaxRange(open), length: close.location - NSMaxRange(open))
result.append(.emphasis(kind, range: range, markers: [open, close],
children: buildTree(region: content, ordered: ordered,
cursor: &cursor, ns: ns, registry: registry)))
case .link(let range, let textRange, let url, let markers):
result.append(.link(range: range, textRange: textRange, url: url, markers: markers,
children: reparse(textRange, ns: ns, registry: registry)))
case .image(let range, let alt, let url, let markers):
result.append(.image(range: range, alt: alt, url: url, markers: markers))
case .wikiLink(let range, let name, let id, let markers):
result.append(.wikiLink(range: range, name: name, id: id, markers: markers))
case .imageEmbed(let range, let target, let markers):
result.append(.imageEmbed(range: range, target: target, markers: markers))
case .inlineLatex(let range, let content, let markers):
result.append(.inlineLatex(range: range, content: content, markers: markers))
case .escape(let range, let character, let marker):
result.append(.escape(range: range, character: character, marker: marker))
case .ext(let id, let range, let contentRange, let markers, let parsesContent):
result.append(.ext(ExtensionInlineNode(
extensionID: id, range: range, contentRange: contentRange, markers: markers,
children: parsesContent ? reparse(contentRange, ns: ns, registry: registry) : []
)))
}
// Every span but emphasis is opaque, so nothing should remain
// inside one. Skipping keeps the walk well-formed if that ever
// changes, rather than emitting a node past the cursor.
while cursor < ordered.count, rangeContains(fr, ordered[cursor].fullRange) { cursor += 1 }
textStart = NSMaxRange(fr)
}
if textStart < NSMaxRange(region) {
result.append(.text(NSRange(location: textStart, length: NSMaxRange(region) - textStart)))
}
return result
}
/// Recursively parse a sub-range's content, offset back to absolute coordinates.
private static func reparse(_ range: NSRange, ns: NSString, registry: ExtensionRegistry) -> [InlineNode] {
offsetNodes(parse(ns.substring(with: range), registry: registry), by: range.location)
}
// MARK: - Helpers
private static func offsetNodes(_ nodes: [InlineNode], by delta: Int) -> [InlineNode] {
nodes.map { offset($0, by: delta) }
}
private static func offset(_ node: InlineNode, by d: Int) -> InlineNode {
func s(_ r: NSRange) -> NSRange { NSRange(location: r.location + d, length: r.length) }
switch node {
case .text(let r): return .text(s(r))
case .code(let r, let c): return .code(range: s(r), content: s(c))
case .emphasis(let k, let r, let m, let ch): return .emphasis(k, range: s(r), markers: m.map(s), children: offsetNodes(ch, by: d))
case .link(let r, let tr, let u, let m, let ch): return .link(range: s(r), textRange: s(tr), url: s(u), markers: m.map(s), children: offsetNodes(ch, by: d))
case .image(let r, let a, let u, let m): return .image(range: s(r), alt: s(a), url: s(u), markers: m.map(s))
case .wikiLink(let r, let n, let id, let m): return .wikiLink(range: s(r), name: s(n), id: id.map(s), markers: m.map(s))
case .imageEmbed(let r, let t, let m): return .imageEmbed(range: s(r), target: s(t), markers: m.map(s))
case .inlineLatex(let r, let c, let m): return .inlineLatex(range: s(r), content: s(c), markers: m.map(s))
case .escape(let r, let c, let m): return .escape(range: s(r), character: s(c), marker: s(m))
case .ext(let n): return .ext(ExtensionInlineNode(
extensionID: n.extensionID, range: s(n.range), contentRange: s(n.contentRange),
markers: n.markers.map(s), children: offsetNodes(n.children, by: d)))
}
}
private static func rangeContains(_ outer: NSRange, _ inner: NSRange) -> Bool {
inner.location >= outer.location && NSMaxRange(inner) <= NSMaxRange(outer)
}
private static func isWhitespaceOrBoundary(_ idx: Int, _ ns: NSString, _ len: Int) -> Bool {
guard idx >= 0, idx < len else { return true }
let c = ns.character(at: idx)
return c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D
}
private static func isAsciiPunctuation(_ idx: Int, _ ns: NSString, _ len: Int) -> Bool {
guard idx >= 0, idx < len else { return false }
return isAsciiPunctuationChar(ns.character(at: idx))
}
private static func isAsciiPunctuationChar(_ c: unichar) -> Bool {
(c >= 0x21 && c <= 0x2F) || (c >= 0x3A && c <= 0x40)
|| (c >= 0x5B && c <= 0x60) || (c >= 0x7B && c <= 0x7E)
}
/// A character is backslash-escaped when preceded by an odd run of `\`.
private static func isEscaped(_ idx: Int, _ ns: NSString) -> Bool {
var count = 0
var k = idx - 1
while k >= 0, ns.character(at: k) == backslash { count += 1; k -= 1 }
return count % 2 == 1
}
}
@@ -0,0 +1,247 @@
//
// MarkdownAST.swift
// MarkdownEngine
//
// Phase 2.5 foundation: the semantic document AST. Combines the block-structure
// pass (BlockParser) with the inline pass (InlineParser) into one tree of
// `BlockNode`s, each inline-bearing block carrying its parsed inline children
// in absolute document coordinates. The AST-native styler (next increments)
// walks this tree instead of consuming flat tokens.
//
import Foundation
/// One list-item line: marker run, optional GFM checkbox, and indent column count.
struct ListItem: Equatable {
let range: NSRange // the item's full line (incl. trailing newline)
let marker: NSRange
let ordered: Bool
let number: Int? // ordered start value, e.g. `5.` 5
let checkbox: NSRange?
let checked: Bool
let indent: Int
let contentRange: NSRange // text after the marker (and checkbox)
let inlines: [InlineNode]
}
/// An extension-supplied fenced block. `closeFence` is nil when the block is
/// unclosed (it then runs to the end of the document).
struct ExtensionBlockNode: Equatable {
let extensionID: String
let range: NSRange
let openFence: NSRange // opening fence line incl. its newline
let closeFence: NSRange? // closing fence line, nil when unclosed
let contentRange: NSRange // lines between the fences
let inlines: [InlineNode]
}
/// A top-level block in the document AST.
indirect enum BlockNode: Equatable {
case paragraph(range: NSRange, inlines: [InlineNode])
case heading(level: Int, range: NSRange, markers: [NSRange], inlines: [InlineNode])
case blockquote(range: NSRange, inlines: [InlineNode])
case list(range: NSRange, items: [ListItem])
case codeBlock(range: NSRange)
case blockLatex(range: NSRange)
case table(range: NSRange)
case thematicBreak(range: NSRange)
case blank(range: NSRange)
case ext(ExtensionBlockNode)
var range: NSRange {
switch self {
case .paragraph(let r, _), .heading(_, let r, _, _), .blockquote(let r, _),
.list(let r, _), .codeBlock(let r), .blockLatex(let r), .table(let r),
.thematicBreak(let r), .blank(let r):
return r
case .ext(let node):
return node.range
}
}
}
enum DocumentAST {
private static let hash: unichar = 0x23
private static let space: unichar = 0x20
private static let tab: unichar = 0x09
/// Build the document AST; `scopedRanges` parses inlines only for intersecting blocks.
/// `precomputedBlocks` (the keystroke's own parse state, handed down by the
/// restyle) skips BlockParser.parse whose cache "hit" still re-extracts
/// and memcmps the full document buffer entirely.
static func parse(_ text: String, scopedRanges: [NSRange]? = nil, precomputedBlocks: [Block]? = nil,
registry: ExtensionRegistry = .empty) -> [BlockNode] {
let ns = text as NSString
let blocks = precomputedBlocks ?? BlockParser.parse(text, registry: registry)
// Scoped mode: skip building BlockNodes for blocks outside the edit.
// Blocks tile the document in order, so one sweep over sorted candidate
// ranges replaces scanning every candidate per block (which went
// quadratic in formula-rich documents with dozens of candidates).
let relevant: [Block]
if let scopedRanges {
let sorted = scopedRanges
.filter { $0.location != NSNotFound && $0.length > 0 }
.sorted { $0.location < $1.location }
var out: [Block] = []
var ci = 0
for block in blocks {
while ci < sorted.count, NSMaxRange(sorted[ci]) <= block.range.location { ci += 1 }
guard ci < sorted.count else { break }
if sorted[ci].location < NSMaxRange(block.range) { out.append(block) }
}
relevant = out
} else {
relevant = blocks
}
return relevant.map { node(for: $0, ns: ns, scopedRanges: scopedRanges, registry: registry) }
}
private static func inScope(_ range: NSRange, _ scopedRanges: [NSRange]?) -> Bool {
guard let scopedRanges else { return true }
return scopedRanges.contains { NSIntersectionRange($0, range).length > 0 }
}
private static func node(for block: Block, ns: NSString, scopedRanges: [NSRange]?, registry: ExtensionRegistry) -> BlockNode {
let scoped = inScope(block.range, scopedRanges)
switch block.kind {
case .paragraph:
return .paragraph(range: block.range, inlines: scoped ? InlineParser.parse(ns, range: block.range, registry: registry) : [])
case .heading:
return heading(block.range, ns, scoped: scoped, registry: registry)
case .blockquote:
return .blockquote(range: block.range, inlines: scoped ? InlineParser.parse(ns, range: block.range, registry: registry) : [])
case .list:
return list(block.range, ns, scoped: scoped, registry: registry)
case .fencedCode:
return .codeBlock(range: block.range)
case .blockLatex:
return .blockLatex(range: block.range)
case .table:
return .table(range: block.range)
case .thematicBreak:
return .thematicBreak(range: block.range)
case .blank:
return .blank(range: block.range)
case .ext(let id):
return extensionBlock(id: id, range: block.range, ns, scoped: scoped, registry: registry)
}
}
/// Split an extension fenced block into open fence line, optional closing
/// fence line, and the content between; inlines parse over the content.
private static func extensionBlock(id: String, range: NSRange, _ ns: NSString,
scoped: Bool, registry: ExtensionRegistry) -> BlockNode {
let fence = registry.blockEntry(for: id)?.fence ?? ""
let end = NSMaxRange(range)
// Opening fence line including its terminator via lineRange, the
// same primitive the block parser tiles with, so the fence/content
// split agrees on EVERY line terminator (\n, \r\n, U+2028, ).
let openLine = ns.lineRange(for: NSRange(location: range.location, length: 0))
let openEnd = min(NSMaxRange(openLine), end)
let openFence = NSRange(location: range.location, length: openEnd - range.location)
// Closing fence: the block's last line, when it starts with the fence
// and is not the opening line itself.
var closeFence: NSRange?
if openEnd < end {
let lastLine = ns.lineRange(for: NSRange(location: end - 1, length: 0))
if lastLine.location >= openEnd,
!fence.isEmpty,
ns.substring(with: lastLine).hasPrefix(fence) {
closeFence = lastLine
}
}
let contentEnd = closeFence?.location ?? end
let contentRange = NSRange(location: openEnd, length: max(0, contentEnd - openEnd))
return .ext(ExtensionBlockNode(
extensionID: id,
range: range,
openFence: openFence,
closeFence: closeFence,
contentRange: contentRange,
inlines: scoped && contentRange.length > 0
? InlineParser.parse(ns, range: contentRange, registry: registry) : []
))
}
/// ATX heading: optional indent, `#`×level, space(s), then inline content.
private static func heading(_ range: NSRange, _ ns: NSString, scoped: Bool = true, registry: ExtensionRegistry = .empty) -> BlockNode {
let end = NSMaxRange(range)
var i = range.location
while i < end, ns.character(at: i) == space || ns.character(at: i) == tab { i += 1 }
let hashStart = i
var level = 0
while i < end, ns.character(at: i) == hash { level += 1; i += 1 }
var contentStart = i
while contentStart < end, ns.character(at: contentStart) == space { contentStart += 1 }
// Markers span `#`(s) plus trailing space(s) so the whole syntax collapses on shrink.
let markers = [NSRange(location: hashStart, length: contentStart - hashStart)]
var contentEnd = end
while contentEnd > contentStart, isLineBreak(ns.character(at: contentEnd - 1)) { contentEnd -= 1 }
let contentRange = NSRange(location: contentStart, length: contentEnd - contentStart)
return .heading(level: level, range: range, markers: markers,
inlines: scoped ? InlineParser.parse(ns, range: contentRange, registry: registry) : [])
}
/// Split a list block into one `ListItem` per physical line.
private static func list(_ range: NSRange, _ ns: NSString, scoped: Bool = true, registry: ExtensionRegistry = .empty) -> BlockNode {
var items: [ListItem] = []
var cursor = range.location
let end = NSMaxRange(range)
while cursor < end {
let line = ns.lineRange(for: NSRange(location: cursor, length: 0))
items.append(listItem(line, ns, scoped: scoped, registry: registry))
cursor = NSMaxRange(line)
}
return .list(range: range, items: items)
}
/// Parse one list-item line: indent, marker, optional task checkbox, inline content.
private static func listItem(_ lineRange: NSRange, _ ns: NSString, scoped: Bool = true, registry: ExtensionRegistry = .empty) -> ListItem {
let end = NSMaxRange(lineRange)
var i = lineRange.location
var indent = 0
while i < end, ns.character(at: i) == space || ns.character(at: i) == tab { i += 1; indent += 1 }
let markerStart = i
var ordered = false
var number: Int?
let c = i < end ? ns.character(at: i) : 0
if c == 0x2D || c == 0x2A || c == 0x2B { // - * +
i += 1
} else { // N. / N)
var value = 0
var digits = 0
while i < end, ns.character(at: i) >= 0x30, ns.character(at: i) <= 0x39, digits < 9 {
value = value * 10 + Int(ns.character(at: i) - 0x30); i += 1; digits += 1
}
ordered = true
number = value
if i < end { i += 1 } // the `.` or `)`
}
let marker = NSRange(location: markerStart, length: i - markerStart)
if i < end, ns.character(at: i) == space || ns.character(at: i) == tab { i += 1 }
var checkbox: NSRange?
var checked = false
if i + 2 < end, ns.character(at: i) == 0x5B, ns.character(at: i + 2) == 0x5D { // [ x ]
let mid = ns.character(at: i + 1)
if mid == space || mid == 0x78 || mid == 0x58 { // space / x / X
checkbox = NSRange(location: i, length: 3)
checked = (mid == 0x78 || mid == 0x58)
i += 3
if i < end, ns.character(at: i) == space || ns.character(at: i) == tab { i += 1 }
}
}
var contentEnd = end
while contentEnd > i, isLineBreak(ns.character(at: contentEnd - 1)) { contentEnd -= 1 }
let content = NSRange(location: i, length: max(0, contentEnd - i))
return ListItem(range: lineRange, marker: marker, ordered: ordered, number: number,
checkbox: checkbox, checked: checked, indent: indent,
contentRange: content, inlines: scoped ? InlineParser.parse(ns, range: content, registry: registry) : [])
}
private static func isLineBreak(_ c: unichar) -> Bool { c == 0x0A || c == 0x0D }
}
@@ -0,0 +1,170 @@
//
// MarkdownDetection.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Helper checks for questions like "is the cursor inside code or LaTeX?"
// and "which Markdown part is currently active?".
import Foundation
enum MarkdownDetection {
// MARK: - Active Token Indices
static func computeActiveTokenIndices(
selectionRange: NSRange,
tokens: [MarkdownToken],
in text: NSString,
suppressed: Bool = false
) -> Set<Int> {
// Read-only mode (no caret) hides all tokens regardless of any trailing selection.
if suppressed { return [] }
var indices: Set<Int> = []
let caretLocation = selectionRange.location
for (index, token) in tokens.enumerated() {
let start = token.range.location
let end = NSMaxRange(token.range)
if selectionRange.length > 0 && (token.kind == .inlineLatex || token.kind == .blockLatex) && NSIntersectionRange(selectionRange, token.range).length > 0 {
indices.insert(index)
continue
}
if caretLocation >= start && caretLocation < end {
indices.insert(index)
continue
}
if caretLocation == end {
let lastIndex = end - 1
if lastIndex >= start && lastIndex < text.length {
let lastChar = text.substring(with: NSRange(location: lastIndex, length: 1))
if lastChar != "\n" {
indices.insert(index)
}
}
}
}
// When a container token (e.g. a table) is active, every inline token inside it becomes active too.
let activeContainers: [MarkdownToken] = indices.compactMap { idx in
let token = tokens[idx]
return token.kind == .table ? token : nil
}
if !activeContainers.isEmpty {
for (i, token) in tokens.enumerated() where !indices.contains(i) {
let tStart = token.range.location
let tEnd = NSMaxRange(token.range)
if activeContainers.contains(where: {
tStart >= $0.range.location && tEnd <= NSMaxRange($0.range)
}) {
indices.insert(i)
}
}
}
return indices
}
// MARK: - Code Block Detection
/// Slow: parses tokens each call. Pass the editor's registry so the parse
/// matches the styled document's grammar (an extension span can pre-claim
/// text a built-in would otherwise recognize).
static func isInsideCodeBlock(range: NSRange, in text: String, registry: ExtensionRegistry = .empty) -> Bool {
let codeTokens = MarkdownTokenizer.parseTokensViaAST(in: text, registry: registry).filter { $0.kind == .codeBlock || $0.kind == .inlineCode }
return isInsideCodeBlock(range: range, codeTokens: codeTokens)
}
static func isInsideCodeBlock(location: Int, in text: String, registry: ExtensionRegistry = .empty) -> Bool {
isInsideCodeBlock(range: NSRange(location: location, length: 0), in: text, registry: registry)
}
/// Fast: uses pre-parsed tokens
static func isInsideCodeBlock(range: NSRange, codeTokens: [MarkdownToken]) -> Bool {
guard !codeTokens.isEmpty else { return false }
for token in codeTokens {
let start = token.range.location
let end = start + token.range.length
if range.length == 0 {
if range.location >= start && range.location <= end { return true }
} else {
if range.location < end && range.location + range.length > start { return true }
}
}
return false
}
static func isInsideCodeBlock(location: Int, codeTokens: [MarkdownToken]) -> Bool {
isInsideCodeBlock(range: NSRange(location: location, length: 0), codeTokens: codeTokens)
}
/// Count of non-overlapping ``` occurrences, scanning left to right
/// exactly `components(separatedBy: "```").count - 1`, but as one UTF-16
/// pass with no substring-array allocation.
static func tripleBacktickCount(in text: NSString) -> Int {
let length = text.length
guard length >= 3 else { return 0 }
var buffer = [unichar](repeating: 0, count: length)
text.getCharacters(&buffer, range: NSRange(location: 0, length: length))
var count = 0
var i = 0
while i + 2 < length { // i can reach length - 3
if buffer[i] == 0x60, buffer[i + 1] == 0x60, buffer[i + 2] == 0x60 {
count += 1
i += 3
} else {
i += 1
}
}
return count
}
/// The ``` count contributed by the backtick runs that intersect `range`.
/// The window expands through adjacent backticks on both sides, so every
/// run inside it is a MAXIMAL run of the whole text and the greedy global
/// count is exactly Σ floor(runLen/3) over maximal runs, which makes these
/// window counts composable: full = fullBefore windowBefore + windowAfter.
static func backtickWindowCount(in text: NSString, around range: NSRange) -> Int {
let length = text.length
guard range.location >= 0, NSMaxRange(range) <= length else { return 0 }
var lo = range.location
while lo > 0, text.character(at: lo - 1) == 0x60 { lo -= 1 }
var hi = NSMaxRange(range)
while hi < length, text.character(at: hi) == 0x60 { hi += 1 }
var count = 0
var run = 0
var i = lo
while i < hi {
if text.character(at: i) == 0x60 {
run += 1
} else {
count += run / 3
run = 0
}
i += 1
}
return count + run / 3
}
// MARK: - LaTeX Detection
/// Slow: parses tokens each call. The registry matters here: a registered
/// extension (e.g. `==$==$`) can claim characters that would otherwise
/// pair into a phantom `$$`, so parsing with `.empty` diverges from the
/// styled document.
static func isInsideLatex(location: Int, in text: String, registry: ExtensionRegistry = .empty) -> Bool {
let tokens = MarkdownTokenizer.parseTokensViaAST(in: text, registry: registry)
let latexTokens = tokens.filter { $0.kind == .inlineLatex || $0.kind == .blockLatex }
return isInsideLatex(location: location, latexTokens: latexTokens)
}
static func isInsideLatex(location: Int, latexTokens: [MarkdownToken]) -> Bool {
guard !latexTokens.isEmpty else { return false }
for token in latexTokens {
let start = token.range.location
let end = start + token.range.length
if location >= start && location <= end { return true }
}
return false
}
}
@@ -0,0 +1,83 @@
//
// MarkdownToken.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Defines the basic Markdown building blocks the editor works with (bold,
// links, code, LaTeX, etc.), plus shared text attributes.
import AppKit
import Foundation
extension NSAttributedString.Key {
public static let wikiLinkID = NSAttributedString.Key("NodeLinkID")
public static let taskCheckbox = NSAttributedString.Key("TaskCheckbox")
}
enum MarkdownTokenKind: Equatable {
case italic
case boldItalic
case bold
case link
case wikiLink
case heading
/// One blockquote line; `markerRanges[0]` is the `>` run, nesting = count of `>`.
case blockquote
case codeBlock
case inlineCode
case blockLatex
case inlineLatex
case imageEmbed
case imageLink
case table
/// A CommonMark backslash escape; marker is the `\`, content the escaped literal char.
case backslashEscape
/// A span contributed by a registered `MarkdownExtension`,
/// carrying the extension's id (e.g. `.extensionSpan("highlight")`).
case extensionSpan(String)
/// A fenced block contributed by a registered `MarkdownExtension`,
/// carrying the extension's id (e.g. `.extensionBlock("container")`).
case extensionBlock(String)
}
struct MarkdownToken {
let kind: MarkdownTokenKind
let range: NSRange
let contentRange: NSRange
let markerRanges: [NSRange]
}
extension MarkdownToken {
func standaloneParagraphRange(in text: NSString) -> NSRange? {
let paragraphRange = text.paragraphRange(for: range)
let paragraphText = text.substring(with: paragraphRange) as NSString
let tokenRelativeRange = NSRange(
location: range.location - paragraphRange.location,
length: range.length
)
let mutableParagraph = paragraphText.mutableCopy() as! NSMutableString
mutableParagraph.replaceCharacters(in: tokenRelativeRange, with: "")
return mutableParagraph.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? paragraphRange : nil
}
func containsSelectionOrStandaloneParagraph(_ selectionLocation: Int, in text: NSString) -> Bool {
let start = range.location
let end = NSMaxRange(range) - 1
if selectionLocation >= start && selectionLocation <= end {
return true
}
guard let paragraphRange = standaloneParagraphRange(in: text) else {
return false
}
let paragraphEnd = NSMaxRange(paragraphRange)
// Reveal source when caret is at document end right after the image, unless that line ends in a newline.
let endsWithNewline = paragraphEnd > paragraphRange.location
&& (text.character(at: paragraphEnd - 1) == 0x0A || text.character(at: paragraphEnd - 1) == 0x0D)
let isAtLastParagraphEnd = selectionLocation == text.length
&& paragraphEnd == text.length && !endsWithNewline
return (selectionLocation >= paragraphRange.location && selectionLocation < paragraphEnd)
|| isAtLastParagraphEnd
}
}
@@ -0,0 +1,33 @@
//
// MarkdownTokenizer.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// The token namespace. Tokens are produced by `parseTokensViaAST`
// (`BlockScopedTokenizer`): block structure + block-level tokens come from
// `BlockParser` + `BlockLevelTokenizer` (hand scanners, no regex), inline
// tokens from the AST (`InlineParser` `InlineASTAdapter`). This file keeps
// only the code-block language helper.
import Foundation
// MARK: - Tokenizer
enum MarkdownTokenizer {
// MARK: - Code Block Helpers
static func extractLanguage(from token: MarkdownToken, in text: String) -> String? {
guard token.kind == .codeBlock,
let openingMarker = token.markerRanges.first,
openingMarker.length > 4 else { return nil }
let nsText = text as NSString
let langRange = NSRange(location: openingMarker.location + 3, length: openingMarker.length - 4)
guard langRange.location + langRange.length <= nsText.length else { return nil }
let langString = nsText.substring(with: langRange).trimmingCharacters(in: .whitespacesAndNewlines)
return langString.isEmpty ? nil : langString
}
}
@@ -0,0 +1,124 @@
//
// EmbeddedImageCache.swift
// MarkdownEngine
//
// Created by Luca Chen on 16.03.26.
//
import AppKit
import CoreGraphics
/// Parsed `![[name|optional-id|optional-width]]` reference.
public struct ImageEmbedReference: Sendable {
private static let markdownRegex = try! NSRegularExpression(
pattern: "!\\[\\[([^\\]\\r\\n]*)\\]\\]"
)
public let name: String
public let nodeID: UUID?
public let requestedWidth: CGFloat?
public init(name: String, nodeID: UUID? = nil, requestedWidth: CGFloat? = nil) {
self.name = name
self.nodeID = nodeID
self.requestedWidth = requestedWidth
}
public init?(content: String) {
let parts = content
.split(separator: "|", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard let name = parts.first, !name.isEmpty else { return nil }
var parsedID: UUID?
var parsedWidth: CGFloat?
for part in parts.dropFirst() where !part.isEmpty {
if parsedID == nil, let id = UUID(uuidString: part) {
parsedID = id
continue
}
if parsedWidth == nil, let value = Double(part), value > 0 {
parsedWidth = CGFloat(value)
}
}
self.init(name: name, nodeID: parsedID, requestedWidth: parsedWidth)
}
public static func parse(markdown: String) -> ImageEmbedReference? {
guard markdown.hasPrefix("![["), markdown.hasSuffix("]]") else { return nil }
return ImageEmbedReference(content: String(markdown.dropFirst(3).dropLast(2)))
}
public static func replacingEmbeds(
in text: String,
transform: (ImageEmbedReference) -> String
) -> String {
let nsText = text as NSString
let fullRange = NSRange(location: 0, length: nsText.length)
let mutable = NSMutableString(string: text)
let matches = markdownRegex.matches(in: text, range: fullRange).reversed()
for match in matches {
let rawContent = nsText.substring(with: match.range(at: 1))
guard let reference = ImageEmbedReference(content: rawContent) else { continue }
mutable.replaceCharacters(in: match.range, with: transform(reference))
}
return mutable as String
}
public var markdown: String {
var parts = [name]
if let nodeID {
parts.append(nodeID.uuidString)
}
if let requestedWidth, requestedWidth > 0 {
let widthValue = Double(requestedWidth)
parts.append(widthValue.rounded() == widthValue ? String(Int(widthValue)) : String(widthValue))
}
return "![[\(parts.joined(separator: "|"))]]"
}
/// Convert to the engine-side request shape consumed by `EmbeddedImageProvider`.
public var providerRequest: EmbeddedImageRequest {
EmbeddedImageRequest(
name: name,
id: nodeID?.uuidString,
requestedWidth: requestedWidth
)
}
}
/// Caches images returned by an ``EmbeddedImageProvider``. The cache
/// invalidates when the provider's fingerprint changes, so the engine
/// stays correct even when the embedder swaps out its data source.
final class EmbeddedImageCache {
static let shared = EmbeddedImageCache()
private init() {}
private var cache: [String: NSImage] = [:]
private var lastFingerprint: AnyHashable?
func image(for reference: ImageEmbedReference, services: MarkdownEditorServices) -> NSImage? {
let currentFingerprint = services.images.fingerprint()
if currentFingerprint != lastFingerprint {
cache.removeAll()
lastFingerprint = currentFingerprint
}
let cacheKey = reference.nodeID?.uuidString ?? reference.name
if let cached = cache[cacheKey] {
return cached
}
guard let image = services.images.image(for: reference.providerRequest) else {
return nil
}
cache[cacheKey] = image
return image
}
}
@@ -0,0 +1,104 @@
//
// LayoutBridge.swift
// MarkdownEngine
//
// Created by Luca Chen on 12.04.26.
//
// Thin helper around TextKit 2's NSTextLayoutManager for the handful of
// character-range queries the editor needs.
import AppKit
/// Line height from the bridge, or a font-metric fallback when no bridge is available.
func layoutBridgeDefaultLineHeight(for font: NSFont, using bridge: LayoutBridge? = nil) -> CGFloat {
bridge?.defaultLineHeight(for: font)
?? (font.ascender - font.descender + font.leading)
}
final class LayoutBridge {
private let textLayoutManager: NSTextLayoutManager
init(_ textLayoutManager: NSTextLayoutManager) {
self.textLayoutManager = textLayoutManager
}
private var textContentStorage: NSTextContentStorage? {
textLayoutManager.textContentManager as? NSTextContentStorage
}
private func textRange(for range: NSRange) -> NSTextRange? {
guard let tcs = textContentStorage,
let start = tcs.location(tcs.documentRange.location, offsetBy: range.location),
let end = tcs.location(start, offsetBy: range.length) else { return nil }
return NSTextRange(location: start, end: end)
}
func defaultLineHeight(for font: NSFont) -> CGFloat {
font.ascender - font.descender + font.leading
}
func boundingRect(forCharacterRange range: NSRange, in textContainer: NSTextContainer) -> CGRect {
guard let textRange = textRange(for: range) else { return .zero }
var result = CGRect.null
textLayoutManager.enumerateTextSegments(
in: textRange, type: .standard, options: []
) { _, rect, _, _ in
result = result.isNull ? rect : result.union(rect)
return true
}
return result.isNull ? .zero : result
}
func characterIndex(
for point: CGPoint,
in textContainer: NSTextContainer,
fractionOfDistanceBetweenInsertionPoints fraction: inout CGFloat
) -> Int {
guard let tcs = textContentStorage else {
fraction = 0
return NSNotFound
}
guard let fragment = textLayoutManager.textLayoutFragment(for: point) else {
fraction = 0
return NSNotFound
}
let fragFrame = fragment.layoutFragmentFrame
let localPoint = CGPoint(
x: point.x - fragFrame.origin.x,
y: point.y - fragFrame.origin.y
)
var lineY: CGFloat = 0
for lineFragment in fragment.textLineFragments {
let lineBounds = lineFragment.typographicBounds
if localPoint.y < lineY + lineBounds.height || lineFragment === fragment.textLineFragments.last {
let lineLocalPoint = CGPoint(x: localPoint.x - lineFragment.glyphOrigin.x,
y: localPoint.y - lineY)
let charIndex = lineFragment.characterIndex(for: lineLocalPoint)
let fragmentStart = tcs.offset(
from: tcs.documentRange.location,
to: fragment.rangeInElement.location
)
fraction = lineFragment.fractionOfDistanceThroughGlyph(for: lineLocalPoint)
return fragmentStart + lineFragment.characterRange.location + charIndex
}
lineY += lineBounds.height
}
fraction = 0
return NSNotFound
}
func invalidateDisplay(forCharacterRange range: NSRange) {
// In TextKit 2, invalidating layout also triggers redisplay.
guard textRange(for: range) != nil else { return }
textLayoutManager.textViewportLayoutController.layoutViewport()
}
func removeTemporaryAttribute(_ attrName: NSAttributedString.Key, forCharacterRange range: NSRange) {
guard let textRange = textRange(for: range) else { return }
textLayoutManager.removeRenderingAttribute(attrName, for: textRange)
}
var firstTextContainer: NSTextContainer? {
textLayoutManager.textContainer
}
}
@@ -0,0 +1,297 @@
//
// MarkdownHTMLRenderer.swift
// MarkdownEngine
//
// Created by Luca Chen on 09.07.26.
//
// A clean Markdown HTML fragment renderer. The editor's NSTextStorage holds
// RAW markdown styled in place (syntax markers merely colored, thematic breaks
// drawn by a layout fragment, tables as image attachments), so the default copy
// serializes junk. This walks the semantic `DocumentAST` and emits a clean HTML
// fragment a sequence of block elements, no <html>/<body> wrapper that the
// copy override wraps and places on the pasteboard.
//
import Foundation
public enum MarkdownHTMLRenderer {
/// Render `markdown` to an HTML fragment (block elements joined by newlines).
/// `extensions` render their spans (e.g. `<mark>` for highlight); an
/// unregistered extension's syntax stays literal text.
public static func html(from markdown: String, extensions: [any MarkdownExtension] = []) -> String {
let ns = markdown as NSString
let env = Env(registry: ExtensionRegistry(extensions: extensions),
byID: {
var out: [String: any MarkdownExtension] = [:]
for ext in extensions { out[ext.id] = ext }
return out
}())
let blocks = DocumentAST.parse(markdown, registry: env.registry)
let pieces = blocks.compactMap { block(for: $0, ns: ns, env: env) }
return pieces.joined(separator: "\n")
}
/// Extension lookup threaded through the render walk.
private struct Env {
let registry: ExtensionRegistry
let byID: [String: any MarkdownExtension]
static let empty = Env(registry: .empty, byID: [:])
}
// MARK: - Blocks
private static func block(for node: BlockNode, ns: NSString, env: Env) -> String? {
switch node {
case .heading(let level, _, _, let inlines):
let l = min(max(level, 1), 6)
return "<h\(l)>\(renderInlines(inlines, ns: ns, env: env))</h\(l)>"
case .paragraph(_, let inlines):
return "<p>\(renderInlines(inlines, ns: ns, env: env))</p>"
case .blockquote(let range, _):
return renderBlockquote(range: range, ns: ns, env: env)
case .list(_, let items):
return renderList(items: items, ns: ns, env: env)
case .codeBlock(let range):
return renderCodeBlock(range: range, ns: ns)
case .blockLatex(let range):
return "<pre>\(escape(ns.substring(with: range).trimmingCharacters(in: .newlines)))</pre>"
case .table(let range):
return renderTable(range: range, ns: ns)
case .thematicBreak:
return "<hr>"
case .blank:
return nil
case .ext(let node):
guard let ext = env.byID[node.extensionID] else {
return "<p>\(escape(ns.substring(with: node.range).trimmingCharacters(in: .newlines)))</p>"
}
// Content lines are separate lines of one block keep them as
// <br> breaks so multi-line bodies don't collapse to one line.
let inner = renderInlines(node.inlines, ns: ns, env: env)
.trimmingCharacters(in: .newlines)
.replacingOccurrences(of: "\n", with: "<br>\n")
return ext.html(childrenHTML: inner)
}
}
/// Blockquote inlines are parsed over the block range *including* the `> `
/// markers, so strip the markers per line and re-parse the content clean.
private static func renderBlockquote(range: NSRange, ns: NSString, env: Env) -> String {
let raw = ns.substring(with: range)
let stripped = raw
.components(separatedBy: "\n")
.map(stripQuoteMarkers)
.filter { !$0.isEmpty }
.joined(separator: "\n")
let inlines = InlineParser.parse(stripped, registry: env.registry)
return "<blockquote>\(renderInlines(inlines, ns: stripped as NSString, env: env))</blockquote>"
}
/// Drop leading indent (3 spaces/tabs) then one-or-more `>` each with an
/// optional trailing space, matching the block styler's marker scan.
private static func stripQuoteMarkers(_ line: String) -> String {
var s = Substring(line)
var indent = 0
while let c = s.first, c == " " || c == "\t", indent < 3 { s = s.dropFirst(); indent += 1 }
while s.first == ">" {
s = s.dropFirst()
if s.first == " " || s.first == "\t" { s = s.dropFirst() }
}
return String(s)
}
/// Emit `<ul>`/`<ol>` groups, switching container when ordered-ness flips.
/// Nesting is flattened to a single level for v1 (see deviations).
private static func renderList(items: [ListItem], ns: NSString, env: Env) -> String {
var out: [String] = []
var currentOrdered: Bool?
var buffer: [String] = []
func flush() {
guard let ordered = currentOrdered, !buffer.isEmpty else { return }
let tag = ordered ? "ol" : "ul"
out.append("<\(tag)>\n" + buffer.joined(separator: "\n") + "\n</\(tag)>")
buffer.removeAll()
}
for item in items {
if currentOrdered != item.ordered {
flush()
currentOrdered = item.ordered
}
buffer.append(listItem(item, ns: ns, env: env))
}
flush()
return out.joined(separator: "\n")
}
private static func listItem(_ item: ListItem, ns: NSString, env: Env) -> String {
let content = renderInlines(item.inlines, ns: ns, env: env)
if item.checkbox != nil {
// GFM task markup so markdown consumers (Obsidian etc.) restore
// `- [ ]` on paste. Rich targets get this stripped to a plain
// bullet by the pasteboard writer (user's call).
let box = item.checked
? "<input type=\"checkbox\" checked disabled> "
: "<input type=\"checkbox\" disabled> "
return "<li>\(box)\(content)</li>"
}
return "<li>\(content)</li>"
}
/// Fenced code: drop the opening ```lang / closing ``` fence lines, escape body.
private static func renderCodeBlock(range: NSRange, ns: NSString) -> String {
let raw = ns.substring(with: range)
var lines = raw.components(separatedBy: "\n")
if lines.last == "" { lines.removeLast() } // drop trailing-newline artifact
let language = fenceLanguage(lines.first ?? "")
var body = Array(lines.dropFirst())
if let last = body.last, isFenceLine(last) { body.removeLast() }
let escaped = escape(body.joined(separator: "\n"))
if let language, !language.isEmpty {
return "<pre><code class=\"language-\(escape(language))\">\(escaped)</code></pre>"
}
return "<pre><code>\(escaped)</code></pre>"
}
/// The parser only produces column-0 backtick fences (BlockParser.isFence),
/// so match that contract when stripping the closing fence line.
private static func isFenceLine(_ line: String) -> Bool {
line.hasPrefix("```")
}
/// Language info-string from an opening fence line (chars after the backticks).
private static func fenceLanguage(_ line: String) -> String? {
let lang = line.drop { $0 == "`" }.trimmingCharacters(in: .whitespaces)
return lang.isEmpty ? nil : lang
}
private static func renderTable(range: NSRange, ns: NSString) -> String {
let raw = ns.substring(with: range)
guard let parsed = MarkdownStyler.parseTableSource(raw) else {
return "<pre>\(escape(raw.trimmingCharacters(in: .newlines)))</pre>"
}
let head = parsed.header.map { "<th>\(escape($0))</th>" }.joined()
let body = parsed.rows.map { row in
"<tr>" + row.map { "<td>\(escape($0))</td>" }.joined() + "</tr>"
}.joined()
return "<table><thead><tr>\(head)</tr></thead><tbody>\(body)</tbody></table>"
}
// MARK: - Inlines
/// `linkable` is false inside an explicit link's title text, where wrapping
/// a URL-shaped run in its own anchor would nest `<a>` inside `<a>`.
private static func renderInlines(_ nodes: [InlineNode], ns: NSString, env: Env, linkable: Bool = true) -> String {
var out = ""
for node in nodes { out += renderInline(node, ns: ns, env: env, linkable: linkable) }
return out
}
private static func renderInline(_ node: InlineNode, ns: NSString, env: Env, linkable: Bool = true) -> String {
switch node {
case .text(let r):
let s = ns.substring(with: r)
return linkable ? escapeAndAutolink(s) : escape(s)
case .code(_, let content):
return "<code>\(escape(ns.substring(with: content)))</code>"
case .emphasis(let kind, _, _, let children):
let inner = renderInlines(children, ns: ns, env: env, linkable: linkable)
switch kind {
case .italic: return "<em>\(inner)</em>"
case .bold: return "<strong>\(inner)</strong>"
case .boldItalic: return "<strong><em>\(inner)</em></strong>"
}
case .link(_, _, let url, _, let children):
return "<a href=\"\(escape(ns.substring(with: url)))\">\(renderInlines(children, ns: ns, env: env, linkable: false))</a>"
case .image(_, let alt, let url, _):
return "<img src=\"\(escape(ns.substring(with: url)))\" alt=\"\(escape(ns.substring(with: alt)))\">"
case .wikiLink(_, let name, _, _):
return escape(ns.substring(with: name))
case .imageEmbed(_, let target, _):
let t = escape(ns.substring(with: target))
return "<img src=\"\(t)\" alt=\"\(t)\">"
case .ext(let node):
guard let ext = env.byID[node.extensionID] else {
return escape(ns.substring(with: node.range)) // unknown id literal
}
let inner = node.children.isEmpty
? escape(ns.substring(with: node.contentRange))
: renderInlines(node.children, ns: ns, env: env, linkable: linkable)
return ext.html(childrenHTML: inner)
case .inlineLatex(let range, _, _):
return escape(ns.substring(with: range))
case .escape(_, let character, _):
return escape(ns.substring(with: character))
}
}
// MARK: - Autolinking
// Built once: rebuilding the detector per render is the styler's documented
// 43ms trap (ENG-8g1b).
private static let autoLinkDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
/// Escape a text run, wrapping bare URLs/emails in anchors. The editor's
/// styler linkifies these via the same system detector, but rich-paste
/// consumers (Mail, Outlook) take the pasteboard's HTML/RTF flavor verbatim
/// and never run their own link detection on it without a real `<a>` a
/// URL that is clickable in the editor pastes as dead text. Code spans
/// never reach this path (they are their own inline node), matching the
/// styler's in-code exclusion.
private static func escapeAndAutolink(_ s: String) -> String {
guard let detector = autoLinkDetector else { return escape(s) }
let ns = s as NSString
let matches = detector.matches(in: s, range: NSRange(location: 0, length: ns.length))
guard !matches.isEmpty else { return escape(s) }
var out = ""
var cursor = 0
for match in matches {
guard let url = match.url else { continue }
out += escape(ns.substring(with: NSRange(location: cursor, length: match.range.location - cursor)))
out += "<a href=\"\(escape(url.absoluteString))\">\(escape(ns.substring(with: match.range)))</a>"
cursor = NSMaxRange(match.range)
}
out += escape(ns.substring(from: cursor))
return out
}
// MARK: - Escaping
private static func escape(_ s: String) -> String {
var out = ""
out.reserveCapacity(s.count)
for ch in s {
switch ch {
case "&": out += "&amp;"
case "<": out += "&lt;"
case ">": out += "&gt;"
case "\"": out += "&quot;"
default: out.append(ch)
}
}
return out
}
}
@@ -0,0 +1,776 @@
//
// MarkdownTextLayoutFragment.swift
// MarkdownEngine
//
// Created by Luca Chen on 12.04.26.
//
// TextKit 2 replacement for CodeBlockLayoutManager.
// Draws code-block backgrounds, LaTeX images, and task checkboxes
// via NSTextLayoutFragment instead of NSLayoutManager glyph overrides.
import AppKit
// MARK: - Custom attribute keys for rendering overlays
extension NSAttributedString.Key {
static let latexImage = NSAttributedString.Key("LatexRenderedImage")
static let latexBounds = NSAttributedString.Key("LatexImageBounds")
static let latexIsBlock = NSAttributedString.Key("LatexIsBlock")
static let latexBlockOffsetY = NSAttributedString.Key("LatexBlockOffsetY")
static let thematicBreak = NSAttributedString.Key("ThematicBreak")
/// Int nesting level (1-based) of a blockquote line; the fragment
/// paints that many vertical bars in the left gutter.
static let blockquoteLevel = NSAttributedString.Key("BlockquoteLevel")
/// Marks a bullet-list marker char (`-`/`*`/`+`) whose glyph is hidden so
/// the fragment can paint a `` in its place. Set to `true`.
static let bulletMarker = NSAttributedString.Key("BulletListMarker")
static let orderedMarker = NSAttributedString.Key("OrderedListMarker")
/// CGFloat natural image width; presence flags block as overlay-rendered.
static let scrollableBlockNaturalWidth = NSAttributedString.Key("ScrollableBlockNaturalWidth")
/// Int hash of source text; key for overlay reconcile + offset persistence.
static let scrollableBlockSourceID = NSAttributedString.Key("ScrollableBlockSourceID")
/// CGFloat total reserved height (image + scroller strip) for overlay sizing.
static let scrollableBlockTotalHeight = NSAttributedString.Key("ScrollableBlockTotalHeight")
/// NSValue(range:) full multi-line range of a rendered table, used to scope width-change restyles.
static let scrollableBlockFullRange = NSAttributedString.Key("ScrollableBlockFullRange")
}
public extension NSAttributedString.Key {
/// NSColor a background painted across the whole LINE BOX (the line
/// fragment's typographic bounds) instead of the glyph box AppKit's
/// `.backgroundColor` covers. Use it for marker-style fills: a span that
/// wraps over several lines then reads as one solid block, at any font
/// size and with any `paragraph.lineHeightExtraSpacing`, where
/// `.backgroundColor` leaves a gap between every pair of lines.
///
/// Painted by `MarkdownTextLayoutFragment`, so it renders in the editor
/// only table cells rasterize their own text and fall back to
/// `.backgroundColor` (see `MarkdownStyler+Tables`).
static let markdownBlockBackground = NSAttributedString.Key("MarkdownBlockBackground")
}
final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
/// Horizontal space (points) each blockquote nesting level occupies
/// shared so the styler's text indent and the painted bars line up.
static let blockquoteIndentPerLevel: CGFloat = 18
static let blockquoteBarWidth: CGFloat = 3
/// Strip below an overlay block for the legacy-small scroller (~11pt) + buffer.
static let scrollableBlockScrollerStrip: CGFloat = 14
// MARK: - FB15131180
/// Maps to TextKit-2's private `extraLineFragmentAttributes` selector so we can pin the trailing extra-line metrics to body font; otherwise a trailing heading paragraph inflates `usageBoundsForTextContainer` by ~30pt when the caret enters it. Pattern from STTextView.
@objc(extraLineFragmentAttributes)
dynamic var stExtraLineFragmentAttributes: NSDictionary?
// MARK: - Rendering surface
/// Extend rendering bounds for code-block backgrounds (full container width)
/// and block images drawn below text via paragraphSpacing.
override var renderingSurfaceBounds: CGRect {
var bounds = super.renderingSurfaceBounds
// Task checkboxes too: the box draws left of the first glyph (marker
// slot), outside the default text surface TextKit would clip it.
if hasCodeBlockBackground || hasThematicBreak || hasBlockquote || hasTaskCheckbox {
let containerWidth = textLayoutManager?.textContainer?.size.width ?? bounds.width
// Extend left to container edge
bounds.origin.x = -layoutFragmentFrame.origin.x
bounds.size.width = containerWidth
}
// Extend bounds to cover block images that render below the text line
// (visibleSource mode uses paragraphSpacing to create space for the image).
for rect in blockImageRects(at: .zero) {
bounds = bounds.union(rect)
}
// Line-box fills are taller than the glyphs they sit behind.
for fill in blockBackgroundFills(at: .zero) {
bounds = bounds.union(fill.rect)
}
return bounds
}
// MARK: - Drawing
override func draw(at point: CGPoint, in context: CGContext) {
// 1. Code-block backgrounds (behind text)
drawCodeBlockBackground(at: point, in: context)
// 1b. Line-box fills (`==highlight==` and friends), behind text
drawBlockBackgrounds(at: point, in: context)
// 2. LaTeX images (behind text hidden markers are invisible anyway)
drawLatexImages(at: point, in: context)
// 3. Normal text
super.draw(at: point, in: context)
// 4. Task checkboxes (on top of hidden [ ]/[x] markers)
drawTaskCheckboxes(at: point, in: context)
// 4b. Bullet glyphs (on top of hidden -/*/+ markers)
drawBulletMarkers(at: point, in: context)
drawOrderedMarkers(at: point, in: context)
// 5. Thematic breaks (full-width line, painted last so it doesn't
// fight with anything that already drew at the line's center)
drawThematicBreaks(at: point, in: context)
// 6. Blockquote bars (left gutter, behind nothing text is indented)
drawBlockquoteBars(at: point, in: context)
}
// MARK: - Helpers
/// NSRange in the document for this fragment's content.
private var fragmentNSRange: NSRange? {
guard let tcs = textLayoutManager?.textContentManager as? NSTextContentStorage else { return nil }
let start = tcs.offset(from: tcs.documentRange.location, to: rangeInElement.location)
let end = tcs.offset(from: tcs.documentRange.location, to: rangeInElement.endLocation)
guard start != NSNotFound, end != NSNotFound, end > start else { return nil }
return NSRange(location: start, length: end - start)
}
private var textStorage: NSTextStorage? {
(textLayoutManager?.textContentManager as? NSTextContentStorage)?.textStorage
}
/// Returns the drawing position for a character at `docIndex` (document-level NSRange location).
/// `point` is the draw origin passed to `draw(at:in:)`.
private func drawPosition(forDocumentCharAt docIndex: Int, point: CGPoint) -> (x: CGFloat, baselineY: CGFloat, lineHeight: CGFloat)? {
guard let fragRange = fragmentNSRange else { return nil }
let localIndex = docIndex - fragRange.location
guard localIndex >= 0 else { return nil }
// NSTextLineFragment.typographicBounds.origin.y is already relative to the
// parent layout fragment, so we use it directly accumulating per-line
// heights would double-count the inter-line offset on wrapped lines.
for lineFragment in textLineFragments {
let lr = lineFragment.characterRange
if localIndex >= lr.location && localIndex < lr.location + lr.length {
let charPos = lineFragment.locationForCharacter(at: localIndex)
let tb = lineFragment.typographicBounds
return (
x: point.x + tb.origin.x + charPos.x,
baselineY: point.y + tb.origin.y + charPos.y,
lineHeight: tb.height
)
}
}
return nil
}
/// Typographic bounds of the line fragment containing `localIndex`
/// (index relative to the fragment, not the document).
private func lineBounds(forLocalIndex localIndex: Int, point: CGPoint) -> CGRect? {
for lineFragment in textLineFragments {
let lr = lineFragment.characterRange
if localIndex >= lr.location && localIndex < lr.location + lr.length {
let tb = lineFragment.typographicBounds
return CGRect(x: point.x + lineFragment.glyphOrigin.x + tb.origin.x,
y: point.y + tb.origin.y,
width: tb.width,
height: tb.height)
}
}
return nil
}
// MARK: - Code Block Background
private var hasCodeBlockBackground: Bool {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return false }
let bgColor = ts.attribute(.backgroundColor, at: range.location, effectiveRange: nil) as? NSColor
guard let bgColor else { return false }
return isCodeBlockBackgroundColor(bgColor)
}
private var hasThematicBreak: Bool {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return false }
var found = false
ts.enumerateAttribute(.thematicBreak, in: range, options: []) { value, _, stop in
if value as? Bool == true {
found = true
stop.pointee = true
}
}
return found
}
private var hasBlockquote: Bool {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return false }
var found = false
ts.enumerateAttribute(.blockquoteLevel, in: range, options: []) { value, _, stop in
if value is Int {
found = true
stop.pointee = true
}
}
return found
}
private var hasTaskCheckbox: Bool {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return false }
var found = false
ts.enumerateAttribute(.taskCheckbox, in: range, options: []) { value, _, stop in
if value is Bool {
found = true
stop.pointee = true
}
}
return found
}
private func drawCodeBlockBackground(at point: CGPoint, in context: CGContext) {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return }
// Only fenced code-block fragments get the full-width fill (first char must carry the code background).
guard let color = ts.attribute(.backgroundColor, at: range.location, effectiveRange: nil) as? NSColor,
isCodeBlockBackgroundColor(color) else { return }
let containerWidth = textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width
var effectiveHeight = layoutFragmentFrame.height
if textLineFragments.count > 1,
let lastLF = textLineFragments.last,
lastLF.characterRange.length == 0 {
effectiveHeight -= lastLF.typographicBounds.height
}
let scale = textLayoutManager?.textContainer?.textView?.window?.backingScaleFactor
?? NSScreen.main?.backingScaleFactor ?? 2.0
let rawY = point.y
let rawMaxY = point.y + effectiveHeight
let snappedY = floor(rawY * scale) / scale
let snappedMaxY = ceil(rawMaxY * scale) / scale
// Draw full-width background, clipping out any active selection rects
// so the system's blue selection highlight remains visible inside code blocks.
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
let nsContext = NSGraphicsContext(cgContext: context, flipped: true)
NSGraphicsContext.current = nsContext
let bgRect = CGRect(
x: point.x - layoutFragmentFrame.origin.x,
y: snappedY,
width: containerWidth,
height: snappedMaxY - snappedY
)
let selectionRects = selectionRectsInDrawCoordinates(drawPoint: point, snappedY: snappedY, snappedMaxY: snappedMaxY)
color.setFill()
if selectionRects.isEmpty {
NSBezierPath(rect: bgRect).fill()
} else {
let path = NSBezierPath()
path.windingRule = .evenOdd
path.appendRect(bgRect)
for r in selectionRects {
path.appendRect(r.intersection(bgRect))
}
path.fill()
}
}
/// Returns active text-selection rectangles intersecting this fragment, in
/// the same draw-relative coordinate system used by `drawCodeBlockBackground`.
private func selectionRectsInDrawCoordinates(drawPoint: CGPoint, snappedY: CGFloat, snappedMaxY: CGFloat) -> [CGRect] {
guard let tlm = textLayoutManager else { return [] }
var rects: [CGRect] = []
let dx = drawPoint.x - layoutFragmentFrame.origin.x
let myRange = self.rangeInElement
for selection in tlm.textSelections {
for textRange in selection.textRanges {
let interStart = textRange.location.compare(myRange.location) == .orderedAscending
? myRange.location : textRange.location
let interEnd = textRange.endLocation.compare(myRange.endLocation) == .orderedDescending
? myRange.endLocation : textRange.endLocation
guard interStart.compare(interEnd) == .orderedAscending,
let intersection = NSTextRange(location: interStart, end: interEnd) else { continue }
tlm.enumerateTextSegments(in: intersection, type: .selection, options: []) { _, segFrame, _, _ in
// Expand vertically to match the bgRect's snapped span so the
// even-odd cut-out is geometrically congruent with the fill.
let drawRect = CGRect(
x: segFrame.origin.x + dx,
y: snappedY,
width: segFrame.width,
height: snappedMaxY - snappedY
)
rects.append(drawRect)
return true
}
}
}
return rects
}
private func isCodeBlockBackgroundColor(_ color: NSColor) -> Bool {
let highlighter = (textLayoutManager?.textContainer?.textView as? NativeTextView)?
.configuration.services.syntaxHighlighter
?? PlainTextSyntaxHighlighter()
let currentBg = highlighter.backgroundColor()
guard let colorRGB = color.usingColorSpace(.deviceRGB),
let currentBgRGB = currentBg.usingColorSpace(.deviceRGB) else { return false }
let tolerance: CGFloat = 0.03
return abs(colorRGB.redComponent - currentBgRGB.redComponent) < tolerance &&
abs(colorRGB.greenComponent - currentBgRGB.greenComponent) < tolerance &&
abs(colorRGB.blueComponent - currentBgRGB.blueComponent) < tolerance
}
// MARK: - Line-Box Backgrounds
/// Fill rects for every `.markdownBlockBackground` run in this fragment,
/// one per line the run touches, relative to `point`.
///
/// Each rect spans the line fragment's full typographic bounds the same
/// box the blockquote bars use, which is why a run of them reads as one
/// continuous shape. AppKit's own `.backgroundColor` fill is the glyph box
/// instead (ascent + descent), so it falls short of the line height by the
/// leading plus `paragraph.lineHeightExtraSpacing` and a wrapped highlight
/// comes out as stacked bands.
func blockBackgroundFills(at point: CGPoint) -> [(rect: CGRect, color: NSColor)] {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return [] }
var fills: [(rect: CGRect, color: NSColor)] = []
ts.enumerateAttribute(.markdownBlockBackground, in: range, options: []) { value, attrRange, _ in
guard let color = value as? NSColor else { return }
let local = NSRange(location: attrRange.location - range.location, length: attrRange.length)
for lineFragment in textLineFragments {
let lineRange = lineFragment.characterRange
let hit = NSIntersectionRange(lineRange, local)
guard hit.length > 0 else { continue }
let tb = lineFragment.typographicBounds
let startX = lineFragment.locationForCharacter(at: hit.location).x
// A run reaching the line's end fills to the line's own width:
// the index one past the line belongs to the next fragment, and
// asking this one for it is undefined.
let reachesEnd = hit.location + hit.length >= lineRange.location + lineRange.length
let endX = reachesEnd
? tb.width
: lineFragment.locationForCharacter(at: hit.location + hit.length).x
guard endX > startX else { continue }
fills.append((
rect: CGRect(x: point.x + tb.origin.x + startX,
y: point.y + tb.origin.y,
width: endX - startX,
height: tb.height),
color: color
))
}
}
return fills
}
private func drawBlockBackgrounds(at point: CGPoint, in context: CGContext) {
let fills = blockBackgroundFills(at: point)
guard !fills.isEmpty else { return }
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
for fill in fills {
fill.color.setFill()
NSBezierPath(rect: fill.rect).fill()
}
}
// MARK: - LaTeX / Block Image Helpers
/// Compute the draw rect for a block image at `attrRange` using `point` as
/// the draw origin. Shared by `drawLatexImages` and `blockImageRects` so
/// bounds and rendering stay in sync.
private func blockImageDrawRect(
attrRange: NSRange,
imageBounds: CGRect,
blockOffsetY: CGFloat?,
point: CGPoint
) -> CGRect? {
guard let pos = drawPosition(forDocumentCharAt: attrRange.location, point: point) else { return nil }
let fragLocation = fragmentNSRange?.location ?? 0
let localStart = attrRange.location - fragLocation
let localLast = max(localStart, localStart + attrRange.length - 1)
let firstLb = lineBounds(forLocalIndex: localStart, point: point)
// For a wrapped source span (e.g. a long `![alt](url)` that wraps in
// a narrow window), anchor to the LAST line's maxY so the image
// doesn't paint over subsequent wrapped lines of its own source.
let lastLb = lineBounds(forLocalIndex: localLast, point: point) ?? firstLb
let lineHeight = firstLb?.height ?? pos.lineHeight
let firstLineMinY = firstLb?.origin.y ?? (pos.baselineY - lineHeight)
let lastLineMaxY = (lastLb?.origin.y ?? firstLineMinY) + (lastLb?.height ?? lineHeight)
let yPosition: CGFloat
if let blockOffsetY {
// Backward-compatible interpretation: `blockOffsetY` is the gap
// from the FIRST line's top to the image's top (= baseLineHeight
// + imageGap on a single-line source). Re-anchor to the last
// line by subtracting one line height, leaving the same single-
// line geometry intact while pushing the image down by one
// extra line per wrap.
yPosition = lastLineMaxY + blockOffsetY - lineHeight
} else {
yPosition = firstLineMinY + (lineHeight - imageBounds.height) / 2
}
return CGRect(x: pos.x, y: yPosition,
width: imageBounds.width, height: imageBounds.height)
}
/// Returns the rects of all block images in this fragment, relative to
/// `point`. Used by `renderingSurfaceBounds` (with `.zero`) to extend
/// the surface so images drawn in paragraphSpacing aren't clipped.
private func blockImageRects(at point: CGPoint) -> [CGRect] {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return [] }
var rects: [CGRect] = []
ts.enumerateAttribute(.latexImage, in: range, options: []) { value, attrRange, _ in
guard value is NSImage else { return }
let isBlock = ts.attribute(.latexIsBlock, at: attrRange.location, effectiveRange: nil) as? Bool ?? false
guard isBlock else { return }
// Skip overlay blocks; surface bounds must stay within container.
if ts.attribute(.scrollableBlockNaturalWidth, at: attrRange.location, effectiveRange: nil) != nil {
return
}
let boundsVal = ts.attribute(.latexBounds, at: attrRange.location, effectiveRange: nil) as? NSValue
let imageBounds = boundsVal?.rectValue ?? .zero
let blockOffsetY = ts.attribute(.latexBlockOffsetY, at: attrRange.location, effectiveRange: nil) as? CGFloat
if let rect = blockImageDrawRect(attrRange: attrRange, imageBounds: imageBounds, blockOffsetY: blockOffsetY, point: point) {
rects.append(rect)
}
}
return rects
}
// MARK: - LaTeX Images
private func drawLatexImages(at point: CGPoint, in context: CGContext) {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return }
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
let nsContext = NSGraphicsContext(cgContext: context, flipped: true)
NSGraphicsContext.current = nsContext
ts.enumerateAttribute(.latexImage, in: range, options: []) { [weak self] value, attrRange, _ in
guard let self, let image = value as? NSImage else { return }
// Skip overlay-rendered blocks; WideTableOverlay owns the visual.
if ts.attribute(.scrollableBlockNaturalWidth, at: attrRange.location, effectiveRange: nil) != nil {
return
}
let boundsVal = ts.attribute(.latexBounds, at: attrRange.location, effectiveRange: nil) as? NSValue
let imageBounds = boundsVal?.rectValue ?? CGRect(origin: .zero, size: image.size)
let isBlock = ts.attribute(.latexIsBlock, at: attrRange.location, effectiveRange: nil) as? Bool ?? false
let blockOffsetY = ts.attribute(.latexBlockOffsetY, at: attrRange.location, effectiveRange: nil) as? CGFloat
guard let pos = drawPosition(forDocumentCharAt: attrRange.location, point: point) else { return }
let drawRect: CGRect
if isBlock {
guard let rect = blockImageDrawRect(attrRange: attrRange, imageBounds: imageBounds, blockOffsetY: blockOffsetY, point: point) else { return }
drawRect = rect
} else {
let descent = imageBounds.origin.y
drawRect = CGRect(x: pos.x,
y: pos.baselineY + descent - imageBounds.height,
width: imageBounds.width, height: imageBounds.height)
}
image.draw(in: drawRect)
}
}
// MARK: - Thematic Breaks (---, ***, ___)
/// Draw a 1pt horizontal rule across the full container width for any
/// line fragment whose backing text carries the `.thematicBreak`
/// attribute. This decouples HR rendering from the source-text length,
/// so a 3-char `---` looks the same as a 80-char auto-expanded line.
private func drawThematicBreaks(at point: CGPoint, in context: CGContext) {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return }
var hasThematic = false
ts.enumerateAttribute(.thematicBreak, in: range, options: []) { value, _, stop in
if value as? Bool == true {
hasThematic = true
stop.pointee = true
}
}
guard hasThematic else { return }
let containerWidth = textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width
let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)?
.configuration.theme ?? .default
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
let nsContext = NSGraphicsContext(cgContext: context, flipped: true)
NSGraphicsContext.current = nsContext
let strokeColor = theme.strikethroughColor.withAlphaComponent(0.4)
strokeColor.setFill()
// Walk each line fragment in this layout fragment and paint a
// band on those whose first character carries the marker. (HR
// tokens are always single-line, but the loop is robust if a
// future caller ever stacks several rules in one paragraph.)
let fragLocation = fragmentNSRange?.location ?? 0
for lineFragment in textLineFragments {
let lr = lineFragment.characterRange
let docStart = fragLocation + lr.location
// TextKit 2 appends a synthetic trailing empty line fragment whose
// characterRange lands at exactly `tsLen` `attribute(at:)` needs
// a strictly in-bounds index, so skip the sentinel.
guard docStart < ts.length else { continue }
let isHR = ts.attribute(.thematicBreak, at: docStart, effectiveRange: nil) as? Bool == true
let tb = lineFragment.typographicBounds
if isHR {
// tb.origin.y is already relative to this layout fragment.
let centerY = point.y + tb.origin.y + tb.height / 2
let bandRect = CGRect(
x: point.x - layoutFragmentFrame.origin.x,
y: centerY - 0.5,
width: containerWidth,
height: 1
)
NSBezierPath(rect: bandRect).fill()
}
}
}
// MARK: - Blockquote Bars
/// Paint `level` vertical bars in the left gutter of every line that
/// carries `.blockquoteLevel`. Each line paints its own segment, so a
/// run of quote lines reads as one continuous bar.
private func drawBlockquoteBars(at point: CGPoint, in context: CGContext) {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return }
var anyLevel = false
ts.enumerateAttribute(.blockquoteLevel, in: range, options: []) { value, _, stop in
if value is Int { anyLevel = true; stop.pointee = true }
}
guard anyLevel else { return }
let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)?
.configuration.theme ?? .default
let indentPerLevel = Self.blockquoteIndentPerLevel
let barWidth = Self.blockquoteBarWidth
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
let nsContext = NSGraphicsContext(cgContext: context, flipped: true)
NSGraphicsContext.current = nsContext
theme.mutedText.withAlphaComponent(0.5).setFill()
let fragLocation = fragmentNSRange?.location ?? 0
let leftEdge = point.x - layoutFragmentFrame.origin.x
for lineFragment in textLineFragments {
let lr = lineFragment.characterRange
let docStart = fragLocation + lr.location
// TextKit 2 appends a synthetic trailing empty line fragment whose
// characterRange lands at exactly `tsLen` `attribute(at:)` needs
// a strictly in-bounds index, so skip the sentinel.
guard docStart < ts.length else { continue }
let tb = lineFragment.typographicBounds
if let level = ts.attribute(.blockquoteLevel, at: docStart, effectiveRange: nil) as? Int {
// tb.origin.y is already relative to this layout fragment.
let barY = point.y + tb.origin.y
for i in 0..<level {
let barX = leftEdge + CGFloat(i) * indentPerLevel + indentPerLevel * 0.25
NSBezierPath(rect: CGRect(
x: barX, y: barY, width: barWidth, height: tb.height
)).fill()
}
}
}
}
// MARK: - Bullet Markers
/// Paint a `` over every hidden bullet marker (`.bulletMarker`). The
/// glyph is drawn in the same font as the source so its baseline matches
/// the surrounding text, and centered within the original marker char's
/// advance so a `` of a different width still sits where `-`/`*`/`+` was.
private func drawBulletMarkers(at point: CGPoint, in context: CGContext) {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return }
let selectionRanges: [NSRange] = {
guard let tv = textLayoutManager?.textContainer?.textView else { return [] }
return tv.selectedRanges.map { $0.rangeValue }.filter { $0.length > 0 }
}()
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
let nsContext = NSGraphicsContext(cgContext: context, flipped: true)
NSGraphicsContext.current = nsContext
let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)?
.configuration.theme ?? .default
let storageString = ts.string as NSString
ts.enumerateAttribute(.bulletMarker, in: range, options: []) { [weak self] value, attrRange, _ in
guard let self, (value as? Bool) == true else { return }
guard let pos = self.drawPosition(forDocumentCharAt: attrRange.location, point: point) else { return }
let font = (ts.attribute(.font, at: attrRange.location, effectiveRange: nil) as? NSFont)
?? (self.textLayoutManager?.textContainer?.textView?.font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize))
// A `.bulletMarker` range means the styler painted the raw char
// `.clear`, so something must ALWAYS be drawn over the slot. Outside
// a selection that's the rendered ``; while the marker sits inside
// a selection the raw source char (`-`/`*`/`+`) is painted instead,
// so selecting a list line reveals its raw syntax. (The styler's own
// reveal is caret-based and doesn't fire for selections an earlier
// selection-skip here drew nothing over the cleared char, which left
// an empty slot wherever the selection anchor wasn't in the marker.)
let isSelected = selectionRanges.contains(where: { NSIntersectionRange($0, attrRange).length > 0 })
let raw = storageString.substring(with: attrRange)
let glyph = (isSelected ? raw : "") as NSString
let glyphAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.bodyText]
let markerWidth = (raw as NSString).size(withAttributes: [.font: font]).width
let glyphWidth = glyph.size(withAttributes: glyphAttrs).width
let xOffset = max(0, (markerWidth - glyphWidth) / 2)
// Flipped context: text origin is its top edge, baseline sits one
// ascent below so top = baseline ascent aligns the glyph.
let topY = pos.baselineY - font.ascender
glyph.draw(at: CGPoint(x: pos.x + xOffset, y: topY), withAttributes: glyphAttrs)
}
}
// MARK: - Ordered List Markers
/// Paint the whole display marker "N." (`.orderedMarker` value) over the
/// hidden source marker (digits + dot, cleared by the styler as one unit and
/// kerned to the display width so any digit count aligns and content/wrapped
/// lines hang at that width). Draws the raw source marker instead while the
/// line is selected, so selection reveals the literal digits.
private func drawOrderedMarkers(at point: CGPoint, in context: CGContext) {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return }
let selectionRanges: [NSRange] = {
guard let tv = textLayoutManager?.textContainer?.textView else { return [] }
return tv.selectedRanges.map { $0.rangeValue }.filter { $0.length > 0 }
}()
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)?
.configuration.theme ?? .default
let storageString = ts.string as NSString
ts.enumerateAttribute(.orderedMarker, in: range, options: []) { [weak self] value, attrRange, _ in
guard let self, let number = value as? String else { return }
guard let pos = self.drawPosition(forDocumentCharAt: attrRange.location, point: point) else { return }
let font = (ts.attribute(.font, at: attrRange.location, effectiveRange: nil) as? NSFont)
?? (self.textLayoutManager?.textContainer?.textView?.font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize))
let isSelected = selectionRanges.contains(where: { NSIntersectionRange($0, attrRange).length > 0 })
let raw = storageString.substring(with: attrRange)
let glyph = (isSelected ? raw : number) as NSString
let glyphAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.bodyText]
let topY = pos.baselineY - font.ascender
glyph.draw(at: CGPoint(x: pos.x, y: topY), withAttributes: glyphAttrs)
}
}
// MARK: - Task List Checkboxes
private func drawTaskCheckboxes(at point: CGPoint, in context: CGContext) {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return }
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
let nsContext = NSGraphicsContext(cgContext: context, flipped: true)
NSGraphicsContext.current = nsContext
ts.enumerateAttribute(.taskCheckbox, in: range, options: []) { [weak self] value, attrRange, _ in
guard let self, value != nil else { return }
// A `.taskCheckbox` range means the styler cleared the raw `- [ ]`
// (and collapsed the box's advance), so the box must ALWAYS be
// drawn including while the range sits inside a selection. An
// earlier selection-skip here left an empty marker-width gap (the
// bullet-marker blank-slot bug's twin). Unlike bullets, the raw
// source can't be painted here instead: the hidden `[ ]` advance
// is collapsed, so raw glyphs would overlap the content raw
// reveal stays caret-based (taskRevealed in the styler).
let isChecked = (value as? Bool) ?? false
guard let pos = drawPosition(forDocumentCharAt: attrRange.location, point: point) else { return }
// Box collapsed to 0.1pt, so pos.x sits at the content edge; the
// square is right-aligned to it (shared with the click hit-test).
// Use baseFont, NOT NSTextView.font its getter returns the first
// char's font (0.1pt in a heading-first doc 1px boxes).
let font = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.baseFont
?? NSFont.systemFont(ofSize: NSFont.systemFontSize)
let ascent = max(0, font.ascender)
let descent = max(0, -font.descender)
let size = TaskCheckboxGeometry.size(for: font)
let boxX = TaskCheckboxGeometry.boxX(contentX: pos.x, size: size)
let centerY = pos.baselineY + (descent - ascent) / 2
let boxY = centerY - size / 2
let scale = textLayoutManager?.textContainer?.textView?.window?.backingScaleFactor
?? NSScreen.main?.backingScaleFactor ?? 2.0
func alignToPixel(_ value: CGFloat) -> CGFloat {
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
let boxRect = CGRect(x: alignToPixel(boxX), y: alignToPixel(boxY), width: size, height: size)
guard !boxRect.isEmpty, !boxRect.isNull else { return }
let iconInset = max(0.0, size * 0.01)
let iconRect = boxRect.insetBy(dx: iconInset, dy: iconInset)
let configuration = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.configuration
?? .default
let style = configuration.taskCheckbox
let symbolName = isChecked ? style.checkedSymbolName : style.uncheckedSymbolName
let fallbackName = isChecked
? TaskCheckboxStyle.default.checkedSymbolName
: TaskCheckboxStyle.default.uncheckedSymbolName
if let baseSymbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
?? NSImage(systemSymbolName: fallbackName, accessibilityDescription: nil) {
let sizeConfig = NSImage.SymbolConfiguration(pointSize: iconRect.height, weight: .regular)
let tint = isChecked ? configuration.theme.bodyText : configuration.theme.mutedText
let colorConfig = NSImage.SymbolConfiguration(hierarchicalColor: tint)
let symbolConfig = sizeConfig.applying(colorConfig)
let symbol = baseSymbol.withSymbolConfiguration(symbolConfig) ?? baseSymbol
symbol.draw(in: iconRect)
}
}
}
}
// MARK: - Layout Manager Delegate
final class MarkdownLayoutManagerDelegate: NSObject, NSTextLayoutManagerDelegate {
func textLayoutManager(
_ textLayoutManager: NSTextLayoutManager,
textLayoutFragmentFor location: any NSTextLocation,
in textElement: NSTextElement
) -> NSTextLayoutFragment {
PerfTrace.accumulate("fragProv") {
makeFragment(textLayoutManager: textLayoutManager, textElement: textElement)
}
}
private func makeFragment(
textLayoutManager: NSTextLayoutManager,
textElement: NSTextElement
) -> NSTextLayoutFragment {
let fragment = MarkdownTextLayoutFragment(textElement: textElement, range: textElement.elementRange)
// Seed body font + paragraphStyle so the trailing fragment doesn't inherit heading metrics (FB15131180).
if let textView = textLayoutManager.textContainer?.textView as? NativeTextView {
let baseFont = textView.baseFont
let para = NSMutableParagraphStyle()
let lineHeight = layoutBridgeDefaultLineHeight(for: baseFont, using: textView.layoutBridge)
para.minimumLineHeight = ceil(lineHeight) + textView.configuration.paragraph.lineHeightExtraSpacing
para.paragraphSpacing = ceil(lineHeight * textView.configuration.paragraph.spacingFactor)
para.paragraphSpacingBefore = 0
fragment.stExtraLineFragmentAttributes = NSDictionary(dictionary: [
NSAttributedString.Key.font: baseFont,
NSAttributedString.Key.foregroundColor: textView.configuration.theme.bodyText,
NSAttributedString.Key.paragraphStyle: para
])
}
return fragment
}
}
@@ -0,0 +1,35 @@
//
// TaskCheckboxGeometry.swift
// MarkdownEngine
//
// Created by Luca Chen on 09.07.26.
//
// Shared geometry for the drawn task-checkbox square. The hidden `[ ] ` chars
// are collapsed to ~zero advance by the styler, so `drawPosition`/
// `boundingRect` of the box range sit at the task CONTENT's left edge. The
// square is right-aligned to that edge with a small gap (Obsidian-style),
// occupying the `- ` marker slot. Fragment draw and click hit-test both use
// these functions so their rects can't drift apart.
//
import AppKit
enum TaskCheckboxGeometry {
/// Gap between the box's right edge and the task content's left edge.
static let gap: CGFloat = 2.0
/// Side length of the square for the given (body) font.
static func size(for font: NSFont) -> CGFloat {
let ascent = max(0, font.ascender)
let descent = max(0, -font.descender)
let fontHeight = max(1, ceil(ascent + descent))
let markerWidth = ("[ ]" as NSString).size(withAttributes: [.font: font]).width
return max(1.0, min(floor(fontHeight * 1.2), floor(markerWidth * 1.2)))
}
/// Left edge of the square: right-aligned to the content start x with `gap`.
static func boxX(contentX: CGFloat, size: CGFloat) -> CGFloat {
contentX - size - gap
}
}
@@ -0,0 +1,321 @@
//
// WideTableOverlay.swift
// MarkdownEngine
//
// NSScrollView subview of NativeTextView hosting a wide-table image with
// native horizontal scrolling. Sidesteps TextKit 2's fragment surface
// cache that defeats in-fragment custom scrolling.
//
import AppKit
// MARK: - Faint scroller
/// Legacy scroller with a fainter knob.
final class SubtleScroller: NSScroller {
override func drawKnobSlot(in slot: NSRect, highlight: Bool) {
// Transparent track only the knob is drawn.
}
override func drawKnob() {
let knob = rect(for: .knob)
guard knob.width > 1, knob.height > 1 else { return }
let thickness: CGFloat = 5
let pill = knob.insetBy(dx: 2, dy: max(0, (knob.height - thickness) / 2))
NSColor.secondaryLabelColor.withAlphaComponent(0.3).setFill() // faintness; tweak here
NSBezierPath(roundedRect: pill, xRadius: pill.height / 2, yRadius: pill.height / 2).fill()
}
}
// MARK: - Overlay view
final class WideTableOverlay: NSScrollView {
/// Hash of table source; key for offset persistence + reconcile lookup.
let sourceID: Int
/// Document index of the table anchor; click on image moves caret here.
var anchorTextLocation: Int
/// Weak parent ref for offset persistence + caret forwarding.
weak var ownerTextView: NativeTextView?
/// Table's left-edge offset (breakout: text-column left); scrollable space.
var leftContentInset: CGFloat = 0 {
didSet {
guard abs(contentInsets.left - leftContentInset) > 0.5 else { return }
contentInsets = NSEdgeInsets(top: 0, left: leftContentInset, bottom: 0, right: 0)
}
}
private let tableImageView: WideTableImageView
init(sourceID: Int, image: NSImage, ownerTextView: NativeTextView, anchorLocation: Int) {
self.sourceID = sourceID
self.anchorTextLocation = anchorLocation
self.ownerTextView = ownerTextView
self.tableImageView = WideTableImageView(frame: CGRect(origin: .zero, size: image.size))
tableImageView.image = image
tableImageView.imageScaling = .scaleNone
tableImageView.imageAlignment = .alignTopLeft
super.init(frame: .zero)
hasHorizontalScroller = true
hasVerticalScroller = false
// Auto-hide: only show the scroller when the table actually overflows.
autohidesScrollers = true
borderType = .noBorder
drawsBackground = false
// Legacy scroller, fainter knob (see SubtleScroller).
scrollerStyle = .legacy
let subtleScroller = SubtleScroller()
subtleScroller.scrollerStyle = .legacy
horizontalScroller = subtleScroller
horizontalScrollElasticity = .allowed
verticalScrollElasticity = .none
usesPredominantAxisScrolling = true
horizontalScroller?.controlSize = .small
automaticallyAdjustsContentInsets = false
documentView = tableImageView
tableImageView.ownerOverlay = self
NotificationCenter.default.addObserver(
self, selector: #selector(scrollOffsetDidChange),
name: NSScrollView.didLiveScrollNotification, object: self
)
NotificationCenter.default.addObserver(
self, selector: #selector(scrollOffsetDidChange),
name: NSScrollView.didEndLiveScrollNotification, object: self
)
}
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
deinit { NotificationCenter.default.removeObserver(self) }
/// Clamp vertical scroll to 0 on every layout kills sub-pixel wobble.
override func tile() {
super.tile()
let origin = contentView.bounds.origin
if origin.y != 0 {
contentView.scroll(to: NSPoint(x: origin.x, y: 0))
reflectScrolledClipView(contentView)
}
}
/// Forward everything except clearly-horizontal events to the outer
/// document scroll. Zero-delta phase/momentum lifecycle events count
/// as "not horizontal" so the outer scroll view still receives the
/// gesture-ended notifications it needs to stop cleanly.
override func scrollWheel(with event: NSEvent) {
if abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) {
super.scrollWheel(with: event)
} else {
nextResponder?.scrollWheel(with: event)
}
}
/// Swap the rendered image after a restyle regenerated it.
func updateImage(_ image: NSImage) {
if tableImageView.image !== image {
tableImageView.image = image
tableImageView.frame = CGRect(origin: .zero, size: image.size)
}
}
var horizontalOffset: CGFloat {
get { contentView.bounds.origin.x }
set {
contentView.scroll(to: NSPoint(x: max(0, newValue), y: 0))
reflectScrolledClipView(contentView)
}
}
@objc private func scrollOffsetDidChange() {
ownerTextView?.tableHorizontalScrollOffsets[sourceID] = horizontalOffset
}
}
// MARK: - Document view inside the overlay
/// Forwards mouseDown to caret-into-table (so clicking switches to edit mode).
final class WideTableImageView: NSImageView {
weak var ownerOverlay: WideTableOverlay?
override func mouseDown(with event: NSEvent) {
guard let overlay = ownerOverlay,
let textView = overlay.ownerTextView else {
super.mouseDown(with: event)
return
}
let location = overlay.anchorTextLocation
let docLen = (textView.string as NSString).length
guard location >= 0, location <= docLen else {
super.mouseDown(with: event)
return
}
textView.window?.makeFirstResponder(textView)
textView.setSelectedRange(NSRange(location: location, length: 0))
}
}
// MARK: - NativeTextView reconcile extension
extension NativeTextView {
/// Coalesce overlay updates to one per runloop tick (resize fires bursts); first run is sync to avoid a load flash.
func updateWideTableOverlays() {
if wideTableOverlays.isEmpty {
performWideTableOverlayUpdate()
return
}
if pendingWideTableOverlayUpdate { return }
pendingWideTableOverlayUpdate = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.pendingWideTableOverlayUpdate = false
self.performWideTableOverlayUpdate()
}
}
/// Cheap per-frame overlay shift when the container moves the text view
/// vertically (header band growing/collapsing). Breakout overlays are
/// SIBLINGS in the container whose frames bake in the text view's offset
/// at update time; non-breakout overlays are text-view subviews and move
/// with it automatically.
func shiftWideTableOverlays(byY deltaY: CGFloat) {
guard configuration.readingWidth != nil, !wideTableOverlays.isEmpty else { return }
for (_, overlay) in wideTableOverlays {
var f = overlay.frame
f.origin.y += deltaY
overlay.frame = f
}
}
/// Cheap per-frame overlay reposition on width change (no layout) keeps tables glued to the text during resize.
func repositionWideTableOverlaysForWidthChange(insetDelta: CGFloat) {
guard configuration.readingWidth != nil, !wideTableOverlays.isEmpty else { return }
// Overlays live in the full-width reading-column container; use its width.
let viewWidth = (superview ?? self).bounds.width
for (_, overlay) in wideTableOverlays {
var f = overlay.frame
f.origin.x = 0
f.size.width = viewWidth
overlay.frame = f
overlay.leftContentInset = max(0, overlay.leftContentInset + insetDelta)
}
}
/// Walk storage; create / position / destroy overlays to match attrs.
func performWideTableOverlayUpdate() {
guard let storage = textStorage,
let bridge = layoutBridge,
let container = bridge.firstTextContainer,
let tlm = textLayoutManager,
let tcs = tlm.textContentManager as? NSTextContentStorage else {
removeAllWideTableOverlays()
return
}
let containerWidth = container.size.width
guard containerWidth.isFinite, containerWidth > 0 else { return }
// Breakout: tables span full width, flush with the text column's left.
let breakout = configuration.readingWidth != nil
// Breakout host: the full-width reading-column container, else the text view itself.
let host: NSView = breakout ? (superview ?? self) : self
let viewWidth = host.bounds.width
var seenSourceIDs: Set<Int> = []
let fullRange = NSRange(location: 0, length: storage.length)
// Cheap presence-check first: skip the full-document layout pass when
// the doc has no wide tables. enumerateAttribute stops on first hit
// but a MISS walks every attribute run in the document (scheduled
// after each restyle), so stamp it when it gets slow.
let presenceT0 = DispatchTime.now().uptimeNanoseconds
var hasAnyWideTable = false
storage.enumerateAttribute(.scrollableBlockSourceID, in: fullRange, options: []) { value, _, stop in
if value is Int { hasAnyWideTable = true; stop.pointee = true }
}
let presenceMs = Double(DispatchTime.now().uptimeNanoseconds - presenceT0) / 1_000_000
if presenceMs > 0.3 {
PerfTrace.stamp("wideTableOverlay.presenceScan", presenceMs, "wide=\(hasAnyWideTable ? 1 : 0) docLen=\(storage.length)")
}
guard hasAnyWideTable else {
removeAllWideTableOverlays()
return
}
// Settle layout before measuring stale fragments would yield wrong anchor Ys.
let overlayT0 = DispatchTime.now().uptimeNanoseconds
tlm.ensureLayout(for: tlm.documentRange)
PerfTrace.stamp("wideTableOverlay.ensureLayout(fullDoc)",
Double(DispatchTime.now().uptimeNanoseconds - overlayT0) / 1_000_000,
"docLen=\(storage.length)")
storage.enumerateAttribute(.scrollableBlockSourceID, in: fullRange, options: []) { value, attrRange, _ in
guard let sourceID = value as? Int,
let image = storage.attribute(.latexImage, at: attrRange.location, effectiveRange: nil) as? NSImage else { return }
seenSourceIDs.insert(sourceID)
if let start = tcs.location(tcs.documentRange.location, offsetBy: attrRange.location),
let end = tcs.location(start, offsetBy: attrRange.length),
let textRange = NSTextRange(location: start, end: end) {
tlm.ensureLayout(for: textRange)
}
let anchorRect = bridge.boundingRect(forCharacterRange: attrRange, in: container)
guard !anchorRect.isEmpty else { return }
let totalHeight = (storage.attribute(.scrollableBlockTotalHeight, at: attrRange.location, effectiveRange: nil) as? CGFloat) ?? image.size.height
// In breakout the overlay lives in the container, so add the column's X
// offset and the text view's Y offset (the scroll-away header band).
let columnLeft = (breakout ? frame.origin.x : 0) + textContainerOrigin.x + anchorRect.minX
let breakoutTop = frame.origin.y + textContainerOrigin.y + anchorRect.minY
let overlayFrame: NSRect = breakout
? NSRect(x: 0, y: breakoutTop, width: viewWidth, height: totalHeight)
: NSRect(x: columnLeft, y: textContainerOrigin.y + anchorRect.minY, width: containerWidth, height: totalHeight)
// Breakout: table starts at the text column's left edge; the left margin is scrollable space.
let leftContentInset: CGFloat = breakout ? max(0, columnLeft) : 0
if let existing = wideTableOverlays[sourceID] {
if !existing.frame.equalTo(overlayFrame) {
// Invalidate both old + new region so the vacated area redraws.
host.setNeedsDisplay(existing.frame)
existing.frame = overlayFrame
host.setNeedsDisplay(overlayFrame)
}
existing.leftContentInset = leftContentInset
existing.updateImage(image)
existing.anchorTextLocation = attrRange.location
} else {
let overlay = WideTableOverlay(
sourceID: sourceID, image: image,
ownerTextView: self, anchorLocation: attrRange.location
)
overlay.frame = overlayFrame
overlay.leftContentInset = leftContentInset
host.addSubview(overlay)
wideTableOverlays[sourceID] = overlay
let savedOffset = tableHorizontalScrollOffsets[sourceID] ?? 0
if savedOffset > 0 { overlay.horizontalOffset = savedOffset }
}
}
for (sourceID, overlay) in wideTableOverlays where !seenSourceIDs.contains(sourceID) {
host.setNeedsDisplay(overlay.frame)
overlay.removeFromSuperview()
wideTableOverlays.removeValue(forKey: sourceID)
}
}
/// Drop all overlays synchronously (file switch path).
func removeAllWideTableOverlays() {
for (_, overlay) in wideTableOverlays { overlay.removeFromSuperview() }
wideTableOverlays.removeAll()
}
}
@@ -0,0 +1,339 @@
//
// MarkdownEditorServices.swift
// MarkdownEngine
//
// Created by Luca Chen on 16.03.26.
//
// Protocols and default implementations for engine-side dependencies.
//
// The Markdown editor engine resolves wiki-links, syntax highlighting,
// LaTeX rendering, and embedded image lookup through these protocols.
// Embedders supply the concrete implementations; the engine never
// reaches into the host app for any of these concerns.
//
import AppKit
import Foundation
// MARK: - Wiki Links
/// Resolves a wiki-link's display name to a stable storage identifier.
///
/// The engine stores wiki-links as `[[Name|<id>]]` and displays them as
/// `[[Name]]`. The resolver maps a display name (and the range it occupies
/// in the document) to whatever stable identifier the embedder uses for
/// linked content. The identifier is opaque to the engine.
public protocol WikiLinkResolver: Sendable {
/// Resolve a wiki-link by its visible name.
///
/// - Parameters:
/// - displayName: The text inside `[[ ]]` as the user sees it.
/// - range: The character range the link occupies in the document.
/// - Returns: A resolution if the link points at known content; `nil` otherwise.
func resolve(displayName: String, range: NSRange) -> WikiLinkResolution?
/// Returns the target's CURRENT display name for a stable id, nil if unknown
/// (renderer falls back to the stored label).
func name(forID id: String) -> String?
/// Coarse fingerprint of the resolver's known targets (typically IDs + names).
/// A different value triggers a wiki-link restyle, so a rename refreshes link
/// clickability/display without waiting for the next keystroke.
func fingerprint() -> AnyHashable
}
public extension WikiLinkResolver {
func fingerprint() -> AnyHashable { 0 }
func name(forID id: String) -> String? { nil }
}
/// The result of resolving a wiki-link.
public struct WikiLinkResolution: Sendable, Equatable {
/// Stable identifier persisted in the storage form `[[Name|<id>]]`.
public let id: String
/// Whether the linked target currently exists/is reachable.
public let exists: Bool
public init(id: String, exists: Bool) {
self.id = id
self.exists = exists
}
}
/// Default resolver that never resolves anything. Useful when an embedder
/// doesn't ship wiki-link support.
public struct NoOpWikiLinkResolver: WikiLinkResolver {
public init() {}
public func resolve(displayName: String, range: NSRange) -> WikiLinkResolution? { nil }
}
// MARK: - Embedded Images
/// Loads an `NSImage` for an `![[...]]` embed reference.
///
/// The engine parses `![[name|optional-id|optional-width]]` into a
/// reference and asks the provider for an image. The provider decides
/// where the image actually lives (filesystem, remote, asset catalog).
public protocol EmbeddedImageProvider: Sendable {
/// Returns an image for the given reference, or `nil` if no image
/// is available.
func image(for reference: EmbeddedImageRequest) -> NSImage?
/// A coarse fingerprint of the provider's current state. Returning
/// a different value invalidates the engine's image cache. Embedders
/// typically combine the IDs of all known images.
func fingerprint() -> AnyHashable
}
/// What the engine asks an `EmbeddedImageProvider` for.
public struct EmbeddedImageRequest: Sendable, Equatable {
/// Display name of the embed (the part before any `|`).
public let name: String
/// Optional explicit identifier supplied as `![[name|id]]`.
public let id: String?
/// Optional explicit width supplied as `![[name|...|width]]`.
public let requestedWidth: CGFloat?
public init(name: String, id: String? = nil, requestedWidth: CGFloat? = nil) {
self.name = name
self.id = id
self.requestedWidth = requestedWidth
}
}
/// Default provider that never returns images.
public struct NoOpEmbeddedImageProvider: EmbeddedImageProvider {
public init() {}
public func image(for reference: EmbeddedImageRequest) -> NSImage? { nil }
public func fingerprint() -> AnyHashable { 0 }
}
// MARK: - Syntax Highlighting
/// Provides code-block font, background color, and syntax highlighting.
public protocol SyntaxHighlighter: Sendable {
/// Monospace font used for fenced code blocks at the requested size.
func codeFont(size: CGFloat) -> NSFont
/// Background color used to fill code-block paragraphs. The engine
/// also uses this color to detect which fragments are code blocks
/// when drawing custom backgrounds.
func backgroundColor() -> NSColor
/// Highlight `code` written in `language`. Return an attributed string
/// whose attributes carry per-token foreground colors. Return `nil` if
/// no highlighting is available for this language.
func highlight(code: String, language: String?) -> NSAttributedString?
/// Notification name posted when the highlighter's appearance source
/// changes (light/dark mode flip, theme switch). The engine subscribes
/// to this notification so it can invalidate cached attributes.
/// Return `nil` if the highlighter never changes after construction.
var appearanceDidChangeNotification: Notification.Name? { get }
}
/// Default highlighter that produces no highlighting and supplies a
/// basic system monospace font on a transparent background.
public struct PlainTextSyntaxHighlighter: SyntaxHighlighter {
public init() {}
public func codeFont(size: CGFloat) -> NSFont {
NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
public func backgroundColor() -> NSColor {
NSColor.textBackgroundColor.withAlphaComponent(0)
}
public func highlight(code: String, language: String?) -> NSAttributedString? {
nil
}
public var appearanceDidChangeNotification: Notification.Name? { nil }
}
// MARK: - LaTeX
/// Renders LaTeX formulas to images for inline display.
public protocol LatexRenderer: Sendable {
/// Render `latex` at the requested font size, optionally tinted by `theme`.
/// - Returns: A rendered result, or `nil` if the renderer cannot produce
/// an image (unsupported syntax, missing dependency, ).
func render(latex: String, fontSize: CGFloat, theme: MarkdownEditorTheme) -> LatexRenderResult?
}
/// Output of a LaTeX render call.
public struct LatexRenderResult: Sendable {
public let image: NSImage
public let size: CGSize
/// Distance from the image's bottom edge to its visual baseline.
/// Used to align inline math with the surrounding text.
public let baselineOffset: CGFloat
public init(image: NSImage, size: CGSize, baselineOffset: CGFloat) {
self.image = image
self.size = size
self.baselineOffset = baselineOffset
}
}
/// Default renderer that ignores LaTeX entirely. The engine falls back to
/// rendering the source text when this is in use.
public struct NoOpLatexRenderer: LatexRenderer {
public init() {}
public func render(latex: String, fontSize: CGFloat, theme: MarkdownEditorTheme) -> LatexRenderResult? { nil }
}
// MARK: - Event Bus
/// Optional notification-name bridge that lets the editor communicate with
/// surrounding UI without hard-coding any names of its own.
///
/// The engine observes the request notifications it is configured with and
/// posts the response notifications when supplied. Embedders that don't
/// need cross-view formatting commands simply leave every name `nil`.
public struct MarkdownEditorBus: Sendable {
/// Posted by the host UI to request the engine apply bold styling.
public var applyBoldRequest: Notification.Name?
/// Posted by the host UI to request the engine apply italic styling.
public var applyItalicRequest: Notification.Name?
/// Posted by the host UI to request the engine apply a heading level.
/// Expected `userInfo["level"] as? Int`.
public var applyHeadingRequest: Notification.Name?
/// Posted by the host UI to request the engine apply highlight styling.
public var applyHighlightRequest: Notification.Name?
/// Posted by the host UI to request the engine apply strikethrough styling.
public var applyStrikethroughRequest: Notification.Name?
/// Posted by the host UI to request the engine apply inline code styling.
public var applyInlineCodeRequest: Notification.Name?
/// Posted by the host UI to request the engine apply blockquote styling.
public var applyBlockquoteRequest: Notification.Name?
/// Posted by the host UI to request the engine apply unordered list styling.
public var applyUnorderedListRequest: Notification.Name?
/// Posted by the host UI to request the engine apply ordered list styling.
public var applyOrderedListRequest: Notification.Name?
/// Posted by the host UI to insert a Markdown link.
/// Expected `userInfo["url"] as? String`.
public var applyLinkRequest: Notification.Name?
/// Posted by the host UI to insert a fenced code block at the cursor.
public var applyCodeBlockRequest: Notification.Name?
/// Posted by the host UI to insert a horizontal rule (`---`) at the cursor.
public var applyHorizontalRuleRequest: Notification.Name?
/// Posted by the host UI to insert an image embed.
/// Expected `userInfo["url"] as? String`.
public var applyImageRequest: Notification.Name?
/// Posted by the engine after every selection change with `userInfo["isBold"] as? Bool`.
public var selectionBoldDidChange: Notification.Name?
/// Posted by the engine after every selection change with `userInfo["isItalic"] as? Bool`.
public var selectionItalicDidChange: Notification.Name?
/// Posted by the engine after every selection change with `userInfo["isHighlight"] as? Bool`.
public var selectionHighlightDidChange: Notification.Name?
/// Posted by the host UI to scroll an in-document find match into view
/// and highlight all matches. Expected `userInfo["range"] as? NSRange`,
/// `userInfo["currentIndex"] as? Int`, `userInfo["allRanges"] as? [NSRange]`.
public var findScrollToRange: Notification.Name?
/// Posted by the host UI to clear all in-document find highlights.
public var findClearHighlights: Notification.Name?
/// Posted by the host UI to run an in-document find against the engine's OWN displayed
/// text. Expected `userInfo["query"] as? String`, optional `userInfo["currentIndex"] as? Int`.
/// The engine matches in DISPLAY coordinates, so highlights land correctly even where the
/// displayed text differs from the source (e.g. node links rendered shorter than
/// `[[Name|UUID]]`, LaTeX, images). Preferred over `findScrollToRange`, which trusts
/// host-computed (source-coordinate) ranges.
public var findQuery: Notification.Name?
/// Posted by the engine in response to `findQuery` with `userInfo["count"] as? Int`
/// (number of matches in the displayed text), so the host can show "x of y".
public var findResults: Notification.Name?
/// Posted by the host UI to replace the current find match. Expected
/// `userInfo["query"] as? String`, `userInfo["replacement"] as? String`,
/// optional `userInfo["currentIndex"] as? Int`. The engine edits its own
/// displayed text (with undo), re-highlights the remaining matches, and
/// posts `findResults` with the new count.
public var replaceCurrent: Notification.Name?
/// Posted by the host UI to replace every find match in one undo step.
/// Expected `userInfo["query"] as? String`, `userInfo["replacement"] as? String`.
/// The engine posts `findResults` with the count still matching afterward
/// (non-zero only if the replacement itself contains the query).
public var replaceAll: Notification.Name?
public init(
applyBoldRequest: Notification.Name? = nil,
applyItalicRequest: Notification.Name? = nil,
applyHeadingRequest: Notification.Name? = nil,
applyHighlightRequest: Notification.Name? = nil,
applyStrikethroughRequest: Notification.Name? = nil,
applyInlineCodeRequest: Notification.Name? = nil,
applyBlockquoteRequest: Notification.Name? = nil,
applyUnorderedListRequest: Notification.Name? = nil,
applyOrderedListRequest: Notification.Name? = nil,
applyLinkRequest: Notification.Name? = nil,
applyCodeBlockRequest: Notification.Name? = nil,
applyHorizontalRuleRequest: Notification.Name? = nil,
applyImageRequest: Notification.Name? = nil,
selectionBoldDidChange: Notification.Name? = nil,
selectionItalicDidChange: Notification.Name? = nil,
selectionHighlightDidChange: Notification.Name? = nil,
findScrollToRange: Notification.Name? = nil,
findClearHighlights: Notification.Name? = nil,
findQuery: Notification.Name? = nil,
findResults: Notification.Name? = nil,
replaceCurrent: Notification.Name? = nil,
replaceAll: Notification.Name? = nil
) {
self.applyBoldRequest = applyBoldRequest
self.applyItalicRequest = applyItalicRequest
self.applyHeadingRequest = applyHeadingRequest
self.applyHighlightRequest = applyHighlightRequest
self.applyStrikethroughRequest = applyStrikethroughRequest
self.applyInlineCodeRequest = applyInlineCodeRequest
self.applyBlockquoteRequest = applyBlockquoteRequest
self.applyUnorderedListRequest = applyUnorderedListRequest
self.applyOrderedListRequest = applyOrderedListRequest
self.applyLinkRequest = applyLinkRequest
self.applyCodeBlockRequest = applyCodeBlockRequest
self.applyHorizontalRuleRequest = applyHorizontalRuleRequest
self.applyImageRequest = applyImageRequest
self.selectionBoldDidChange = selectionBoldDidChange
self.selectionItalicDidChange = selectionItalicDidChange
self.selectionHighlightDidChange = selectionHighlightDidChange
self.findScrollToRange = findScrollToRange
self.findClearHighlights = findClearHighlights
self.findQuery = findQuery
self.findResults = findResults
self.replaceCurrent = replaceCurrent
self.replaceAll = replaceAll
}
public static let `default` = MarkdownEditorBus()
}
// MARK: - Services Container
/// Bundles every external service the engine needs.
///
/// Held by ``MarkdownEditorConfiguration/services``. The engine reads its
/// dependencies exclusively from this container; embedders inject the
/// implementations they want.
public struct MarkdownEditorServices: Sendable {
public var wikiLinks: any WikiLinkResolver
public var images: any EmbeddedImageProvider
public var syntaxHighlighter: any SyntaxHighlighter
public var latex: any LatexRenderer
public var bus: MarkdownEditorBus
public init(
wikiLinks: any WikiLinkResolver = NoOpWikiLinkResolver(),
images: any EmbeddedImageProvider = NoOpEmbeddedImageProvider(),
syntaxHighlighter: any SyntaxHighlighter = PlainTextSyntaxHighlighter(),
latex: any LatexRenderer = NoOpLatexRenderer(),
bus: MarkdownEditorBus = .default
) {
self.wikiLinks = wikiLinks
self.images = images
self.syntaxHighlighter = syntaxHighlighter
self.latex = latex
self.bus = bus
}
public static let `default` = MarkdownEditorServices()
}
@@ -0,0 +1,328 @@
//
// WikiLinkService.swift
// MarkdownEngine
//
// Created by Luca Chen on 16.03.26.
//
// Generic wiki-link transformer used by the editor engine.
//
// Wiki-links live in two forms:
// Storage form [[Name|<opaque-id>]]
// Display form [[Name]]
//
// This service converts between the two and maintains a metadata map
// that lets callers look up the storage range and identifier for any
// display occurrence. The identifier is opaque to the engine it can
// be a UUID, a slug, a database key, anything an embedder hands out
// via the ``WikiLinkResolver`` protocol.
//
import AppKit
import Foundation
import os
/// Bidirectional transform between the storage and display forms of wiki-links.
public enum WikiLinkService {
/// Hashable wrapper around `NSRange` so we can use it as a dictionary key.
public struct RangeKey: Hashable, Sendable {
public let location: Int
public let length: Int
public init(_ range: NSRange) {
self.location = range.location
self.length = range.length
}
}
/// Identifier and storage-side range associated with a display occurrence.
public struct LinkMetadata: Sendable {
public let id: String?
public let storageRange: NSRange
public init(id: String?, storageRange: NSRange) {
self.id = id
self.storageRange = storageRange
}
}
/// Regex pattern matching the storage form `[[Name|optional-id]]`.
public static let storagePattern = #"!?\[\[([^\|\]\r\n]*)(?:\|([^\]\r\n]+))?\]\]"#
/// Regex pattern matching the display form `[[Name]]` (no `|`).
public static let displayPattern = #"(?<!!)\[\[([^\]\r\n]*)\]\]"#
private static let storageLinkRegex = try! NSRegularExpression(pattern: storagePattern)
private static let displayLinkRegex = try! NSRegularExpression(pattern: displayPattern)
private static let logger = Logger(subsystem: "com.markdownengine.wikilinks", category: "WikiLink")
/// Convert storage form `[[Name|<id>]]` to display `[[Name]]`, returning a display-range metadata map.
///
/// When `nameForID` is supplied, each matched link's stored label is replaced in the
/// DISPLAY text by the target's current name looked up via the opaque suffix's uuid
/// (the suffix itself uuid for links, uuid|width for images is preserved unchanged
/// in the metadata). Unknown/empty/unsafe live names fall back to the stored label.
public static func makeDisplayState(
from storageText: String,
nameForID: ((String) -> String?)? = nil
) -> (display: String, metadata: [RangeKey: LinkMetadata]) {
let nsStorage = storageText as NSString
let fullRange = NSRange(location: 0, length: nsStorage.length)
var result = ""
result.reserveCapacity(storageText.count)
var metadata: [RangeKey: LinkMetadata] = [:]
var cursor = 0
var displayLength = 0
for match in storageLinkRegex.matches(in: storageText, options: [], range: fullRange) {
let prefixLength = match.range.location - cursor
if prefixLength > 0 {
let prefixRange = NSRange(location: cursor, length: prefixLength)
let prefix = nsStorage.substring(with: prefixRange)
result.append(prefix)
displayLength += prefix.utf16.count
cursor += prefixLength
}
let nameRange = match.range(at: 1)
let name = nsStorage.substring(with: nameRange)
let isImage = nsStorage.character(at: match.range.location) == 0x21 // '!'
var linkID: String? = nil
if match.numberOfRanges > 2 {
let idRange = match.range(at: 2)
if idRange.location != NSNotFound && idRange.length > 0 {
linkID = nsStorage.substring(with: idRange)
}
}
// Auto-sync the DISPLAY label to the target's current name (looked up by the
// uuid carried in the suffix). The suffix in metadata.id stays untouched.
var displayName = name
if let linkID, let nameForID {
let bareID = linkID.split(separator: "|", maxSplits: 1).first.map(String.init) ?? linkID
if let live = nameForID(bareID), !live.isEmpty,
live.rangeOfCharacter(from: CharacterSet(charactersIn: "|]\n\r")) == nil {
displayName = live
}
}
let displayFragment = isImage ? "![[\(displayName)]]" : "[[\(displayName)]]"
let displayRange = NSRange(location: displayLength, length: displayFragment.utf16.count)
result.append(displayFragment)
displayLength += displayFragment.utf16.count
metadata[RangeKey(displayRange)] = LinkMetadata(id: linkID, storageRange: match.range)
cursor = match.range.location + match.range.length
}
if cursor < nsStorage.length {
let suffixRange = NSRange(location: cursor, length: nsStorage.length - cursor)
result.append(nsStorage.substring(with: suffixRange))
}
return (result, metadata)
}
/// Convert display `[[Name]]` back to storage `[[Name|<id>]]`, preferring the `.wikiLinkID` attribute.
public static func makeStorageState(
from displayText: String,
existingMetadata: [RangeKey: LinkMetadata],
textStorage: NSTextStorage?
) -> (storage: String, metadata: [RangeKey: LinkMetadata]) {
let nsDisplay = displayText as NSString
// No `[[` anywhere storage == display; skip the O(document) rebuild.
if nsDisplay.range(of: "[[").location == NSNotFound {
return (displayText, [:])
}
var storage = ""
storage.reserveCapacity(nsDisplay.length) // was displayText.count (O(doc) grapheme count)
var metadata: [RangeKey: LinkMetadata] = [:]
var cursor = 0
var storageLength = 0
for (matchRange, isImage) in displayLinkRanges(nsDisplay) {
let prefixLength = matchRange.location - cursor
if prefixLength > 0 {
let prefixRange = NSRange(location: cursor, length: prefixLength)
let prefix = nsDisplay.substring(with: prefixRange)
storage.append(prefix)
storageLength += prefix.utf16.count
cursor += prefixLength
}
let openMarker = isImage ? 3 : 2
let contentLength = max(0, matchRange.length - (openMarker + 2))
let contentRange = NSRange(location: matchRange.location + openMarker, length: contentLength)
let name = nsDisplay.substring(with: contentRange)
var linkID: String? = nil
if contentRange.length > 0 {
if let idAttr = textStorage?.attribute(.wikiLinkID, at: contentRange.location, effectiveRange: nil) as? String {
linkID = idAttr
}
}
if linkID == nil {
linkID = existingMetadata[RangeKey(matchRange)]?.id
}
let marker = isImage ? "![[" : "[["
let storageFragment: String
if let linkID, !linkID.isEmpty {
storageFragment = "\(marker)\(name)|\(linkID)]]"
} else {
storageFragment = "\(marker)\(name)]]"
}
let fragmentLength = storageFragment.utf16.count
let storageRange = NSRange(location: storageLength, length: fragmentLength)
storage.append(storageFragment)
storageLength += fragmentLength
metadata[RangeKey(matchRange)] = LinkMetadata(id: linkID, storageRange: storageRange)
cursor = matchRange.location + matchRange.length
}
if cursor < nsDisplay.length {
let suffixRange = NSRange(location: cursor, length: nsDisplay.length - cursor)
storage.append(nsDisplay.substring(with: suffixRange))
}
return (storage, metadata)
}
/// Incremental counterpart to `makeStorageState`: splice a single contiguous
/// edit into the previous storage form in O(edit + #links).
///
/// Outside link syntax, display and storage text are identical, so an edit
/// that provably cannot create, destroy, or touch a link maps 1:1 into the
/// storage string; links after the edit just shift by the length delta.
/// Returns nil whenever that proof fails callers fall back to the full
/// `makeStorageState` rebuild.
public static func updatedStorageState(
displayText: String,
editedRange: NSRange,
changeInLength delta: Int,
previousStorage: String,
previousMetadata: [RangeKey: LinkMetadata]
) -> (storage: String, metadata: [RangeKey: LinkMetadata])? {
let nsDisplay = displayText as NSString
let nsPrevStorage = previousStorage as NSString
// Only contiguous, small, well-formed edits take the fast path.
guard delta != Int.min,
editedRange.location != NSNotFound,
editedRange.length >= 0, editedRange.length <= 4096,
NSMaxRange(editedRange) <= nsDisplay.length,
editedRange.length - delta >= 0 else { return nil }
let oldEditLength = editedRange.length - delta
let oldEditRange = NSRange(location: editedRange.location, length: oldEditLength)
// The edit must not create or complete link syntax: no [[ or ]] near it
// in the NEW text (±3 covers a bracket typed against an existing one)
let probeStart = max(0, editedRange.location - 3)
let probeEnd = min(nsDisplay.length, NSMaxRange(editedRange) + 3)
let probe = nsDisplay.substring(with: NSRange(location: probeStart, length: probeEnd - probeStart))
if probe.contains("[[") || probe.contains("]]") { return nil }
// and no existing link may overlap the edit (old display coordinates;
// ±3 also rejects edits adjacent to a link's markers).
let guardRange = NSRange(location: max(0, oldEditRange.location - 3), length: oldEditLength + 6)
for key in previousMetadata.keys {
if NSIntersectionRange(NSRange(location: key.location, length: key.length), guardRange).length > 0 {
return nil
}
}
// Map the display edit offset into storage coordinates: every link
// before the edit is longer in storage by (storage length display length).
var storageOffsetDelta = 0
for (key, meta) in previousMetadata where key.location < editedRange.location {
storageOffsetDelta += meta.storageRange.length - key.length
}
let storageEditStart = editedRange.location + storageOffsetDelta
guard storageEditStart >= 0,
storageEditStart + oldEditLength <= nsPrevStorage.length else { return nil }
// Splice outside links the replaced/inserted characters are identical
// in both forms.
let replacement = nsDisplay.substring(with: editedRange)
let storage = nsPrevStorage.replacingCharacters(
in: NSRange(location: storageEditStart, length: oldEditLength),
with: replacement
)
// Shift every link after the edit by the delta; links before it are untouched.
var metadata: [RangeKey: LinkMetadata] = [:]
metadata.reserveCapacity(previousMetadata.count)
for (key, meta) in previousMetadata {
if key.location >= NSMaxRange(oldEditRange) {
metadata[RangeKey(NSRange(location: key.location + delta, length: key.length))] =
LinkMetadata(id: meta.id,
storageRange: NSRange(location: meta.storageRange.location + delta,
length: meta.storageRange.length))
} else {
metadata[key] = meta
}
}
return (storage, metadata)
}
/// Hand scan for display-form wiki links `(?<!!)\[\[...\]\]`, replacing the slow regex lookbehind.
static func displayLinkRanges(_ s: NSString) -> [(range: NSRange, isImage: Bool)] {
let len = s.length
guard len >= 4 else { return [] }
var buf = [unichar](repeating: 0, count: len) // one bulk extract, then array access
s.getCharacters(&buf, range: NSRange(location: 0, length: len))
var result: [(range: NSRange, isImage: Bool)] = []
var i = 0
while i + 1 < len {
guard buf[i] == 0x5B, buf[i + 1] == 0x5B else { i += 1; continue } // [[
let isImage = i > 0 && buf[i - 1] == 0x21 // preceded by ! image embed
let start = isImage ? i - 1 : i
var j = i + 2
var matched = false
while j < len {
let c = buf[j]
if c == 0x0A || c == 0x0D { break } // newline no match
if c == 0x5D { // ]
if j + 1 < len, buf[j + 1] == 0x5D { // ]]
result.append((NSRange(location: start, length: (j + 2) - start), isImage))
i = j + 2
matched = true
}
break
}
j += 1
}
if !matched { i += 1 }
}
return result
}
/// Resolve a clicked link's id from the caret's `.wikiLinkID` attribute, else its display string.
public static func resolveIdentifier(link: Any, textView: NSTextView, at charIndex: Int) -> String? {
if let idAttr = textView.textStorage?.attribute(.wikiLinkID, at: charIndex, effectiveRange: nil) as? String {
return idAttr
}
if let name = link as? String {
return name
}
return nil
}
/// Split a storage fragment `[[Name|<id>]]` into its display form and the opaque id.
public static func displayFragmentAndID(from storageFragment: String) -> (display: String, id: String?) {
let displayState = makeDisplayState(from: storageFragment)
return (displayState.display, displayState.metadata.values.first?.id)
}
/// Zero-length caret range following replacement of `displayRange` with `storageFragment`.
public static func caretRangeAfterReplacing(
displayRange: NSRange,
with storageFragment: String
) -> NSRange {
let displayFragment = makeDisplayState(from: storageFragment).display as NSString
return NSRange(location: displayRange.location + displayFragment.length, length: 0)
}
}
@@ -0,0 +1,50 @@
//
// HeadingHelpers.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Small helper values for heading size/spacing, plus shared text measurements.
import AppKit
enum HeadingHelpers {
/// Use heading context to scale LaTeX font size consistently with surrounding text.
/// `headings` is the document's heading tokens, built once per styling pass
/// scanning all tokens per LaTeX token here was O(#latex × #tokens).
static func latexFontSize(
for token: MarkdownToken,
headings: [MarkdownToken],
baseFont: NSFont,
configuration: HeadingStyle = .default
) -> CGFloat {
if let headingToken = headings.first(where: { NSLocationInRange(token.contentRange.location, $0.contentRange) }) {
let level = headingToken.markerRanges.first?.length ?? 1
return baseFont.pointSize * configuration.fontMultiplier(for: level)
}
return baseFont.pointSize
}
/// Memoized string-width measurement. `size(withAttributes:)` is a full CoreText
/// measure (~31µs); the styler calls this thousands of times per open on a small set
/// of repeated strings list markers (`- `, `1. `), the `$`/`$$` latex markers, per
/// formula/char slices. Only 2 distinct inline formulas × 4 calls each, 372 list
/// items with ~5 distinct markers, etc. Same (text, font) same width, so the cache
/// is byte-identical to the direct call. `NSCache` bounds memory and is thread-safe.
private static let widthCache: NSCache<NSString, NSNumber> = {
let c = NSCache<NSString, NSNumber>()
c.countLimit = 4096
return c
}()
static func textWidth(_ text: String, font: NSFont) -> CGFloat {
let key = "\(font.fontName)|\(font.pointSize)|\(text)" as NSString
if let cached = widthCache.object(forKey: key) {
return CGFloat(cached.doubleValue)
}
let width = (text as NSString).size(withAttributes: [.font: font]).width
widthCache.setObject(NSNumber(value: Double(width)), forKey: key)
return width
}
}
@@ -0,0 +1,910 @@
//
// MarkdownASTStyler.swift
// MarkdownEngine
//
// Phase 2.5 the AST-native styler. Walks the document AST and emits
// [StyledRange], COMPOSING attributes on descent: a heading sets a large bold
// font, descending into bold adds the bold trait (keeping the size), into
// italic adds italic so nested/combined inline styles stack instead of
// overwriting each other (the flaw in the flat 18-pass styler, e.g. the
// shrinking bold in `# **n*o*des**`).
//
// Built incrementally behind the existing styler; not wired until complete
// and visually verified. Covered so far: heading/paragraph/blockquote blocks;
// inline emphasis (font composition), strikethrough, inline code, markdown
// links, wiki links. Still TODO: images, image embeds, inline LaTeX, escapes,
// autolinks, marker-shrinking, paragraph styles, code/table/latex blocks,
// bullets, task checkboxes, horizontal rules.
//
import AppKit
import Foundation
enum MarkdownASTStyler {
static func styleAttributes(
text: String,
fontName: String,
fontSize: CGFloat,
caretLocation: Int = -1,
selection: NSRange? = nil,
wikiLinkIDProvider: @escaping (NSRange) -> String? = { _ in nil },
scopedRanges: [NSRange]? = nil,
precomputedBlocks: [Block]? = nil,
configuration: MarkdownEditorConfiguration = .default
) -> [StyledRange] {
let baseFont = NSFont(name: fontName, size: fontSize) ?? .systemFont(ofSize: fontSize)
let baseLineHeight = ceil(baseFont.ascender - baseFont.descender + baseFont.leading)
let baseParagraphSpacing = ceil(baseLineHeight * configuration.paragraph.spacingFactor)
let codeFontSize = round(fontSize * configuration.codeBlock.fontSizeScale)
let hiddenSize = configuration.markers.hiddenMarkerFontSize
let ns = text as NSString
let codeFont = configuration.services.syntaxHighlighter.codeFont(size: codeFontSize)
let codeLineHeight = ceil(codeFont.ascender - codeFont.descender + codeFont.leading)
let codePara = NSMutableParagraphStyle()
codePara.lineBreakMode = .byCharWrapping
codePara.lineSpacing = 0
codePara.paragraphSpacingBefore = configuration.codeBlock.paragraphSpacing
codePara.paragraphSpacing = configuration.codeBlock.paragraphSpacing
codePara.headIndent = configuration.codeBlock.horizontalIndent
codePara.firstLineHeadIndent = configuration.codeBlock.horizontalIndent
codePara.tailIndent = -configuration.codeBlock.horizontalIndent
codePara.minimumLineHeight = codeLineHeight
codePara.maximumLineHeight = codeLineHeight
// precomputed=0 the document is parsed a SECOND time here (the rebuild already parsed it).
// Ahead of `ctx` because the ordered-list display numbers are derived from these blocks.
let blocks = DocumentAST.parse(text, scopedRanges: scopedRanges, precomputedBlocks: precomputedBlocks,
registry: configuration.extensionRegistry)
let ctx = Ctx(
ns: ns,
fontName: fontName,
baseFont: baseFont,
baseLineHeight: baseLineHeight,
baseParagraphSpacing: baseParagraphSpacing,
codeFont: codeFont,
codeBackground: configuration.services.syntaxHighlighter.backgroundColor(),
codeParagraphStyle: codePara,
inlineMarkerFont: NSFont(name: fontName, size: hiddenSize) ?? .systemFont(ofSize: hiddenSize),
caret: caretLocation,
selection: selection,
config: configuration,
extensionsByID: configuration.extensionsByID,
wikiLinkID: wikiLinkIDProvider,
scopedRanges: scopedRanges,
orderedDisplayNumbers: computeOrderedDisplayNumbers(blocks: blocks, ns: ns)
)
var attrs: [StyledRange] = []
for block in blocks where ctx.inScope(block.range) {
styleBlock(block, font: baseFont, ctx: ctx, into: &attrs)
}
shrinkInactiveMarkers(in: blocks, ctx: ctx, into: &attrs)
// Text/regex passes (AST-agnostic); AST code ranges drive the "skip inside code" checks.
let codeRanges = collectCodeRanges(in: blocks)
let checkboxRanges = collectCheckboxRanges(in: blocks)
let linkRanges = collectLinkRanges(in: blocks)
styleAutoLinks(ctx: ctx, codeRanges: codeRanges, linkRanges: linkRanges, into: &attrs)
styleIncompleteLinkBrackets(ctx: ctx, codeRanges: codeRanges, checkboxRanges: checkboxRanges, into: &attrs)
return attrs
}
// MARK: - Text/regex-based passes (ported 1:1, AST-agnostic)
private static func collectCodeRanges(in blocks: [BlockNode]) -> [NSRange] {
var ranges: [NSRange] = []
func walk(_ nodes: [InlineNode]) {
for node in nodes {
switch node {
case .code(let range, _): ranges.append(range)
case .emphasis(_, _, _, let children),
.link(_, _, _, _, let children): walk(children)
case .ext(let node): walk(node.children)
default: break
}
}
}
for block in blocks {
switch block {
case .codeBlock(let range): ranges.append(range)
case .paragraph(_, let inlines), .heading(_, _, _, let inlines), .blockquote(_, let inlines):
walk(inlines)
case .list(_, let items):
for item in items { walk(item.inlines) }
case .ext(let node):
walk(node.inlines)
default: break
}
}
return ranges
}
private static func isInCode(_ range: NSRange, _ codeRanges: [NSRange]) -> Bool {
codeRanges.contains { NSIntersectionRange($0, range).length > 0 }
}
/// Full ranges of markdown links `[text](url)` and wiki links `[[]]`. The NSDataDetector
/// auto-link pass skips URLs inside these, so a link's own `(url)` isn't independently
/// linkified into a second, competing `.link` region overlapping the link (which offsets
/// the click edit-zone and makes the raw URL navigable).
private static func collectLinkRanges(in blocks: [BlockNode]) -> [NSRange] {
var ranges: [NSRange] = []
func walk(_ nodes: [InlineNode]) {
for node in nodes {
switch node {
case .link(let range, _, _, _, let children):
ranges.append(range)
walk(children)
case .wikiLink(let range, _, _, _):
ranges.append(range)
case .emphasis(_, _, _, let children):
walk(children)
case .ext(let node):
walk(node.children)
default: break
}
}
}
for block in blocks {
switch block {
case .paragraph(_, let inlines), .heading(_, _, _, let inlines), .blockquote(_, let inlines):
walk(inlines)
case .list(_, let items):
for item in items { walk(item.inlines) }
case .ext(let node):
walk(node.inlines)
default: break
}
}
return ranges
}
/// Checkbox boxes (`[ ]`/`[x]`), excluded so the incomplete-link pass doesn't repaint their brackets.
private static func collectCheckboxRanges(in blocks: [BlockNode]) -> [NSRange] {
var ranges: [NSRange] = []
for block in blocks {
if case .list(_, let items) = block {
for item in items where item.checkbox != nil { ranges.append(item.checkbox!) }
}
}
return ranges
}
private static func regex(_ pattern: String, _ anchored: Bool = true) -> NSRegularExpression? {
try? NSRegularExpression(pattern: pattern, options: anchored ? [.anchorsMatchLines] : [])
}
// Built once, reused: rebuilding these on every restyle cost 43ms (detector)
// + 78ms (6 regexes) on a 346k note with hits=0 (ENG-8g1b/c).
private static let autoLinkDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
private static let incompleteLinkPatterns: [NSRegularExpression] =
[#"\[\]"#, #"\[\[\]\]"#, #"\[[^\]\r\n]*$"#, #"\[[^\]\r\n]+\](?!\()"#,
#"\[[^\]\r\n]+\]\([^)\r\n]*$"#, #"\[[^\]\r\n]+\]\(\)"#].compactMap { regex($0, false) }
/// Tag a thematic-break line for a full-width rule (AST-driven); suppressed while the caret edits it.
private static func styleThematicBreak(range: NSRange, ctx: Ctx, into attrs: inout [StyledRange]) {
var hr = range
while hr.length > 0 {
let last = ctx.ns.character(at: NSMaxRange(hr) - 1)
guard last == 0x0A || last == 0x0D else { break }
hr.length -= 1
}
guard hr.length > 0,
!(NSLocationInRange(ctx.caret, hr) || ctx.caret == NSMaxRange(hr)) else { return }
attrs.append((hr, [.foregroundColor: NSColor.clear, .thematicBreak: true]))
attrs.append((hr, [.paragraphStyle: NSMutableParagraphStyle()]))
}
/// Ordered-list display numbers computed across the WHOLE document, keyed by
/// each ordered item's marker location. Positional, not the literal digit, so
/// any edit renumbers correctly; the count carries across a blank line (a
/// loose-list separator) so `1.`/`2.`blank`2.` shows 1,2,3 real content
/// between lists resets it. First item of a run keeps its own start value.
/// Like MarkdownLists.listRegex but also accepts `)` ordered markers (`5)`),
/// matching the AST used by the backward seed scan (group 2 = digits).
private static let seedOrderedLineRegex = try! NSRegularExpression(
pattern: #"^\s*((?:(\d+)[.)]|[-•*+])(?:\s+\[[ xX]\])?\s+)"#
)
/// First non-blank character in a range answers "was there content in the
/// hole between two scoped blocks" without materializing the substring.
private static let nonWhitespace = CharacterSet.whitespacesAndNewlines.inverted
/// Replays the ordered-list run that continues ABOVE `loc` (scanning backward
/// in the full source: same-indent items counted, blank lines skipped, real
/// content stops it) and returns the next number per indent. Lets a scoped
/// restyle that only sees a local window continue the document's numbering.
private static func seedOrderedCounters(above loc: Int, in ns: NSString) -> [Int: Int] {
guard loc > 0, loc <= ns.length else { return [:] }
var runLines: [(indent: Int, number: Int?)] = [] // bottom-to-top; nil = bullet/other list
// From the START of loc's line: callers pass a MARKER offset, which for
// an indented item still sits inside its own line scanning up from
// there counted the item itself, so every nested list rendered one too
// high (" 1. a" showing 2.).
var scan = ns.lineRange(for: NSRange(location: min(loc, ns.length), length: 0)).location
while scan > 0 {
let lineRange = ns.lineRange(for: NSRange(location: scan - 1, length: 0))
let line = ns.substring(with: lineRange) as NSString
let full = NSRange(location: 0, length: line.length)
if (line as String).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
scan = lineRange.location // blank line: loose-list spacing
continue
}
guard let m = seedOrderedLineRegex.firstMatch(in: line as String, range: full) else { break }
let ws = MarkdownLists.leadingWhitespaceRegex.firstMatch(in: line as String, range: full)
.map { line.substring(with: $0.range) } ?? ""
let number = m.range(at: 2).location != NSNotFound ? Int(line.substring(with: m.range(at: 2))) : nil
// Key by the raw leading-whitespace char count to match the parser's
// `item.indent` used by computeOrderedDisplayNumbers otherwise a
// nested item's seed counter wouldn't line up and it'd fall back to
// the literal digit.
runLines.append(((ws as NSString).length, number))
scan = lineRange.location
}
var counters: [Int: Int] = [:]
for item in runLines.reversed() { // replay top-to-bottom
if let number = item.number {
counters[item.indent] = (counters[item.indent] ?? number) + 1
} else {
counters[item.indent] = nil
}
for key in counters.keys where key > item.indent { counters[key] = nil }
}
return counters
}
private static func computeOrderedDisplayNumbers(blocks: [BlockNode], ns: NSString) -> [Int: Int] {
var result: [Int: Int] = [:]
var counters: [Int: Int] = [:]
// `blocks` is NOT the document: a scoped restyle keeps only the blocks that
// intersect the scope, and a multi-region scope (caret paragraph + previous
// caret paragraph, built on every click) drops everything between them
// including the prose whose `default:` below is what ends a run. So treat
// every discontinuity like a fresh start and re-seed from the SOURCE, the
// only thing that can still see the skipped text. Lazily, at the first
// ordered item after the gap, so prose/bullet restyles never pay the scan.
var needsSeed = true
var contiguousEnd: Int?
for block in blocks {
if let contiguousEnd, block.range.location > contiguousEnd,
ns.rangeOfCharacter(from: Self.nonWhitespace, options: [],
range: NSRange(location: contiguousEnd,
length: block.range.location - contiguousEnd)).location != NSNotFound {
// Only CONTENT in the hole ends the run. A hole of pure blank
// lines is loose-list spacing and it is the shape the
// coordinator's forward walk hands us (list, list, list, blanks
// skipped), where re-seeding meant one full backward scan per
// item: 639 ms for a single Return in an 800-item loose list.
counters = [:]
needsSeed = true
}
contiguousEnd = NSMaxRange(block.range)
switch block {
case .list(_, let items):
for item in items {
if item.ordered, let literal = item.number {
if needsSeed {
counters = seedOrderedCounters(above: item.marker.location, in: ns)
needsSeed = false
}
let n = counters[item.indent] ?? literal
result[item.marker.location] = n
counters[item.indent] = n + 1
} else {
counters[item.indent] = nil
}
for key in counters.keys where key > item.indent { counters[key] = nil }
}
case .blank:
break // blank lines keep the count (spacing, not a reset)
case .paragraph(_, let inlines) where inlines.isEmpty:
break // an empty paragraph line is spacing too
default:
counters = [:] // real text/content ends the run
needsSeed = false // a seed scan would stop on this line anyway
}
}
return result
}
/// AST list-item decoration: indent paragraph, `` bullet, checkbox + strikethrough, all caret-aware.
private static func styleListItem(_ item: ListItem, displayNumber: Int?, ctx: Ctx, into attrs: inout [StyledRange]) {
guard ctx.config.lists.helpersEnabled else { return }
// Line content (item line minus its trailing newline).
var line = item.range
while line.length > 0 {
let last = ctx.ns.character(at: NSMaxRange(line) - 1)
guard last == 0x0A || last == 0x0D else { break }
line.length -= 1
}
// 1. Indent paragraph style (hanging indent so wrapped lines align).
let wsRange = NSRange(location: item.range.location, length: item.marker.location - item.range.location)
let ws = ctx.ns.substring(with: wsRange)
// Revealed while the caret edits the syntax (same test as the early
// return below): the raw `- [ ]` stays at full advance.
let taskRevealed: Bool = {
guard let box = item.checkbox else { return false }
let syntax = NSRange(location: item.marker.location, length: NSMaxRange(box) - item.marker.location)
// Caret edit OR a selection sweeping the syntax reveals the raw
// `- [ ]` matching how token-based elements reveal on selection.
return NSLocationInRange(ctx.caret, syntax) || ctx.caret == NSMaxRange(box)
|| ctx.selectionIntersects(syntax)
}()
// Hidden task item shares the bullet geometry: `[ ] ` collapses to ~zero
// advance below, so the hanging indent measures only `- ` and task
// content aligns with bullet content (the box replaces the bullet slot).
let markerGroup: NSRange
if let box = item.checkbox, !taskRevealed {
markerGroup = NSRange(location: item.marker.location,
length: box.location - item.marker.location)
} else {
markerGroup = NSRange(location: item.marker.location,
length: item.contentRange.location - item.marker.location)
}
// An ordered item whose displayed number differs from its source digit
// gets its WHOLE marker overlaid (below); the hanging indent must then
// measure the DISPLAY marker so wrapped lines align at any digit count.
// False while the caret reveals the marker (edit at raw width) and for
// tasks (the checkbox branch owns those).
let orderedSyntax = NSRange(location: item.marker.location,
length: item.contentRange.location - item.marker.location)
// Also off while the marker is inside a selection: the painter reveals the
// raw source digits there, so the slot must revert to raw width (else a
// kerned slot leaves a gap/overlap over the raw digits).
let orderedOverlayActive = item.ordered && item.checkbox == nil && item.number != nil
&& displayNumber != nil && displayNumber != item.number
&& !MarkdownStyler.caretRevealsOrderedMarker(caret: ctx.caret, syntax: orderedSyntax)
&& !ctx.selectionIntersects(orderedSyntax)
// Keep the source punctuation (`.` or `)`) when overlaying, so a paren list stays a paren list.
let orderedPunct = orderedOverlayActive && item.marker.length > 0
? ctx.ns.substring(with: NSRange(location: NSMaxRange(item.marker) - 1, length: 1)) : "."
// Via the memoized measure list markers are a tiny repeated set (`- `, `1. `).
let markerWidth: CGFloat = {
if orderedOverlayActive, let displayNumber {
let gap = ctx.ns.substring(with: NSRange(location: NSMaxRange(item.marker),
length: item.contentRange.location - NSMaxRange(item.marker)))
return HeadingHelpers.textWidth("\(displayNumber)\(orderedPunct)" + gap, font: ctx.baseFont)
}
return HeadingHelpers.textWidth(ctx.ns.substring(with: markerGroup), font: ctx.baseFont)
}()
let depthIndent = CGFloat(MarkdownLists.indentLevel(from: ws)) * ctx.config.lists.indentPerLevel
let ps = NSMutableParagraphStyle()
let lineHeight = ctx.baseLineHeight + ctx.config.lists.extraLineHeight
ps.minimumLineHeight = lineHeight
ps.maximumLineHeight = lineHeight
ps.lineSpacing = 0
ps.paragraphSpacing = ctx.baseParagraphSpacing
ps.paragraphSpacingBefore = 0
ps.tabStops = []
ps.defaultTabInterval = ctx.config.lists.indentPerLevel
ps.firstLineHeadIndent = ctx.config.lists.indentPerLevel
// Wrapped lines hang under the first line's content (indent + marker
// width). No checkbox-specific extra: the box is a drawn overlay that
// doesn't change text advance, so adding it here (and only here, not to
// firstLineHeadIndent) shifted an unchecked task's wrapped lines right
// of its first line.
ps.headIndent = ctx.config.lists.indentPerLevel + depthIndent + markerWidth
attrs.append((line, [.paragraphStyle: ps]))
// 2. Marker decoration (suppressed while the caret edits the syntax).
if let box = item.checkbox {
if taskRevealed { return }
let spacer = NSRange(location: NSMaxRange(item.marker), length: box.location - NSMaxRange(item.marker))
// `- ` keeps full advance (the box's slot, like the bullet ``);
// `[ ]` + trailing space collapse to the hidden-marker font so the
// content starts at the bullet-content x.
attrs.append((item.marker, [.foregroundColor: NSColor.clear]))
if spacer.length > 0 { attrs.append((spacer, [.foregroundColor: NSColor.clear])) }
attrs.append((box, [.taskCheckbox: item.checked, .foregroundColor: NSColor.clear,
.font: ctx.inlineMarkerFont]))
let postGap = NSRange(location: NSMaxRange(box),
length: item.contentRange.location - NSMaxRange(box))
if postGap.length > 0 {
attrs.append((postGap, [.foregroundColor: NSColor.clear, .font: ctx.inlineMarkerFont]))
}
if item.checked, NSMaxRange(item.range) > NSMaxRange(box) {
attrs.append((NSRange(location: NSMaxRange(box), length: NSMaxRange(item.range) - NSMaxRange(box)), [
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
.strikethroughColor: ctx.theme.strikethroughColor,
]))
}
} else if !item.ordered {
let syntax = NSRange(location: item.marker.location,
length: item.contentRange.location - item.marker.location)
if NSLocationInRange(ctx.caret, syntax) { return }
attrs.append((item.marker, [.bulletMarker: true, .foregroundColor: NSColor.clear]))
} else if orderedOverlayActive, let displayNumber {
// Hide the ENTIRE source marker (digits + dot) as one unit and paint
// the whole display marker "N." over it, so the dot travels with the
// digits. Kern the slot to the display marker's width (horizontal
// only a scaled font would inflate the marker ascent and push the
// content baseline down under the pinned line height); spread across
// all marker chars so every glyph advance stays positive even when
// the number shrinks (10 9).
let sourceW = (ctx.ns.substring(with: item.marker) as NSString)
.size(withAttributes: [.font: ctx.baseFont]).width
let displayW = ("\(displayNumber)\(orderedPunct)" as NSString)
.size(withAttributes: [.font: ctx.baseFont]).width
var markerAttrs: [NSAttributedString.Key: Any] = [
.orderedMarker: "\(displayNumber)\(orderedPunct)", .foregroundColor: NSColor.clear,
]
if abs(displayW - sourceW) > 0.01 {
markerAttrs[.kern] = (displayW - sourceW) / CGFloat(max(1, item.marker.length))
}
attrs.append((item.marker, markerAttrs))
}
}
private static func styleAutoLinks(ctx: Ctx, codeRanges: [NSRange], linkRanges: [NSRange], into attrs: inout [StyledRange]) {
guard let detector = autoLinkDetector else { return }
for scan in ctx.scanRanges {
detector.enumerateMatches(in: ctx.text, range: scan) { match, _, _ in
// Skip URLs inside code and inside a markdown/wiki link's own range a link's
// `(url)` must not become a second `.link` region competing with the link itself.
guard let match, let url = match.url,
!isInCode(match.range, codeRanges),
!isInCode(match.range, linkRanges) else { return }
attrs.append((match.range, [.link: url]))
}
}
}
private static func styleIncompleteLinkBrackets(ctx: Ctx, codeRanges: [NSRange], checkboxRanges: [NSRange], into attrs: inout [StyledRange]) {
// Every pattern starts with `\[`, so no `[` in the text no match: skip
// all 6 regex sweeps (the 78ms on hits=0 docs).
guard ctx.ns.range(of: "[").location != NSNotFound else { return }
let muted = ctx.theme.mutedText
let faded = ctx.theme.incompleteLink.withAlphaComponent(ctx.config.link.incompleteLinkAlpha)
for re in incompleteLinkPatterns {
for scan in ctx.scanRanges {
for m in re.matches(in: ctx.text, options: [], range: scan)
where !isInCode(m.range, codeRanges) && !isInCode(m.range, checkboxRanges) {
// One range per RUN of same-colored characters, not per character: a
// single `[Design System]` used to emit 15 ranges, and the note in the
// bug report reached 25,504 from this pass alone every one of them a
// separate storage mutation downstream.
var runStart = m.range.location
var runLength = 0
var runIsBracket = false
for ch in ctx.ns.substring(with: m.range) {
let isBracket = ch == "[" || ch == "]" || ch == "(" || ch == ")"
let width = ch.utf16.count
if runLength > 0, isBracket != runIsBracket {
attrs.append((NSRange(location: runStart, length: runLength),
[.foregroundColor: runIsBracket ? muted : faded]))
runStart += runLength
runLength = 0
}
runIsBracket = isBracket
runLength += width
}
if runLength > 0 {
attrs.append((NSRange(location: runStart, length: runLength),
[.foregroundColor: runIsBracket ? muted : faded]))
}
}
}
}
}
/// Shared inputs threaded through the walk.
private struct Ctx {
let ns: NSString
let fontName: String
let baseFont: NSFont
let baseLineHeight: CGFloat
let baseParagraphSpacing: CGFloat
let codeFont: NSFont
let codeBackground: NSColor
let codeParagraphStyle: NSParagraphStyle
let inlineMarkerFont: NSFont
let caret: Int
/// The full selected range (nil/empty when the selection is a bare
/// caret). Token-based elements already reveal on selection via
/// `activeTokenIndices(selection:)`; this brings the same signal to
/// non-token elements (task checkboxes). Proven root cause: the
/// caret-only reveal can hit at most ONE selected task line every
/// other selected line stayed hidden.
let selection: NSRange?
let config: MarkdownEditorConfiguration
let extensionsByID: [String: any MarkdownExtension]
let wikiLinkID: (NSRange) -> String?
let scopedRanges: [NSRange]?
let orderedDisplayNumbers: [Int: Int]
/// True when a non-empty selection overlaps `range` the selection
/// counterpart of `isActive` for elements that reveal on select.
func selectionIntersects(_ range: NSRange) -> Bool {
guard let selection, selection.length > 0 else { return false }
return NSIntersectionRange(selection, range).length > 0
}
/// Active (syntax revealed) when the caret is inside the range or at its end (minus a newline).
func isActive(_ range: NSRange) -> Bool {
if NSLocationInRange(caret, range) { return true }
guard range.length > 0, caret == NSMaxRange(range) else { return false }
let last = ns.character(at: caret - 1)
return last != 0x0A && last != 0x0D
}
var theme: MarkdownEditorTheme { config.theme }
var text: String { ns as String }
var fullRange: NSRange { NSRange(location: 0, length: ns.length) }
/// Whether a range falls in the styled region (nil scope = whole doc).
func inScope(_ r: NSRange) -> Bool {
guard let scopedRanges else { return true }
return scopedRanges.contains { NSIntersectionRange($0, r).length > 0 }
}
/// Ranges the regex/text passes scan (edited paragraphs, or whole doc).
var scanRanges: [NSRange] { scopedRanges ?? [fullRange] }
}
// MARK: - Blocks
private static func styleBlock(_ block: BlockNode, font: NSFont, ctx: Ctx, into attrs: inout [StyledRange]) {
switch block {
case .paragraph(_, let inlines):
styleInlines(inlines, font: font, ctx: ctx, into: &attrs)
case .heading(let level, let range, let markers, let inlines):
let multiplier = ctx.config.headings.fontMultiplier(for: level)
let headingBase = NSFont(name: ctx.fontName, size: ctx.baseFont.pointSize * multiplier)
?? .systemFont(ofSize: ctx.baseFont.pointSize * multiplier)
let headingFont = adding(.bold, to: headingBase)
let lineHeight = ceil(headingFont.ascender - headingFont.descender + headingFont.leading) + 1
let headingPara = NSMutableParagraphStyle()
headingPara.minimumLineHeight = lineHeight
headingPara.maximumLineHeight = lineHeight
headingPara.paragraphSpacingBefore = headingFont.pointSize * ctx.config.headings.topSpacingEm(for: level)
headingPara.paragraphSpacing = ctx.baseParagraphSpacing
attrs.append((ctx.ns.paragraphRange(for: range), [.paragraphStyle: headingPara]))
attrs.append((range, [.font: headingFont]))
for marker in markers {
attrs.append((marker, [.foregroundColor: ctx.theme.headingMarker]))
}
styleInlines(inlines, font: headingFont, ctx: ctx, into: &attrs)
case .blockquote(let range, let inlines):
styleBlockquote(range: range, ctx: ctx, into: &attrs)
styleInlines(inlines, font: font, ctx: ctx, into: &attrs)
case .list(_, let items):
for item in items {
styleListItem(item, displayNumber: ctx.orderedDisplayNumbers[item.marker.location],
ctx: ctx, into: &attrs)
styleInlines(item.inlines, font: font, ctx: ctx, into: &attrs)
}
case .codeBlock(let range):
styleCodeBlock(range: range, ctx: ctx, into: &attrs)
case .thematicBreak(let range):
styleThematicBreak(range: range, ctx: ctx, into: &attrs)
case .ext(let node):
styleExtensionBlock(node, font: font, ctx: ctx, into: &attrs)
case .blockLatex, .table, .blank:
break // NSImage rendering ported next
}
}
/// Extension fenced block: the extension supplies content ATTRIBUTES only;
/// they cover the WHOLE block (fence lines included) so the block reads as
/// one cohesive band the hidden fences would otherwise sit as uncolored
/// blank rows above and below the body. Fence lines then mute while the
/// caret is inside the block and hide otherwise (mirroring code fences
/// clear color, unchanged font, so the line keeps its height and layout
/// stays stable across the active flip).
private static func styleExtensionBlock(_ node: ExtensionBlockNode, font: NSFont, ctx: Ctx, into attrs: inout [StyledRange]) {
if let ext = ctx.extensionsByID[node.extensionID] {
var block = node.range
// Keep the block's trailing newline out, so the band doesn't
// bleed a full-width background onto the following line.
while block.length > 0 {
let last = ctx.ns.character(at: NSMaxRange(block) - 1)
guard last == 0x0A || last == 0x0D else { break }
block.length -= 1
}
if block.length > 0 {
attrs.append((block, ext.contentAttributes(theme: ctx.theme)))
}
}
let markerAttrs: [NSAttributedString.Key: Any] = ctx.isActive(node.range)
? [.foregroundColor: ctx.theme.mutedText]
: [.foregroundColor: NSColor.clear]
attrs.append((node.openFence, markerAttrs))
if let close = node.closeFence { attrs.append((close, markerAttrs)) }
styleInlines(node.inlines, font: font, ctx: ctx, into: &attrs)
}
/// Per-line blockquote: indent, mute content, hide/show `>` markers, tag first char with bar level.
private static func styleBlockquote(range: NSRange, ctx: Ctx, into attrs: inout [StyledRange]) {
let indentPerLevel = MarkdownTextLayoutFragment.blockquoteIndentPerLevel
var lineStart = range.location
let end = NSMaxRange(range)
while lineStart < end {
let line = ctx.ns.lineRange(for: NSRange(location: lineStart, length: 0))
let lineEnd = NSMaxRange(line)
var i = line.location
var indent = 0
while i < lineEnd, indent < 3, ctx.ns.character(at: i) == 0x20 || ctx.ns.character(at: i) == 0x09 {
i += 1; indent += 1
}
let markerStart = i
var level = 0
var j = i
while j < lineEnd, ctx.ns.character(at: j) == 0x3E /* > */ {
level += 1; j += 1
if j < lineEnd, ctx.ns.character(at: j) == 0x20 || ctx.ns.character(at: j) == 0x09 { j += 1 }
}
defer { lineStart = lineEnd }
guard level > 0 else { continue }
var contentEnd = lineEnd
if contentEnd > j {
let last = ctx.ns.character(at: contentEnd - 1)
if last == 0x0A || last == 0x0D { contentEnd -= 1 }
}
let markerRange = NSRange(location: markerStart, length: j - markerStart)
let contentRange = NSRange(location: j, length: max(0, contentEnd - j))
let tokenRange = NSRange(location: line.location, length: contentEnd - line.location)
let textIndent = CGFloat(level) * indentPerLevel + indentPerLevel * 0.5
let para = NSMutableParagraphStyle()
para.firstLineHeadIndent = textIndent
para.headIndent = textIndent
let lineHeight = ctx.baseLineHeight + ctx.config.blockquote.extraLineHeight
para.minimumLineHeight = lineHeight
para.maximumLineHeight = lineHeight
// Inner quote lines stay tight (0); the LAST line gets the normal
para.paragraphSpacing = (lineEnd >= end) ? ctx.baseParagraphSpacing : 0
para.paragraphSpacingBefore = 0
attrs.append((ctx.ns.paragraphRange(for: tokenRange), [.paragraphStyle: para]))
if contentRange.length > 0 {
attrs.append((contentRange, [.foregroundColor: ctx.theme.mutedText]))
}
if ctx.isActive(tokenRange) {
attrs.append((markerRange, [.foregroundColor: ctx.theme.mutedText]))
} else {
attrs.append((markerRange, [.foregroundColor: NSColor.clear, .font: ctx.inlineMarkerFont]))
}
// Whole line, not just the first char, so each soft-wrapped visual line
attrs.append((tokenRange, [.blockquoteLevel: level]))
}
}
private static func styleCodeBlock(range: NSRange, ctx: Ctx, into attrs: inout [StyledRange]) {
let parts = codeBlockParts(range, ctx.ns)
attrs.append((parts.codeRange, [
.font: ctx.codeFont, .backgroundColor: ctx.codeBackground, .paragraphStyle: ctx.codeParagraphStyle,
]))
// Suppress spell-check underlines on the whole fenced block code is not prose.
attrs.append((parts.codeRange, [.spellingState: 0]))
let codeContent = ctx.ns.substring(with: parts.content)
if !codeContent.isEmpty,
let highlighted = ctx.config.services.syntaxHighlighter.highlight(code: codeContent, language: parts.language) {
highlighted.enumerateAttributes(in: NSRange(location: 0, length: highlighted.length)) { a, r, _ in
guard let fg = a[.foregroundColor] else { return }
attrs.append((NSRange(location: parts.content.location + r.location, length: r.length), [.foregroundColor: fg]))
}
}
// Use the whole block range (not codeRange): an incomplete fence collapses codeRange to the ```.
let markerAttrs: [NSAttributedString.Key: Any] = ctx.isActive(range)
? [.foregroundColor: ctx.theme.mutedText, .font: ctx.codeFont]
: [.foregroundColor: NSColor.clear, .font: ctx.codeFont] // hiddenMarkerFont == codeFont
attrs.append((parts.openFence, markerAttrs))
attrs.append((parts.closeFence, markerAttrs))
}
/// Split a fenced-code range into open fence (+language), content, close fence, and language.
private static func codeBlockParts(_ range: NSRange, _ ns: NSString)
-> (codeRange: NSRange, openFence: NSRange, content: NSRange, closeFence: NSRange, language: String?) {
let start = range.location
let end = NSMaxRange(range)
var openEnd = start
while openEnd < end, ns.character(at: openEnd) != 0x0A { openEnd += 1 }
if openEnd < end { openEnd += 1 }
let openFence = NSRange(location: start, length: openEnd - start)
let lastLine = ns.lineRange(for: NSRange(location: max(start, end - 1), length: 0))
var bt = lastLine.location
while bt < NSMaxRange(lastLine), ns.character(at: bt) == 0x60 { bt += 1 }
let closeFence = NSRange(location: lastLine.location, length: bt - lastLine.location)
let codeRange = NSRange(location: start, length: NSMaxRange(closeFence) - start)
let content = NSRange(location: openEnd, length: max(0, lastLine.location - openEnd))
var language: String?
if openFence.length > 3 {
let raw = ns.substring(with: NSRange(location: start + 3, length: openFence.length - 3))
.trimmingCharacters(in: .whitespacesAndNewlines)
language = raw.isEmpty ? nil : raw
}
return (codeRange, openFence, content, closeFence, language)
}
// MARK: - Inlines (composing)
private static func styleInlines(_ nodes: [InlineNode], font: NSFont, ctx: Ctx, into attrs: inout [StyledRange]) {
for node in nodes {
switch node {
case .text:
break
case .emphasis(let kind, let range, let markers, let children):
let composed = adding(traits(for: kind), to: font)
attrs.append((content(of: markers), [.font: composed]))
if ctx.isActive(range) {
for marker in markers { attrs.append((marker, [.foregroundColor: ctx.theme.mutedText])) }
}
styleInlines(children, font: composed, ctx: ctx, into: &attrs)
case .ext(let node):
// Extension-contributed span: the extension supplies content
// ATTRIBUTES only; every range comes from the parser, so a
// misbehaving extension can restyle its own span at worst.
if let ext = ctx.extensionsByID[node.extensionID] {
attrs.append((node.contentRange, ext.contentAttributes(theme: ctx.theme)))
}
if ctx.isActive(node.range) {
for marker in node.markers { attrs.append((marker, [.foregroundColor: ctx.theme.mutedText])) }
}
styleInlines(node.children, font: font, ctx: ctx, into: &attrs)
case .code(let range, let contentRange):
attrs.append((contentRange, [.font: ctx.codeFont, .backgroundColor: ctx.codeBackground]))
// Suppress spell-check underlines on inline `code` spans (markers + content).
attrs.append((range, [.spellingState: 0]))
let markerAttrs: [NSAttributedString.Key: Any] = ctx.isActive(range)
? [.foregroundColor: ctx.theme.mutedText, .font: ctx.codeFont]
: [.foregroundColor: ctx.theme.mutedText.withAlphaComponent(ctx.config.markers.inlineCodeMarkerAlpha),
.font: ctx.inlineMarkerFont]
for marker in markers(of: range, content: contentRange) { attrs.append((marker, markerAttrs)) }
case .link(let range, let textRange, let url, let markers, let children):
styleLink(range: range, textRange: textRange, url: url, markers: markers, children: children, font: font, ctx: ctx, into: &attrs)
case .wikiLink(let range, let name, _, let markers):
styleWikiLink(range: range, name: name, markers: markers, ctx: ctx, into: &attrs)
case .image, .imageEmbed, .inlineLatex, .escape:
break // ported in later increments
}
}
}
private static func styleLink(
range: NSRange, textRange: NSRange, url urlRange: NSRange, markers: [NSRange],
children: [InlineNode], font: NSFont, ctx: Ctx, into attrs: inout [StyledRange]
) {
attrs.append((range, [.spellingState: 0]))
var urlString = ctx.ns.substring(with: urlRange)
if !urlString.contains("://") { urlString = "https://\(urlString)" }
let isActive = ctx.isActive(range)
if let url = URL(string: urlString) {
if isActive {
attrs.append((textRange, [
.foregroundColor: ctx.theme.link.withAlphaComponent(ctx.config.link.activeLinkAlpha),
]))
} else {
attrs.append((textRange, [
.link: url,
.underlineStyle: NSUnderlineStyle.single.rawValue,
.foregroundColor: ctx.theme.link,
]))
}
}
for marker in markers { attrs.append((marker, [.foregroundColor: ctx.theme.mutedText])) }
styleInlines(children, font: font, ctx: ctx, into: &attrs)
}
private static func styleWikiLink(
range: NSRange, name: NSRange, markers: [NSRange], ctx: Ctx, into attrs: inout [StyledRange]
) {
attrs.append((range, [.spellingState: 0]))
let nodeName = ctx.ns.substring(with: name)
let linkID = ctx.wikiLinkID(range)
var contentAttrs: [NSAttributedString.Key: Any] = [:]
if let linkID { contentAttrs[.wikiLinkID] = linkID }
if !ctx.isActive(range) {
// Resolve by the stable UUID when present
let exists = ctx.config.services.wikiLinks.resolve(displayName: linkID ?? nodeName, range: name)?.exists ?? false
if exists {
contentAttrs[.link] = linkID ?? nodeName
} else {
contentAttrs[.foregroundColor] = ctx.theme.disabledText
}
}
if !contentAttrs.isEmpty { attrs.append((name, contentAttrs)) }
for marker in markers { attrs.append((marker, [.foregroundColor: ctx.theme.mutedText])) }
}
// MARK: - Marker shrinking (hide syntax of inactive nodes)
/// Collapse inactive nodes' markers to a tiny kerned font so syntax vanishes; code/LaTeX skip themselves.
private static func shrinkInactiveMarkers(in blocks: [BlockNode], ctx: Ctx, into attrs: inout [StyledRange]) {
for block in blocks where ctx.inScope(block.range) {
switch block {
case .heading(_, let range, let markers, let inlines):
if !ctx.isActive(range) { shrink(markers, ctx: ctx, into: &attrs) }
shrinkInlineMarkers(inlines, ctx: ctx, into: &attrs)
case .paragraph(_, let inlines), .blockquote(_, let inlines):
shrinkInlineMarkers(inlines, ctx: ctx, into: &attrs)
case .list(_, let items):
// Phase A: shrink only inline markers; the list marker is hidden by the bullet/task pass.
for item in items { shrinkInlineMarkers(item.inlines, ctx: ctx, into: &attrs) }
case .ext(let node):
shrinkInlineMarkers(node.inlines, ctx: ctx, into: &attrs)
case .codeBlock, .blockLatex, .table, .thematicBreak, .blank:
break
}
}
}
/// Shrink inactive markers; an active ancestor reveals its whole subtree via `forceReveal`.
private static func shrinkInlineMarkers(_ nodes: [InlineNode], ctx: Ctx, forceReveal: Bool = false, into attrs: inout [StyledRange]) {
for node in nodes {
switch node {
case .emphasis(_, let range, let markers, let children):
let active = forceReveal || ctx.isActive(range)
if !active { shrink(markers, ctx: ctx, into: &attrs) }
shrinkInlineMarkers(children, ctx: ctx, forceReveal: active, into: &attrs)
case .ext(let node):
let active = forceReveal || ctx.isActive(node.range)
if !active { shrink(node.markers, ctx: ctx, into: &attrs) }
shrinkInlineMarkers(node.children, ctx: ctx, forceReveal: active, into: &attrs)
case .link(let range, _, _, let markers, let children):
let active = forceReveal || ctx.isActive(range)
if !active {
shrink(markers, ctx: ctx, into: &attrs)
if markers.count >= 4 { // also hide the "(url)" run
let hide = NSRange(location: markers[2].location,
length: NSMaxRange(markers[3]) - markers[2].location)
attrs.append((hide, [.font: ctx.inlineMarkerFont, .foregroundColor: NSColor.clear]))
}
}
shrinkInlineMarkers(children, ctx: ctx, forceReveal: active, into: &attrs)
case .wikiLink(let range, _, _, let markers):
if !(forceReveal || ctx.isActive(range)) { shrink(markers, ctx: ctx, into: &attrs) }
case .image(let range, _, _, let markers):
if !(forceReveal || ctx.isActive(range)) { shrink(markers, ctx: ctx, into: &attrs) }
case .escape(let range, _, let marker):
if !(forceReveal || ctx.isActive(range)) { shrink([marker], ctx: ctx, into: &attrs) }
case .text, .code, .imageEmbed, .inlineLatex:
break // own marker handling / not shrunk
}
}
}
private static func shrink(_ markers: [NSRange], ctx: Ctx, into attrs: inout [StyledRange]) {
for marker in markers {
attrs.append((marker, [.font: ctx.inlineMarkerFont, .kern: -ctx.inlineMarkerFont.pointSize]))
}
}
// MARK: - Helpers
private static func traits(for kind: EmphasisKind) -> NSFontDescriptor.SymbolicTraits {
switch kind {
case .italic: return .italic
case .bold: return .bold
case .boldItalic: return [.bold, .italic]
}
}
private static func adding(_ extra: NSFontDescriptor.SymbolicTraits, to font: NSFont) -> NSFont {
let merged = font.fontDescriptor.symbolicTraits.union(extra)
return NSFont(descriptor: font.fontDescriptor.withSymbolicTraits(merged), size: font.pointSize) ?? font
}
private static func content(of markers: [NSRange]) -> NSRange {
let start = NSMaxRange(markers[0])
return NSRange(location: start, length: markers[1].location - start)
}
/// The two backtick marker ranges of an inline code span (range minus content).
private static func markers(of range: NSRange, content: NSRange) -> [NSRange] {
[
NSRange(location: range.location, length: content.location - range.location),
NSRange(location: NSMaxRange(content), length: NSMaxRange(range) - NSMaxRange(content)),
]
}
}
@@ -0,0 +1,46 @@
//
// MarkdownStyler+BulletMarkers.swift
// MarkdownEngine
//
// Caret-crossing helper for `-`/`*`/`+` bullet syntax. Bullet *rendering*
// (the `` overlay) now lives in the AST styler (`MarkdownASTStyler`); this
// only reports caret membership so the coordinator can restyle on crossings.
//
import AppKit
import Foundation
extension MarkdownStyler {
/// Indented bullet marker at line start (trailing space excludes `---`/`*bold*`), not a checkbox.
static let bulletListRegex: NSRegularExpression = try! NSRegularExpression(
pattern: #"^([ \t]*)([-*+])([ \t]+)(?!\[[ xX]\])"#,
options: [.anchorsMatchLines]
)
// MARK: Bullet Syntax Membership
/// `<marker><spaces>` range on `location`'s line, or `nil` if the caret isn't strictly inside.
static func bulletSyntaxRange(at location: Int, in text: String) -> NSRange? {
let nsText = text as NSString
let safeLoc = max(0, min(location, nsText.length))
let lineRange = nsText.lineRange(for: NSRange(location: safeLoc, length: 0))
let line = nsText.substring(with: lineRange)
guard let match = bulletListRegex.firstMatch(
in: line,
options: [],
range: NSRange(location: 0, length: line.utf16.count)
) else { return nil }
let markerLineRange = match.range(at: 2)
let spacerLineRange = match.range(at: 3)
guard markerLineRange.location != NSNotFound,
spacerLineRange.location != NSNotFound else { return nil }
let syntaxStart = lineRange.location + markerLineRange.location
let syntaxEnd = lineRange.location + spacerLineRange.location + spacerLineRange.length
let syntaxRange = NSRange(location: syntaxStart, length: syntaxEnd - syntaxStart)
if NSLocationInRange(location, syntaxRange) {
return syntaxRange
}
return nil
}
}
@@ -0,0 +1,200 @@
//
// MarkdownStyler+Images.swift
// MarkdownEngine
//
// Created by Luca Chen on 16.03.26.
//
// Image embed (`![[...]]`) styling and layout.
//
import AppKit
import Foundation
extension MarkdownStyler {
// MARK: Markdown Image Links ![alt](url)
/// Style standalone `![alt](url)` paragraphs by routing the URL through
/// the embedder's `EmbeddedImageProvider` (URL goes into the request's
/// `name` field providers that don't speak URLs simply return `nil`,
/// at which point we fall back to dimming the markdown source).
static func styleImageLinks(_ ctx: StylingContext) -> [StyledRange] {
var attrs: [StyledRange] = []
for (idx, token) in ctx.scoped(ctx.imageLinkIndexed) {
if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue }
// The URL lives between markerRanges[2] ('(') and markerRanges[3] (')').
guard token.markerRanges.count >= 4 else {
appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme)
continue
}
let openParen = token.markerRanges[2]
let closeParen = token.markerRanges[3]
let urlStart = NSMaxRange(openParen)
let urlLength = closeParen.location - urlStart
guard urlLength > 0 else {
appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme)
continue
}
let urlRange = NSRange(location: urlStart, length: urlLength)
let url = ctx.nsText.substring(with: urlRange)
let isActive = ctx.activeTokenIndices.contains(idx)
let request = EmbeddedImageRequest(name: url)
guard let image = ctx.services.images.image(for: request) else {
appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme)
continue
}
let imageEmbedConfig = ctx.configuration.imageEmbed
let maxWidth: CGFloat = {
if let tc = ctx.layoutBridge?.firstTextContainer {
let w = tc.containerSize.width - tc.lineFragmentPadding * 2
if w > 0 && w < imageEmbedConfig.unreasonableMaxWidth { return w }
}
return imageEmbedConfig.fallbackMaxWidth
}()
let minWidth = imageEmbedConfig.minimumWidth
let imageSize = image.size
let targetWidth = min(max(imageSize.width, minWidth), maxWidth)
let scale = imageSize.width > 0 ? targetWidth / imageSize.width : 1
let displayWidth = imageSize.width * scale
let displayHeight = imageSize.height * scale
let imageBounds = CGRect(x: 0, y: 0, width: displayWidth, height: displayHeight)
let rawContent = ctx.nsText.substring(with: token.range)
let rendered: Bool
if isActive {
rendered = appendRenderedStandaloneBlock(
for: token,
rawContent: rawContent,
image: image,
imageBounds: imageBounds,
paragraphSpacingBefore: imageEmbedConfig.paragraphSpacing,
paragraphSpacing: imageEmbedConfig.paragraphSpacing,
alignment: .left,
mode: .visibleSource(imageGap: imageEmbedConfig.imageGap),
restyleOnWidthChange: true,
ctx: ctx,
attrs: &attrs
)
} else {
rendered = appendRenderedStandaloneBlock(
for: token,
rawContent: rawContent,
image: image,
imageBounds: imageBounds,
paragraphSpacingBefore: imageEmbedConfig.paragraphSpacing,
paragraphSpacing: imageEmbedConfig.paragraphSpacing,
alignment: .left,
mode: .collapsedSource(markerTexts: ["![", "]", "(", ")"]),
restyleOnWidthChange: true,
ctx: ctx,
attrs: &attrs
)
if rendered {
// The standalone helper hides the alt text + the four
// markers, but the URL between '(' and ')' is its own
// range and stays visible unless we collapse it too.
let urlText = ctx.nsText.substring(with: urlRange)
attrs.append((urlRange, [
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: -HeadingHelpers.textWidth(urlText, font: ctx.latexMarkerFont)
]))
}
}
if !rendered {
appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme)
}
}
return attrs
}
// MARK: Image Embeds ![[Name]]
static func styleImageEmbeds(_ ctx: StylingContext) -> [StyledRange] {
var attrs: [StyledRange] = []
for (idx, token) in ctx.scoped(ctx.imageEmbedIndexed) {
if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue }
let isActive = ctx.activeTokenIndices.contains(idx)
let rawContent = ctx.nsText.substring(with: token.contentRange) // = display name (no suffix)
// The uuid|width suffix lives in the `.wikiLinkID` side-channel (same as node links).
let suffix = ctx.wikiLinkIDProvider(token.range)
// Re-apply the attribute every restyle so makeStorageState can recover the suffix on
// save even on the image-not-found path (mirrors styleWikiLink). Load-bearing.
if let suffix, !suffix.isEmpty {
attrs.append((token.contentRange, [.wikiLinkID: suffix]))
}
let referenceContent = (suffix?.isEmpty == false) ? "\(rawContent)|\(suffix!)" : rawContent
guard let reference = ImageEmbedReference(content: referenceContent) else {
appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme)
continue
}
if let image = EmbeddedImageCache.shared.image(for: reference, services: ctx.services) {
let imageEmbedConfig = ctx.configuration.imageEmbed
// Determine max width from text container
let maxWidth: CGFloat = {
if let tc = ctx.layoutBridge?.firstTextContainer {
let w = tc.containerSize.width - tc.lineFragmentPadding * 2
if w > 0 && w < imageEmbedConfig.unreasonableMaxWidth { return w }
}
return imageEmbedConfig.fallbackMaxWidth
}()
let minWidth = imageEmbedConfig.minimumWidth
let imageSize = image.size
let targetWidth: CGFloat
if let rw = reference.requestedWidth, rw > 0 {
targetWidth = min(max(rw, minWidth), maxWidth)
} else {
targetWidth = min(imageSize.width, maxWidth)
}
let scale = targetWidth / imageSize.width
let displayWidth = imageSize.width * scale
let displayHeight = imageSize.height * scale
let imageBounds = CGRect(x: 0, y: 0, width: displayWidth, height: displayHeight)
let rendered: Bool
if isActive {
rendered = appendRenderedStandaloneBlock(
for: token,
rawContent: rawContent,
image: image,
imageBounds: imageBounds,
paragraphSpacingBefore: imageEmbedConfig.paragraphSpacing,
paragraphSpacing: imageEmbedConfig.paragraphSpacing,
alignment: .left,
mode: .visibleSource(imageGap: imageEmbedConfig.imageGap),
restyleOnWidthChange: true,
ctx: ctx,
attrs: &attrs
)
} else {
rendered = appendRenderedStandaloneBlock(
for: token,
rawContent: rawContent,
image: image,
imageBounds: imageBounds,
paragraphSpacingBefore: imageEmbedConfig.paragraphSpacing,
paragraphSpacing: imageEmbedConfig.paragraphSpacing,
alignment: .left,
mode: .collapsedSource(markerTexts: ["![[", "]]"]),
restyleOnWidthChange: true,
ctx: ctx,
attrs: &attrs
)
}
if !rendered {
appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme)
}
} else {
// Image not found show syntax with marker coloring (like broken link)
appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme)
}
}
return attrs
}
}
@@ -0,0 +1,160 @@
//
// MarkdownStyler+Latex.swift
// MarkdownEngine
//
// Created by Luca Chen on 16.03.26.
//
// Block ($$...$$) and inline ($...$) LaTeX formula rendering.
//
import AppKit
import Foundation
extension MarkdownStyler {
// MARK: Block LaTeX $$...$$
static func styleBlockLatex(_ ctx: StylingContext) -> [StyledRange] {
var attrs: [StyledRange] = []
for (idx, token) in ctx.scoped(ctx.blockLatexIndexed) {
if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue }
let isActive = ctx.activeTokenIndices.contains(idx)
let rawLatexContent = ctx.nsText.substring(with: token.contentRange)
let latexContent = rawLatexContent.trimmingCharacters(in: .whitespacesAndNewlines)
attrs.append((token.range, [NSAttributedString.Key.spellingState: 0]))
guard token.standaloneParagraphRange(in: ctx.nsText) != nil else { continue }
let latexFontSize = HeadingHelpers.latexFontSize(for: token, headings: [], baseFont: ctx.baseFont) // block $$ is never inside a heading
if isActive {
appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme)
} else if !latexContent.isEmpty,
let entry = ctx.services.latex.render(latex: latexContent, fontSize: latexFontSize, theme: ctx.configuration.theme) {
_ = appendRenderedStandaloneBlock(
for: token,
rawContent: rawLatexContent,
image: entry.image,
imageBounds: CGRect(
x: 0,
y: entry.baselineOffset,
width: entry.size.width,
height: entry.size.height
),
paragraphSpacingBefore: ctx.configuration.blockLatex.paragraphSpacingBefore,
paragraphSpacing: ctx.configuration.blockLatex.paragraphSpacing,
alignment: .center,
mode: .collapsedSource(markerTexts: ["$$", "$$"]),
ctx: ctx,
attrs: &attrs
)
} else {
appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme)
}
}
return attrs
}
// MARK: Inline LaTeX $formula$
static func styleInlineLatex(_ ctx: StylingContext) -> [StyledRange] {
var attrs: [StyledRange] = []
// Tables render their own cell contents (including `$$`) into a single
// image via `formattedCellString` + `collapsedSource`. If we also tag
// the source-text `$x^2$` with a `.latexImage` attribute, the renderer
// draws that tiny inline image on the collapsed 1pt source line under
// the table visible as a stray dot. Skip inline LaTeX inside a
// table; the table image already covers it.
let scopedLatex = ctx.scoped(ctx.inlineLatexIndexed)
guard !scopedLatex.isEmpty else { return attrs }
// Containers that ENCLOSE an in-scope formula must overlap the scope, so
// scope-slicing these is exact; built once, not per formula.
let tableRanges = ctx.scoped(ctx.tableIndexed).map { $0.token.range }
// Quote lines mute their text via foregroundColor, which the LaTeX *image* ignores render it in mutedText instead so it matches the grey.
let blockquoteRanges = MarkdownStyler.StylingContext.indexed(ctx.tokens, .blockquote).map { $0.token.range }
// Built once, not re-scanned per formula (latexFontSize was O(#latex × #tokens)).
let headings = ctx.scoped(MarkdownStyler.StylingContext.indexed(ctx.tokens, .heading)).map { $0.token }
// Each textWidth is a CoreText measurement; the two "$" marker widths are
// loop-invariant (fonts constant) and firstChar/restText repeat massively (2
// distinct formulas × 1,594 tokens). Hoisting + memoizing removes all per-formula
// width measurement ENG-8g2b self ~186ms~152ms on the 346k note (Debug).
let tinyDollarWidth = HeadingHelpers.textWidth("$", font: ctx.latexMarkerFont)
let baseDollarWidth = HeadingHelpers.textWidth("$", font: ctx.baseFont)
var markerFontWidthCache: [String: CGFloat] = [:] // all measured with latexMarkerFont
func markerFontWidth(_ s: String) -> CGFloat {
if let cached = markerFontWidthCache[s] { return cached }
let w = HeadingHelpers.textWidth(s, font: ctx.latexMarkerFont)
markerFontWidthCache[s] = w
return w
}
for (idx, token) in scopedLatex {
if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue }
if tableRanges.contains(where: { tableRange in
token.range.location >= tableRange.location
&& NSMaxRange(token.range) <= NSMaxRange(tableRange)
}) { continue }
attrs.append((token.range, [NSAttributedString.Key.spellingState: 0]))
let isActive = ctx.activeTokenIndices.contains(idx)
let latexContent = ctx.nsText.substring(with: token.contentRange)
let latexFontSize = HeadingHelpers.latexFontSize(for: token, headings: headings, baseFont: ctx.baseFont)
if isActive {
for markerRange in token.markerRanges {
attrs.append((markerRange, [.foregroundColor: ctx.configuration.theme.mutedText]))
}
} else {
var renderTheme = ctx.configuration.theme
if blockquoteRanges.contains(where: { NSLocationInRange(token.range.location, $0) }) {
renderTheme.latexLightModeText = renderTheme.mutedText
renderTheme.latexDarkModeText = renderTheme.mutedText
}
if let entry = ctx.services.latex.render(latex: latexContent, fontSize: latexFontSize, theme: renderTheme) {
let imageBounds = CGRect(x: 0, y: entry.baselineOffset, width: entry.size.width, height: entry.size.height)
let contentLength = token.contentRange.length
if contentLength > 0 {
let firstCharRange = NSRange(location: token.contentRange.location, length: 1)
let firstChar = ctx.nsText.substring(with: firstCharRange)
attrs.append((firstCharRange, [
.latexImage: entry.image,
.latexBounds: NSValue(rect: imageBounds),
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: entry.size.width - markerFontWidth(firstChar)
]))
if contentLength > 1 {
let restRange = NSRange(location: token.contentRange.location + 1, length: contentLength - 1)
let restText = ctx.nsText.substring(with: restRange)
attrs.append((restRange, [
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: -markerFontWidth(restText)
]))
}
}
let openMarker = token.markerRanges[0]
attrs.append((openMarker, [
.font: ctx.latexMarkerFont,
.foregroundColor: NSColor.clear,
.kern: -tinyDollarWidth
]))
let closeMarker = token.markerRanges[1]
attrs.append((closeMarker, [
.foregroundColor: NSColor.clear,
.kern: -baseDollarWidth
]))
} else {
for markerRange in token.markerRanges {
attrs.append((markerRange, [.foregroundColor: ctx.configuration.theme.mutedText]))
}
}
}
}
return attrs
}
}
@@ -0,0 +1,60 @@
//
// MarkdownStyler+OrderedMarkers.swift
// MarkdownEngine
//
// Created by Luca Chen on 30.07.26.
//
// Caret-crossing helper for `1.` / `1)` ordered markers. The number the editor
// SHOWS is positional (`MarkdownASTStyler`), and the raw source digits are
// revealed while the caret edits them so the coordinator needs to know when
// the caret crosses that boundary, exactly like it already does for bullets,
// task checkboxes and thematic breaks. Rendering itself lives in the AST
// styler; this file only answers membership.
//
import AppKit
import Foundation
extension MarkdownStyler {
/// Indented ordered marker at line start, excluding task items the AST
/// styler never overlays those, so their digits are always literal.
static let orderedListRegex: NSRegularExpression = try! NSRegularExpression(
pattern: #"^([ \t]*)(\d+[.)])([ \t]+)(?!\[[ xX]\])"#,
options: [.anchorsMatchLines]
)
// MARK: Ordered Marker Membership
/// True while the caret sits ON the digits inside `syntax`, but never at
/// its first offset. That one offset is excluded on purpose: every
/// whole-line delete and line-join parks the caret exactly there, and
/// counting it as "editing the digits" left the surviving item showing its
/// stale literal (a 1./2./3. list read 1./1. after deleting item 2).
static func caretRevealsOrderedMarker(caret: Int, syntax: NSRange) -> Bool {
caret > syntax.location && caret < NSMaxRange(syntax)
}
/// `<digits><.|)><spaces>` range on `location`'s line while the caret
/// reveals it, else `nil`. Paired with ``caretRevealsOrderedMarker`` so the
/// coordinator's crossing signal and the styler's reveal cannot disagree.
static func orderedSyntaxRange(at location: Int, in text: String) -> NSRange? {
let nsText = text as NSString
let safeLoc = max(0, min(location, nsText.length))
let lineRange = nsText.lineRange(for: NSRange(location: safeLoc, length: 0))
let line = nsText.substring(with: lineRange)
guard let match = orderedListRegex.firstMatch(
in: line,
options: [],
range: NSRange(location: 0, length: line.utf16.count)
) else { return nil }
let markerLineRange = match.range(at: 2)
let spacerLineRange = match.range(at: 3)
guard markerLineRange.location != NSNotFound,
spacerLineRange.location != NSNotFound else { return nil }
let syntaxStart = lineRange.location + markerLineRange.location
let syntaxEnd = lineRange.location + spacerLineRange.location + spacerLineRange.length
let syntaxRange = NSRange(location: syntaxStart, length: syntaxEnd - syntaxStart)
return caretRevealsOrderedMarker(caret: safeLoc, syntax: syntaxRange) ? syntaxRange : nil
}
}
@@ -0,0 +1,724 @@
//
// MarkdownStyler+Tables.swift
// MarkdownEngine
//
// GFM tables. The block is rendered to a single NSImage and emitted via
// the same collapsedSource path block-LaTeX uses, so the source stays
// in sync with the document but the user only sees the rendered grid
// when the caret is outside the table.
//
import AppKit
import Foundation
extension MarkdownStyler {
enum TableAlignment {
case left
case center
case right
}
struct ParsedTable {
let header: [String]
let alignments: [TableAlignment]
let rows: [[String]]
}
/// Rendered-table image cache. A table's pixels depend only on its source,
/// font, colors, and appearance so identical keys can reuse the NSImage
/// instead of re-rendering every inactive table on every keystroke.
private static let tableImageCache: NSCache<NSString, NSImage> = {
let cache = NSCache<NSString, NSImage>()
// Must exceed a document's unique-table count or a full restyle (load /
// theme / font change) re-renders every table (same thrash class as the
// metadata cap). NSCache still auto-evicts under memory pressure.
cache.countLimit = 2048
return cache
}()
/// Pixel-level fingerprint of a theme color: its sRGB components resolved
/// under `appearance`. NSColor descriptions are not sound identities
/// named dynamic colors describe by name only (two providers collide),
/// unnamed ones by per-instance UUID (never hit) so key on what actually
/// reaches the bitmap.
private static func colorKey(_ color: NSColor, under appearance: NSAppearance) -> String {
var srgb: NSColor?
appearance.performAsCurrentDrawingAppearance {
srgb = color.usingColorSpace(.sRGB)
}
guard let c = srgb else { return "\(color)" }
return String(format: "%.4f,%.4f,%.4f,%.4f",
c.redComponent, c.greenComponent, c.blueComponent, c.alphaComponent)
}
/// Resolving six colors per table per keystroke is measurable (10 tables ×
/// 6 appearance-scoped resolutions). The resolved prefix depends only on
/// the color INSTANCES + appearance + font, so memoize it by identity
/// theme copies keep the same NSColor references across keystrokes.
private static let themeKeyLock = NSLock()
private static var themeKeyCache: [String: String] = [:]
private static func themeKeyPrefix(ctx: StylingContext, appearance: NSAppearance) -> String {
let theme = ctx.configuration.theme
let identity = "\(ctx.baseFont.fontName)|\(ctx.baseFont.pointSize)|\(appearance.name.rawValue)|"
+ "\(ObjectIdentifier(theme.bodyText))|\(ObjectIdentifier(theme.mutedText))|"
+ "\(ObjectIdentifier(theme.highlightColor))|\(ObjectIdentifier(ctx.codeBackgroundColor))|"
+ "\(ObjectIdentifier(theme.latexLightModeText))|\(ObjectIdentifier(theme.latexDarkModeText))|"
+ "\(ObjectIdentifier(type(of: ctx.services.latex)))"
themeKeyLock.lock()
if let cached = themeKeyCache[identity] {
themeKeyLock.unlock()
return cached
}
themeKeyLock.unlock()
// Every input renderTable reads must be in the key: fonts, all theme
// colors it draws with, and the latex renderer (by type a NoOp and a
// real renderer must not share entries).
let prefix = [
ctx.baseFont.fontName,
"\(ctx.baseFont.pointSize)",
appearance.name.rawValue,
colorKey(theme.bodyText, under: appearance),
colorKey(theme.mutedText, under: appearance),
colorKey(theme.highlightColor, under: appearance),
colorKey(ctx.codeBackgroundColor, under: appearance),
colorKey(theme.latexLightModeText, under: appearance),
colorKey(theme.latexDarkModeText, under: appearance),
"\(ObjectIdentifier(type(of: ctx.services.latex)))",
].joined(separator: "|")
themeKeyLock.lock()
if themeKeyCache.count > 32 { themeKeyCache.removeAll() }
themeKeyCache[identity] = prefix
themeKeyLock.unlock()
return prefix
}
/// Parse + content-hash for a table source, memoized: both are pure in
/// the source text but were recomputed for every table on every keystroke
/// (the non-render share of styleTables). FIFO-capped like the block token
/// memo the cap MUST exceed a document's table count, else cyclic access
/// over the full table set is Bélády-pessimal under FIFO (~100% miss) and
/// every table re-parses+re-hashes every keystroke.
private static let tableMetaCap = 8192
private static let tableMetaLock = NSLock()
private static var tableMetaCache: [String: (parsed: ParsedTable?, hash: Int)] = [:]
private static var tableMetaOrder: [String] = []
static func tableMeta(for source: String) -> (parsed: ParsedTable?, hash: Int) {
tableMetaLock.lock()
if let cached = tableMetaCache[source] {
tableMetaLock.unlock()
return cached
}
tableMetaLock.unlock()
let computed = (parseTableSource(source), stableTableContentHash(for: source))
tableMetaLock.lock()
if tableMetaCache[source] == nil {
tableMetaCache[source] = computed
tableMetaOrder.append(source)
if tableMetaOrder.count > tableMetaCap {
tableMetaCache[tableMetaOrder.removeFirst()] = nil
}
}
tableMetaLock.unlock()
return computed
}
/// Returns the rendered image for `source`, from cache when possible.
/// `rendered` is true only when a fresh render actually happened.
/// `availableWidth` caps the table's width (cells wrap onto extra lines);
/// it is part of the cache key because the layout depends on it.
static func tableImage(
for source: String,
parsed: ParsedTable,
ctx: StylingContext,
appearance: NSAppearance,
availableWidth: CGFloat
) -> (image: NSImage, rendered: Bool) {
let widthKey = Int(availableWidth.rounded())
// The extension registry is part of the key: `==x==` in a cell renders
// highlighted under one config and literal under another those must
// never share a cached image.
let extensionKey = ctx.configuration.extensionRegistry.fingerprint
let key = (themeKeyPrefix(ctx: ctx, appearance: appearance) + "|x\(extensionKey)|w\(widthKey)|" + source) as NSString
if let cached = tableImageCache.object(forKey: key) {
return (cached, false)
}
let image = renderTable(
parsed,
baseFont: ctx.baseFont,
theme: ctx.configuration.theme,
codeBackgroundColor: ctx.codeBackgroundColor,
latex: ctx.services.latex,
appearance: appearance,
availableWidth: availableWidth,
extensions: ctx.configuration.extensions
)
tableImageCache.setObject(image, forKey: key)
return (image, true)
}
static func styleTables(_ ctx: StylingContext) -> [StyledRange] {
var attrs: [StyledRange] = []
// Per-content occurrence counter so identical tables get distinct sourceIDs.
var occurrenceByContentHash: [Int: Int] = [:]
var tableCount = 0
var renderedCount = 0
let tablesT0 = DispatchTime.now().uptimeNanoseconds
// Iterate the pre-classified table array (not all document tokens).
// The occurrence counter exists for stable duplicate-table sourceIDs,
// and a sourceID is only CONSUMED by a table that renders this pass
// (inactive + in scope). Equal content implies equal source length,
// so only tables sharing a length with a rendering table can affect
// its occurrence index every other inactive table skips the
// substring + parse/hash AND the .spellingState write (applied
// UNCLIPPED, it used to touch every table in the document on every
// keystroke). Typing prose renders no table all tables skip.
let tableIndexed = ctx.tableIndexed
var neededLengths: Set<Int> = []
for (idx, token) in tableIndexed
where !ctx.activeTokenIndices.contains(idx) && !ctx.outsideScope(token.range) {
neededLengths.insert(token.range.length)
}
var skippedCount = 0
var metaNanos: UInt64 = 0
for (idx, token) in tableIndexed {
tableCount += 1
if !ctx.activeTokenIndices.contains(idx),
!neededLengths.contains(token.range.length) {
skippedCount += 1
continue
}
// Tokenizer already drops tables overlapping fenced code, so no re-check here.
attrs.append((token.range, [.spellingState: 0]))
let metaT0 = DispatchTime.now().uptimeNanoseconds
let source = ctx.nsText.substring(with: token.range)
let meta = tableMeta(for: source)
metaNanos &+= DispatchTime.now().uptimeNanoseconds - metaT0
guard let parsed = meta.parsed else { continue }
// Advance occurrence index even for active/out-of-scope tables so
// inactive duplicates keep stable sourceIDs.
let occurrenceIndex = occurrenceByContentHash[meta.hash, default: 0]
occurrenceByContentHash[meta.hash] = occurrenceIndex + 1
let isActive = ctx.activeTokenIndices.contains(idx)
if isActive {
// Caret inside the table show editable source, pipes muted like other syntax.
let muted = ctx.configuration.theme.mutedText
let body = ctx.configuration.theme.bodyText
attrs.append((token.range, [.foregroundColor: body, .font: ctx.baseFont]))
// Mute each `|` so the structure stays legible while editing.
let end = NSMaxRange(token.range)
var i = token.range.location
while i < end {
if ctx.nsText.character(at: i) == 0x7C { // '|'
attrs.append((NSRange(location: i, length: 1), [.foregroundColor: muted]))
}
i += 1
}
continue
}
// Outside the restyle scope the anchor attrs would be clipped away
// at application time skip the render lookup and anchor build
// (occurrence bookkeeping above already ran, keeping IDs stable).
if ctx.outsideScope(token.range) { continue }
// See renderTable: resolve table colors under the text view's real appearance.
let renderAppearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance
?? NSApp.effectiveAppearance
// Cells wrap to the container width (Obsidian-style); the render
// only exceeds it when the per-column floors genuinely don't fit,
// in which case the scrollable overlay below takes over.
let containerWidth = effectiveContainerWidth(for: ctx)
let (image, rendered) = tableImage(
for: source,
parsed: parsed,
ctx: ctx,
appearance: renderAppearance,
availableWidth: containerWidth
)
if rendered { renderedCount += 1 }
let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)
// Wide tables scrollable mode (NSScrollView overlay); narrow collapsed.
let isWide = image.size.width > containerWidth + 0.5
let computedSourceID = stableTableSourceID(
for: source,
occurrenceIndex: occurrenceIndex
)
let mode: RenderedStandaloneBlockMode = isWide
? .collapsedSourceScrollable(
markerTexts: [],
displayWidth: containerWidth,
sourceID: computedSourceID
)
: .collapsedSource(markerTexts: [])
_ = appendRenderedStandaloneBlock(
for: token,
rawContent: source,
image: image,
imageBounds: imageBounds,
paragraphSpacingBefore: ctx.baseDefaultLineHeight * 0.5,
paragraphSpacing: ctx.baseDefaultLineHeight * 0.5,
alignment: .left,
mode: mode,
restyleOnWidthChange: true,
ctx: ctx,
attrs: &attrs
)
}
if tableCount > 0 {
let ms = Double(DispatchTime.now().uptimeNanoseconds - tablesT0) / 1_000_000
let metaMs = Double(metaNanos) / 1_000_000
PerfTrace.note { "styleTables scanned=\(tableCount) tables (skipped=\(skippedCount)), re-rendered=\(renderedCount) NSImage in \(String(format: "%.2f", ms))ms (substring+meta=\(String(format: "%.2f", metaMs))ms)" }
}
return attrs
}
// MARK: - Parsing
static func parseTableSource(_ source: String) -> ParsedTable? {
let rawLines = source.components(separatedBy: CharacterSet.newlines)
let lines = rawLines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
guard lines.count >= 2 else { return nil }
let header = parseTableRow(lines[0])
let alignments = parseTableAlignments(lines[1])
guard !header.isEmpty, !alignments.isEmpty else { return nil }
let columnCount = max(header.count, alignments.count)
let bodyLines = Array(lines.dropFirst(2))
func pad<T>(_ array: [T], to count: Int, with fill: T) -> [T] {
if array.count == count { return array }
if array.count > count { return Array(array.prefix(count)) }
return array + Array(repeating: fill, count: count - array.count)
}
let paddedHeader = pad(header, to: columnCount, with: "")
let paddedAlign = pad(alignments, to: columnCount, with: .left)
let rows = bodyLines.map { pad(parseTableRow($0), to: columnCount, with: "") }
return ParsedTable(header: paddedHeader, alignments: paddedAlign, rows: rows)
}
private static func parseTableRow(_ line: String) -> [String] {
var s = line.trimmingCharacters(in: .whitespaces)
if s.hasPrefix("|") { s.removeFirst() }
if s.hasSuffix("|") { s.removeLast() }
return s.split(separator: "|", omittingEmptySubsequences: false).map {
$0.trimmingCharacters(in: .whitespaces)
}
}
private static func parseTableAlignments(_ line: String) -> [TableAlignment] {
let cells = parseTableRow(line)
return cells.map { cell in
let trimmed = cell.trimmingCharacters(in: .whitespaces)
let leading = trimmed.hasPrefix(":")
let trailing = trimmed.hasSuffix(":")
switch (leading, trailing) {
case (true, true): return .center
case (false, true): return .right
default: return .left
}
}
}
// MARK: - Inline-formatted cell strings
/// Raw cell `NSAttributedString`: inline markdown applied, markers stripped, LaTeX as attachments.
static func formattedCellString(
_ raw: String,
baseFont: NSFont,
header: Bool,
theme: MarkdownEditorTheme,
codeBackgroundColor: NSColor,
latex: any LatexRenderer,
extensions: [any MarkdownExtension] = []
) -> NSAttributedString {
let descriptor = baseFont.fontDescriptor
let pointSize = baseFont.pointSize
let codeFont = NSFont.monospacedSystemFont(ofSize: pointSize, weight: .regular)
let startFont = header
? (NSFont(descriptor: descriptor.withSymbolicTraits(.bold), size: pointSize) ?? baseFont)
: baseFont
let out = NSMutableAttributedString()
var extensionsByID: [String: any MarkdownExtension] = [:]
for ext in extensions { extensionsByID[ext.id] = ext }
appendInlineCell(
InlineParser.parse(raw, registry: ExtensionRegistry(extensions: extensions)),
in: raw as NSString, into: out,
font: startFont, baseDescriptor: descriptor, pointSize: pointSize,
codeFont: codeFont, theme: theme, codeBackgroundColor: codeBackgroundColor, latex: latex,
extensionsByID: extensionsByID
)
return out
}
/// Compose `current`'s bold/italic traits with `kind` so nested emphasis stacks (italic+bold).
private static func composeEmphasis(
_ current: NSFont, _ kind: EmphasisKind,
baseDescriptor: NSFontDescriptor, pointSize: CGFloat
) -> NSFont {
var traits = current.fontDescriptor.symbolicTraits.intersection([.bold, .italic])
switch kind {
case .bold: traits.insert(.bold)
case .italic: traits.insert(.italic)
case .boldItalic: traits.formUnion([.bold, .italic])
}
return NSFont(descriptor: baseDescriptor.withSymbolicTraits(traits), size: pointSize) ?? current
}
/// Walk the inline AST into marker-stripped runs; LaTeX as attachments, links/embeds emitted raw.
private static func appendInlineCell(
_ nodes: [InlineNode],
in ns: NSString,
into out: NSMutableAttributedString,
font: NSFont,
baseDescriptor: NSFontDescriptor,
pointSize: CGFloat,
codeFont: NSFont,
theme: MarkdownEditorTheme,
codeBackgroundColor: NSColor,
latex: any LatexRenderer,
extensionsByID: [String: any MarkdownExtension] = [:]
) {
func recurse(_ children: [InlineNode], _ f: NSFont) {
appendInlineCell(children, in: ns, into: out, font: f, baseDescriptor: baseDescriptor,
pointSize: pointSize, codeFont: codeFont, theme: theme,
codeBackgroundColor: codeBackgroundColor, latex: latex,
extensionsByID: extensionsByID)
}
func appendPlain(_ range: NSRange, _ f: NSFont) {
out.append(NSAttributedString(string: ns.substring(with: range),
attributes: [.font: f, .foregroundColor: theme.bodyText]))
}
for node in nodes {
switch node {
case .text(let r):
appendPlain(r, font)
case .escape(_, let character, _):
appendPlain(character, font)
case .emphasis(let kind, _, _, let children):
recurse(children, composeEmphasis(font, kind, baseDescriptor: baseDescriptor, pointSize: pointSize))
case .ext(let extNode):
let start = out.length
if extNode.children.isEmpty {
appendPlain(extNode.contentRange, font)
} else {
recurse(extNode.children, font)
}
if out.length > start, let ext = extensionsByID[extNode.extensionID] {
let span = NSRange(location: start, length: out.length - start)
var attributes = ext.contentAttributes(theme: theme)
// A cell rasterizes through NSAttributedString drawing, which
// knows nothing of the line-box fill the layout fragment paints
// fall back to the glyph-box background so a highlight inside
// a table still has one.
if let fill = attributes.removeValue(forKey: .markdownBlockBackground) {
attributes[.backgroundColor] = fill
}
out.addAttributes(attributes, range: span)
}
case .code(_, let content):
out.append(NSAttributedString(string: ns.substring(with: content), attributes: [
.font: codeFont, .backgroundColor: codeBackgroundColor, .foregroundColor: theme.bodyText
]))
case .inlineLatex(let range, let content, _):
if let entry = latex.render(latex: ns.substring(with: content), fontSize: pointSize, theme: theme) {
let attachment = NSTextAttachment()
attachment.image = entry.image
attachment.bounds = CGRect(x: 0, y: entry.baselineOffset,
width: entry.size.width, height: entry.size.height)
out.append(NSAttributedString(attachment: attachment))
} else {
appendPlain(range, font) // renderer unavailable keep raw `$$`
}
case .link(let range, _, _, _, _),
.image(let range, _, _, _),
.wikiLink(let range, _, _, _),
.imageEmbed(let range, _, _):
appendPlain(range, font)
}
}
}
// MARK: - Rendering
private static func renderTable(
_ table: ParsedTable,
baseFont: NSFont,
theme: MarkdownEditorTheme,
codeBackgroundColor: NSColor,
latex: any LatexRenderer,
appearance: NSAppearance,
availableWidth: CGFloat,
extensions: [any MarkdownExtension] = []
) -> NSImage {
let columnCount = table.alignments.count
let cellHPadding: CGFloat = 12
let cellVPadding: CGFloat = 6
let borderWidth: CGFloat = 1
// Resolve under the real appearance: `.withAlphaComponent()` freezes a dynamic color otherwise.
func mutedColor(alpha: CGFloat) -> NSColor {
var resolved: NSColor = theme.mutedText
appearance.performAsCurrentDrawingAppearance {
resolved = theme.mutedText.usingColorSpace(.sRGB) ?? theme.mutedText
}
return resolved.withAlphaComponent(alpha)
}
let borderColor = mutedColor(alpha: 0.5)
let baseLineHeight: CGFloat = ceil(baseFont.ascender - baseFont.descender + baseFont.leading)
let minColumnContentWidth: CGFloat = 16
// Pre-format every cell so width measurement and drawing share one NSAttributedString.
let headerCells = table.header.map {
formattedCellString(
$0, baseFont: baseFont, header: true, theme: theme,
codeBackgroundColor: codeBackgroundColor, latex: latex,
extensions: extensions
)
}
let bodyCells = table.rows.map { row in
row.map {
formattedCellString(
$0, baseFont: baseFont, header: false, theme: theme,
codeBackgroundColor: codeBackgroundColor, latex: latex,
extensions: extensions
)
}
}
// CSS automatic table layout (W3C 17.5.2.2), which is what browser-based
// editors like Obsidian get for free: each column has a MAXIMUM width
// (content on one line) and a MINIMUM width (MCW content may wrap but
// must not overflow, i.e. the widest unbreakable whitespace-separated
// segment). Measured segment-by-segment: a too-narrow boundingRect
// would emergency-break INSIDE words and understate the minimum.
func widestUnbreakableSegment(_ cell: NSAttributedString) -> CGFloat {
let str = cell.string as NSString
let whitespace = CharacterSet.whitespacesAndNewlines
var widest: CGFloat = 0
var segStart = -1
for i in 0...str.length {
let isBreak = i == str.length || {
guard let scalar = Unicode.Scalar(str.character(at: i)) else { return false }
return whitespace.contains(scalar)
}()
if isBreak {
if segStart >= 0 {
let segment = cell.attributedSubstring(from: NSRange(location: segStart, length: i - segStart))
widest = max(widest, ceil(segment.size().width))
segStart = -1
}
} else if segStart < 0 {
segStart = i
}
}
return widest
}
var maxWidths = [CGFloat](repeating: minColumnContentWidth, count: columnCount)
var minWidths = [CGFloat](repeating: minColumnContentWidth, count: columnCount)
func considerCell(_ cell: NSAttributedString, col: Int) {
maxWidths[col] = max(maxWidths[col], ceil(cell.size().width))
minWidths[col] = max(minWidths[col], widestUnbreakableSegment(cell))
}
for (i, cell) in headerCells.enumerated() where i < columnCount {
considerCell(cell, col: i)
}
for row in bodyCells {
for (i, cell) in row.enumerated() where i < columnCount {
considerCell(cell, col: i)
}
}
// Distribute the available width:
// - everything fits on one line natural (maximum) widths;
// - too wide shrink to the available width, but never below a
// column's longest unbreakable word; the slack above the minimums is
// distributed proportionally to each column's (max min) stretch;
// - even the minimums don't fit (many-column tables) columns stay at
// their minimums, the table renders wider than the container, and
// the horizontal-scroll overlay takes over as before.
let chrome = CGFloat(columnCount) * 2 * cellHPadding
+ CGFloat(columnCount + 1) * borderWidth
let contentAvailable = availableWidth - chrome
let sumMax = maxWidths.reduce(0, +)
let sumMin = minWidths.reduce(0, +)
var columnWidths = maxWidths
if contentAvailable > 0, sumMax > contentAvailable {
if sumMin >= contentAvailable {
columnWidths = minWidths
} else {
let extra = contentAvailable - sumMin
let totalStretch = sumMax - sumMin
columnWidths = zip(minWidths, maxWidths).map { mn, mx in
mn + ((mx - mn) / totalStretch * extra).rounded(.down)
}
}
}
// Per-row heights: each row is as tall as its tallest (wrapped) cell.
func cellHeight(_ cell: NSAttributedString, col: Int) -> CGFloat {
let bounds = cell.boundingRect(
with: NSSize(width: columnWidths[col], height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin]
)
return ceil(bounds.height)
}
let rowCount = 1 + table.rows.count // header + body rows
var rowContentHeights = [CGFloat](repeating: baseLineHeight, count: rowCount)
for (i, cell) in headerCells.enumerated() where i < columnCount {
rowContentHeights[0] = max(rowContentHeights[0], cellHeight(cell, col: i))
}
for (rowIdx, row) in bodyCells.enumerated() {
for (i, cell) in row.enumerated() where i < columnCount {
rowContentHeights[rowIdx + 1] = max(rowContentHeights[rowIdx + 1], cellHeight(cell, col: i))
}
}
let totalWidth = columnWidths.reduce(0, +)
+ CGFloat(columnCount) * 2 * cellHPadding
+ CGFloat(columnCount + 1) * borderWidth
let totalHeight = rowContentHeights.reduce(0) { $0 + $1 + 2 * cellVPadding }
+ CGFloat(rowCount + 1) * borderWidth
let size = NSSize(width: totalWidth, height: totalHeight)
// Pre-compute layout offsets (top-down coords; drawing runs flipped).
var columnLeft = [CGFloat](repeating: 0, count: columnCount + 1)
columnLeft[0] = borderWidth
for i in 0..<columnCount {
columnLeft[i + 1] = columnLeft[i] + columnWidths[i] + 2 * cellHPadding + borderWidth
}
var rowTop = [CGFloat](repeating: 0, count: rowCount + 1)
rowTop[0] = borderWidth
for i in 0..<rowCount {
rowTop[i + 1] = rowTop[i] + rowContentHeights[i] + 2 * cellVPadding + borderWidth
}
let alignments = table.alignments
let headerFill = mutedColor(alpha: 0.08)
// Flipped image so AppKit handles the y-flip; a manual transform mirror would flip glyphs too.
return NSImage(size: size, flipped: true) { _ in
// Header row fill
headerFill.setFill()
NSBezierPath(rect: NSRect(
x: borderWidth,
y: borderWidth,
width: size.width - 2 * borderWidth,
height: rowContentHeights[0] + 2 * cellVPadding
)).fill()
// Outer border
borderColor.setStroke()
let outer = NSBezierPath(rect: NSRect(
x: borderWidth / 2,
y: borderWidth / 2,
width: size.width - borderWidth,
height: size.height - borderWidth
))
outer.lineWidth = borderWidth
outer.stroke()
// Internal separators
let separators = NSBezierPath()
separators.lineWidth = borderWidth
for i in 1..<columnCount {
let x = columnLeft[i] - borderWidth / 2
separators.move(to: NSPoint(x: x, y: 0))
separators.line(to: NSPoint(x: x, y: size.height))
}
for i in 1..<rowCount {
let y = rowTop[i] - borderWidth / 2
separators.move(to: NSPoint(x: 0, y: y))
separators.line(to: NSPoint(x: size.width, y: y))
}
separators.stroke()
func drawCell(_ s: NSAttributedString, col: Int, row: Int) {
guard col < columnCount else { return }
let cellLeft = columnLeft[col] + cellHPadding
let cellRight = columnLeft[col + 1] - borderWidth - cellHPadding
let cellContentWidth = cellRight - cellLeft
// Align via NSParagraphStyle; word-wrap fills the row height
// measured above (long words fall back to character breaks).
let paragraph = NSMutableParagraphStyle()
switch alignments[col] {
case .left: paragraph.alignment = .left
case .center: paragraph.alignment = .center
case .right: paragraph.alignment = .right
}
paragraph.lineBreakMode = .byWordWrapping
let aligned = NSMutableAttributedString(attributedString: s)
aligned.addAttribute(
.paragraphStyle,
value: paragraph,
range: NSRange(location: 0, length: aligned.length)
)
let drawRect = NSRect(
x: cellLeft,
y: rowTop[row] + cellVPadding,
width: cellContentWidth,
height: rowContentHeights[row]
)
aligned.draw(with: drawRect, options: [.usesLineFragmentOrigin], context: nil)
}
for (col, cell) in headerCells.enumerated() {
drawCell(cell, col: col, row: 0)
}
for (rowIdx, row) in bodyCells.enumerated() {
for (col, cell) in row.enumerated() {
drawCell(cell, col: col, row: rowIdx + 1)
}
}
return true
}
}
// MARK: - Scrollable table helpers
/// Container width with fallback chain for "styler runs before layout" case.
static func effectiveContainerWidth(for ctx: StylingContext) -> CGFloat {
if let container = ctx.layoutBridge?.firstTextContainer {
let raw = container.size.width
if raw.isFinite, raw > 0, raw < 100_000 { return raw }
if let textView = container.textView {
let inset = textView.textContainerInset
let usable = textView.bounds.width - inset.width * 2
if usable.isFinite, usable > 0 { return usable }
let frameUsable = textView.frame.width - inset.width * 2
if frameUsable.isFinite, frameUsable > 0 { return frameUsable }
}
}
return 500
}
/// Content-only hash; intentionally collides for identical tables disambiguated by occurrence index.
static func stableTableContentHash(for source: String) -> Int {
var hasher = Hasher()
hasher.combine("table-overlay-v1")
hasher.combine(source)
return hasher.finalize()
}
/// Per-instance ID = (content, nth-occurrence); stable across re-styles so scroll offsets persist.
static func stableTableSourceID(for source: String, occurrenceIndex: Int) -> Int {
var hasher = Hasher()
hasher.combine("table-overlay-v2")
hasher.combine(source)
hasher.combine(occurrenceIndex)
return hasher.finalize()
}
}
@@ -0,0 +1,48 @@
//
// MarkdownStyler+TaskCheckboxes.swift
// MarkdownEngine
//
// Caret-crossing helper for GitHub-style `- [ ] / - [x]` task syntax. The
// checkbox *styling* now lives in the AST styler (`MarkdownASTStyler`); this
// only reports whether the caret sits inside the task syntax so the
// coordinator can trigger a restyle when the caret enters/leaves.
//
import AppKit
import Foundation
extension MarkdownStyler {
/// Task-list line: indent, marker (`-`/`*`/`+`/``/`N.` matching the AST), spacer, `[ ]`/`[x]` box.
static let taskListRegex: NSRegularExpression = try! NSRegularExpression(
pattern: #"^([ \t]*)([-•*+]|\d+\.)([ \t]+)(\[[ xX]\])(?=[ \t])"#,
options: [.anchorsMatchLines]
)
// MARK: Task Syntax Membership
/// Full `<marker><spacer>[ ]` range if `location` is inside (or at the trailing edge of) it, else nil.
static func taskSyntaxRange(at location: Int, in text: String) -> NSRange? {
let nsText = text as NSString
let safeLoc = max(0, min(location, nsText.length))
let lineRange = nsText.lineRange(for: NSRange(location: safeLoc, length: 0))
let line = nsText.substring(with: lineRange)
let match = taskListRegex.firstMatch(
in: line,
options: [],
range: NSRange(location: 0, length: line.utf16.count)
)
guard let match else { return nil }
let markerLineRange = match.range(at: 2)
let checkboxLineRange = match.range(at: 4)
guard markerLineRange.location != NSNotFound,
checkboxLineRange.location != NSNotFound else { return nil }
let syntaxStart = lineRange.location + markerLineRange.location
let syntaxEnd = lineRange.location + checkboxLineRange.location + checkboxLineRange.length
let syntaxRange = NSRange(location: syntaxStart, length: syntaxEnd - syntaxStart)
if NSLocationInRange(location, syntaxRange) || location == syntaxEnd {
return syntaxRange
}
return nil
}
}
@@ -0,0 +1,498 @@
//
// MarkdownStyler.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Applies the Markdown look (bold, links, code, headings, etc.). Most styling
// is now produced by the AST-native styler (`MarkdownASTStyler`); this type
// builds the `StylingContext` and runs the NSImage rendering passes that still
// consume tokens:
// - MarkdownStyler+Latex.swift (block + inline LaTeX rendering)
// - MarkdownStyler+Images.swift (image embeds / image links)
// - MarkdownStyler+Tables.swift (rendered tables)
import AppKit
import Foundation
// MARK: - Styling Context
extension MarkdownStyler {
typealias IndexedToken = (index: Int, token: MarkdownToken)
/// Per-kind token arrays (each with the token's index into the full array,
/// for activeTokenIndices), built ONCE in the parse classification and
/// reused across keystrokes. Lets the NSImage passes iterate a small,
/// scope-sliced array instead of walking every document token per pass.
struct ClassifiedStyleTokens {
let inlineLatex: [IndexedToken]
let blockLatex: [IndexedToken]
let imageEmbed: [IndexedToken]
let imageLink: [IndexedToken]
let table: [IndexedToken]
let code: [MarkdownToken] // codeBlock + inlineCode, for isInsideCodeBlock checks
}
struct StylingContext {
let nsText: NSString
let tokens: [MarkdownToken]
let codeTokens: [MarkdownToken]
let activeTokenIndices: Set<Int>
let baseFont: NSFont
let layoutBridge: LayoutBridge?
let baseDefaultLineHeight: CGFloat
let codeBackgroundColor: NSColor
let latexMarkerFont: NSFont
let configuration: MarkdownEditorConfiguration
let wikiLinkIDProvider: (NSRange) -> String?
/// Union bounds of the restyle's paragraph scope; nil = whole document
/// (initial load). Attribute application clips per paragraph anyway,
/// so the NSImage passes can skip tokens wholly outside these bounds
/// instead of walking every token in the document per keystroke.
var scopeBounds: (lo: Int, hi: Int)? = nil
/// Pre-classified per-kind token arrays; nil for direct callers (tests),
/// which fall back to classifying `tokens` on demand.
var classified: ClassifiedStyleTokens? = nil
var services: MarkdownEditorServices { configuration.services }
// Per-kind indexed arrays: the cached classification, or a one-off
// classification of `tokens` when a direct caller passed none.
var inlineLatexIndexed: [IndexedToken] { classified?.inlineLatex ?? Self.indexed(tokens, .inlineLatex) }
var blockLatexIndexed: [IndexedToken] { classified?.blockLatex ?? Self.indexed(tokens, .blockLatex) }
var imageEmbedIndexed: [IndexedToken] { classified?.imageEmbed ?? Self.indexed(tokens, .imageEmbed) }
var imageLinkIndexed: [IndexedToken] { classified?.imageLink ?? Self.indexed(tokens, .imageLink) }
var tableIndexed: [IndexedToken] { classified?.table ?? Self.indexed(tokens, .table) }
static func indexed(_ tokens: [MarkdownToken], _ kind: MarkdownTokenKind) -> [IndexedToken] {
tokens.enumerated().compactMap { $0.element.kind == kind ? ($0.offset, $0.element) : nil }
}
/// The slice of a location-sorted, non-overlapping per-kind array that
/// intersects the restyle scope binary-searched so out-of-scope
/// tokens are never even visited. Whole array when scope is nil.
func scoped(_ arr: [IndexedToken]) -> ArraySlice<IndexedToken> {
guard let bounds = scopeBounds else { return arr[...] }
return MarkdownStyler.scopedSlice(arr, lo: bounds.lo, hi: bounds.hi)
}
/// True when `range` lies entirely outside the restyle scope its
/// attributes would be clipped away at application time.
func outsideScope(_ range: NSRange) -> Bool {
guard let scopeBounds else { return false }
return NSMaxRange(range) <= scopeBounds.lo || range.location >= scopeBounds.hi
}
/// True when iteration (over location-sorted tokens) is past the scope.
func pastScope(_ range: NSRange) -> Bool {
guard let scopeBounds else { return false }
return range.location >= scopeBounds.hi
}
}
/// Binary-searched slice of a location-sorted, non-overlapping per-kind
/// array whose tokens intersect `[lo, hi)` tokens outside are never
/// visited. Shared by the styler's scope culling and the coordinator's
/// edit-scoped candidate collection (which used to walk each array
/// linearly from the document head to the edit).
static func scopedSlice(_ arr: [IndexedToken], lo bound: Int, hi upper: Int) -> ArraySlice<IndexedToken> {
var lo = 0, hi = arr.count
while lo < hi { // first NSMaxRange > bound
let m = (lo + hi) / 2
if NSMaxRange(arr[m].token.range) > bound { hi = m } else { lo = m + 1 }
}
let start = lo
hi = arr.count
while lo < hi { // first location >= upper
let m = (lo + hi) / 2
if arr[m].token.range.location >= upper { hi = m } else { lo = m + 1 }
}
return arr[start..<lo]
}
}
typealias StyledRange = (range: NSRange, attributes: [NSAttributedString.Key: Any])
// MARK: - Public API
enum MarkdownStyler {
/// Collapses styler output into non-overlapping runs, ascending, so a caller can
/// write each character range to the text storage exactly once.
///
/// The styler emits ranges pass by pass, so they arrive unordered and heavily
/// overlapping. Applying them with one `addAttribute` per key per range mutates
/// the storage once per pair 89,916 times on a 346k-char note and every
/// mutation re-splits the attribute-run array, so the cost of a single call grows
/// with the runs already present: measured 0.54µs/call on a 437-char note against
/// 213µs/call on the big one. That quadratic was 19.1s of a 21s open. Writing
/// left to right instead only ever splits the trailing run.
///
/// Semantics are identical to the loop it replaces: later ranges win per key, and
/// `base` fills what no range covers (the caller applies it to the whole document
/// first, so each returned run must carry it too `setAttributes` replaces).
static func flattenedRuns(
_ ranges: [StyledRange],
base: [NSAttributedString.Key: Any],
documentLength: Int
) -> [StyledRange] {
// (position, isStart, emission index). Ends sort before starts at the same
// position so a range ending where the next begins doesn't briefly overlap it.
var events: [(pos: Int, isStart: Bool, idx: Int)] = []
events.reserveCapacity(ranges.count * 2)
for (i, styled) in ranges.enumerated() {
let r = styled.range
guard r.location != NSNotFound, r.location >= 0, r.length > 0,
NSMaxRange(r) <= documentLength else { continue }
events.append((r.location, true, i))
events.append((NSMaxRange(r), false, i))
}
guard !events.isEmpty else { return [] }
events.sort { a, b in
if a.pos != b.pos { return a.pos < b.pos }
if a.isStart != b.isStart { return !a.isStart }
return a.idx < b.idx
}
var runs: [StyledRange] = []
runs.reserveCapacity(min(events.count, 4096))
// Emission indices of the ranges covering the current position, kept ascending
// so merging them in order reproduces "later range wins".
var active: [Int] = []
var cursor = events[0].pos
var i = 0
while i < events.count {
let pos = events[i].pos
if pos > cursor, !active.isEmpty {
var attrs = base
for idx in active {
attrs.merge(ranges[idx].attributes) { _, newer in newer }
}
let run = NSRange(location: cursor, length: pos - cursor)
// Adjacent runs frequently carry identical attributes one styling
// pass emits a separate range per character so coalesce before the
// write instead of paying a storage mutation per character.
if let last = runs.last, NSMaxRange(last.range) == run.location,
(last.attributes as NSDictionary).isEqual(to: attrs) {
runs[runs.count - 1].range.length += run.length
} else {
runs.append((run, attrs))
}
}
while i < events.count, events[i].pos == pos {
let event = events[i]
if event.isStart {
let slot = active.firstIndex { $0 > event.idx } ?? active.count
active.insert(event.idx, at: slot)
} else if let slot = active.firstIndex(of: event.idx) {
active.remove(at: slot)
}
i += 1
}
cursor = pos
}
return runs
}
static func styleAttributes(
text: String,
fontName: String,
fontSize: CGFloat,
layoutBridge: LayoutBridge? = nil,
caretLocation: Int,
selection: NSRange? = nil,
activeTokenIndices: Set<Int>,
wikiLinkIDProvider: @escaping (NSRange) -> String? = { _ in nil },
precomputedTokens: [MarkdownToken]? = nil,
classified: ClassifiedStyleTokens? = nil,
precomputedBlocks: [Block]? = nil,
scopedRanges: [NSRange]? = nil,
configuration: MarkdownEditorConfiguration = .default
) -> [StyledRange] {
let tokens = precomputedTokens ?? MarkdownTokenizer.parseTokensViaAST(in: text, registry: configuration.extensionRegistry)
let nsText = text as NSString
let scopeBounds: (lo: Int, hi: Int)? = scopedRanges.flatMap { ranges in
let valid = ranges.filter { $0.location != NSNotFound && $0.length > 0 }
guard let lo = valid.map(\.location).min(),
let hi = valid.map({ NSMaxRange($0) }).max() else { return nil }
return (lo, hi)
}
let codeTokens = classified?.code ?? tokens.filter { $0.kind == .codeBlock || $0.kind == .inlineCode }
let baseFont = NSFont(name: fontName, size: fontSize) ?? NSFont.systemFont(ofSize: fontSize)
let baseDefaultLineHeight = ceil(
layoutBridge?.defaultLineHeight(for: baseFont)
?? (baseFont.ascender - baseFont.descender + baseFont.leading)
)
let codeBackgroundColor = configuration.services.syntaxHighlighter.backgroundColor()
let hiddenMarkerSize = configuration.markers.hiddenMarkerFontSize
let ctx = StylingContext(
nsText: nsText,
tokens: tokens,
codeTokens: codeTokens,
activeTokenIndices: activeTokenIndices,
baseFont: baseFont,
layoutBridge: layoutBridge,
baseDefaultLineHeight: baseDefaultLineHeight,
codeBackgroundColor: codeBackgroundColor,
latexMarkerFont: NSFont(name: fontName, size: hiddenMarkerSize)
?? NSFont.systemFont(ofSize: hiddenMarkerSize),
configuration: configuration,
wikiLinkIDProvider: wikiLinkIDProvider,
scopeBounds: scopeBounds,
classified: classified
)
var result: [StyledRange] = []
// AST-native styler handles everything but NSImage rendering (incl. the composition fixes).
let astT0 = DispatchTime.now().uptimeNanoseconds
result += MarkdownASTStyler.styleAttributes(
text: text, fontName: fontName, fontSize: fontSize,
caretLocation: caretLocation, selection: selection, wikiLinkIDProvider: wikiLinkIDProvider,
scopedRanges: scopedRanges, precomputedBlocks: precomputedBlocks,
configuration: configuration
)
let astMs = Double(DispatchTime.now().uptimeNanoseconds - astT0) / 1_000_000
// NSImage rendering reuses the existing, proven machinery.
let imgT0 = DispatchTime.now().uptimeNanoseconds
result += styleBlockLatex(ctx)
result += styleInlineLatex(ctx)
result += styleImageEmbeds(ctx)
result += styleImageLinks(ctx)
let imgMs = Double(DispatchTime.now().uptimeNanoseconds - imgT0) / 1_000_000
result += styleTables(ctx)
PerfTrace.note { " styleAttributes: ast=\(String(format: "%.2f", astMs))ms latex+img4=\(String(format: "%.2f", imgMs))ms styledRanges=\(result.count)" }
return result
}
}
// MARK: - Shared helpers used by multiple styling extensions
extension MarkdownStyler {
static func appendSecondaryMarkers(
for token: MarkdownToken,
to attrs: inout [StyledRange],
theme: MarkdownEditorTheme
) {
token.markerRanges.forEach {
attrs.append(($0, [.foregroundColor: theme.mutedText]))
}
}
enum RenderedStandaloneBlockMode {
case collapsedSource(markerTexts: [String])
case visibleSource(imageGap: CGFloat)
/// Wide-table mode: anchor reserves container width, line gains scroller strip, tagged by sourceID.
case collapsedSourceScrollable(
markerTexts: [String],
displayWidth: CGFloat,
sourceID: Int
)
}
static func appendRenderedStandaloneBlock(
for token: MarkdownToken,
rawContent: String,
image: NSImage,
imageBounds: CGRect,
paragraphSpacingBefore: CGFloat,
paragraphSpacing: CGFloat,
alignment: NSTextAlignment,
mode: RenderedStandaloneBlockMode,
restyleOnWidthChange: Bool = false,
ctx: StylingContext,
attrs: inout [StyledRange]
) -> Bool {
guard let paraRange = token.standaloneParagraphRange(in: ctx.nsText) else { return false }
let para = NSMutableParagraphStyle()
let baseLineHeight = layoutBridgeDefaultLineHeight(for: ctx.baseFont, using: ctx.layoutBridge)
para.paragraphSpacingBefore = max(para.paragraphSpacingBefore, paragraphSpacingBefore)
para.alignment = alignment
let widthChangeAnchorAttrs: [NSAttributedString.Key: Any] = restyleOnWidthChange
? [.scrollableBlockFullRange: NSValue(range: paraRange)]
: [:]
switch mode {
case .collapsedSource(let markerTexts):
emitCollapsedAttrs(
token: token,
rawContent: rawContent,
image: image,
imageBounds: imageBounds,
paragraphSpacing: paragraphSpacing,
para: para,
paraRange: paraRange,
advanceWidth: imageBounds.width,
neededLineHeight: imageBounds.height,
extraAnchorAttrs: widthChangeAnchorAttrs,
markerTexts: markerTexts,
ctx: ctx,
attrs: &attrs
)
case .collapsedSourceScrollable(let markerTexts, let displayWidth, let sourceID):
let scrollerStrip = MarkdownTextLayoutFragment.scrollableBlockScrollerStrip
let totalHeight = imageBounds.height + scrollerStrip
var anchorAttrs = widthChangeAnchorAttrs
anchorAttrs[.scrollableBlockNaturalWidth] = imageBounds.width
anchorAttrs[.scrollableBlockSourceID] = sourceID
anchorAttrs[.scrollableBlockTotalHeight] = totalHeight
emitCollapsedAttrs(
token: token,
rawContent: rawContent,
image: image,
imageBounds: imageBounds,
paragraphSpacing: paragraphSpacing,
para: para,
paraRange: paraRange,
advanceWidth: displayWidth,
neededLineHeight: totalHeight,
extraAnchorAttrs: anchorAttrs,
markerTexts: markerTexts,
ctx: ctx,
attrs: &attrs
)
case .visibleSource(let imageGap):
para.minimumLineHeight = max(para.minimumLineHeight, baseLineHeight)
para.maximumLineHeight = max(para.maximumLineHeight, baseLineHeight)
para.paragraphSpacing = max(para.paragraphSpacing, imageBounds.height + imageGap + paragraphSpacing)
attrs.append((paraRange, [.paragraphStyle: para]))
attrs.append((token.range, [
.latexImage: image,
.latexBounds: NSValue(rect: imageBounds),
.latexIsBlock: true,
.latexBlockOffsetY: baseLineHeight + imageGap
]))
appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme)
}
return true
}
/// Shared body for collapsed-source modes; hides raw source, plants image on anchor.
private static func emitCollapsedAttrs(
token: MarkdownToken,
rawContent: String,
image: NSImage,
imageBounds: CGRect,
paragraphSpacing: CGFloat,
para: NSMutableParagraphStyle,
paraRange: NSRange,
advanceWidth: CGFloat,
neededLineHeight: CGFloat,
extraAnchorAttrs: [NSAttributedString.Key: Any],
markerTexts: [String],
ctx: StylingContext,
attrs: inout [StyledRange]
) {
let baseLineHeight = layoutBridgeDefaultLineHeight(for: ctx.baseFont, using: ctx.layoutBridge)
let resolved = max(para.minimumLineHeight, neededLineHeight, baseLineHeight)
para.minimumLineHeight = resolved
para.maximumLineHeight = max(para.maximumLineHeight, resolved)
para.paragraphSpacing = max(para.paragraphSpacing, paragraphSpacing)
para.lineBreakMode = .byClipping
let collapsedPara = NSMutableParagraphStyle()
collapsedPara.maximumLineHeight = 1
collapsedPara.paragraphSpacing = 0
collapsedPara.paragraphSpacingBefore = 0
let leadingWhitespaceUnits = rawContent.utf16.prefix { codeUnit in
guard let scalar = UnicodeScalar(UInt32(codeUnit)) else { return false }
return CharacterSet.whitespacesAndNewlines.contains(scalar)
}.count
let contentEnd = NSMaxRange(token.contentRange)
let anchorLocation = min(token.contentRange.location + leadingWhitespaceUnits, contentEnd - 1)
var paragraphAttributes: [StyledRange] = []
ctx.nsText.enumerateSubstrings(in: paraRange, options: .byParagraphs) { _, _, enclosingRange, _ in
if NSLocationInRange(anchorLocation, enclosingRange) {
paragraphAttributes.append((enclosingRange, [.paragraphStyle: para]))
} else {
paragraphAttributes.append((enclosingRange, [.paragraphStyle: collapsedPara]))
}
}
attrs.append(contentsOf: paragraphAttributes)
if leadingWhitespaceUnits > 0 {
let leadingRange = NSRange(location: token.contentRange.location, length: leadingWhitespaceUnits)
let leadingText = ctx.nsText.substring(with: leadingRange)
attrs.append((leadingRange, [
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: -HeadingHelpers.textWidth(leadingText, font: ctx.latexMarkerFont)
]))
}
let anchorRange = NSRange(location: anchorLocation, length: 1)
let anchorChar = ctx.nsText.substring(with: anchorRange)
var anchorAttrs: [NSAttributedString.Key: Any] = [
.latexImage: image,
.latexBounds: NSValue(rect: imageBounds),
.latexIsBlock: true,
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: advanceWidth - HeadingHelpers.textWidth(anchorChar, font: ctx.latexMarkerFont)
]
for (key, value) in extraAnchorAttrs { anchorAttrs[key] = value }
attrs.append((anchorRange, anchorAttrs))
let trailingStart = anchorLocation + 1
let trailingLength = contentEnd - trailingStart
if trailingLength > 0 {
let trailingRange = NSRange(location: trailingStart, length: trailingLength)
let trailingText = ctx.nsText.substring(with: trailingRange)
attrs.append((trailingRange, [
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: -HeadingHelpers.textWidth(trailingText, font: ctx.latexMarkerFont)
]))
}
for (index, markerRange) in token.markerRanges.enumerated() {
let markerText = markerTexts.indices.contains(index)
? markerTexts[index]
: ctx.nsText.substring(with: markerRange)
attrs.append((markerRange, [
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: -HeadingHelpers.textWidth(markerText, font: ctx.latexMarkerFont)
]))
}
let preTokenLength = token.range.location - paraRange.location
if preTokenLength > 0 {
let preTokenRange = NSRange(location: paraRange.location, length: preTokenLength)
let preTokenText = ctx.nsText.substring(with: preTokenRange)
attrs.append((preTokenRange, [
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: -HeadingHelpers.textWidth(preTokenText, font: ctx.latexMarkerFont)
]))
}
}
}
// MARK: - Whole-document & inline-only styling kept inline (small helpers)
extension MarkdownStyler {
/// Line range if `location` is on a thematic-break line (3+ `-`/`*`/`_`), else nil; drives HR restyle.
static func hrLineRange(at location: Int, in text: String) -> NSRange? {
let nsText = text as NSString
let safeLoc = max(0, min(location, nsText.length))
let lineRange = nsText.lineRange(for: NSRange(location: safeLoc, length: 0))
let line = nsText.substring(with: lineRange)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard line.range(
of: #"^[ \t]*(-{3,}|\*{3,}|_{3,})[ \t]*$"#,
options: .regularExpression
) != nil else {
return nil
}
return lineRange
}
}
@@ -0,0 +1,277 @@
//
// TextStylingService.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Applies base text styling and refreshes only changed sections so editing
// stays smooth while Markdown formatting updates.
import AppKit
import Foundation
struct TextStylingService {
static func makeBaseTypingAttributes(
font: NSFont,
paragraphStyle: NSParagraphStyle,
theme: MarkdownEditorTheme = .default
) -> [NSAttributedString.Key: Any] {
[
.font: font,
.foregroundColor: theme.bodyText,
.paragraphStyle: paragraphStyle
]
}
static func makeBaseFontAndStyle(
fontName: String,
fontSize: CGFloat,
layoutBridge: LayoutBridge? = nil,
configuration: MarkdownEditorConfiguration = .default
) -> (font: NSFont, style: NSMutableParagraphStyle) {
let baseFont = NSFont(name: fontName, size: fontSize) ?? NSFont.systemFont(ofSize: fontSize)
let defaultLineHeight = layoutBridgeDefaultLineHeight(for: baseFont, using: layoutBridge)
let paragraph = NSMutableParagraphStyle()
paragraph.minimumLineHeight = ceil(defaultLineHeight) + configuration.paragraph.lineHeightExtraSpacing
paragraph.lineSpacing = 0
let baseParagraphSpacing = ceil(defaultLineHeight * configuration.paragraph.spacingFactor)
paragraph.paragraphSpacing = baseParagraphSpacing
paragraph.paragraphSpacingBefore = 0
paragraph.lineBreakMode = .byWordWrapping
// 24 explicit tab stops at indentPerLevel intervals, then natural wrap.
let perLevel = configuration.lists.indentPerLevel
paragraph.tabStops = (1...24).map { NSTextTab(textAlignment: .left, location: CGFloat($0) * perLevel) }
paragraph.defaultTabInterval = 0
return (baseFont, paragraph)
}
static func restyle(
textView: NSTextView,
layoutBridge: LayoutBridge?,
paragraphCandidates: [NSRange],
baseFont: NSFont,
paragraphStyle: NSMutableParagraphStyle,
caretLocation: Int,
selection: NSRange? = nil,
activeTokenIndices: Set<Int>,
wikiLinkIDProvider: @escaping (NSRange) -> String?,
precomputedTokens: [MarkdownToken]? = nil,
classified: MarkdownStyler.ClassifiedStyleTokens? = nil,
precomputedBlocks: [Block]? = nil,
configuration: MarkdownEditorConfiguration = .default
) {
let paragraphs = normalize(paragraphCandidates)
textView.typingAttributes = makeBaseTypingAttributes(
font: baseFont,
paragraphStyle: paragraphStyle,
theme: configuration.theme
)
guard !paragraphs.isEmpty else {
textView.setNeedsDisplay(textView.visibleRect)
return
}
let styleT0 = DispatchTime.now().uptimeNanoseconds
let styledRanges = MarkdownStyler.styleAttributes(
text: textView.string,
fontName: baseFont.fontName,
fontSize: baseFont.pointSize,
layoutBridge: layoutBridge,
caretLocation: caretLocation,
selection: selection,
activeTokenIndices: activeTokenIndices,
wikiLinkIDProvider: wikiLinkIDProvider,
precomputedTokens: precomputedTokens,
classified: classified,
precomputedBlocks: precomputedBlocks,
scopedRanges: paragraphs,
configuration: configuration
)
let styleMs = Double(DispatchTime.now().uptimeNanoseconds - styleT0) / 1_000_000
let spellT0 = DispatchTime.now().uptimeNanoseconds
let spellingDisabledRanges = styledRanges.compactMap { (range, attrs) -> NSRange? in
attrs[.spellingState] as? Int == 0 ? range : nil
}
// Remove existing spelling markers before reapplying disabled ranges.
for disabledRange in spellingDisabledRanges {
layoutBridge?.removeTemporaryAttribute(.spellingState, forCharacterRange: disabledRange)
}
textView.textStorage?.beginEditing()
for disabledRange in spellingDisabledRanges {
textView.textStorage?.addAttribute(.spellingState, value: 0, range: disabledRange)
}
let spellMs = Double(DispatchTime.now().uptimeNanoseconds - spellT0) / 1_000_000
let attrT0 = DispatchTime.now().uptimeNanoseconds
applyStyledRanges(
styledRanges,
paragraphs: paragraphs,
baseAttributes: [
.font: baseFont,
.foregroundColor: configuration.theme.bodyText,
.paragraphStyle: paragraphStyle
],
to: textView.textStorage
)
textView.textStorage?.endEditing()
let attrMs = Double(DispatchTime.now().uptimeNanoseconds - attrT0) / 1_000_000
// No ensureLayout here:
let evlT0 = DispatchTime.now().uptimeNanoseconds
textView.setNeedsDisplay(textView.visibleRect)
(textView as? NativeTextView)?.ensureVisibleLayout()
let evlMs = Double(DispatchTime.now().uptimeNanoseconds - evlT0) / 1_000_000
PerfTrace.note { " restyle split: styleAttrs=\(String(format: "%.2f", styleMs))ms spell=\(String(format: "%.2f", spellMs))ms attrApply(paras=\(paragraphs.count))=\(String(format: "%.2f", attrMs))ms ensureVisLayout=\(String(format: "%.2f", evlMs))ms" }
}
/// Lays the base attributes down per paragraph and paints the styled ranges over
/// them, clipped to the paragraph. Order is load-bearing twice: paragraphs run in
/// the given order (paragraphs may nest, and the later `setAttributes` wipes what an
/// earlier one painted), and inside a paragraph the ranges run in their original
/// order (repeated `addAttribute` is what makes a later range win per key).
static func applyStyledRanges(
_ styledRanges: [StyledRange],
paragraphs: [NSRange],
baseAttributes: [NSAttributedString.Key: Any],
to storage: NSMutableAttributedString?,
minimumParagraphsForIndex: Int = 32
) {
// Rescanning every range per paragraph is paragraphs × ranges fine for the 1-3
// paragraphs a keystroke restyles, 10.8s of main thread for a selection-wide
// restyle (7,714 paragraphs × 33k ranges on the 346k note). Not the open path:
// that one passes a single full-document paragraph. The index sorts once instead
// 76ms there but loses under ~32 paragraphs, hence the floor.
let index = paragraphs.count >= minimumParagraphsForIndex
? overlapIndex(styledRanges: styledRanges, paragraphs: paragraphs)
: nil
let everyRange: [Int] = index == nil ? Array(styledRanges.indices) : []
for (paragraphIndex, paragraph) in paragraphs.enumerated() {
storage?.setAttributes(baseAttributes, range: paragraph)
storage?.removeAttribute(.link, range: paragraph)
for rangeIndex in index?.members(for: paragraphIndex) ?? everyRange[...] {
let (range, attrs) = styledRanges[rangeIndex]
let clippedRange = NSIntersectionRange(range, paragraph)
guard clippedRange.length > 0 else { continue }
for (key, value) in attrs {
storage?.addAttribute(key, value: value, range: clippedRange)
}
}
}
}
/// Which styled ranges reach which paragraph, in original range order per paragraph.
struct OverlapIndex {
let offsets: [Int]
let indices: [Int]
func members(for paragraph: Int) -> ArraySlice<Int> {
indices[offsets[paragraph]..<offsets[paragraph + 1]]
}
}
/// One sweep over both lists in ascending start order, so a paragraph only visits
/// ranges that can still reach it.
static func overlapIndex(styledRanges: [StyledRange], paragraphs: [NSRange]) -> OverlapIndex {
var offsets = [Int](repeating: 0, count: paragraphs.count + 1)
guard !paragraphs.isEmpty, !styledRanges.isEmpty else {
return OverlapIndex(offsets: offsets, indices: [])
}
// Bounds unpacked once so the sort and the sweep walk two flat Int arrays instead
// of striding over the attribute dictionaries. Degenerate ranges need no special
// case: the end saturates instead of overflowing, and an NSNotFound location
// starts past every paragraph, so both fall out of the tests below exactly as
// `NSIntersectionRange` drops them.
var starts = [Int](repeating: 0, count: styledRanges.count)
var ends = [Int](repeating: 0, count: styledRanges.count)
for index in styledRanges.indices {
let range = styledRanges[index].range
starts[index] = range.location
let (end, overflowed) = range.location.addingReportingOverflow(range.length)
ends[index] = overflowed ? Int.max : end
}
let rangesByStart = Array(styledRanges.indices).sorted {
starts[$0] == starts[$1] ? $0 < $1 : starts[$0] < starts[$1]
}
let paragraphsByStart = Array(paragraphs.indices).sorted {
paragraphs[$0].location == paragraphs[$1].location
? $0 < $1
: paragraphs[$0].location < paragraphs[$1].location
}
var pairs: [(paragraph: Int, range: Int)] = []
pairs.reserveCapacity(styledRanges.count)
var active: [Int] = []
var cursor = 0
for paragraphIndex in paragraphsByStart {
let paragraph = paragraphs[paragraphIndex]
let paragraphStart = paragraph.location
let (end, overflowed) = paragraphStart.addingReportingOverflow(paragraph.length)
let paragraphEnd = overflowed ? Int.max : end
while cursor < rangesByStart.count, starts[rangesByStart[cursor]] < paragraphEnd {
active.append(rangesByStart[cursor])
cursor += 1
}
// Paragraphs are visited by ascending start, so a range ending at or before
// this start can never reach a later paragraph either dropping it for good
// is what keeps the sweep linear. A range starting past this paragraph's end
// is only skipped: a later, longer paragraph can still contain it.
var kept = 0
for slot in 0..<active.count {
let rangeIndex = active[slot]
guard ends[rangeIndex] > paragraphStart else { continue }
active[kept] = rangeIndex
kept += 1
if starts[rangeIndex] < paragraphEnd {
pairs.append((paragraphIndex, rangeIndex))
}
}
active.removeLast(active.count - kept)
}
for pair in pairs { offsets[pair.paragraph + 1] += 1 }
for index in 1...paragraphs.count { offsets[index] += offsets[index - 1] }
var indices = [Int](repeating: 0, count: pairs.count)
var fill = offsets
for pair in pairs {
indices[fill[pair.paragraph]] = pair.range
fill[pair.paragraph] += 1
}
// The sweep collects by start; the apply loop needs the original emission order.
for paragraphIndex in paragraphs.indices
where offsets[paragraphIndex + 1] - offsets[paragraphIndex] > 1 {
indices[offsets[paragraphIndex]..<offsets[paragraphIndex + 1]].sort()
}
return OverlapIndex(offsets: offsets, indices: indices)
}
private static func normalize(_ candidates: [NSRange]) -> [NSRange] {
// Exact-duplicate drop in one pass (was O(n²) via contains); order and
// overlapping-but-unequal ranges are preserved exactly as before.
var seen = Set<Int>()
seen.reserveCapacity(candidates.count)
var result: [NSRange] = []
for candidate in candidates where candidate.location != NSNotFound && candidate.length > 0 {
let key = candidate.location &* 1_000_003 &+ candidate.length
if seen.insert(key).inserted { result.append(candidate) }
}
return result
}
/// Convert an NSRange into an NSTextRange for use with NSTextLayoutManager.
static func textRange(from range: NSRange, in contentStorage: NSTextContentStorage) -> NSTextRange? {
let docStart = contentStorage.documentRange.location
guard let start = contentStorage.location(docStart, offsetBy: range.location),
let end = contentStorage.location(start, offsetBy: range.length) else {
return nil
}
return NSTextRange(location: start, end: end)
}
}
@@ -0,0 +1,71 @@
//
// BottomOverscrollPolicy.swift
// MarkdownEngine
//
// Created by Luca Chen on 16.03.26.
//
// Computes the comfortable bottom-of-document slack so the caret never
// hugs the bottom edge of the visible viewport while typing.
//
import AppKit
import Foundation
struct BottomOverscrollPolicy {
let overscrollPercent: CGFloat
let minOverscrollPoints: CGFloat
let maxOverscrollPoints: CGFloat
let activationStartFraction: CGFloat
let activationRangeFraction: CGFloat
init(configuration: OverscrollPolicy) {
self.overscrollPercent = configuration.percent
self.minOverscrollPoints = configuration.minPoints
self.maxOverscrollPoints = configuration.maxPoints
self.activationStartFraction = configuration.activationStartFraction
self.activationRangeFraction = configuration.activationRangeFraction
}
init(
overscrollPercent: CGFloat,
minOverscrollPoints: CGFloat,
maxOverscrollPoints: CGFloat,
activationStartFraction: CGFloat = OverscrollPolicy.default.activationStartFraction,
activationRangeFraction: CGFloat = OverscrollPolicy.default.activationRangeFraction
) {
self.overscrollPercent = overscrollPercent
self.minOverscrollPoints = minOverscrollPoints
self.maxOverscrollPoints = maxOverscrollPoints
self.activationStartFraction = activationStartFraction
self.activationRangeFraction = activationRangeFraction
}
func activeOverscroll(
baseContentHeight: CGFloat,
headerHeight: CGFloat = 0,
visibleHeight: CGFloat,
lineHeight: CGFloat
) -> CGFloat {
// The header band scrolls with the content, so it counts toward activation
// and unlock; the comfortable slack stays viewport-based (the band is gone
// once the user reaches the document bottom).
let stackedContentHeight = baseContentHeight + headerHeight
let activationStartHeight = visibleHeight * activationStartFraction
let activationRange = max(visibleHeight * activationRangeFraction, 1)
let activationProgress = min(
max((stackedContentHeight - activationStartHeight) / activationRange, 0),
1
)
guard activationProgress > 0 else { return 0 }
var desiredSlack = visibleHeight * overscrollPercent
desiredSlack = min(desiredSlack, maxOverscrollPoints)
desiredSlack = max(desiredSlack, minOverscrollPoints)
desiredSlack = max(0, floor(desiredSlack - lineHeight))
// Start unlocking downward scroll before the stacked content fully fills
// the viewport, then blend into the final comfortable bottom slack.
let scrollUnlockDistance = max(visibleHeight - stackedContentHeight, 0)
return floor((scrollUnlockDistance + desiredSlack) * activationProgress)
}
}

Some files were not shown because too many files have changed in this diff Show More