Skip to content

Commit 688cfdc

Browse files
angusbezzinaclaude
andcommitted
Annotation UX round 3 (cli-i2bnm): numbered comment pins, pill polish, composer autofocus
- Numbered comment pins (feature 1): each captured note stores a WINDOW-LOCAL anchor (element AX top-left minus axOrigin at capture) so a pin stays glued to where the note was made under host-window moves (fixed, not live-tracked). Pins render only in annotate mode, layered above the catcher so they consume their own hover/click and never re-select the element. Hovering a pin opens an inline-editable popover (edit + delete-this-comment) via new AnnotationSession.updateNote/deleteNote; pin numbers and the count badge derive from the retained set, so delete reflows both. anchor is UI-only (omitted from CodingKeys) so the JSON store + MCP payload are byte-identical. - Pill polish (feature 2): count bubble overlaps the whole pill's top-left corner; annotate toggle active state is a white glyph with no blue circle; the media play/pause glyph is replaced with a pencil (Lucide square-dashed-mouse-pointer needs elliptical arcs + dashed strokes the shared LucideShape parser/stroker do not support). - Composer autofocus (feature 3): the field focuses on element click (panel made key first, since the child panel is non-activating); Escape cancels; the multiline field keeps Return-as-newline with Cmd-Return to submit. Focus is re-asserted on the next tick for both the composer and the pin editor to beat the first-responder race. New AnnotKitOverlayProbe pin-model assertions (distinct window-local anchors; updateNote edits; deleteNote reflows). Build clean (Swift 6); 44 tests; both probes pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e3b2ddd commit 688cfdc

8 files changed

Lines changed: 390 additions & 43 deletions

File tree

Sources/AnnotKit/AnnotationSink.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import CoreGraphics
12
import Foundation
23

