feat: drafts, publish flow, and full comment threading
Drafts + Publish:
- New Home tab backed by documents.drafts (undocumented request shape
confirmed from a live network capture, not the OpenAPI spec).
- Reader toolbar menu now shows Publish... for an unpublished
document instead of an unconditional Unpublish (which could
previously be tapped on a draft at all). Publish opens a
MoveDocumentSheet-style collection/parent picker, pre-filled from
the draft's own collectionId/parentDocumentId when it already has
one. Publishing itself is documents.update(publish: true,
collectionId:) for the collection placement, plus a second
documents.move call only when a specific parent document was also
picked (documents.update has no parentDocumentId field).
- New Document defaults to Draft (collectionId/publish both now
optional on CreateDocumentRequest, previously collectionId was
required so a draft couldn't be created from this sheet at all).
Contextual entry points (right-click a collection/document) still
pre-fill that location, but now show a warning that doing so
auto-publishes.
- Fixed onDeleted only popping the reader's nav path without telling
the sidebar to refresh - Delete/Archive/Unpublish/Move all left the
sidebar showing stale state until an unrelated trigger (the 45s
poll, navigating away and back) happened to catch it up.
Comments:
- Replies (comments.create with parentCommentId, one level of nesting
same as Outline's own limit) and emoji reactions
(comments.add_reaction/remove_reaction, confirmed against Outline's
server source - not in the spec, and return {success: true} rather
than the updated comment, so a toggle refetches via comments.info
for the real post-toggle state) plus a document-level "new comment"
composer, since replying needs something to reply to.
- Inline anchor markers: a new engine-side mechanism
(CommentAnchorQuery/CommentAnchorRect/onCommentAnchorRectsChange)
resolves an anchored comment's anchorText to an on-screen rect via
the same viewRect utility the code-block copy button uses, kept in
sync on typing/resize/reflow the same way the code-block and image
positioning fixes earlier this session are. Renders as a thin blue
bar next to the commented text; tapping it opens the comments sheet
scrolled and highlighted to that thread. First-occurrence text
search only (Outline's API returns no position data, and no
prefix/suffix on read) - creating new anchored comments from this
app still isn't supported.
- Toolbar badge: tighter offset so the count doesn't clip past the
icon, caps at "10+".
This commit is contained in:
@@ -92,6 +92,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
||||
try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)") {
|
||||
try await self.live.listDrafts(request)
|
||||
}
|
||||
}
|
||||
|
||||
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||
try await cachedFetch(key: "collections:\(offset):\(limit)") {
|
||||
try await self.live.listCollections(offset: offset, limit: limit)
|
||||
@@ -307,6 +313,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
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)
|
||||
}
|
||||
@@ -315,6 +329,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
try await live.unresolveComment(id: id)
|
||||
}
|
||||
|
||||
public func addReaction(commentId: String, emoji: String) async throws {
|
||||
try await live.addReaction(commentId: commentId, emoji: emoji)
|
||||
}
|
||||
|
||||
public func removeReaction(commentId: String, emoji: String) async throws {
|
||||
try await live.removeReaction(commentId: commentId, emoji: emoji)
|
||||
}
|
||||
|
||||
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||
try await live.addDocumentUser(request)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ public protocol OutlineAPIClient: Sendable {
|
||||
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
|
||||
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
|
||||
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument]
|
||||
/// Draft (unpublished) documents belonging to the current user. Backed
|
||||
/// by `documents.drafts`.
|
||||
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument]
|
||||
/// Full-text search with snippets/ranking. Backed by `documents.search`.
|
||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
||||
/// Title-only search — faster, no snippets. Backed by `documents.search_titles`.
|
||||
@@ -48,11 +51,19 @@ public protocol OutlineAPIClient: Sendable {
|
||||
/// Historical view records, not live presence. Backed by `views.list`.
|
||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
||||
|
||||
/// See `OutlineComment`. `resolve`/`unresolve` are confirmed real
|
||||
/// against Outline's own server source but aren't in the vendored spec.
|
||||
/// See `OutlineComment`. `resolve`/`unresolve`/`add_reaction`/
|
||||
/// `remove_reaction` are confirmed real against Outline's own server
|
||||
/// source but aren't in the vendored spec.
|
||||
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment]
|
||||
func commentInfo(id: String) async throws -> OutlineComment
|
||||
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment
|
||||
func resolveComment(id: String) async throws -> OutlineComment
|
||||
func unresolveComment(id: String) async throws -> OutlineComment
|
||||
/// Reaction endpoints return `{success: true}`, not the updated
|
||||
/// comment — callers refetch via `commentInfo` for the fresh
|
||||
/// `reactions` array.
|
||||
func addReaction(commentId: String, emoji: String) async throws
|
||||
func removeReaction(commentId: String, emoji: String) async throws
|
||||
|
||||
/// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed
|
||||
/// from Outline's official docs, the rest are best-effort.
|
||||
|
||||
@@ -59,6 +59,10 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit))
|
||||
}
|
||||
|
||||
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
||||
try await post("documents.drafts", body: request)
|
||||
}
|
||||
|
||||
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||
try await post("documents.search", body: request)
|
||||
}
|
||||
@@ -179,6 +183,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
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))
|
||||
}
|
||||
@@ -187,6 +199,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("comments.unresolve", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func addReaction(commentId: String, emoji: String) async throws {
|
||||
try await postForSuccess("comments.add_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
|
||||
}
|
||||
|
||||
public func removeReaction(commentId: String, emoji: String) async throws {
|
||||
try await postForSuccess("comments.remove_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
|
||||
}
|
||||
|
||||
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||
try await post("documents.add_user", body: request)
|
||||
}
|
||||
@@ -536,6 +556,11 @@ private struct StarIDParams: Encodable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
private struct CommentReactionParams: Encodable {
|
||||
let id: String
|
||||
let emoji: String
|
||||
}
|
||||
|
||||
private struct MoveDocumentResponse: Decodable {
|
||||
let documents: [OutlineDocument]?
|
||||
let collections: [OutlineCollection]?
|
||||
|
||||
@@ -2,10 +2,11 @@ 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` 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()`.
|
||||
/// 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
|
||||
@@ -16,9 +17,30 @@ public struct OutlineComment: Decodable, Identifiable, Sendable {
|
||||
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,21 @@
|
||||
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). Anchored (text-selection) comments aren't
|
||||
/// supported here — that needs `anchorText`, out of scope for this app.
|
||||
/// `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 init(documentId: String, parentCommentId: String? = nil, text: String) {
|
||||
self.documentId = documentId
|
||||
self.parentCommentId = parentCommentId
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,16 @@ import Foundation
|
||||
public struct CreateDocumentRequest: Codable, Sendable {
|
||||
public let title: String
|
||||
public let text: String
|
||||
public let collectionId: String
|
||||
/// `nil` (with `parentDocumentId` also `nil`) creates a draft — Outline
|
||||
/// requires one of the two to publish at all, regardless of `publish`.
|
||||
public let collectionId: String?
|
||||
public let parentDocumentId: String?
|
||||
public let publish: Bool
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
text: String,
|
||||
collectionId: String,
|
||||
collectionId: String? = nil,
|
||||
parentDocumentId: String? = nil,
|
||||
publish: Bool = true
|
||||
) {
|
||||
|
||||
@@ -4,10 +4,15 @@ 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) {
|
||||
public init(documentId: String, offset: Int = 0, limit: Int = 100, includeAnchorText: Bool = false) {
|
||||
self.documentId = documentId
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
self.includeAnchorText = includeAnchorText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public struct ListDraftsRequest: Encodable, Sendable {
|
||||
public let offset: Int
|
||||
public let limit: Int
|
||||
|
||||
public init(offset: Int = 0, limit: Int = 25) {
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,13 @@ public struct UpdateDocumentRequest: Codable, Sendable {
|
||||
public let append: Bool?
|
||||
public let fullWidth: Bool?
|
||||
public let insightsEnabled: Bool?
|
||||
/// Moves the document to this collection. Combined with `publish: true`,
|
||||
/// this is how a draft (no `collectionId`, or one it just hasn't left
|
||||
/// yet) gets published into a specific collection in one call.
|
||||
public let collectionId: String?
|
||||
/// Publishes a draft, making it visible to other workspace members.
|
||||
/// Documented as a no-op if the document is already published.
|
||||
public let publish: Bool?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
@@ -14,7 +21,9 @@ public struct UpdateDocumentRequest: Codable, Sendable {
|
||||
text: String? = nil,
|
||||
append: Bool? = nil,
|
||||
fullWidth: Bool? = nil,
|
||||
insightsEnabled: Bool? = nil
|
||||
insightsEnabled: Bool? = nil,
|
||||
collectionId: String? = nil,
|
||||
publish: Bool? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
@@ -22,5 +31,7 @@ public struct UpdateDocumentRequest: Codable, Sendable {
|
||||
self.append = append
|
||||
self.fullWidth = fullWidth
|
||||
self.insightsEnabled = insightsEnabled
|
||||
self.collectionId = collectionId
|
||||
self.publish = publish
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
||||
|
||||
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
|
||||
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||
@@ -72,8 +73,12 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
||||
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() }
|
||||
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] { throw NotStubbed() }
|
||||
func commentInfo(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func resolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func unresolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func addReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
|
||||
func removeReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
|
||||
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
|
||||
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
|
||||
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
|
||||
|
||||
@@ -860,6 +860,9 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"resolvedAt": null,
|
||||
"resolvedBy": null,
|
||||
"reactions": [
|
||||
{ "emoji": "\u{1F44D}", "userIds": ["user-2", "user-3"] }
|
||||
],
|
||||
"data": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
@@ -887,6 +890,8 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -903,6 +908,7 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
"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": [] }
|
||||
}
|
||||
}
|
||||
@@ -921,6 +927,56 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.resolve")
|
||||
}
|
||||
|
||||
func testCreateCommentSendsParentCommentIdForAReply() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "comment-2",
|
||||
"documentId": "doc-1",
|
||||
"parentCommentId": "comment-1",
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"resolvedAt": null,
|
||||
"resolvedBy": null,
|
||||
"reactions": [],
|
||||
"data": { "type": "doc", "content": [] }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let reply = try await client.createComment(
|
||||
CreateCommentRequest(documentId: "doc-1", parentCommentId: "comment-1", text: "Agreed")
|
||||
)
|
||||
|
||||
XCTAssertEqual(reply.parentCommentId, "comment-1")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.create")
|
||||
}
|
||||
|
||||
func testAddReactionPostsIdAndEmoji() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
try await client.addReaction(commentId: "comment-1", emoji: "\u{1F44D}")
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.add_reaction")
|
||||
}
|
||||
|
||||
func testCreatePinDecodesPin() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
|
||||
@@ -445,6 +445,13 @@ struct ContentView_macOS: View {
|
||||
if !documentPath.isEmpty {
|
||||
documentPath.removeLast()
|
||||
}
|
||||
// Delete/Archive/Unpublish/Move all change what
|
||||
// should show in the sidebar tree — this only
|
||||
// popped the reader before, leaving the sidebar
|
||||
// showing the document until some unrelated
|
||||
// trigger (the 45s poll, navigating away and
|
||||
// back) happened to refresh it.
|
||||
documentsChangedToken += 1
|
||||
},
|
||||
onDocumentCreated: { documentsChangedToken += 1 }
|
||||
)
|
||||
|
||||
@@ -2,23 +2,61 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Read-only comment list + resolve/unresolve — see `OutlineComment` for why
|
||||
/// creating/replying isn't here yet (needs a selection→anchorText flow on
|
||||
/// top of this, scoped out for now). Flat, not threaded: replies
|
||||
/// (`parentCommentId != nil`) are just marked with a small "Reply" label
|
||||
/// rather than nested/grouped, to keep this a straightforward list.
|
||||
/// 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. Creating a NEW anchored comment (from a text selection) isn't
|
||||
/// supported here — only viewing/replying to ones already anchored via the
|
||||
/// web app, whose position `DocumentReaderView` resolves to an inline
|
||||
/// marker.
|
||||
@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
|
||||
|
||||
/// 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 resolvingCommentIds: Set<String> = []
|
||||
@State private var actionErrorMessage: String?
|
||||
@State private var currentUserId: String?
|
||||
|
||||
@State private var newCommentText = ""
|
||||
@State private var isPostingNewComment = false
|
||||
|
||||
@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) {
|
||||
@@ -43,22 +81,37 @@ struct DocumentCommentsSheet: View {
|
||||
} actions: {
|
||||
Button("Retry") { Task { await load() } }
|
||||
}
|
||||
} else if comments.isEmpty {
|
||||
} else if threads.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Comments", systemImage: "bubble.left.and.bubble.right")
|
||||
} description: {
|
||||
Text("This document has no comments yet.")
|
||||
}
|
||||
} else {
|
||||
List(comments.sorted { $0.createdAt < $1.createdAt }) { comment in
|
||||
commentRow(comment)
|
||||
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: 460, height: 480)
|
||||
.frame(width: 480, height: 560)
|
||||
.task { await load() }
|
||||
.task { currentUserId = try? await apiClient.currentUser().id }
|
||||
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
||||
Button("OK") { actionErrorMessage = nil }
|
||||
} message: {
|
||||
@@ -66,18 +119,39 @@ struct DocumentCommentsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func commentRow(_ comment: OutlineComment) -> some View {
|
||||
// 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))
|
||||
if comment.parentCommentId != nil {
|
||||
Text("Reply")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
Spacer()
|
||||
Text(comment.createdAt, format: .relative(presentation: .named))
|
||||
.font(.caption)
|
||||
@@ -88,7 +162,26 @@ struct DocumentCommentsSheet: View {
|
||||
.font(.callout)
|
||||
.foregroundStyle(comment.bodyText.isEmpty ? .secondary : .primary)
|
||||
|
||||
HStack {
|
||||
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)
|
||||
@@ -105,10 +198,102 @@ struct DocumentCommentsSheet: View {
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
} else {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.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 {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField("Add a comment…", 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
|
||||
@@ -120,6 +305,39 @@ struct DocumentCommentsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
comments.append(created)
|
||||
newCommentText = ""
|
||||
} 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
|
||||
} 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) }
|
||||
@@ -127,12 +345,36 @@ struct DocumentCommentsSheet: View {
|
||||
let updated = comment.isResolved
|
||||
? try await apiClient.unresolveComment(id: comment.id)
|
||||
: try await apiClient.resolveComment(id: comment.id)
|
||||
if let index = comments.firstIndex(where: { $0.id == updated.id }) {
|
||||
comments[index] = updated
|
||||
}
|
||||
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
|
||||
|
||||
@@ -83,17 +83,34 @@ struct DocumentReaderView: View {
|
||||
let onDocumentCreated: () -> Void
|
||||
|
||||
@State private var isShowingUnpublishConfirmation = false
|
||||
@State private var isShowingPublishSheet = false
|
||||
@State private var isShowingArchiveConfirmation = false
|
||||
@State private var isShowingDeleteConfirmation = false
|
||||
@State private var isShowingMoveSheet = false
|
||||
@State private var isShowingHistorySheet = false
|
||||
@State private var isShowingInsightsSheet = false
|
||||
@State private var isShowingCommentsSheet = false
|
||||
/// Fetched once on load, purely to decide whether to show the comment
|
||||
/// marker + its count — the sheet itself fetches its own copy
|
||||
/// 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 commentCount: Int?
|
||||
@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] = []
|
||||
|
||||
private var commentCount: Int? {
|
||||
loadedComments.isEmpty ? nil : loadedComments.count
|
||||
}
|
||||
private var commentAnchorQueries: [CommentAnchorQuery] {
|
||||
loadedComments.compactMap { comment in
|
||||
guard let anchorText = comment.anchorText, !anchorText.isEmpty else { return nil }
|
||||
return CommentAnchorQuery(id: comment.id, anchorText: anchorText)
|
||||
}
|
||||
}
|
||||
@State private var isShowingPresentSheet = false
|
||||
@State private var isShowingSearchSheet = false
|
||||
@State private var isShowingShareSheet = false
|
||||
@@ -201,12 +218,15 @@ struct DocumentReaderView: View {
|
||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||
}
|
||||
|
||||
// Marker, not a permanent chrome element — only shows once
|
||||
// there's actually something to point at, per explicit
|
||||
// instruction ("a simple app-side symbol", not a true
|
||||
// in-document gutter marker at each comment's anchor).
|
||||
// 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
|
||||
isShowingCommentsSheet = true
|
||||
} label: {
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
@@ -214,15 +234,16 @@ struct DocumentReaderView: View {
|
||||
.help("\(commentCount) Comment\(commentCount == 1 ? "" : "s")")
|
||||
.disabled(!isEffectivelyOnline)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Text("\(commentCount)")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
Text(commentCount > 10 ? "10+" : "\(commentCount)")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(3)
|
||||
.padding(2)
|
||||
.frame(minWidth: 12, minHeight: 12)
|
||||
.background(.blue, in: Circle())
|
||||
.offset(x: 6, y: -6)
|
||||
.offset(x: 0, y: -1)
|
||||
}
|
||||
.sheet(isPresented: $isShowingCommentsSheet) {
|
||||
DocumentCommentsSheet(apiClient: apiClient, document: document)
|
||||
DocumentCommentsSheet(apiClient: apiClient, document: document, focusedCommentId: focusedCommentId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,9 +323,9 @@ struct DocumentReaderView: View {
|
||||
await viewModel.loadInsightsEnabledState()
|
||||
}
|
||||
.task {
|
||||
commentCount = try? await apiClient.listComments(
|
||||
ListCommentsRequest(documentId: viewModel.documentId)
|
||||
).count
|
||||
loadedComments = (try? await apiClient.listComments(
|
||||
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||
)) ?? []
|
||||
}
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
@@ -353,6 +374,21 @@ struct DocumentReaderView: View {
|
||||
} message: {
|
||||
Text(actionErrorMessage ?? "")
|
||||
}
|
||||
.sheet(isPresented: $isShowingPublishSheet) {
|
||||
PublishDocumentSheet(apiClient: apiClient, document: document) {
|
||||
// Stays in the reader — unlike Move/Unpublish, publishing
|
||||
// doesn't make the document any less visible from here, so
|
||||
// there's no reason to pop back like onDeleted() does
|
||||
// elsewhere. Refresh from the server rather than guessing
|
||||
// the new collectionId locally (moveDocument may have run
|
||||
// as a second step inside the sheet).
|
||||
await viewModel.loadFullContent()
|
||||
// The doc previously had no collectionId (or a different
|
||||
// one) — the sidebar tree for its new collection needs to
|
||||
// know it exists now, same signal "New Document" sends.
|
||||
onDocumentCreated()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isShowingMoveSheet) {
|
||||
MoveDocumentSheet(apiClient: apiClient, document: document) {
|
||||
onDeleted()
|
||||
@@ -488,13 +524,21 @@ struct DocumentReaderView: View {
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEffectivelyEditable,
|
||||
onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
|
||||
onSelectedTextChange: { currentSelectedText = $0 }
|
||||
onSelectedTextChange: { currentSelectedText = $0 },
|
||||
commentAnchorQueries: commentAnchorQueries,
|
||||
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
|
||||
)
|
||||
if showCodeBlockLineNumbers {
|
||||
ForEach(readerCodeBlocks) { selection in
|
||||
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||
}
|
||||
}
|
||||
ForEach(commentAnchorRects) { anchor in
|
||||
CommentAnchorMarker(rect: anchor.rect) {
|
||||
focusedCommentId = anchor.id
|
||||
isShowingCommentsSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -562,13 +606,21 @@ struct DocumentReaderView: View {
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: false,
|
||||
onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
|
||||
onSelectedTextChange: { currentSelectedText = $0 }
|
||||
onSelectedTextChange: { currentSelectedText = $0 },
|
||||
commentAnchorQueries: commentAnchorQueries,
|
||||
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
|
||||
)
|
||||
if showCodeBlockLineNumbers {
|
||||
ForEach(previewCodeBlocks) { selection in
|
||||
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||
}
|
||||
}
|
||||
ForEach(commentAnchorRects) { anchor in
|
||||
CommentAnchorMarker(rect: anchor.rect) {
|
||||
focusedCommentId = anchor.id
|
||||
isShowingCommentsSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
@@ -613,10 +665,17 @@ struct DocumentReaderView: View {
|
||||
Task { await duplicate() }
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
if viewModel.publishedAt == nil {
|
||||
Button("Publish…") {
|
||||
isShowingPublishSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
} else {
|
||||
Button("Unpublish") {
|
||||
isShowingUnpublishConfirmation = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
}
|
||||
Button("Archive…") {
|
||||
isShowingArchiveConfirmation = true
|
||||
}
|
||||
@@ -834,6 +893,35 @@ struct DocumentReaderView: View {
|
||||
/// char-wraps code blocks, no way to opt out without forking the package)
|
||||
/// throws this off — every row below it reads one line low. Documented in
|
||||
/// TODO.local.md alongside the same package's other gaps.
|
||||
/// Thin vertical bar next to an anchored comment's text — same visual
|
||||
/// language as Word/Google Docs' margin comment indicators. Tapping opens
|
||||
/// the comments sheet focused to that thread.
|
||||
private struct CommentAnchorMarker: View {
|
||||
let rect: CGRect
|
||||
let onTap: () -> Void
|
||||
|
||||
private static let width: CGFloat = 3
|
||||
private static let gap: CGFloat = 4
|
||||
private static let hitTargetWidth: CGFloat = 16
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: Self.hitTargetWidth, height: max(rect.height, 4))
|
||||
.contentShape(Rectangle())
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.frame(width: Self.width)
|
||||
}
|
||||
.position(
|
||||
x: rect.minX - Self.gap - Self.width / 2,
|
||||
y: rect.minY + rect.height / 2
|
||||
)
|
||||
.onTapGesture(perform: onTap)
|
||||
.help("View comment")
|
||||
}
|
||||
}
|
||||
|
||||
private struct CodeBlockLineNumberGutter: View {
|
||||
let selection: CodeBlockSelection
|
||||
let gutterWidth: CGFloat
|
||||
|
||||
@@ -9,6 +9,9 @@ final class DocumentReaderViewModel {
|
||||
var emoji: String?
|
||||
var text: String
|
||||
var collectionId: String?
|
||||
var parentDocumentId: String?
|
||||
/// `nil` = draft (not published/visible to other workspace members).
|
||||
var publishedAt: Date?
|
||||
var isFullWidth = false
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
@@ -76,6 +79,8 @@ final class DocumentReaderViewModel {
|
||||
self.emoji = document.emoji
|
||||
self.text = document.text
|
||||
self.collectionId = document.collectionId
|
||||
self.parentDocumentId = document.parentDocumentId
|
||||
self.publishedAt = document.publishedAt
|
||||
self.isFullWidth = document.fullWidth ?? false
|
||||
self.separateEditingEnabled = separateEditingEnabled
|
||||
self.lastSyncedText = document.text
|
||||
@@ -95,6 +100,8 @@ final class DocumentReaderViewModel {
|
||||
emoji = full.emoji
|
||||
text = full.text
|
||||
collectionId = full.collectionId
|
||||
parentDocumentId = full.parentDocumentId
|
||||
publishedAt = full.publishedAt
|
||||
isFullWidth = full.fullWidth ?? false
|
||||
lastSyncedText = full.text
|
||||
lastSyncedTitle = full.title
|
||||
|
||||
@@ -71,7 +71,7 @@ struct NewDocumentSheet: View {
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Picker("Collection", selection: $selectedCollectionID) {
|
||||
Text("Choose a collection").tag(String?.none)
|
||||
Text("Draft (not published)").tag(String?.none)
|
||||
ForEach(collections) { collection in
|
||||
Text(collection.name).tag(Optional(collection.id))
|
||||
}
|
||||
@@ -86,6 +86,12 @@ struct NewDocumentSheet: View {
|
||||
}
|
||||
.labelsHidden()
|
||||
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
|
||||
|
||||
if selectedCollectionID != nil {
|
||||
Label("This will publish the document immediately, making it visible to your workspace.", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
@@ -97,18 +103,24 @@ struct NewDocumentSheet: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
Button("Create") {
|
||||
Button(selectedCollectionID == nil ? "Save as Draft" : "Create & Publish") {
|
||||
Task { await create() }
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(selectedCollectionID == nil || isCreating)
|
||||
.disabled(isCreating)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 380)
|
||||
.task {
|
||||
await loadCollections()
|
||||
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id
|
||||
// "By default" means a bare New Document (Home, reader
|
||||
// toolbar) starts as a draft — no `collections.first` fallback
|
||||
// like there used to be. A contextual entry point (right-click
|
||||
// a specific collection/document) still pre-fills that
|
||||
// location, since that already carries clear placement intent;
|
||||
// the publish warning above still applies to it either way.
|
||||
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId
|
||||
selectedParentID = initialParentDocument?.id
|
||||
isTitleFocused = true
|
||||
}
|
||||
@@ -147,7 +159,6 @@ struct NewDocumentSheet: View {
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
guard let selectedCollectionID else { return }
|
||||
isCreating = true
|
||||
defer { isCreating = false }
|
||||
do {
|
||||
@@ -156,7 +167,12 @@ struct NewDocumentSheet: View {
|
||||
title: title.isEmpty ? "Untitled" : title,
|
||||
text: "",
|
||||
collectionId: selectedCollectionID,
|
||||
parentDocumentId: selectedParentID
|
||||
parentDocumentId: selectedParentID,
|
||||
// No collection/parent selected = draft; Outline can't
|
||||
// publish without one of the two regardless of this
|
||||
// flag, but setting it explicitly rather than relying
|
||||
// on that keeps intent obvious at the call site.
|
||||
publish: selectedCollectionID != nil
|
||||
)
|
||||
)
|
||||
onCreated(document)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Destination picker for publishing a draft — same collection-then-
|
||||
/// optional-parent-document shape as `MoveDocumentSheet`, since Outline's
|
||||
/// own web app treats "where does this go" identically for both actions.
|
||||
/// Pre-selects the document's own `collectionId`/`parentDocumentId` when it
|
||||
/// already has one (a draft can already belong to a collection without
|
||||
/// being published) rather than starting blank, per explicit instruction.
|
||||
///
|
||||
/// Publishing itself is two calls, not one: `documents.update(publish:
|
||||
/// true, collectionId:)` does the actual publish and top-level collection
|
||||
/// placement in one round-trip (confirmed from a live network capture);
|
||||
/// nesting under a specific parent document isn't a documented
|
||||
/// `documents.update` field, so that part reuses `documents.move` — the
|
||||
/// same already-working mechanism `MoveDocumentSheet` uses — as a second
|
||||
/// step, only when a parent was actually chosen.
|
||||
@MainActor
|
||||
struct PublishDocumentSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let apiClient: OutlineAPIClient
|
||||
let document: OutlineDocument
|
||||
let onPublished: () async -> Void
|
||||
|
||||
@State private var collections: [OutlineCollection] = []
|
||||
@State private var selectedCollectionID: String?
|
||||
@State private var rootDocuments: [OutlineDocument] = []
|
||||
@State private var selectedParentID: String?
|
||||
@State private var isLoadingCollections = false
|
||||
@State private var isLoadingDestinationDocuments = false
|
||||
@State private var isPublishing = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Publish \"\(document.title.isEmpty ? "Untitled" : document.title)\"")
|
||||
.font(.headline)
|
||||
Text("Choose where this document should live once published.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if isLoadingCollections {
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Picker("Collection", selection: $selectedCollectionID) {
|
||||
Text("Choose a collection").tag(String?.none)
|
||||
ForEach(collections) { collection in
|
||||
Text(collection.name).tag(Optional(collection.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
Picker("Location", selection: $selectedParentID) {
|
||||
Text("Collection root").tag(String?.none)
|
||||
ForEach(rootDocuments.filter { $0.id != document.id }) { candidate in
|
||||
Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
Button("Publish") {
|
||||
Task { await publish() }
|
||||
}
|
||||
.disabled(selectedCollectionID == nil || isPublishing)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 360)
|
||||
.task {
|
||||
await loadCollections()
|
||||
selectedCollectionID = document.collectionId
|
||||
selectedParentID = document.parentDocumentId
|
||||
}
|
||||
.task(id: selectedCollectionID) {
|
||||
await loadRootDocuments()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCollections() async {
|
||||
isLoadingCollections = true
|
||||
defer { isLoadingCollections = false }
|
||||
do {
|
||||
collections = try await apiClient.listCollections(offset: 0, limit: 100)
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRootDocuments() async {
|
||||
guard let selectedCollectionID else {
|
||||
rootDocuments = []
|
||||
return
|
||||
}
|
||||
isLoadingDestinationDocuments = true
|
||||
defer { isLoadingDestinationDocuments = false }
|
||||
do {
|
||||
rootDocuments = try await apiClient.listDocuments(
|
||||
collectionId: selectedCollectionID,
|
||||
parentDocumentId: nil,
|
||||
offset: 0,
|
||||
limit: 100
|
||||
)
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.")
|
||||
}
|
||||
}
|
||||
|
||||
private func publish() async {
|
||||
guard let selectedCollectionID else { return }
|
||||
isPublishing = true
|
||||
defer { isPublishing = false }
|
||||
do {
|
||||
_ = try await apiClient.updateDocument(
|
||||
UpdateDocumentRequest(id: document.id, collectionId: selectedCollectionID, publish: true)
|
||||
)
|
||||
// Only a documents.move call actually supports parentDocumentId —
|
||||
// skip it entirely when publishing straight to the collection root,
|
||||
// the update above already placed it there.
|
||||
if let selectedParentID {
|
||||
try await apiClient.moveDocument(
|
||||
MoveDocumentRequest(id: document.id, collectionId: selectedCollectionID, parentDocumentId: selectedParentID)
|
||||
)
|
||||
}
|
||||
await onPublished()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't publish this document.")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -5,6 +5,7 @@ enum HomeTab: String, CaseIterable, Identifiable {
|
||||
case popular = "Popular"
|
||||
case recentlyUpdated = "Recently Updated"
|
||||
case createdByMe = "Created by Me"
|
||||
case drafts = "Drafts"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ final class HomeViewModel {
|
||||
private(set) var popular: [OutlineDocument] = []
|
||||
private(set) var recentlyUpdated: [OutlineDocument] = []
|
||||
private(set) var createdByMe: [OutlineDocument] = []
|
||||
private(set) var drafts: [OutlineDocument] = []
|
||||
|
||||
var isLoadingPinned = false
|
||||
var isLoadingTab = false
|
||||
@@ -32,6 +33,7 @@ final class HomeViewModel {
|
||||
case .popular: popular
|
||||
case .recentlyUpdated: recentlyUpdated
|
||||
case .createdByMe: createdByMe
|
||||
case .drafts: drafts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +117,8 @@ final class HomeViewModel {
|
||||
return try await apiClient.documentsList(
|
||||
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
|
||||
)
|
||||
case .drafts:
|
||||
return try await apiClient.listDrafts(ListDraftsRequest(limit: 25))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +128,7 @@ final class HomeViewModel {
|
||||
case .popular: popular = documents
|
||||
case .recentlyUpdated: recentlyUpdated = documents
|
||||
case .createdByMe: createdByMe = documents
|
||||
case .drafts: drafts = documents
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
+2
@@ -337,6 +337,7 @@ extension NativeTextViewCoordinator {
|
||||
|
||||
PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens, classified: parsed.classified, blocks: parsed.blocks) }
|
||||
PerfTrace.measure("codeSel") { updateCodeBlockSelection(textView: tv, parsed: parsed) }
|
||||
updateCommentAnchorRects(textView: tv)
|
||||
if wtActive {
|
||||
previousActiveTokenIndices = activeTokenIndices
|
||||
PerfTrace.end()
|
||||
@@ -700,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.
|
||||
if !shouldSkipSelectionRestyle {
|
||||
updateCodeBlockSelection(textView: tv, parsed: parsed)
|
||||
updateCommentAnchorRects(textView: tv)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -85,6 +85,8 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
|
||||
var onInlinePreviewKey: ((InlinePreviewKey) -> Bool)?
|
||||
var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
|
||||
var onSelectedTextChange: ((String?) -> Void)?
|
||||
var commentAnchorQueries: [CommentAnchorQuery] = []
|
||||
var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)?
|
||||
var didInitialFormatting: Bool = false
|
||||
/// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document.
|
||||
var didEnsureLayoutForCurrentDocument: Bool = false
|
||||
|
||||
+6
@@ -281,6 +281,12 @@ extension NativeTextView {
|
||||
self.restyleWidthDependentParagraphsForWidthChange()
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -104,6 +104,14 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
/// 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
|
||||
/// menu items. Embedders persist the policy and pass it back via
|
||||
/// ``MarkdownEditorConfiguration/spellChecking`` on next launch.
|
||||
@@ -164,6 +172,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? = nil,
|
||||
onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? = nil,
|
||||
onSelectedTextChange: ((String?) -> Void)? = nil,
|
||||
commentAnchorQueries: [CommentAnchorQuery] = [],
|
||||
onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)? = nil,
|
||||
onSpellCheckingPolicyChanged: ((SpellCheckingPolicy) -> Void)? = nil,
|
||||
placeholder: NSAttributedString? = nil,
|
||||
header: AnyView? = nil,
|
||||
@@ -191,6 +201,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
self.onInlinePreviewKey = onInlinePreviewKey
|
||||
self.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||
self.onSelectedTextChange = onSelectedTextChange
|
||||
self.commentAnchorQueries = commentAnchorQueries
|
||||
self.onCommentAnchorRectsChange = onCommentAnchorRectsChange
|
||||
self.onSpellCheckingPolicyChanged = onSpellCheckingPolicyChanged
|
||||
self.placeholder = placeholder
|
||||
self.header = header
|
||||
@@ -342,6 +354,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
|
||||
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||
context.coordinator.onSelectedTextChange = onSelectedTextChange
|
||||
context.coordinator.onCommentAnchorRectsChange = onCommentAnchorRectsChange
|
||||
context.coordinator.commentAnchorQueries = commentAnchorQueries
|
||||
|
||||
textView.recalcOverscroll(for: scrollView)
|
||||
textView.setPlaceholder(placeholder)
|
||||
@@ -576,6 +590,20 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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.isSelectable = true
|
||||
// Keep the caret ink the selection handler resolved (an extension span
|
||||
@@ -706,8 +734,10 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
}
|
||||
// Document rebuilds bypass textDidChange — re-derive emptiness here.
|
||||
textView.refreshPlaceholderVisibility()
|
||||
context.coordinator.commentAnchorQueries = commentAnchorQueries
|
||||
DispatchQueue.main.async {
|
||||
context.coordinator.updateCodeBlockSelection(textView: textView)
|
||||
context.coordinator.updateCommentAnchorRects(textView: textView)
|
||||
}
|
||||
|
||||
context.coordinator.onCaretRectChange = onCaretRectChange
|
||||
@@ -716,6 +746,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
|
||||
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||
context.coordinator.onSelectedTextChange = onSelectedTextChange
|
||||
context.coordinator.onCommentAnchorRectsChange = onCommentAnchorRectsChange
|
||||
context.coordinator.didInitialFormatting = true
|
||||
}
|
||||
|
||||
@@ -739,6 +770,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
coordinator.lastWikiFingerprint = configuration.services.wikiLinks.fingerprint()
|
||||
coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||
coordinator.onSelectedTextChange = onSelectedTextChange
|
||||
coordinator.onCommentAnchorRectsChange = onCommentAnchorRectsChange
|
||||
coordinator.commentAnchorQueries = commentAnchorQueries
|
||||
coordinator.onInlinePreviewKey = onInlinePreviewKey
|
||||
coordinator.userPrefersContinuousSpellChecking = configuration.spellChecking.continuousSpellChecking
|
||||
coordinator.userPrefersGrammarChecking = configuration.spellChecking.grammarChecking
|
||||
|
||||
Reference in New Issue
Block a user