Compare commits
5
Commits
69837b3471
...
2d0972de1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0972de1e
|
||
|
|
b31d49bb7f
|
||
|
|
277e5fb4ae
|
||
|
|
1a58d91bb3
|
||
|
|
ccbd84c05b
|
@@ -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] {
|
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||||
try await cachedFetch(key: "collections:\(offset):\(limit)") {
|
try await cachedFetch(key: "collections:\(offset):\(limit)") {
|
||||||
try await self.live.listCollections(offset: offset, limit: limit)
|
try await self.live.listCollections(offset: offset, limit: limit)
|
||||||
@@ -303,6 +309,34 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.listViews(request)
|
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 {
|
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||||
try await live.addDocumentUser(request)
|
try await live.addDocumentUser(request)
|
||||||
}
|
}
|
||||||
@@ -343,6 +377,10 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.uploadAttachmentFile(result, fileData: fileData)
|
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 {
|
public func deleteAttachment(id: String) async throws {
|
||||||
try await live.deleteAttachment(id: id)
|
try await live.deleteAttachment(id: id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
|
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
|
||||||
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
|
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
|
||||||
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument]
|
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`.
|
/// Full-text search with snippets/ranking. Backed by `documents.search`.
|
||||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
||||||
/// Title-only search — faster, no snippets. Backed by `documents.search_titles`.
|
/// 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`.
|
/// Historical view records, not live presence. Backed by `views.list`.
|
||||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
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
|
/// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed
|
||||||
/// from Outline's official docs, the rest are best-effort.
|
/// from Outline's official docs, the rest are best-effort.
|
||||||
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
|
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
|
||||||
@@ -75,6 +92,14 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
||||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
||||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
|
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
|
/// Best-effort — matches the shape every other simple `id`-only delete
|
||||||
/// in this API uses (`pins.delete`, `stars.delete`, …), not confirmed
|
/// in this API uses (`pins.delete`, `stars.delete`, …), not confirmed
|
||||||
/// against a live server specifically for attachments yet.
|
/// 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))
|
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] {
|
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||||
try await post("documents.search", body: request)
|
try await post("documents.search", body: request)
|
||||||
}
|
}
|
||||||
@@ -175,6 +179,34 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await post("views.list", body: request)
|
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 {
|
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||||
try await post("documents.add_user", body: request)
|
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 {
|
public func deleteAttachment(id: String) async throws {
|
||||||
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
|
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
|
||||||
}
|
}
|
||||||
@@ -485,6 +556,11 @@ private struct StarIDParams: Encodable {
|
|||||||
let id: String
|
let id: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct CommentReactionParams: Encodable {
|
||||||
|
let id: String
|
||||||
|
let emoji: String
|
||||||
|
}
|
||||||
|
|
||||||
private struct MoveDocumentResponse: Decodable {
|
private struct MoveDocumentResponse: Decodable {
|
||||||
let documents: [OutlineDocument]?
|
let documents: [OutlineDocument]?
|
||||||
let collections: [OutlineCollection]?
|
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 struct CreateDocumentRequest: Codable, Sendable {
|
||||||
public let title: String
|
public let title: String
|
||||||
public let text: 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 parentDocumentId: String?
|
||||||
public let publish: Bool
|
public let publish: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
title: String,
|
title: String,
|
||||||
text: String,
|
text: String,
|
||||||
collectionId: String,
|
collectionId: String? = nil,
|
||||||
parentDocumentId: String? = nil,
|
parentDocumentId: String? = nil,
|
||||||
publish: Bool = true
|
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 append: Bool?
|
||||||
public let fullWidth: Bool?
|
public let fullWidth: Bool?
|
||||||
public let insightsEnabled: 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(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -14,7 +21,9 @@ public struct UpdateDocumentRequest: Codable, Sendable {
|
|||||||
text: String? = nil,
|
text: String? = nil,
|
||||||
append: Bool? = nil,
|
append: Bool? = nil,
|
||||||
fullWidth: Bool? = nil,
|
fullWidth: Bool? = nil,
|
||||||
insightsEnabled: Bool? = nil
|
insightsEnabled: Bool? = nil,
|
||||||
|
collectionId: String? = nil,
|
||||||
|
publish: Bool? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -22,5 +31,7 @@ public struct UpdateDocumentRequest: Codable, Sendable {
|
|||||||
self.append = append
|
self.append = append
|
||||||
self.fullWidth = fullWidth
|
self.fullWidth = fullWidth
|
||||||
self.insightsEnabled = insightsEnabled
|
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 documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||||
func listViewedDocuments(offset: Int, limit: Int) 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 searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
|
||||||
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||||
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
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 listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
|
||||||
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
||||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { 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 addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
|
||||||
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
|
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
|
||||||
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { 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 currentUser() async throws -> OutlineUser { throw NotStubbed() }
|
||||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
|
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
|
||||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { 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 deleteAttachment(id: String) async throws { throw NotStubbed() }
|
||||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
|
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
|||||||
@@ -846,6 +846,137 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list")
|
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 {
|
func testCreatePinDecodesPin() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ struct SettingsView: View {
|
|||||||
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
||||||
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
||||||
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
|
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
|
||||||
|
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
|
||||||
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
|
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
|
||||||
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
|
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
@@ -98,6 +99,7 @@ struct SettingsView: View {
|
|||||||
switch section {
|
switch section {
|
||||||
case .appearance: appearanceDetail
|
case .appearance: appearanceDetail
|
||||||
case .editor: editorDetail
|
case .editor: editorDetail
|
||||||
|
case .navigation: navigationDetail
|
||||||
case .profile: profileDetail
|
case .profile: profileDetail
|
||||||
case .preferences: preferencesDetail
|
case .preferences: preferencesDetail
|
||||||
case .notifications: notificationsDetail
|
case .notifications: notificationsDetail
|
||||||
@@ -188,6 +190,27 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Divider().frame(maxWidth: 480)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Toggle("Command Palette", isOn: $isCommandPaletteEnabled)
|
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.")
|
Text("Press ⌘K to quickly jump to a document or collection. Always searches locally on your device — never a network request while typing.")
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ import OutlineKit
|
|||||||
/// `isSelectable` and link-opening both still need to work.
|
/// `isSelectable` and link-opening both still need to work.
|
||||||
struct CollectionOverviewContent: View {
|
struct CollectionOverviewContent: View {
|
||||||
@State private var markdown: String
|
@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 ?? "")
|
_markdown = State(initialValue: collection.description ?? "")
|
||||||
|
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -20,7 +24,7 @@ struct CollectionOverviewContent: View {
|
|||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $markdown,
|
text: $markdown,
|
||||||
configuration: .init(
|
configuration: .init(
|
||||||
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
heightBehavior: .fitsContent
|
heightBehavior: .fitsContent
|
||||||
),
|
),
|
||||||
isEditable: false
|
isEditable: false
|
||||||
@@ -28,6 +32,12 @@ struct CollectionOverviewContent: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.animation(nil, value: imageReloadTick)
|
||||||
|
.task {
|
||||||
|
imageProvider.onImageLoaded = {
|
||||||
|
Task { @MainActor in imageReloadTick += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
struct CollectionOverviewView: View {
|
struct CollectionOverviewView: View {
|
||||||
|
let apiClient: OutlineAPIClient
|
||||||
let collection: OutlineCollection
|
let collection: OutlineCollection
|
||||||
@State private var viewModel: DocumentsViewModel
|
@State private var viewModel: DocumentsViewModel
|
||||||
@State private var selectedTab: CollectionTab = .overview
|
@State private var selectedTab: CollectionTab = .overview
|
||||||
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
|
|||||||
searchQuery: Binding<String>,
|
searchQuery: Binding<String>,
|
||||||
onOpenDocument: @escaping (OutlineDocument) -> Void
|
onOpenDocument: @escaping (OutlineDocument) -> Void
|
||||||
) {
|
) {
|
||||||
|
self.apiClient = apiClient
|
||||||
self.collection = collection
|
self.collection = collection
|
||||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||||
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
||||||
@@ -59,7 +61,7 @@ struct CollectionOverviewView: View {
|
|||||||
if !trimmedSearchQuery.isEmpty {
|
if !trimmedSearchQuery.isEmpty {
|
||||||
searchResultsList
|
searchResultsList
|
||||||
} else if selectedTab == .overview {
|
} else if selectedTab == .overview {
|
||||||
CollectionOverviewContent(collection: collection)
|
CollectionOverviewContent(apiClient: apiClient, collection: collection)
|
||||||
} else {
|
} else {
|
||||||
documentList
|
documentList
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,6 +445,13 @@ struct ContentView_macOS: View {
|
|||||||
if !documentPath.isEmpty {
|
if !documentPath.isEmpty {
|
||||||
documentPath.removeLast()
|
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 }
|
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
|
||||||
@@ -17,11 +17,17 @@ struct DocumentPresentSheet: View {
|
|||||||
@State private var text: String
|
@State private var text: String
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@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) {
|
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
self.document = document
|
self.document = document
|
||||||
_text = State(initialValue: document.text)
|
_text = State(initialValue: document.text)
|
||||||
|
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -46,7 +52,7 @@ struct DocumentPresentSheet: View {
|
|||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $text,
|
text: $text,
|
||||||
configuration: .init(
|
configuration: .init(
|
||||||
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
heightBehavior: .fitsContent
|
heightBehavior: .fitsContent
|
||||||
),
|
),
|
||||||
isEditable: false
|
isEditable: false
|
||||||
@@ -71,6 +77,12 @@ struct DocumentPresentSheet: View {
|
|||||||
}
|
}
|
||||||
.frame(minWidth: 800, minHeight: 600)
|
.frame(minWidth: 800, minHeight: 600)
|
||||||
.background(.background)
|
.background(.background)
|
||||||
|
.animation(nil, value: imageReloadTick)
|
||||||
|
.task {
|
||||||
|
imageProvider.onImageLoaded = {
|
||||||
|
Task { @MainActor in imageReloadTick += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import UniformTypeIdentifiers
|
|||||||
import MarkdownEngine
|
import MarkdownEngine
|
||||||
import MarkdownEngineCodeBlocks
|
import MarkdownEngineCodeBlocks
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
#if canImport(ImagePlayground)
|
||||||
|
import ImagePlayground
|
||||||
|
#endif
|
||||||
|
|
||||||
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
||||||
/// below must run on the main thread — see the identical note on
|
/// below must run on the main thread — see the identical note on
|
||||||
@@ -19,6 +22,20 @@ struct DocumentReaderView: View {
|
|||||||
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
||||||
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
||||||
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
|
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
|
||||||
|
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = 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
|
/// Outline's own "Show line numbers" preference (synced, read via
|
||||||
/// `session.userPreferences`, not `@AppStorage` — this one's the
|
/// `session.userPreferences`, not `@AppStorage` — this one's the
|
||||||
@@ -66,16 +83,62 @@ struct DocumentReaderView: View {
|
|||||||
let onDocumentCreated: () -> Void
|
let onDocumentCreated: () -> Void
|
||||||
|
|
||||||
@State private var isShowingUnpublishConfirmation = false
|
@State private var isShowingUnpublishConfirmation = false
|
||||||
|
@State private var isShowingPublishSheet = false
|
||||||
@State private var isShowingArchiveConfirmation = false
|
@State private var isShowingArchiveConfirmation = false
|
||||||
@State private var isShowingDeleteConfirmation = false
|
@State private var isShowingDeleteConfirmation = false
|
||||||
@State private var isShowingMoveSheet = false
|
@State private var isShowingMoveSheet = false
|
||||||
@State private var isShowingHistorySheet = false
|
@State private var isShowingHistorySheet = false
|
||||||
@State private var isShowingInsightsSheet = 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 isShowingPresentSheet = false
|
||||||
@State private var isShowingSearchSheet = false
|
@State private var isShowingSearchSheet = false
|
||||||
@State private var isShowingShareSheet = false
|
@State private var isShowingShareSheet = false
|
||||||
@State private var isShowingNewDocumentSheet = false
|
@State private var isShowingNewDocumentSheet = false
|
||||||
|
@State private var isShowingImagePlayground = false
|
||||||
@State private var actionErrorMessage: String?
|
@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?
|
||||||
|
/// 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 `` 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` —
|
/// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange` —
|
||||||
/// one array per instance (main pane, split-view preview pane), since
|
/// one array per instance (main pane, split-view preview pane), since
|
||||||
/// each lays the same text out at a different width and gets different
|
/// each lays the same text out at a different width and gets different
|
||||||
@@ -100,6 +163,7 @@ struct DocumentReaderView: View {
|
|||||||
// behavior) and gets set for real in `.task` below once `session`
|
// behavior) and gets set for real in `.task` below once `session`
|
||||||
// is actually available.
|
// is actually available.
|
||||||
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
||||||
|
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||||
self.onOpenChild = onOpenChild
|
self.onOpenChild = onOpenChild
|
||||||
self.onDeleted = onDeleted
|
self.onDeleted = onDeleted
|
||||||
self.onDocumentCreated = onDocumentCreated
|
self.onDocumentCreated = onDocumentCreated
|
||||||
@@ -157,6 +221,44 @@ struct DocumentReaderView: View {
|
|||||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isImagePlaygroundEnabled && isImagePlaygroundSupported {
|
||||||
|
Button {
|
||||||
|
imagePlaygroundSeedText = currentSelectedText
|
||||||
|
isShowingImagePlayground = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
}
|
||||||
|
.help("Create Image with Image Playground")
|
||||||
|
.disabled(!viewModel.isEffectivelyEditable)
|
||||||
|
}
|
||||||
|
|
||||||
if viewModel.separateEditingEnabled {
|
if viewModel.separateEditingEnabled {
|
||||||
Button {
|
Button {
|
||||||
Task { await viewModel.toggleEditing() }
|
Task { await viewModel.toggleEditing() }
|
||||||
@@ -197,6 +299,12 @@ struct DocumentReaderView: View {
|
|||||||
.id(menuIdentity)
|
.id(menuIdentity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
imageProvider.onImageLoaded = {
|
||||||
|
Task { @MainActor in imageReloadTick += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(nil, value: imageReloadTick)
|
||||||
.task { await viewModel.loadFullContent() }
|
.task { await viewModel.loadFullContent() }
|
||||||
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
|
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
|
||||||
// for why this can't just be read at `init` time.
|
// for why this can't just be read at `init` time.
|
||||||
@@ -215,6 +323,11 @@ struct DocumentReaderView: View {
|
|||||||
.task {
|
.task {
|
||||||
await viewModel.loadInsightsEnabledState()
|
await viewModel.loadInsightsEnabledState()
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
loadedComments = (try? await apiClient.listComments(
|
||||||
|
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||||
|
)) ?? []
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
await viewModel.loadViewers()
|
await viewModel.loadViewers()
|
||||||
@@ -262,6 +375,36 @@ struct DocumentReaderView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(actionErrorMessage ?? "")
|
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) {
|
.sheet(isPresented: $isShowingMoveSheet) {
|
||||||
MoveDocumentSheet(apiClient: apiClient, document: document) {
|
MoveDocumentSheet(apiClient: apiClient, document: document) {
|
||||||
onDeleted()
|
onDeleted()
|
||||||
@@ -285,6 +428,59 @@ struct DocumentReaderView: View {
|
|||||||
onOpenChild(child)
|
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)\n\n"
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add the generated image.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every toggle-backed piece of state shown as a checkmark inside
|
/// Every toggle-backed piece of state shown as a checkmark inside
|
||||||
@@ -350,8 +546,9 @@ struct DocumentReaderView: View {
|
|||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $viewModel.text,
|
text: $viewModel.text,
|
||||||
|
pendingTextInsertion: $pendingTextInsertion,
|
||||||
configuration: .init(
|
configuration: .init(
|
||||||
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
codeBlock: editorCodeBlockStyle,
|
codeBlock: editorCodeBlockStyle,
|
||||||
textSubstitution: editorTextSubstitution,
|
textSubstitution: editorTextSubstitution,
|
||||||
textCompletion: editorTextCompletion,
|
textCompletion: editorTextCompletion,
|
||||||
@@ -360,13 +557,24 @@ struct DocumentReaderView: View {
|
|||||||
),
|
),
|
||||||
documentId: viewModel.documentId,
|
documentId: viewModel.documentId,
|
||||||
isEditable: viewModel.isEffectivelyEditable,
|
isEditable: viewModel.isEffectivelyEditable,
|
||||||
onCodeBlockSelectionChange: { readerCodeBlocks = $0 }
|
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
|
||||||
|
onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
|
||||||
|
onSelectedTextChange: { currentSelectedText = $0 },
|
||||||
|
commentAnchorQueries: commentAnchorQueries,
|
||||||
|
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
|
||||||
)
|
)
|
||||||
if showCodeBlockLineNumbers {
|
if showCodeBlockLineNumbers {
|
||||||
ForEach(readerCodeBlocks) { selection in
|
ForEach(readerCodeBlocks) { selection in
|
||||||
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ForEach(commentAnchorRects) { anchor in
|
||||||
|
CommentAnchorMarker(rect: anchor.rect) {
|
||||||
|
focusedCommentId = anchor.id
|
||||||
|
pendingCommentAnchorText = nil
|
||||||
|
isShowingCommentsSheet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,11 +602,13 @@ struct DocumentReaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Left is a plain, unrendered raw-text editor (deliberately not
|
/// Left is the literal Markdown source in `rawSourceMode` (no syntax
|
||||||
/// `NativeTextViewWrapper` — just the literal Markdown source); right
|
/// hiding/styling, but still the real engine — needed so selection
|
||||||
/// is the same rich rendering used everywhere else in the app,
|
/// tracking and caret-position insertion, e.g. from the Image Playground
|
||||||
/// read-only, bound to the same `viewModel.text` so it updates live as
|
/// button, work here the same as everywhere else); right is the same
|
||||||
/// the left side is typed into.
|
/// 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
|
/// Scroll position between the two panes is **not** synchronized — the
|
||||||
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
|
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
|
||||||
@@ -408,30 +618,47 @@ struct DocumentReaderView: View {
|
|||||||
/// follow-up, not attempted here.
|
/// follow-up, not attempted here.
|
||||||
private var splitEditorView: some View {
|
private var splitEditorView: some View {
|
||||||
HSplitView {
|
HSplitView {
|
||||||
TextEditor(text: $viewModel.text)
|
NativeTextViewWrapper(
|
||||||
.font(.system(.body, design: .monospaced))
|
text: $viewModel.text,
|
||||||
.scrollContentBackground(.hidden)
|
pendingTextInsertion: $pendingTextInsertion,
|
||||||
.padding(8)
|
configuration: .init(rawSourceMode: true),
|
||||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
fontName: "SFMono-Regular",
|
||||||
|
documentId: viewModel.documentId,
|
||||||
|
isEditable: viewModel.isEffectivelyEditable,
|
||||||
|
onSelectedTextChange: { currentSelectedText = $0 }
|
||||||
|
)
|
||||||
|
.padding(8)
|
||||||
|
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $viewModel.text,
|
text: $viewModel.text,
|
||||||
configuration: .init(
|
configuration: .init(
|
||||||
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
codeBlock: editorCodeBlockStyle,
|
codeBlock: editorCodeBlockStyle,
|
||||||
heightBehavior: .fitsContent
|
heightBehavior: .fitsContent
|
||||||
),
|
),
|
||||||
documentId: viewModel.documentId,
|
documentId: viewModel.documentId,
|
||||||
isEditable: false,
|
isEditable: false,
|
||||||
onCodeBlockSelectionChange: { previewCodeBlocks = $0 }
|
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
|
||||||
|
onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
|
||||||
|
onSelectedTextChange: { currentSelectedText = $0 },
|
||||||
|
commentAnchorQueries: commentAnchorQueries,
|
||||||
|
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
|
||||||
)
|
)
|
||||||
if showCodeBlockLineNumbers {
|
if showCodeBlockLineNumbers {
|
||||||
ForEach(previewCodeBlocks) { selection in
|
ForEach(previewCodeBlocks) { selection in
|
||||||
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ForEach(commentAnchorRects) { anchor in
|
||||||
|
CommentAnchorMarker(rect: anchor.rect) {
|
||||||
|
focusedCommentId = anchor.id
|
||||||
|
pendingCommentAnchorText = nil
|
||||||
|
isShowingCommentsSheet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
@@ -476,10 +703,17 @@ struct DocumentReaderView: View {
|
|||||||
Task { await duplicate() }
|
Task { await duplicate() }
|
||||||
}
|
}
|
||||||
.disabled(!isEffectivelyOnline)
|
.disabled(!isEffectivelyOnline)
|
||||||
Button("Unpublish") {
|
if viewModel.publishedAt == nil {
|
||||||
isShowingUnpublishConfirmation = true
|
Button("Publish…") {
|
||||||
|
isShowingPublishSheet = true
|
||||||
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
} else {
|
||||||
|
Button("Unpublish") {
|
||||||
|
isShowingUnpublishConfirmation = true
|
||||||
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
}
|
}
|
||||||
.disabled(!isEffectivelyOnline)
|
|
||||||
Button("Archive…") {
|
Button("Archive…") {
|
||||||
isShowingArchiveConfirmation = true
|
isShowingArchiveConfirmation = true
|
||||||
}
|
}
|
||||||
@@ -697,6 +931,35 @@ struct DocumentReaderView: View {
|
|||||||
/// char-wraps code blocks, no way to opt out without forking the package)
|
/// 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
|
/// throws this off — every row below it reads one line low. Documented in
|
||||||
/// TODO.local.md alongside the same package's other gaps.
|
/// 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.
|
||||||
|
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 {
|
private struct CodeBlockLineNumberGutter: View {
|
||||||
let selection: CodeBlockSelection
|
let selection: CodeBlockSelection
|
||||||
let gutterWidth: CGFloat
|
let gutterWidth: CGFloat
|
||||||
@@ -731,4 +994,44 @@ private struct CodeBlockLineNumberGutter: View {
|
|||||||
.allowsHitTesting(false)
|
.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
|
#endif
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ final class DocumentReaderViewModel {
|
|||||||
var emoji: String?
|
var emoji: String?
|
||||||
var text: String
|
var text: String
|
||||||
var collectionId: String?
|
var collectionId: String?
|
||||||
|
var parentDocumentId: String?
|
||||||
|
/// `nil` = draft (not published/visible to other workspace members).
|
||||||
|
var publishedAt: Date?
|
||||||
var isFullWidth = false
|
var isFullWidth = false
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
@@ -76,6 +79,8 @@ final class DocumentReaderViewModel {
|
|||||||
self.emoji = document.emoji
|
self.emoji = document.emoji
|
||||||
self.text = document.text
|
self.text = document.text
|
||||||
self.collectionId = document.collectionId
|
self.collectionId = document.collectionId
|
||||||
|
self.parentDocumentId = document.parentDocumentId
|
||||||
|
self.publishedAt = document.publishedAt
|
||||||
self.isFullWidth = document.fullWidth ?? false
|
self.isFullWidth = document.fullWidth ?? false
|
||||||
self.separateEditingEnabled = separateEditingEnabled
|
self.separateEditingEnabled = separateEditingEnabled
|
||||||
self.lastSyncedText = document.text
|
self.lastSyncedText = document.text
|
||||||
@@ -95,6 +100,8 @@ final class DocumentReaderViewModel {
|
|||||||
emoji = full.emoji
|
emoji = full.emoji
|
||||||
text = full.text
|
text = full.text
|
||||||
collectionId = full.collectionId
|
collectionId = full.collectionId
|
||||||
|
parentDocumentId = full.parentDocumentId
|
||||||
|
publishedAt = full.publishedAt
|
||||||
isFullWidth = full.fullWidth ?? false
|
isFullWidth = full.fullWidth ?? false
|
||||||
lastSyncedText = full.text
|
lastSyncedText = full.text
|
||||||
lastSyncedTitle = full.title
|
lastSyncedTitle = full.title
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ struct NewDocumentSheet: View {
|
|||||||
ProgressView().frame(maxWidth: .infinity)
|
ProgressView().frame(maxWidth: .infinity)
|
||||||
} else {
|
} else {
|
||||||
Picker("Collection", selection: $selectedCollectionID) {
|
Picker("Collection", selection: $selectedCollectionID) {
|
||||||
Text("Choose a collection").tag(String?.none)
|
Text("Draft (not published)").tag(String?.none)
|
||||||
ForEach(collections) { collection in
|
ForEach(collections) { collection in
|
||||||
Text(collection.name).tag(Optional(collection.id))
|
Text(collection.name).tag(Optional(collection.id))
|
||||||
}
|
}
|
||||||
@@ -86,6 +86,12 @@ struct NewDocumentSheet: View {
|
|||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
|
.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 {
|
if let errorMessage {
|
||||||
@@ -97,18 +103,24 @@ struct NewDocumentSheet: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Cancel", role: .cancel) { dismiss() }
|
Button("Cancel", role: .cancel) { dismiss() }
|
||||||
Button("Create") {
|
Button(selectedCollectionID == nil ? "Save as Draft" : "Create & Publish") {
|
||||||
Task { await create() }
|
Task { await create() }
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
.disabled(selectedCollectionID == nil || isCreating)
|
.disabled(isCreating)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.frame(width: 380)
|
.frame(width: 380)
|
||||||
.task {
|
.task {
|
||||||
await loadCollections()
|
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
|
selectedParentID = initialParentDocument?.id
|
||||||
isTitleFocused = true
|
isTitleFocused = true
|
||||||
}
|
}
|
||||||
@@ -147,7 +159,6 @@ struct NewDocumentSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func create() async {
|
private func create() async {
|
||||||
guard let selectedCollectionID else { return }
|
|
||||||
isCreating = true
|
isCreating = true
|
||||||
defer { isCreating = false }
|
defer { isCreating = false }
|
||||||
do {
|
do {
|
||||||
@@ -156,7 +167,12 @@ struct NewDocumentSheet: View {
|
|||||||
title: title.isEmpty ? "Untitled" : title,
|
title: title.isEmpty ? "Untitled" : title,
|
||||||
text: "",
|
text: "",
|
||||||
collectionId: selectedCollectionID,
|
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)
|
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
|
||||||
@@ -5,6 +5,7 @@ enum HomeTab: String, CaseIterable, Identifiable {
|
|||||||
case popular = "Popular"
|
case popular = "Popular"
|
||||||
case recentlyUpdated = "Recently Updated"
|
case recentlyUpdated = "Recently Updated"
|
||||||
case createdByMe = "Created by Me"
|
case createdByMe = "Created by Me"
|
||||||
|
case drafts = "Drafts"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ final class HomeViewModel {
|
|||||||
private(set) var popular: [OutlineDocument] = []
|
private(set) var popular: [OutlineDocument] = []
|
||||||
private(set) var recentlyUpdated: [OutlineDocument] = []
|
private(set) var recentlyUpdated: [OutlineDocument] = []
|
||||||
private(set) var createdByMe: [OutlineDocument] = []
|
private(set) var createdByMe: [OutlineDocument] = []
|
||||||
|
private(set) var drafts: [OutlineDocument] = []
|
||||||
|
|
||||||
var isLoadingPinned = false
|
var isLoadingPinned = false
|
||||||
var isLoadingTab = false
|
var isLoadingTab = false
|
||||||
@@ -32,6 +33,7 @@ final class HomeViewModel {
|
|||||||
case .popular: popular
|
case .popular: popular
|
||||||
case .recentlyUpdated: recentlyUpdated
|
case .recentlyUpdated: recentlyUpdated
|
||||||
case .createdByMe: createdByMe
|
case .createdByMe: createdByMe
|
||||||
|
case .drafts: drafts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +117,8 @@ final class HomeViewModel {
|
|||||||
return try await apiClient.documentsList(
|
return try await apiClient.documentsList(
|
||||||
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
|
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 .popular: popular = documents
|
||||||
case .recentlyUpdated: recentlyUpdated = documents
|
case .recentlyUpdated: recentlyUpdated = documents
|
||||||
case .createdByMe: createdByMe = documents
|
case .createdByMe: createdByMe = documents
|
||||||
|
case .drafts: drafts = documents
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct OutpostApp: App {
|
|||||||
.onAppear { applyMacAppearance() }
|
.onAppear { applyMacAppearance() }
|
||||||
.onChange(of: appearance) { _, _ in applyMacAppearance() }
|
.onChange(of: appearance) { _, _ in applyMacAppearance() }
|
||||||
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
||||||
|
.background(TransparentTitlebarWindowAccessor())
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -101,3 +102,27 @@ struct OutpostApp: App {
|
|||||||
}
|
}
|
||||||
#endif
|
#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
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
/// explicitly built yet. Content lands section by section.
|
/// explicitly built yet. Content lands section by section.
|
||||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||||
// General (ours)
|
// General (ours)
|
||||||
case appearance, editor, offlineSync, advanced, about
|
case appearance, editor, navigation, offlineSync, advanced, about
|
||||||
|
|
||||||
// Account
|
// Account
|
||||||
case profile, preferences, notifications, passkeys, apiAccess
|
case profile, preferences, notifications, passkeys, apiAccess
|
||||||
@@ -45,7 +45,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
|
|
||||||
var category: SettingsCategory {
|
var category: SettingsCategory {
|
||||||
switch self {
|
switch self {
|
||||||
case .appearance, .editor, .offlineSync, .advanced, .about:
|
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about:
|
||||||
return .general
|
return .general
|
||||||
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||||
return .account
|
return .account
|
||||||
@@ -58,6 +58,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .appearance: return "Appearance"
|
case .appearance: return "Appearance"
|
||||||
case .editor: return "Editor"
|
case .editor: return "Editor"
|
||||||
|
case .navigation: return "Navigation"
|
||||||
case .offlineSync: return "Offline & Sync"
|
case .offlineSync: return "Offline & Sync"
|
||||||
case .advanced: return "Advanced"
|
case .advanced: return "Advanced"
|
||||||
case .about: return "About"
|
case .about: return "About"
|
||||||
@@ -87,6 +88,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .appearance: return "paintbrush"
|
case .appearance: return "paintbrush"
|
||||||
case .editor: return "square.split.2x1"
|
case .editor: return "square.split.2x1"
|
||||||
|
case .navigation: return "command"
|
||||||
case .offlineSync: return "arrow.triangle.2.circlepath"
|
case .offlineSync: return "arrow.triangle.2.circlepath"
|
||||||
case .advanced: return "wrench.and.screwdriver"
|
case .advanced: return "wrench.and.screwdriver"
|
||||||
case .about: return "info.circle"
|
case .about: return "info.circle"
|
||||||
@@ -117,7 +119,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
/// specified and built.
|
/// specified and built.
|
||||||
var isImplemented: Bool {
|
var isImplemented: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .appearance, .editor, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -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,84 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
import MarkdownEngine
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
|
/// Resolves `` 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
|
||||||
+4
@@ -75,6 +75,7 @@ extension MarkdownStyler {
|
|||||||
paragraphSpacing: imageEmbedConfig.paragraphSpacing,
|
paragraphSpacing: imageEmbedConfig.paragraphSpacing,
|
||||||
alignment: .left,
|
alignment: .left,
|
||||||
mode: .visibleSource(imageGap: imageEmbedConfig.imageGap),
|
mode: .visibleSource(imageGap: imageEmbedConfig.imageGap),
|
||||||
|
restyleOnWidthChange: true,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
attrs: &attrs
|
attrs: &attrs
|
||||||
)
|
)
|
||||||
@@ -88,6 +89,7 @@ extension MarkdownStyler {
|
|||||||
paragraphSpacing: imageEmbedConfig.paragraphSpacing,
|
paragraphSpacing: imageEmbedConfig.paragraphSpacing,
|
||||||
alignment: .left,
|
alignment: .left,
|
||||||
mode: .collapsedSource(markerTexts: ["![", "]", "(", ")"]),
|
mode: .collapsedSource(markerTexts: ["![", "]", "(", ")"]),
|
||||||
|
restyleOnWidthChange: true,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
attrs: &attrs
|
attrs: &attrs
|
||||||
)
|
)
|
||||||
@@ -166,6 +168,7 @@ extension MarkdownStyler {
|
|||||||
paragraphSpacing: imageEmbedConfig.paragraphSpacing,
|
paragraphSpacing: imageEmbedConfig.paragraphSpacing,
|
||||||
alignment: .left,
|
alignment: .left,
|
||||||
mode: .visibleSource(imageGap: imageEmbedConfig.imageGap),
|
mode: .visibleSource(imageGap: imageEmbedConfig.imageGap),
|
||||||
|
restyleOnWidthChange: true,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
attrs: &attrs
|
attrs: &attrs
|
||||||
)
|
)
|
||||||
@@ -179,6 +182,7 @@ extension MarkdownStyler {
|
|||||||
paragraphSpacing: imageEmbedConfig.paragraphSpacing,
|
paragraphSpacing: imageEmbedConfig.paragraphSpacing,
|
||||||
alignment: .left,
|
alignment: .left,
|
||||||
mode: .collapsedSource(markerTexts: ["![[", "]]"]),
|
mode: .collapsedSource(markerTexts: ["![[", "]]"]),
|
||||||
|
restyleOnWidthChange: true,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
attrs: &attrs
|
attrs: &attrs
|
||||||
)
|
)
|
||||||
|
|||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// CommentAnchor.swift
|
||||||
|
// MarkdownEngine
|
||||||
|
//
|
||||||
|
// Lets an embedder ask "where on screen is this text?" for arbitrary
|
||||||
|
// plain-text needles (e.g. an anchored comment's `anchorText`) that the
|
||||||
|
// engine has no concept of on its own — it doesn't know what a comment is,
|
||||||
|
// it just resolves a search term to a rect the same way it already
|
||||||
|
// resolves code-block token ranges.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A plain-text search term the embedder wants a screen rect for, tagged
|
||||||
|
/// with an opaque id so results can be matched back to whatever the
|
||||||
|
/// embedder cares about (e.g. a comment id). Engine does a first-occurrence
|
||||||
|
/// substring search against the current display text — no fuzzier
|
||||||
|
/// disambiguation than that, since the engine has no positional data to
|
||||||
|
/// work from beyond the text itself.
|
||||||
|
public struct CommentAnchorQuery: Sendable, Equatable {
|
||||||
|
public let id: String
|
||||||
|
public let anchorText: String
|
||||||
|
|
||||||
|
public init(id: String, anchorText: String) {
|
||||||
|
self.id = id
|
||||||
|
self.anchorText = anchorText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved screen rect for one ``CommentAnchorQuery``. Delivered through
|
||||||
|
/// ``NativeTextViewWrapper/onCommentAnchorRectsChange`` — queries whose
|
||||||
|
/// `anchorText` isn't found in the current text (deleted, edited past
|
||||||
|
/// recognition) simply don't appear in the result.
|
||||||
|
public struct CommentAnchorRect: Identifiable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
/// Frame of the matched text in the text view's coordinate space —
|
||||||
|
/// same coordinate system as ``CodeBlockSelection/rect``.
|
||||||
|
public let rect: CGRect
|
||||||
|
|
||||||
|
public init(id: String, rect: CGRect) {
|
||||||
|
self.id = id
|
||||||
|
self.rect = rect
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
//
|
||||||
|
// NativeTextViewCoordinator+CommentAnchors.swift
|
||||||
|
// MarkdownEngine
|
||||||
|
//
|
||||||
|
// Resolves embedder-supplied CommentAnchorQuery search terms to on-screen
|
||||||
|
// rects, the same viewRect(forCharacterRange:using:) utility the code-block
|
||||||
|
// copy-button overlay already uses. Deliberately no per-keystroke caching
|
||||||
|
// like updateCodeBlockSelection has — queries only change when the
|
||||||
|
// embedder's comment list changes (rare), not every keystroke, so the
|
||||||
|
// first-occurrence substring search + a handful of viewRect calls is cheap
|
||||||
|
// enough to just always redo.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
extension NativeTextViewCoordinator {
|
||||||
|
func updateCommentAnchorRects(textView: NSTextView) {
|
||||||
|
guard !commentAnchorQueries.isEmpty else {
|
||||||
|
onCommentAnchorRectsChange?([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let nsText = textView.string as NSString
|
||||||
|
var results: [CommentAnchorRect] = []
|
||||||
|
for query in commentAnchorQueries {
|
||||||
|
guard !query.anchorText.isEmpty else { continue }
|
||||||
|
let found = nsText.range(of: query.anchorText)
|
||||||
|
guard found.location != NSNotFound,
|
||||||
|
let rect = textView.viewRect(forCharacterRange: found, using: layoutBridge) else { continue }
|
||||||
|
results.append(CommentAnchorRect(id: query.id, rect: rect))
|
||||||
|
}
|
||||||
|
onCommentAnchorRectsChange?(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -200,6 +200,31 @@ extension NativeTextViewCoordinator {
|
|||||||
nativeTextView?.updateWideTableOverlays()
|
nativeTextView?.updateWideTableOverlays()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standalone images (``/`![[embed]]`) size themselves off
|
||||||
|
// the text container's width AT THIS MOMENT (see styleImageLinks/
|
||||||
|
// styleImageEmbeds' maxWidth) — every keystroke rebuilds this whole
|
||||||
|
// pane when it's a read-only mirror of another editable view driving
|
||||||
|
// the same `text` binding (e.g. Split View's preview pane), and mid-
|
||||||
|
// typing the container can be reflowing (HSplitView divider, a
|
||||||
|
// `.fitsContent` pane still resizing) and read too small/zero for a
|
||||||
|
// moment. That undersized measurement then just sticks — nothing
|
||||||
|
// else re-triggers a restyle once typing stops and the fingerprint-
|
||||||
|
// based image-load path (see NativeTextViewWrapper's `imageChanged`
|
||||||
|
// handling) doesn't fire for an already-cached image. Re-measure
|
||||||
|
// once more, one tick later, only when there's actually an image in
|
||||||
|
// the document — same fix shape as the wide-table reconciliation
|
||||||
|
// above, just for image sizing instead of overlay frames.
|
||||||
|
let hasImages = (parsedForReplay?.classified.imageLink.isEmpty == false)
|
||||||
|
|| (parsedForReplay?.classified.imageEmbed.isEmpty == false)
|
||||||
|
if hasImages {
|
||||||
|
DispatchQueue.main.async { [weak self, weak textView] in
|
||||||
|
guard let self, let textView else { return }
|
||||||
|
let range = NSRange(location: 0, length: (textView.string as NSString).length)
|
||||||
|
guard range.length > 0 else { return }
|
||||||
|
self.restyleParagraphs([range], in: textView)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func restyleTextView(
|
func restyleTextView(
|
||||||
@@ -417,6 +442,33 @@ extension NativeTextViewCoordinator {
|
|||||||
classified: parsed.classified, blocks: parsed.blocks)
|
classified: parsed.classified, blocks: parsed.blocks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inserts `request.text` at the current caret (replacing the selection,
|
||||||
|
/// if any) as a normal undoable edit. Simpler than
|
||||||
|
/// ``applyInlineReplacement(_:to:)`` — no inline-token range, no
|
||||||
|
/// wiki-link ID side-channel, just a plain insert.
|
||||||
|
func applyTextInsertion(_ request: TextInsertionRequest, to textView: NSTextView) {
|
||||||
|
lastAppliedTextInsertionID = request.id
|
||||||
|
|
||||||
|
let range = textView.selectedRange()
|
||||||
|
textView.breakUndoCoalescing()
|
||||||
|
|
||||||
|
isProgrammaticEdit = true
|
||||||
|
defer { isProgrammaticEdit = false }
|
||||||
|
|
||||||
|
guard textView.shouldChangeText(in: range, replacementString: request.text) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
textView.textStorage?.replaceCharacters(in: range, with: request.text)
|
||||||
|
textView.didChangeText()
|
||||||
|
textView.undoManager?.setActionName("Insert Image")
|
||||||
|
textView.breakUndoCoalescing()
|
||||||
|
|
||||||
|
let documentLength = (textView.string as NSString).length
|
||||||
|
let caretLocation = min(range.location + (request.text as NSString).length, documentLength)
|
||||||
|
textView.setSelectedRange(NSRange(location: caretLocation, length: 0))
|
||||||
|
}
|
||||||
|
|
||||||
func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) {
|
func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) {
|
||||||
lastAppliedInlineReplacementID = request.id
|
lastAppliedInlineReplacementID = request.id
|
||||||
|
|
||||||
|
|||||||
+10
@@ -337,6 +337,7 @@ extension NativeTextViewCoordinator {
|
|||||||
|
|
||||||
PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens, classified: parsed.classified, blocks: parsed.blocks) }
|
PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens, classified: parsed.classified, blocks: parsed.blocks) }
|
||||||
PerfTrace.measure("codeSel") { updateCodeBlockSelection(textView: tv, parsed: parsed) }
|
PerfTrace.measure("codeSel") { updateCodeBlockSelection(textView: tv, parsed: parsed) }
|
||||||
|
updateCommentAnchorRects(textView: tv)
|
||||||
if wtActive {
|
if wtActive {
|
||||||
previousActiveTokenIndices = activeTokenIndices
|
previousActiveTokenIndices = activeTokenIndices
|
||||||
PerfTrace.end()
|
PerfTrace.end()
|
||||||
@@ -355,6 +356,14 @@ extension NativeTextViewCoordinator {
|
|||||||
|
|
||||||
public func textViewDidChangeSelection(_ notification: Notification) {
|
public func textViewDidChangeSelection(_ notification: Notification) {
|
||||||
guard let tv = notification.object as? NSTextView else { return }
|
guard let tv = notification.object as? NSTextView else { return }
|
||||||
|
// Cheap and mode-independent — fire before the raw-mode/rebuild
|
||||||
|
// early-returns below, which would otherwise mean a `rawSourceMode`
|
||||||
|
// editor (e.g. Split View's raw-source pane) never reports a
|
||||||
|
// selection at all.
|
||||||
|
if !isRebuildingDocument {
|
||||||
|
let selRange = tv.selectedRange()
|
||||||
|
onSelectedTextChange?(selRange.length > 0 ? (tv.string as NSString).substring(with: selRange) : nil)
|
||||||
|
}
|
||||||
// Raw mode: plain source — no reveal, snap-back, or inline previews.
|
// Raw mode: plain source — no reveal, snap-back, or inline previews.
|
||||||
if configuration.rawSourceMode { return }
|
if configuration.rawSourceMode { return }
|
||||||
if isWritingToolsActive { return }
|
if isWritingToolsActive { return }
|
||||||
@@ -692,6 +701,7 @@ extension NativeTextViewCoordinator {
|
|||||||
// Skip during a pending edit — viewRect is stale until textDidChange's restyle runs; otherwise the overlay flashes to the old Y before settling.
|
// Skip during a pending edit — viewRect is stale until textDidChange's restyle runs; otherwise the overlay flashes to the old Y before settling.
|
||||||
if !shouldSkipSelectionRestyle {
|
if !shouldSkipSelectionRestyle {
|
||||||
updateCodeBlockSelection(textView: tv, parsed: parsed)
|
updateCodeBlockSelection(textView: tv, parsed: parsed)
|
||||||
|
updateCommentAnchorRects(textView: tv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
@@ -84,6 +84,9 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
|
|||||||
var onInlineSelectionChange: ((InlineSelectionState?) -> Void)?
|
var onInlineSelectionChange: ((InlineSelectionState?) -> Void)?
|
||||||
var onInlinePreviewKey: ((InlinePreviewKey) -> Bool)?
|
var onInlinePreviewKey: ((InlinePreviewKey) -> Bool)?
|
||||||
var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
|
var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
|
||||||
|
var onSelectedTextChange: ((String?) -> Void)?
|
||||||
|
var commentAnchorQueries: [CommentAnchorQuery] = []
|
||||||
|
var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)?
|
||||||
var didInitialFormatting: Bool = false
|
var didInitialFormatting: Bool = false
|
||||||
/// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document.
|
/// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document.
|
||||||
var didEnsureLayoutForCurrentDocument: Bool = false
|
var didEnsureLayoutForCurrentDocument: Bool = false
|
||||||
@@ -106,6 +109,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
|
|||||||
var wtUndoneDuringSession: Bool = false
|
var wtUndoneDuringSession: Bool = false
|
||||||
var wtPostUndoSnapshot: String?
|
var wtPostUndoSnapshot: String?
|
||||||
var lastAppliedInlineReplacementID: UUID?
|
var lastAppliedInlineReplacementID: UUID?
|
||||||
|
var lastAppliedTextInsertionID: UUID?
|
||||||
var activeTokenIndices: Set<Int> = []
|
var activeTokenIndices: Set<Int> = []
|
||||||
var previousActiveTokenIndices: Set<Int> = []
|
var previousActiveTokenIndices: Set<Int> = []
|
||||||
var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:]
|
var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:]
|
||||||
|
|||||||
+15
-5
@@ -269,21 +269,31 @@ extension NativeTextView {
|
|||||||
|
|
||||||
recalcOverscroll(for: scrollView, targetWidth: newSize.width, debugTag: "setFrameSize")
|
recalcOverscroll(for: scrollView, targetWidth: newSize.width, debugTag: "setFrameSize")
|
||||||
|
|
||||||
// Width change → only rendered table paragraphs need restyling. Their image
|
// Width change → only paragraphs tagged `.scrollableBlockFullRange`
|
||||||
// width can change, and an initially narrow table can become scrollable.
|
// (rendered tables and standalone images — see appendRenderedStandaloneBlock's
|
||||||
|
// `restyleOnWidthChange`) need restyling. Their display size is measured
|
||||||
|
// off the container width, and an initially narrow table can become
|
||||||
|
// scrollable.
|
||||||
if widthChanged {
|
if widthChanged {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if self.configuration.readingWidth == nil {
|
if self.configuration.readingWidth == nil {
|
||||||
self.restyleTableParagraphsForWidthChange()
|
self.restyleWidthDependentParagraphsForWidthChange()
|
||||||
}
|
}
|
||||||
self.updateWideTableOverlays()
|
self.updateWideTableOverlays()
|
||||||
|
// Comment anchor bars are just as width-dependent as the
|
||||||
|
// table/image restyle above — wrapped lines reflow, shifting
|
||||||
|
// every anchor's y-position.
|
||||||
|
if let coordinator = self.delegate as? NativeTextViewCoordinator {
|
||||||
|
coordinator.updateCommentAnchorRects(textView: self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restyle only table paragraphs via stamped anchor ranges; avoids re-tokenizing the doc.
|
/// Restyle only width-dependent paragraphs (tables, standalone images) via
|
||||||
private func restyleTableParagraphsForWidthChange() {
|
/// stamped anchor ranges; avoids re-tokenizing the doc.
|
||||||
|
private func restyleWidthDependentParagraphsForWidthChange() {
|
||||||
guard let storage = textStorage,
|
guard let storage = textStorage,
|
||||||
let coord = delegate as? NativeTextViewCoordinator else { return }
|
let coord = delegate as? NativeTextViewCoordinator else { return }
|
||||||
var ranges: [NSRange] = []
|
var ranges: [NSRange] = []
|
||||||
|
|||||||
Vendored
+27
@@ -93,3 +93,30 @@ public struct InlineReplacementRequest: Sendable {
|
|||||||
self.isImageEmbedMode = isImageEmbedMode
|
self.isImageEmbedMode = isImageEmbedMode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request to insert literal text at the current caret (replacing the
|
||||||
|
/// current selection, if any) — e.g. a Markdown image reference from an
|
||||||
|
/// embedder-side image picker or generator.
|
||||||
|
///
|
||||||
|
/// Embedders push one of these into
|
||||||
|
/// ``NativeTextViewWrapper/pendingTextInsertion`` to commit it. The engine
|
||||||
|
/// inserts it as a normal (undoable) edit, moves the caret past it, and
|
||||||
|
/// clears the binding. Unlike ``InlineReplacementRequest``, this doesn't
|
||||||
|
/// target an existing inline token — it just inserts at wherever the caret
|
||||||
|
/// currently is.
|
||||||
|
public struct TextInsertionRequest: Sendable {
|
||||||
|
/// Stable identifier so the engine can detect already-applied requests
|
||||||
|
/// across SwiftUI re-renders.
|
||||||
|
public let id: UUID
|
||||||
|
/// Document the insertion targets. Ignored if it doesn't match the
|
||||||
|
/// editor's current `documentId` (prevents cross-document writes).
|
||||||
|
public let documentId: String
|
||||||
|
/// Storage-form text to insert at the caret.
|
||||||
|
public let text: String
|
||||||
|
|
||||||
|
public init(id: UUID = UUID(), documentId: String, text: String) {
|
||||||
|
self.id = id
|
||||||
|
self.documentId = documentId
|
||||||
|
self.text = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+81
@@ -55,6 +55,10 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
/// Push a replacement into the editor by setting this to a non-nil value;
|
/// Push a replacement into the editor by setting this to a non-nil value;
|
||||||
/// the engine applies it on the next update and then clears the binding.
|
/// the engine applies it on the next update and then clears the binding.
|
||||||
@Binding public var pendingInlineReplacement: InlineReplacementRequest?
|
@Binding public var pendingInlineReplacement: InlineReplacementRequest?
|
||||||
|
/// Push a plain-text insertion at the caret by setting this to a non-nil
|
||||||
|
/// value; the engine applies it on the next update and then clears the
|
||||||
|
/// binding. See ``TextInsertionRequest``.
|
||||||
|
@Binding public var pendingTextInsertion: TextInsertionRequest?
|
||||||
/// The full editor configuration (theme + services + style toggles). Engine
|
/// The full editor configuration (theme + services + style toggles). Engine
|
||||||
/// embedders construct this themselves and pass it in; the wrapper does
|
/// embedders construct this themselves and pass it in; the wrapper does
|
||||||
/// not read UserDefaults or know about app-specific colors/services.
|
/// not read UserDefaults or know about app-specific colors/services.
|
||||||
@@ -95,6 +99,19 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
/// Fires when the set of visible code blocks changes, so embedders can
|
/// Fires when the set of visible code blocks changes, so embedders can
|
||||||
/// overlay copy buttons (see ``CodeBlockButton``).
|
/// overlay copy buttons (see ``CodeBlockButton``).
|
||||||
public var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
|
public var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
|
||||||
|
/// Fires whenever the text selection changes. `nil` for an empty (caret-only)
|
||||||
|
/// selection, otherwise the plain-text substring currently selected — useful
|
||||||
|
/// for embedder features that act on "whatever's selected" (e.g. seeding a
|
||||||
|
/// generator's prompt).
|
||||||
|
public var onSelectedTextChange: ((String?) -> Void)?
|
||||||
|
/// Plain-text search terms the embedder wants a screen rect for — e.g.
|
||||||
|
/// anchored comments' `anchorText`, to draw an inline marker next to
|
||||||
|
/// them. See ``CommentAnchorQuery``/``CommentAnchorRect``.
|
||||||
|
public var commentAnchorQueries: [CommentAnchorQuery] = []
|
||||||
|
/// Fires whenever `commentAnchorQueries`' resolved positions change
|
||||||
|
/// (queries updated, or the document reflows). A query whose text isn't
|
||||||
|
/// found in the current document simply doesn't appear in the array.
|
||||||
|
public var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)?
|
||||||
/// Fires after the user toggles any of the three spell/grammar/auto-correction
|
/// Fires after the user toggles any of the three spell/grammar/auto-correction
|
||||||
/// menu items. Embedders persist the policy and pass it back via
|
/// menu items. Embedders persist the policy and pass it back via
|
||||||
/// ``MarkdownEditorConfiguration/spellChecking`` on next launch.
|
/// ``MarkdownEditorConfiguration/spellChecking`` on next launch.
|
||||||
@@ -141,6 +158,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
text: Binding<String>,
|
text: Binding<String>,
|
||||||
isWikiLinkActive: Binding<Bool> = .constant(false),
|
isWikiLinkActive: Binding<Bool> = .constant(false),
|
||||||
pendingInlineReplacement: Binding<InlineReplacementRequest?> = .constant(nil),
|
pendingInlineReplacement: Binding<InlineReplacementRequest?> = .constant(nil),
|
||||||
|
pendingTextInsertion: Binding<TextInsertionRequest?> = .constant(nil),
|
||||||
configuration: MarkdownEditorConfiguration = .default,
|
configuration: MarkdownEditorConfiguration = .default,
|
||||||
fontName: String = "SF Pro",
|
fontName: String = "SF Pro",
|
||||||
fontSize: CGFloat = 16,
|
fontSize: CGFloat = 16,
|
||||||
@@ -153,6 +171,9 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
onInlineSelectionChange: ((InlineSelectionState?) -> Void)? = nil,
|
onInlineSelectionChange: ((InlineSelectionState?) -> Void)? = nil,
|
||||||
onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? = nil,
|
onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? = nil,
|
||||||
onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? = nil,
|
onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? = nil,
|
||||||
|
onSelectedTextChange: ((String?) -> Void)? = nil,
|
||||||
|
commentAnchorQueries: [CommentAnchorQuery] = [],
|
||||||
|
onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)? = nil,
|
||||||
onSpellCheckingPolicyChanged: ((SpellCheckingPolicy) -> Void)? = nil,
|
onSpellCheckingPolicyChanged: ((SpellCheckingPolicy) -> Void)? = nil,
|
||||||
placeholder: NSAttributedString? = nil,
|
placeholder: NSAttributedString? = nil,
|
||||||
header: AnyView? = nil,
|
header: AnyView? = nil,
|
||||||
@@ -166,6 +187,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
self._text = text
|
self._text = text
|
||||||
self._isWikiLinkActive = isWikiLinkActive
|
self._isWikiLinkActive = isWikiLinkActive
|
||||||
self._pendingInlineReplacement = pendingInlineReplacement
|
self._pendingInlineReplacement = pendingInlineReplacement
|
||||||
|
self._pendingTextInsertion = pendingTextInsertion
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
self.fontName = fontName
|
self.fontName = fontName
|
||||||
self.fontSize = fontSize
|
self.fontSize = fontSize
|
||||||
@@ -178,6 +200,9 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
self.onInlineSelectionChange = onInlineSelectionChange
|
self.onInlineSelectionChange = onInlineSelectionChange
|
||||||
self.onInlinePreviewKey = onInlinePreviewKey
|
self.onInlinePreviewKey = onInlinePreviewKey
|
||||||
self.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
self.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||||
|
self.onSelectedTextChange = onSelectedTextChange
|
||||||
|
self.commentAnchorQueries = commentAnchorQueries
|
||||||
|
self.onCommentAnchorRectsChange = onCommentAnchorRectsChange
|
||||||
self.onSpellCheckingPolicyChanged = onSpellCheckingPolicyChanged
|
self.onSpellCheckingPolicyChanged = onSpellCheckingPolicyChanged
|
||||||
self.placeholder = placeholder
|
self.placeholder = placeholder
|
||||||
self.header = header
|
self.header = header
|
||||||
@@ -328,6 +353,9 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
context.coordinator.onInlineSelectionChange = onInlineSelectionChange
|
context.coordinator.onInlineSelectionChange = onInlineSelectionChange
|
||||||
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
|
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
|
||||||
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||||
|
context.coordinator.onSelectedTextChange = onSelectedTextChange
|
||||||
|
context.coordinator.onCommentAnchorRectsChange = onCommentAnchorRectsChange
|
||||||
|
context.coordinator.commentAnchorQueries = commentAnchorQueries
|
||||||
|
|
||||||
textView.recalcOverscroll(for: scrollView)
|
textView.recalcOverscroll(for: scrollView)
|
||||||
textView.setPlaceholder(placeholder)
|
textView.setPlaceholder(placeholder)
|
||||||
@@ -541,6 +569,40 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
if fullRange.length > 0 {
|
if fullRange.length > 0 {
|
||||||
context.coordinator.restyleParagraphs([fullRange], in: textView)
|
context.coordinator.restyleParagraphs([fullRange], in: textView)
|
||||||
}
|
}
|
||||||
|
// An image's display width is measured off the text container's
|
||||||
|
// CURRENT width (see styleImageLinks/styleImageEmbeds' maxWidth),
|
||||||
|
// which may not have settled to its real value yet in this same
|
||||||
|
// pass — e.g. right after a text change that also grows/shrinks
|
||||||
|
// the pane (Split View's HSplitView reflow, `.fitsContent`
|
||||||
|
// resizing). A too-small width here falls back to a small
|
||||||
|
// default and, with nothing else in this document changing
|
||||||
|
// afterward, stays wrong until something else forces a restyle
|
||||||
|
// (reopening the document). Re-measure one runloop tick later,
|
||||||
|
// once layout has actually settled — mirrors the WideTableOverlay
|
||||||
|
// reconciliation below.
|
||||||
|
if imageChanged {
|
||||||
|
let coordinator = context.coordinator
|
||||||
|
DispatchQueue.main.async { [weak textView] in
|
||||||
|
guard let textView else { return }
|
||||||
|
let range = NSRange(location: 0, length: (textView.string as NSString).length)
|
||||||
|
guard range.length > 0 else { return }
|
||||||
|
coordinator.restyleParagraphs([range], in: textView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Comment queries can change (comments finished loading, resolved,
|
||||||
|
// etc.) with the document's own text completely unchanged — the
|
||||||
|
// "nothing to do" early-return just below would otherwise skip
|
||||||
|
// recomputing their positions forever. Handled the same way as
|
||||||
|
// imageChanged/wikiChanged just above: detect and act on it before
|
||||||
|
// that gate, independent of whether a real rebuild is happening.
|
||||||
|
if context.coordinator.commentAnchorQueries != commentAnchorQueries {
|
||||||
|
context.coordinator.commentAnchorQueries = commentAnchorQueries
|
||||||
|
let coordinator = context.coordinator
|
||||||
|
DispatchQueue.main.async { [weak textView] in
|
||||||
|
guard let textView else { return }
|
||||||
|
coordinator.updateCommentAnchorRects(textView: textView)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
textView.isEditable = isEditable
|
textView.isEditable = isEditable
|
||||||
textView.isSelectable = true
|
textView.isSelectable = true
|
||||||
@@ -562,6 +624,18 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if let pendingTextInsertion {
|
||||||
|
if pendingTextInsertion.documentId == documentId,
|
||||||
|
context.coordinator.lastAppliedTextInsertionID != pendingTextInsertion.id {
|
||||||
|
context.coordinator.applyTextInsertion(pendingTextInsertion, to: textView)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if self.pendingTextInsertion?.id == pendingTextInsertion.id {
|
||||||
|
self.pendingTextInsertion = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if context.coordinator.didInitialFormatting
|
if context.coordinator.didInitialFormatting
|
||||||
&& context.coordinator.lastSyncedText == text
|
&& context.coordinator.lastSyncedText == text
|
||||||
&& !fontChanged {
|
&& !fontChanged {
|
||||||
@@ -660,8 +734,10 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
// Document rebuilds bypass textDidChange — re-derive emptiness here.
|
// Document rebuilds bypass textDidChange — re-derive emptiness here.
|
||||||
textView.refreshPlaceholderVisibility()
|
textView.refreshPlaceholderVisibility()
|
||||||
|
context.coordinator.commentAnchorQueries = commentAnchorQueries
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
context.coordinator.updateCodeBlockSelection(textView: textView)
|
context.coordinator.updateCodeBlockSelection(textView: textView)
|
||||||
|
context.coordinator.updateCommentAnchorRects(textView: textView)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.coordinator.onCaretRectChange = onCaretRectChange
|
context.coordinator.onCaretRectChange = onCaretRectChange
|
||||||
@@ -669,6 +745,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
context.coordinator.onInlineSelectionChange = onInlineSelectionChange
|
context.coordinator.onInlineSelectionChange = onInlineSelectionChange
|
||||||
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
|
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
|
||||||
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||||
|
context.coordinator.onSelectedTextChange = onSelectedTextChange
|
||||||
|
context.coordinator.onCommentAnchorRectsChange = onCommentAnchorRectsChange
|
||||||
context.coordinator.didInitialFormatting = true
|
context.coordinator.didInitialFormatting = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,6 +769,9 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
coordinator.lastImageFingerprint = configuration.services.images.fingerprint()
|
coordinator.lastImageFingerprint = configuration.services.images.fingerprint()
|
||||||
coordinator.lastWikiFingerprint = configuration.services.wikiLinks.fingerprint()
|
coordinator.lastWikiFingerprint = configuration.services.wikiLinks.fingerprint()
|
||||||
coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||||
|
coordinator.onSelectedTextChange = onSelectedTextChange
|
||||||
|
coordinator.onCommentAnchorRectsChange = onCommentAnchorRectsChange
|
||||||
|
coordinator.commentAnchorQueries = commentAnchorQueries
|
||||||
coordinator.onInlinePreviewKey = onInlinePreviewKey
|
coordinator.onInlinePreviewKey = onInlinePreviewKey
|
||||||
coordinator.userPrefersContinuousSpellChecking = configuration.spellChecking.continuousSpellChecking
|
coordinator.userPrefersContinuousSpellChecking = configuration.spellChecking.continuousSpellChecking
|
||||||
coordinator.userPrefersGrammarChecking = configuration.spellChecking.grammarChecking
|
coordinator.userPrefersGrammarChecking = configuration.spellChecking.grammarChecking
|
||||||
|
|||||||
Reference in New Issue
Block a user