34
/// Output format for sinks that serialize notes as a blob (clipboard, logs).
@@ -40,6 +41,21 @@ public struct AnnotationNote: Sendable, Hashable, Identifiable, Codable {
4041
/// ISO-8601 timestamp, injected by the caller (kept out of pure logic so the
4142
/// model stays deterministic and testable).
4243
public var timestamp: String
44+
/// Window-local top-left where the numbered pin is drawn in annotate mode
45+
/// (the element's AX frame origin minus the host window's `axOrigin` AT
46+
/// CAPTURE). UI-only: intentionally NOT persisted — it is omitted from
47+
/// ``CodingKeys`` so the on-disk JSON store and the MCP payload stay
48+
/// byte-for-byte unchanged, and older files still decode (`anchor` -> nil).
49+
public var anchor: CGPoint?
50+
51+
/// Explicit keys that OMIT `anchor`: `JSONFileSink` and the MCP
52+
/// `FileNotesStore` encode/decode `[AnnotationNote]` directly, so a naked
53+
/// stored property would leak the pin position into the serialized record.
54+
/// `anchor` decodes to nil when absent, so the store and old files round-trip
55+
/// unchanged.
56+
private enum CodingKeys: String, CodingKey {
57+
case id, route, selector, elementPath, selectedText, comment, screenshot, timestamp
58+
}
4359

4460
public init(
4561
id: String,
@@ -49,7 +65,8 @@ public struct AnnotationNote: Sendable, Hashable, Identifiable, Codable {
4965
selectedText: String? = nil,
5066
comment: String,
5167
screenshot: CapturedImage? = nil,
52-
timestamp: String
68+
timestamp: String,
69+
anchor: CGPoint? = nil
5370
) {
5471
self.id = id
5572
self.route = route
@@ -59,5 +76,6 @@ public struct AnnotationNote: Sendable, Hashable, Identifiable, Codable {
5976
self.comment = comment
6077
self.screenshot = screenshot
6178
self.timestamp = timestamp
79+
self.anchor = anchor
6280
}
6381
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import SwiftUI
2+
3+
/// Numbered comment pins (Feature 1), shared by the macOS and iOS overlay hosts.
4+
///
5+
/// Each captured note carries a window-local ``AnnotationNote/anchor`` — the
6+
/// element's AX top-left minus the host window's `axOrigin` AT CAPTURE — so a pin
7+
/// stays glued to where the note was made when the host WINDOW moves (the panel is
8+
/// a child window, so a window-local value is invariant under a window drag). It is
9+
/// FIXED, not live-tracked: it will not chase a scrolled element (accepted).
10+
///
11+
/// Pins are annotate-mode-only chrome, mounted in ``OverlayView`` ABOVE the
12+
/// full-window catcher and BELOW the composer/toolbar, so each pin consumes its
13+
/// own hover/click and can never fall through to re-select the element beneath it.
14+
/// The whole overlay stays `accessibilityHidden`, so pins never disturb the AX
15+
/// point query's see-through.
16+
struct AnnotationPins: View {
17+
@ObservedObject var session: AnnotationSession
18+
19+
var body: some View {
20+
// Enumerate the FULL retained set so a pin's number matches the count
21+
// badge; notes captured without an anchor simply draw nothing.
22+
ForEach(Array(session.pending.enumerated()), id: \.element.id) { index, note in
23+
if let anchor = note.anchor {
24+
AnnotationPin(session: session, note: note, number: index + 1, anchor: anchor)
25+
}
26+
}
27+
}
28+
}
29+
30+
/// A single numbered pin overlapping the element's top-left corner. It is a button
31+
/// that consumes its own click (opens the editor, never selects), and — sitting
32+
/// above the catcher — its hover/click cannot fall through to the element.
33+
private struct AnnotationPin: View {
34+
@ObservedObject var session: AnnotationSession
35+
let note: AnnotationNote
36+
let number: Int
37+
let anchor: CGPoint
38+
39+
@State private var editing = false
40+
41+
private let diameter: CGFloat = 20
42+
43+
var body: some View {
44+
Button {
45+
editing = true
46+
} label: {
47+
Text("\(number)")
48+
.font(.caption2.monospacedDigit().bold())
49+
.foregroundStyle(.white)
50+
.frame(width: diameter, height: diameter)
51+
.background(Circle().fill(Color.accentColor))
52+
.overlay(Circle().strokeBorder(Color.black.opacity(0.35), lineWidth: 1))
53+
.contentShape(Circle())
54+
}
55+
.buttonStyle(.plain)
56+
.shadow(color: .black.opacity(0.3), radius: 3, y: 1)
57+
// Open the editor on hover; do NOT close on hover-exit. Pure hover-driven
58+
// popovers are finicky — moving the pointer from the pin INTO the popover
59+
// can dismiss it — so this uses the stable "open on hover, close
60+
// explicitly" rule: the popover owns dismissal (click-away / Save / Delete
61+
// / Esc).
62+
.onHover { inside in
63+
if inside { editing = true }
64+
}
65+
.popover(isPresented: $editing, arrowEdge: .top) {
66+
PinPopover(session: session, note: note, isPresented: $editing)
67+
}
68+
// The overlay ZStack is `.topLeading`, so centering the pin on the
69+
// element's top-left corner is `anchor - radius`.
70+
.offset(x: anchor.x - diameter / 2, y: anchor.y - diameter / 2)
71+
}
72+
}
73+
74+
/// The pin's edit popover: an auto-focused, inline-editable comment field plus
75+
/// Save and Delete. Reuses Feature 3's focus pattern (`@FocusState` + `.onAppear`),
76+
/// ⌘Return to save, and Escape to cancel. Save edits the retained note in place;
77+
/// Delete drops it from `pending`, so the `ForEach` reflows the numbers and the
78+
/// count badge automatically.
79+
private struct PinPopover: View {
80+
@ObservedObject var session: AnnotationSession
81+
let note: AnnotationNote
82+
@Binding var isPresented: Bool
83+
84+
@State private var draft: String
85+
@FocusState private var focused: Bool
86+
87+
init(session: AnnotationSession, note: AnnotationNote, isPresented: Binding<Bool>) {
88+
self.session = session
89+
self.note = note
90+
self._isPresented = isPresented
91+
self._draft = State(initialValue: note.comment)
92+
}
93+
94+
var body: some View {
95+
VStack(alignment: .leading, spacing: 8) {
96+
HStack(spacing: 8) {
97+
Text(note.selector)
98+
.font(.caption.monospaced())
99+
.lineLimit(1)
100+
Spacer(minLength: 8)
101+
Text("⌘⏎ save · esc cancel")
102+
.font(.caption2)
103+
.foregroundStyle(.secondary)
104+
.fixedSize()
105+
}
106+
.frame(width: 240)
107+
TextField("Describe the change", text: $draft, axis: .vertical)
108+
.textFieldStyle(.roundedBorder)
109+
.lineLimit(2 ... 5)
110+
.frame(width: 240)
111+
.focused($focused)
112+
HStack {
113+
Button(role: .destructive) {
114+
session.deleteNote(id: note.id)
115+
isPresented = false
116+
} label: {
117+
Label("Delete", systemImage: "trash")
118+
}
119+
Spacer()
120+
Button("Save") {
121+
session.updateNote(id: note.id, comment: draft)
122+
isPresented = false
123+
}
124+
.keyboardShortcut(.return, modifiers: .command)
125+
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
126+
}
127+
.frame(width: 240)
128+
}
129+
.padding(12)
130+
// Auto-focus the field on appearance (same pattern as the composer). The
131+
// popover presents in its own window, so `makeKey` on the parent panel does
132+
// not apply here; re-assert focus on the next tick to beat the same
133+
// first-responder race the composer guards against.
134+
.onAppear {
135+
focused = true
136+
Task { @MainActor in focused = true }
137+
}
138+
#if os(macOS)
139+
// Escape cancels without saving (macOS/tvOS-only API; iOS dismisses the
140+
// popover by tapping away).
141+
.onExitCommand { isPresented = false }
142+
#endif
143+
}
144+
}

Sources/AnnotKit/Overlay/AnnotationSession.swift

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ public final class AnnotationSession: ObservableObject {
8989
public func addNote(
9090
comment: String,
9191
selectedText: String? = nil,
92-
screenshot: CapturedImage? = nil
92+
screenshot: CapturedImage? = nil,
93+
anchor: CGPoint? = nil
9394
) -> AnnotationNote? {
9495
guard let element = selected else { return nil }
9596
let note = AnnotationNote(
@@ -100,13 +101,28 @@ public final class AnnotationSession: ObservableObject {
100101
selectedText: selectedText,
101102
comment: comment,
102103
screenshot: screenshot,
103-
timestamp: timestamp()
104+
timestamp: timestamp(),
105+
anchor: anchor
104106
)
105107
pending.append(note)
106108
selected = nil
107109
return note
108110
}
109111

112+
/// Edit a retained note's comment in place (the numbered-pin edit popover).
113+
/// No-op if the id is no longer present.
114+
public func updateNote(id: String, comment: String) {
115+
guard let index = pending.firstIndex(where: { $0.id == id }) else { return }
116+
pending[index].comment = comment
117+
}
118+
119+
/// Remove a retained note (the numbered-pin delete action). The pin numbers
120+
/// and the count badge are DERIVED from `pending`'s order and size, so
121+
/// dropping a note reflows both for free — no explicit renumbering.
122+
public func deleteNote(id: String) {
123+
pending.removeAll { $0.id == id }
124+
}
125+
110126
/// Write the full retained set to the sink, WITHOUT clearing it. Notes
111127
/// persist until ``clear()``, so the same set can be exported repeatedly (and
112128
/// also copied). The file sink overwrites its file with the current set, so

0 commit comments

Comments
 (0)