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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +266,36 @@ struct DocumentCommentsSheet: View {
|
||||
// MARK: - Composers
|
||||
|
||||
private var newCommentComposer: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let composingAnchorText {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "text.quote")
|
||||
.foregroundStyle(.blue)
|
||||
Text(composingAnchorText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer()
|
||||
Button {
|
||||
self.composingAnchorText = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.help("Remove — this will post as a document-level comment instead")
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField("Add a comment…", text: $newCommentText, axis: .vertical)
|
||||
TextField(
|
||||
composingAnchorText == nil ? "Add a comment…" : "Comment on this text…",
|
||||
text: $newCommentText,
|
||||
axis: .vertical
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...4)
|
||||
.onSubmit { Task { await postNewComment() } }
|
||||
@@ -267,6 +308,7 @@ struct DocumentCommentsSheet: View {
|
||||
.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.")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user