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;
|
/// `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,18 +266,47 @@ struct DocumentCommentsSheet: View {
|
|||||||
// MARK: - Composers
|
// MARK: - Composers
|
||||||
|
|
||||||
private var newCommentComposer: some View {
|
private var newCommentComposer: some View {
|
||||||
HStack(alignment: .bottom, spacing: 8) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
TextField("Add a comment…", text: $newCommentText, axis: .vertical)
|
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)
|
.textFieldStyle(.roundedBorder)
|
||||||
.lineLimit(1...4)
|
.lineLimit(1...4)
|
||||||
.onSubmit { Task { await postNewComment() } }
|
.onSubmit { Task { await postNewComment() } }
|
||||||
if isPostingNewComment {
|
if isPostingNewComment {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
} else {
|
} else {
|
||||||
Button("Post") {
|
Button("Post") {
|
||||||
Task { await postNewComment() }
|
Task { await postNewComment() }
|
||||||
|
}
|
||||||
|
.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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