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.
This commit is contained in:
2026-08-20 21:52:13 +01:00
parent b31d49bb7f
commit 2d0972de1e
4 changed files with 135 additions and 22 deletions
@@ -3,19 +3,24 @@ import Foundation
/// `parentCommentId: nil` creates a document-level (top-level) comment; /// `parentCommentId: nil` creates a document-level (top-level) comment;
/// non-nil creates a reply (Outline supports one level of nesting a /// non-nil creates a reply (Outline supports one level of nesting a
/// reply's own `parentCommentId` should always be a top-level comment's /// reply's own `parentCommentId` should always be a top-level comment's
/// id, never another reply's). Anchored (text-selection) comments aren't /// id, never another reply's). `anchorText` creates an inline (anchored)
/// supported here that needs `anchorText`, out of scope for this app. /// comment instead of a document-level one the first occurrence of that
/// `text` is the documented markdown convenience field for `data` /// exact substring in the document's plain text is used, same as Outline's
/// (a ProseMirror document) simplest path for plain-text comment bodies, /// own web editor; `anchorPrefix`/`anchorSuffix` aren't sent (this app has
/// no need to construct ProseMirror JSON by hand. /// 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 struct CreateCommentRequest: Encodable, Sendable {
public let documentId: String public let documentId: String
public let parentCommentId: String? public let parentCommentId: String?
public let text: 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.documentId = documentId
self.parentCommentId = parentCommentId self.parentCommentId = parentCommentId
self.text = text self.text = text
self.anchorText = anchorText
} }
} }
@@ -6,10 +6,7 @@ import OutlineKit
/// reactions + resolve/unresolve. Composing new comments/replies is still /// reactions + resolve/unresolve. Composing new comments/replies is still
/// plain text only (no bold/italic/lists/etc.) sent through the `text` /// plain text only (no bold/italic/lists/etc.) sent through the `text`
/// (markdown) convenience field, not a hand-built ProseMirror `data` /// (markdown) convenience field, not a hand-built ProseMirror `data`
/// document. Creating a NEW anchored comment (from a text selection) isn't /// document.
/// supported here only viewing/replying to ones already anchored via the
/// web app, whose position `DocumentReaderView` resolves to an inline
/// marker.
@MainActor @MainActor
struct DocumentCommentsSheet: View { struct DocumentCommentsSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -20,6 +17,17 @@ struct DocumentCommentsSheet: View {
/// briefly highlights that comment/reply. `nil` opens unfocused (the /// briefly highlights that comment/reply. `nil` opens unfocused (the
/// plain toolbar marker). /// plain toolbar marker).
var focusedCommentId: String? = nil 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 /// A small curated set rather than the full system emoji picker quick
/// taps for the common reactions, matching how most chat apps default. /// 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 newCommentText = ""
@State private var isPostingNewComment = false @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 replyingToThreadId: String?
@State private var replyText = "" @State private var replyText = ""
@@ -112,6 +124,7 @@ struct DocumentCommentsSheet: View {
.frame(width: 480, height: 560) .frame(width: 480, height: 560)
.task { await load() } .task { await load() }
.task { currentUserId = try? await apiClient.currentUser().id } .task { currentUserId = try? await apiClient.currentUser().id }
.task { composingAnchorText = pendingAnchorText }
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) { .alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
Button("OK") { actionErrorMessage = nil } Button("OK") { actionErrorMessage = nil }
} message: { } message: {
@@ -253,8 +266,36 @@ struct DocumentCommentsSheet: View {
// MARK: - Composers // MARK: - Composers
private var newCommentComposer: some View { 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) { HStack(alignment: .bottom, spacing: 8) {
TextField("Add a comment…", text: $newCommentText, axis: .vertical) TextField(
composingAnchorText == nil ? "Add a comment…" : "Comment on this text…",
text: $newCommentText,
axis: .vertical
)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.lineLimit(1...4) .lineLimit(1...4)
.onSubmit { Task { await postNewComment() } } .onSubmit { Task { await postNewComment() } }
@@ -267,6 +308,7 @@ struct DocumentCommentsSheet: View {
.disabled(newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
} }
}
.padding() .padding()
} }
@@ -312,10 +354,12 @@ struct DocumentCommentsSheet: View {
defer { isPostingNewComment = false } defer { isPostingNewComment = false }
do { do {
let created = try await apiClient.createComment( let created = try await apiClient.createComment(
CreateCommentRequest(documentId: document.id, text: text) CreateCommentRequest(documentId: document.id, text: text, anchorText: composingAnchorText)
) )
comments.append(created) comments.append(created)
composingAnchorText = nil
newCommentText = "" newCommentText = ""
onCommentsChanged?()
} catch { } catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this comment.") actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this comment.")
} }
@@ -333,6 +377,7 @@ struct DocumentCommentsSheet: View {
comments.append(created) comments.append(created)
replyText = "" replyText = ""
replyingToThreadId = nil replyingToThreadId = nil
onCommentsChanged?()
} catch { } catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this reply.") actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this reply.")
} }
@@ -101,6 +101,9 @@ struct DocumentReaderView: View {
/// Resolved on-screen positions for each anchored comment's text, from /// Resolved on-screen positions for each anchored comment's text, from
/// `NativeTextViewWrapper.onCommentAnchorRectsChange`. /// `NativeTextViewWrapper.onCommentAnchorRectsChange`.
@State private var commentAnchorRects: [CommentAnchorRect] = [] @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? { private var commentCount: Int? {
loadedComments.isEmpty ? nil : loadedComments.count loadedComments.isEmpty ? nil : loadedComments.count
@@ -227,6 +230,7 @@ struct DocumentReaderView: View {
if let commentCount, commentCount > 0 { if let commentCount, commentCount > 0 {
Button { Button {
focusedCommentId = nil focusedCommentId = nil
pendingCommentAnchorText = nil
isShowingCommentsSheet = true isShowingCommentsSheet = true
} label: { } label: {
Image(systemName: "bubble.left.and.bubble.right") Image(systemName: "bubble.left.and.bubble.right")
@@ -242,9 +246,6 @@ struct DocumentReaderView: View {
.background(.blue, in: Circle()) .background(.blue, in: Circle())
.offset(x: 0, y: -1) .offset(x: 0, y: -1)
} }
.sheet(isPresented: $isShowingCommentsSheet) {
DocumentCommentsSheet(apiClient: apiClient, document: document, focusedCommentId: focusedCommentId)
}
} }
if isImagePlaygroundEnabled && isImagePlaygroundSupported { if isImagePlaygroundEnabled && isImagePlaygroundSupported {
@@ -374,6 +375,21 @@ 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) { .sheet(isPresented: $isShowingPublishSheet) {
PublishDocumentSheet(apiClient: apiClient, document: document) { PublishDocumentSheet(apiClient: apiClient, document: document) {
// Stays in the reader unlike Move/Unpublish, publishing // 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 /// Uploads an Image Playground result the same way the reader would any
/// other attachment (`attachments.create` presigned target, then the /// other attachment (`attachments.create` presigned target, then the
/// direct file POST see `OutlineAPIClient.uploadAttachmentFile`), then /// direct file POST see `OutlineAPIClient.uploadAttachmentFile`), then
@@ -523,6 +557,7 @@ struct DocumentReaderView: View {
), ),
documentId: viewModel.documentId, documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable, isEditable: viewModel.isEffectivelyEditable,
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
onCodeBlockSelectionChange: { readerCodeBlocks = $0 }, onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 }, onSelectedTextChange: { currentSelectedText = $0 },
commentAnchorQueries: commentAnchorQueries, commentAnchorQueries: commentAnchorQueries,
@@ -536,6 +571,7 @@ struct DocumentReaderView: View {
ForEach(commentAnchorRects) { anchor in ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) { CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id focusedCommentId = anchor.id
pendingCommentAnchorText = nil
isShowingCommentsSheet = true isShowingCommentsSheet = true
} }
} }
@@ -605,6 +641,7 @@ struct DocumentReaderView: View {
), ),
documentId: viewModel.documentId, documentId: viewModel.documentId,
isEditable: false, isEditable: false,
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
onCodeBlockSelectionChange: { previewCodeBlocks = $0 }, onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 }, onSelectedTextChange: { currentSelectedText = $0 },
commentAnchorQueries: commentAnchorQueries, commentAnchorQueries: commentAnchorQueries,
@@ -618,6 +655,7 @@ struct DocumentReaderView: View {
ForEach(commentAnchorRects) { anchor in ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) { CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id focusedCommentId = anchor.id
pendingCommentAnchorText = nil
isShowingCommentsSheet = true isShowingCommentsSheet = true
} }
} }
+25
View File
@@ -0,0 +1,25 @@
#if os(macOS)
import AppKit
/// `NSMenuItem` has no closure-based initializer the standard AppKit
/// pattern is a small subclass that's its own target/action, so callers can
/// just pass a Swift closure instead of wiring up a selector by hand.
final class ClosureMenuItem: NSMenuItem {
private let handler: () -> Void
init(title: String, handler: @escaping () -> Void) {
self.handler = handler
super.init(title: title, action: #selector(invokeHandler), keyEquivalent: "")
self.target = self
}
@available(*, unavailable)
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func invokeHandler() {
handler()
}
}
#endif