From 2d0972de1eef8172f264dc4a76ba97cfcefec933 Mon Sep 17 00:00:00 2001 From: psmattas Date: Thu, 20 Aug 2026 21:52:13 +0100 Subject: [PATCH] feat: create inline (anchored) comments from a text selection Right-click a selection -> "Comment on Selection..." -> opens the comments sheet straight into composing, with a removable chip showing what's being anchored to (clearing it falls back to a plain document-level comment). Posts via comments.create with anchorText set to the selection. Only wired on rendered panes (main reader pane, Split View's preview pane) - not the raw-markdown pane, since a selection there would capture literal Markdown syntax as the anchor instead of clean prose. NSMenuItem has no closure initializer, so this needed a small target-is-self subclass (ClosureMenuItem) to wire a Swift closure into onBuildContextMenu's NSMenu without a separate selector per action. Also moved the comments sheet's .sheet(...) modifier off the toolbar badge button (which only exists once the doc already has comments) onto the view root, since a document with zero comments still needs to be able to open the sheet to create its first one. Reader now refetches its own comment list after any create/reply via the sheet's new onCommentsChanged callback, so a freshly anchored comment's inline marker appears without reopening the document. --- .../Requests/CreateCommentRequest.swift | 17 +++-- .../Collections/DocumentCommentsSheet.swift | 71 +++++++++++++++---- .../Collections/DocumentReaderView.swift | 44 +++++++++++- Outpost/Support/ClosureMenuItem.swift | 25 +++++++ 4 files changed, 135 insertions(+), 22 deletions(-) create mode 100644 Outpost/Support/ClosureMenuItem.swift diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateCommentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateCommentRequest.swift index cd8c2f5..1abbd4f 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/CreateCommentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateCommentRequest.swift @@ -3,19 +3,24 @@ 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. +/// 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) { + public init(documentId: String, parentCommentId: String? = nil, text: String, anchorText: String? = nil) { self.documentId = documentId self.parentCommentId = parentCommentId self.text = text + self.anchorText = anchorText } } diff --git a/Outpost/Features/Collections/DocumentCommentsSheet.swift b/Outpost/Features/Collections/DocumentCommentsSheet.swift index f6090f2..8cd446e 100644 --- a/Outpost/Features/Collections/DocumentCommentsSheet.swift +++ b/Outpost/Features/Collections/DocumentCommentsSheet.swift @@ -6,10 +6,7 @@ import OutlineKit /// 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. +/// document. @MainActor struct DocumentCommentsSheet: View { @Environment(\.dismiss) private var dismiss @@ -20,6 +17,17 @@ struct DocumentCommentsSheet: View { /// 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. @@ -33,6 +41,10 @@ struct DocumentCommentsSheet: View { @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 = "" @@ -112,6 +124,7 @@ struct DocumentCommentsSheet: View { .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: { @@ -253,18 +266,47 @@ struct DocumentCommentsSheet: View { // MARK: - Composers private var newCommentComposer: some View { - HStack(alignment: .bottom, spacing: 8) { - TextField("Add a comment…", text: $newCommentText, axis: .vertical) + 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() } + if isPostingNewComment { + ProgressView().controlSize(.small) + } else { + Button("Post") { + Task { await postNewComment() } + } + .disabled(newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - .disabled(newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } .padding() @@ -312,10 +354,12 @@ struct DocumentCommentsSheet: View { defer { isPostingNewComment = false } do { let created = try await apiClient.createComment( - CreateCommentRequest(documentId: document.id, text: text) + 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.") } @@ -333,6 +377,7 @@ struct DocumentCommentsSheet: View { comments.append(created) replyText = "" replyingToThreadId = nil + onCommentsChanged?() } catch { actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this reply.") } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 61c147b..b39a3bd 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -101,6 +101,9 @@ struct DocumentReaderView: View { /// 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 @@ -227,6 +230,7 @@ struct DocumentReaderView: View { if let commentCount, commentCount > 0 { Button { focusedCommentId = nil + pendingCommentAnchorText = nil isShowingCommentsSheet = true } label: { Image(systemName: "bubble.left.and.bubble.right") @@ -242,9 +246,6 @@ struct DocumentReaderView: View { .background(.blue, in: Circle()) .offset(x: 0, y: -1) } - .sheet(isPresented: $isShowingCommentsSheet) { - DocumentCommentsSheet(apiClient: apiClient, document: document, focusedCommentId: focusedCommentId) - } } if isImagePlaygroundEnabled && isImagePlaygroundSupported { @@ -374,6 +375,21 @@ struct DocumentReaderView: View { } message: { Text(actionErrorMessage ?? "") } + .sheet(isPresented: $isShowingCommentsSheet) { + DocumentCommentsSheet( + apiClient: apiClient, + document: document, + focusedCommentId: focusedCommentId, + pendingAnchorText: pendingCommentAnchorText, + onCommentsChanged: { + Task { + loadedComments = (try? await apiClient.listComments( + ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true) + )) ?? loadedComments + } + } + ) + } .sheet(isPresented: $isShowingPublishSheet) { PublishDocumentSheet(apiClient: apiClient, document: document) { // Stays in the reader — unlike Move/Unpublish, publishing @@ -420,6 +436,24 @@ struct DocumentReaderView: View { )) } + /// 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 @@ -523,6 +557,7 @@ struct DocumentReaderView: View { ), documentId: viewModel.documentId, isEditable: viewModel.isEffectivelyEditable, + onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) }, onCodeBlockSelectionChange: { readerCodeBlocks = $0 }, onSelectedTextChange: { currentSelectedText = $0 }, commentAnchorQueries: commentAnchorQueries, @@ -536,6 +571,7 @@ struct DocumentReaderView: View { ForEach(commentAnchorRects) { anchor in CommentAnchorMarker(rect: anchor.rect) { focusedCommentId = anchor.id + pendingCommentAnchorText = nil isShowingCommentsSheet = true } } @@ -605,6 +641,7 @@ struct DocumentReaderView: View { ), documentId: viewModel.documentId, isEditable: false, + onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) }, onCodeBlockSelectionChange: { previewCodeBlocks = $0 }, onSelectedTextChange: { currentSelectedText = $0 }, commentAnchorQueries: commentAnchorQueries, @@ -618,6 +655,7 @@ struct DocumentReaderView: View { ForEach(commentAnchorRects) { anchor in CommentAnchorMarker(rect: anchor.rect) { focusedCommentId = anchor.id + pendingCommentAnchorText = nil isShowingCommentsSheet = true } } diff --git a/Outpost/Support/ClosureMenuItem.swift b/Outpost/Support/ClosureMenuItem.swift new file mode 100644 index 0000000..d1b2d21 --- /dev/null +++ b/Outpost/Support/ClosureMenuItem.swift @@ -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