@nuasite/notes 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +211 -0
  2. package/dist/overlay.js +1367 -0
  3. package/package.json +51 -0
  4. package/src/apply/apply-suggestion.ts +157 -0
  5. package/src/dev/api-handlers.ts +215 -0
  6. package/src/dev/middleware.ts +65 -0
  7. package/src/dev/request-utils.ts +71 -0
  8. package/src/index.ts +2 -0
  9. package/src/integration.ts +168 -0
  10. package/src/overlay/App.tsx +434 -0
  11. package/src/overlay/components/CommentPopover.tsx +96 -0
  12. package/src/overlay/components/DiffPreview.tsx +29 -0
  13. package/src/overlay/components/ElementHighlight.tsx +33 -0
  14. package/src/overlay/components/SelectionTooltip.tsx +48 -0
  15. package/src/overlay/components/Sidebar.tsx +70 -0
  16. package/src/overlay/components/SidebarItem.tsx +104 -0
  17. package/src/overlay/components/StaleWarning.tsx +19 -0
  18. package/src/overlay/components/SuggestPopover.tsx +139 -0
  19. package/src/overlay/components/Toolbar.tsx +38 -0
  20. package/src/overlay/env.d.ts +4 -0
  21. package/src/overlay/index.tsx +71 -0
  22. package/src/overlay/lib/cms-bridge.ts +33 -0
  23. package/src/overlay/lib/dom-walker.ts +61 -0
  24. package/src/overlay/lib/manifest-fetch.ts +35 -0
  25. package/src/overlay/lib/notes-fetch.ts +121 -0
  26. package/src/overlay/lib/range-anchor.ts +87 -0
  27. package/src/overlay/lib/url-mode.ts +43 -0
  28. package/src/overlay/styles.css +526 -0
  29. package/src/overlay/types.ts +66 -0
  30. package/src/storage/id-gen.ts +32 -0
  31. package/src/storage/json-store.ts +196 -0
  32. package/src/storage/slug.ts +35 -0
  33. package/src/storage/types.ts +100 -0
  34. package/src/tsconfig.json +6 -0
  35. package/src/types.ts +50 -0
@@ -0,0 +1,168 @@
1
+ import type { AstroIntegration } from 'astro'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { createNotesDevMiddleware } from './dev/middleware'
6
+ import { NotesJsonStore } from './storage/json-store'
7
+ import type { NuaNotesOptions } from './types'
8
+
9
+ /**
10
+ * `@nuasite/notes` Astro integration.
11
+ *
12
+ * Adds a Pastel-style comment overlay (Phase 2) and a Google Docs-style
13
+ * suggestion overlay (Phase 3+) alongside `@nuasite/cms`. Activated by
14
+ * visiting any page with the `?nua-notes` query flag (or with the
15
+ * `nua-notes-mode=1` cookie set).
16
+ *
17
+ * What ships in each phase:
18
+ * Phase 0: marker script proves the integration mounts
19
+ * Phase 1: dev API + JSON storage at /_nua/notes/*
20
+ * Phase 2: Preact overlay v1 — sidebar, comment popover, mode toggle
21
+ * Phase 3: range suggestions with diff preview
22
+ * Phase 4: apply flow (peer-imports @nuasite/cms source-finder)
23
+ * Phase 5: proxy support, replies, agency inbox
24
+ */
25
+
26
+ const VIRTUAL_OVERLAY_PATH = '/@nuasite/notes-overlay.js'
27
+
28
+ export default function nuaNotes(options: NuaNotesOptions = {}): AstroIntegration {
29
+ const {
30
+ enabled = true,
31
+ urlFlag = 'nua-notes',
32
+ notesDir = 'data/notes',
33
+ } = options
34
+
35
+ // Lazily constructed in `astro:config:setup` once we know the project root.
36
+ let store: NotesJsonStore | null = null
37
+ let projectRoot: string | null = null
38
+
39
+ return {
40
+ name: '@nuasite/notes',
41
+ hooks: {
42
+ 'astro:config:setup': ({ command, config, injectScript, updateConfig, logger }) => {
43
+ // Notes is dev-only, mirroring @nuasite/cms.
44
+ if (command !== 'dev') return
45
+ if (!enabled) {
46
+ logger.info('@nuasite/notes is disabled via options.enabled')
47
+ return
48
+ }
49
+
50
+ // Astro provides the project root as a file:// URL on `config.root`.
51
+ projectRoot = fileURLToPath(config.root)
52
+ store = new NotesJsonStore({ projectRoot, notesDir })
53
+
54
+ // Two delivery modes for the overlay (mirrors @nuasite/cms):
55
+ //
56
+ // 1. NPM install case: a pre-built `dist/overlay.js` ships with
57
+ // the package and is served as the virtual module. The bundle
58
+ // has preact, the overlay sources, and the inlined CSS — the
59
+ // consumer needs zero peer dependencies.
60
+ //
61
+ // 2. Monorepo dev case (this repo's playground): no pre-built
62
+ // bundle exists, so we resolve the virtual module to the
63
+ // source `overlay/index.tsx` and let Vite compile it on the
64
+ // fly using the JSX-pragma transform plugin below. preact is
65
+ // resolved through the workspace devDependency.
66
+ const notesDirAbs = dirname(fileURLToPath(import.meta.url))
67
+ const overlayBundlePath = join(notesDirAbs, '../dist/overlay.js')
68
+ const hasPrebuiltBundle = existsSync(overlayBundlePath)
69
+ const overlayEntry = join(notesDirAbs, 'overlay/index.tsx')
70
+
71
+ // Inject a small loader on every page. It writes the runtime config
72
+ // (window.__NuaNotesConfig) and adds the overlay script tag once.
73
+ // The overlay itself decides at runtime whether to mount based on
74
+ // the URL flag / cookie, so the cost on non-review pages is a tiny
75
+ // idempotent script tag.
76
+ injectScript(
77
+ 'page',
78
+ `
79
+ (function () {
80
+ if (window.__nuasiteNotesAlive) return;
81
+ window.__nuasiteNotesAlive = true;
82
+ window.__NuaNotesConfig = ${JSON.stringify({ urlFlag })};
83
+ if (!document.querySelector('script[data-nuasite-notes]')) {
84
+ const s = document.createElement('script');
85
+ s.type = 'module';
86
+ s.src = ${JSON.stringify(VIRTUAL_OVERLAY_PATH)};
87
+ s.dataset.nuasiteNotes = '';
88
+ document.head.appendChild(s);
89
+ }
90
+ })();
91
+ `,
92
+ )
93
+
94
+ // Vite plugins:
95
+ // - In bundle mode: serve the pre-built overlay.js as the
96
+ // virtual module via a load() hook. No source compilation
97
+ // needed and the consumer doesn't need preact installed.
98
+ // - In source mode (monorepo dev): resolve the virtual path to
99
+ // the real .tsx file AND prepend the @jsxImportSource pragma
100
+ // so Vite's esbuild compiles JSX with Preact's `h` instead
101
+ // of React (which the host project may use).
102
+ const vitePlugins: any[] = []
103
+
104
+ if (hasPrebuiltBundle) {
105
+ const bundleContent = readFileSync(overlayBundlePath, 'utf-8')
106
+ vitePlugins.push({
107
+ name: 'nuasite-notes-overlay-bundle',
108
+ resolveId(id: string) {
109
+ if (id === VIRTUAL_OVERLAY_PATH) return VIRTUAL_OVERLAY_PATH
110
+ },
111
+ load(id: string) {
112
+ if (id === VIRTUAL_OVERLAY_PATH) return bundleContent
113
+ },
114
+ })
115
+ } else {
116
+ vitePlugins.push(
117
+ {
118
+ name: 'nuasite-notes-overlay-resolver',
119
+ resolveId(id: string) {
120
+ if (id === VIRTUAL_OVERLAY_PATH) return overlayEntry
121
+ },
122
+ },
123
+ {
124
+ name: 'nuasite-notes-preact-jsx',
125
+ transform(code: string, id: string) {
126
+ if (id.includes('/notes/src/overlay/') && id.endsWith('.tsx') && !code.includes('@jsxImportSource')) {
127
+ return `/** @jsxImportSource preact */\n${code}`
128
+ }
129
+ },
130
+ },
131
+ )
132
+ }
133
+
134
+ // Only force the react→preact alias in source mode. In bundle
135
+ // mode, preact is already inlined in the bundle and the alias
136
+ // could conflict with consumer apps that legitimately use React.
137
+ updateConfig({
138
+ vite: {
139
+ plugins: vitePlugins,
140
+ resolve: hasPrebuiltBundle ? undefined : {
141
+ alias: {
142
+ 'react': 'preact/compat',
143
+ 'react-dom': 'preact/compat',
144
+ 'react/jsx-runtime': 'preact/jsx-runtime',
145
+ },
146
+ },
147
+ },
148
+ })
149
+
150
+ logger.info(
151
+ `@nuasite/notes injected (notesDir: ${notesDir}, ${hasPrebuiltBundle ? 'pre-built overlay' : 'source overlay'})`,
152
+ )
153
+ },
154
+
155
+ 'astro:server:setup': ({ server, logger }) => {
156
+ if (!enabled) return
157
+ if (!store || !projectRoot) {
158
+ logger.warn('@nuasite/notes server:setup ran before config:setup; skipping')
159
+ return
160
+ }
161
+ createNotesDevMiddleware(server, store, projectRoot)
162
+ logger.info('@nuasite/notes API enabled at /_nua/notes/')
163
+ },
164
+ },
165
+ }
166
+ }
167
+
168
+ export { nuaNotes }
@@ -0,0 +1,434 @@
1
+ /** @jsxImportSource preact */
2
+ import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
3
+ import { CommentPopover } from './components/CommentPopover'
4
+ import { ElementHighlight } from './components/ElementHighlight'
5
+ import { SelectionTooltip } from './components/SelectionTooltip'
6
+ import { Sidebar } from './components/Sidebar'
7
+ import { SuggestPopover } from './components/SuggestPopover'
8
+ import { Toolbar } from './components/Toolbar'
9
+ import { fetchPageManifest } from './lib/manifest-fetch'
10
+ import { applyNote, createNote, deleteNote, listNotes, setNoteStatus } from './lib/notes-fetch'
11
+ import { findAnchorRange, selectionInsideElement } from './lib/range-anchor'
12
+ import { exitReviewMode, getCurrentPagePath } from './lib/url-mode'
13
+ import type { CmsPageManifest, NoteItem } from './types'
14
+
15
+ interface AppProps {
16
+ urlFlag: string
17
+ }
18
+
19
+ interface PickState {
20
+ cmsId: string
21
+ rect: { x: number; y: number; width: number; height: number }
22
+ snippet?: string
23
+ }
24
+
25
+ interface SelectionState {
26
+ cmsId: string
27
+ anchorText: string
28
+ rect: { x: number; y: number; width: number; height: number }
29
+ elementRect: { x: number; y: number; width: number; height: number }
30
+ elementSnippet?: string
31
+ }
32
+
33
+ const AUTHOR_KEY = 'nua-notes-author'
34
+
35
+ function loadAuthor(): string {
36
+ try {
37
+ return localStorage.getItem(AUTHOR_KEY) || ''
38
+ } catch {
39
+ return ''
40
+ }
41
+ }
42
+
43
+ function saveAuthor(name: string): void {
44
+ try {
45
+ localStorage.setItem(AUTHOR_KEY, name)
46
+ } catch {
47
+ // ignore
48
+ }
49
+ }
50
+
51
+ function getRect(el: Element): { x: number; y: number; width: number; height: number } {
52
+ const r = el.getBoundingClientRect()
53
+ return { x: r.x, y: r.y, width: r.width, height: r.height }
54
+ }
55
+
56
+ function rectFromDom(r: DOMRect): { x: number; y: number; width: number; height: number } {
57
+ return { x: r.x, y: r.y, width: r.width, height: r.height }
58
+ }
59
+
60
+ function findCmsAncestor(target: EventTarget | null): Element | null {
61
+ let el = target as Element | null
62
+ while (el && el.nodeType === 1) {
63
+ if (el.hasAttribute?.('data-cms-id')) return el
64
+ el = el.parentElement
65
+ }
66
+ return null
67
+ }
68
+
69
+ export function App({ urlFlag }: AppProps) {
70
+ const page = useMemo(() => getCurrentPagePath(), [])
71
+ const [items, setItems] = useState<NoteItem[]>([])
72
+ const [manifest, setManifest] = useState<CmsPageManifest | null>(null)
73
+ const [picking, setPicking] = useState(false)
74
+ const [hoverRect, setHoverRect] = useState<PickState | null>(null)
75
+ const [pendingPick, setPendingPick] = useState<PickState | null>(null)
76
+ const [pendingSuggest, setPendingSuggest] = useState<SelectionState | null>(null)
77
+ const [pendingSelection, setPendingSelection] = useState<SelectionState | null>(null)
78
+ const [activeId, setActiveId] = useState<string | null>(null)
79
+ const [activeRect, setActiveRect] = useState<{ x: number; y: number; width: number; height: number } | null>(null)
80
+ const [error, setError] = useState<string | null>(null)
81
+ const [author, setAuthor] = useState<string>(() => loadAuthor())
82
+ const [staleIds, setStaleIds] = useState<Set<string>>(new Set())
83
+ const [applyingId, setApplyingId] = useState<string | null>(null)
84
+
85
+ // Load notes + manifest on mount
86
+ useEffect(() => {
87
+ let alive = true
88
+ Promise.all([listNotes(page), fetchPageManifest(page)])
89
+ .then(([file, mf]) => {
90
+ if (!alive) return
91
+ setItems(file.items)
92
+ setManifest(mf)
93
+ })
94
+ .catch((err) => {
95
+ if (!alive) return
96
+ setError(err instanceof Error ? err.message : String(err))
97
+ })
98
+ return () => {
99
+ alive = false
100
+ }
101
+ }, [page])
102
+
103
+ // Re-attach suggestion anchors after items load. A suggestion is "stale"
104
+ // if its anchorText can't be found inside its target element anymore
105
+ // (e.g. the source file changed). Run on items change so HMR-driven
106
+ // edits update the warning live.
107
+ useEffect(() => {
108
+ const stale = new Set<string>()
109
+ for (const item of items) {
110
+ if (item.type !== 'suggestion' || !item.range) continue
111
+ const el = document.querySelector(`[data-cms-id="${item.targetCmsId}"]`)
112
+ if (!el) {
113
+ stale.add(item.id)
114
+ continue
115
+ }
116
+ const match = findAnchorRange(el, item.range.anchorText)
117
+ if (!match) stale.add(item.id)
118
+ }
119
+ setStaleIds(stale)
120
+ }, [items])
121
+
122
+ // Reposition the active highlight when the page scrolls or resizes.
123
+ // For suggestion items, prefer the anchor range rect over the element rect.
124
+ useEffect(() => {
125
+ if (!activeId) {
126
+ setActiveRect(null)
127
+ return
128
+ }
129
+ const item = items.find((i) => i.id === activeId)
130
+ if (!item) return
131
+ const el = document.querySelector(`[data-cms-id="${item.targetCmsId}"]`)
132
+ if (!el) {
133
+ setActiveRect(null)
134
+ return
135
+ }
136
+ const updateRect = () => {
137
+ if (item.type === 'suggestion' && item.range) {
138
+ const match = findAnchorRange(el, item.range.anchorText)
139
+ if (match) {
140
+ setActiveRect(rectFromDom(match.rect))
141
+ return
142
+ }
143
+ }
144
+ setActiveRect(getRect(el))
145
+ }
146
+ updateRect()
147
+ window.addEventListener('scroll', updateRect, true)
148
+ window.addEventListener('resize', updateRect)
149
+ return () => {
150
+ window.removeEventListener('scroll', updateRect, true)
151
+ window.removeEventListener('resize', updateRect)
152
+ }
153
+ }, [activeId, items])
154
+
155
+ // Picking-mode hover and click handlers
156
+ useEffect(() => {
157
+ if (!picking) {
158
+ setHoverRect(null)
159
+ return
160
+ }
161
+ const onMove = (e: MouseEvent) => {
162
+ const el = findCmsAncestor(e.target)
163
+ if (!el) {
164
+ setHoverRect(null)
165
+ return
166
+ }
167
+ const cmsId = el.getAttribute('data-cms-id')!
168
+ setHoverRect({ cmsId, rect: getRect(el) })
169
+ }
170
+ const onClick = (e: MouseEvent) => {
171
+ const el = findCmsAncestor(e.target)
172
+ if (!el) return
173
+ e.preventDefault()
174
+ e.stopPropagation()
175
+ const cmsId = el.getAttribute('data-cms-id')!
176
+ const text = (el.textContent || '').trim().slice(0, 200)
177
+ setPendingPick({ cmsId, rect: getRect(el), snippet: text })
178
+ setPicking(false)
179
+ }
180
+ document.addEventListener('mousemove', onMove, true)
181
+ document.addEventListener('click', onClick, true)
182
+ return () => {
183
+ document.removeEventListener('mousemove', onMove, true)
184
+ document.removeEventListener('click', onClick, true)
185
+ }
186
+ }, [picking])
187
+
188
+ // Text selection capture (for the suggestion flow). Only active when not
189
+ // in pick mode and no popover is open. Listens for selectionchange and
190
+ // translates the current Selection into a SelectionState if it lands
191
+ // inside a single data-cms-id element.
192
+ useEffect(() => {
193
+ if (picking || pendingPick || pendingSuggest) {
194
+ setPendingSelection(null)
195
+ return
196
+ }
197
+ const onSelectionChange = () => {
198
+ const sel = document.getSelection()
199
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
200
+ setPendingSelection(null)
201
+ return
202
+ }
203
+ const range = sel.getRangeAt(0)
204
+ const startEl = range.startContainer.nodeType === 3
205
+ ? range.startContainer.parentElement
206
+ : range.startContainer as Element
207
+ if (!startEl) return
208
+ const cmsEl = findCmsAncestor(startEl)
209
+ if (!cmsEl) {
210
+ setPendingSelection(null)
211
+ return
212
+ }
213
+ // Selection must stay inside the same data-cms-id element
214
+ if (!cmsEl.contains(range.endContainer)) {
215
+ setPendingSelection(null)
216
+ return
217
+ }
218
+ const inside = selectionInsideElement(cmsEl, sel)
219
+ if (!inside) {
220
+ setPendingSelection(null)
221
+ return
222
+ }
223
+ const cmsId = cmsEl.getAttribute('data-cms-id')!
224
+ const elementSnippet = (cmsEl.textContent || '').trim().slice(0, 200)
225
+ setPendingSelection({
226
+ cmsId,
227
+ anchorText: inside.text,
228
+ rect: rectFromDom(inside.rect),
229
+ elementRect: getRect(cmsEl),
230
+ elementSnippet,
231
+ })
232
+ }
233
+ document.addEventListener('selectionchange', onSelectionChange)
234
+ return () => document.removeEventListener('selectionchange', onSelectionChange)
235
+ }, [picking, pendingPick, pendingSuggest])
236
+
237
+ const handleCreateComment = useCallback(
238
+ async (body: string, authorName: string) => {
239
+ if (!pendingPick) return
240
+ saveAuthor(authorName)
241
+ setAuthor(authorName)
242
+ const entry = manifest?.entries?.[pendingPick.cmsId]
243
+ try {
244
+ const item = await createNote({
245
+ page,
246
+ type: 'comment',
247
+ targetCmsId: pendingPick.cmsId,
248
+ targetSourcePath: entry?.sourcePath,
249
+ targetSourceLine: entry?.sourceLine,
250
+ targetSnippet: entry?.sourceSnippet ?? pendingPick.snippet,
251
+ body,
252
+ author: authorName,
253
+ })
254
+ setItems((prev) => [...prev, item])
255
+ setActiveId(item.id)
256
+ setPendingPick(null)
257
+ setError(null)
258
+ } catch (err) {
259
+ setError(err instanceof Error ? err.message : String(err))
260
+ }
261
+ },
262
+ [page, pendingPick, manifest],
263
+ )
264
+
265
+ const handleCreateSuggestion = useCallback(
266
+ async (input: { suggestedText: string; rationale: string; body: string; author: string }) => {
267
+ if (!pendingSuggest) return
268
+ saveAuthor(input.author)
269
+ setAuthor(input.author)
270
+ const entry = manifest?.entries?.[pendingSuggest.cmsId]
271
+ try {
272
+ const item = await createNote({
273
+ page,
274
+ type: 'suggestion',
275
+ targetCmsId: pendingSuggest.cmsId,
276
+ targetSourcePath: entry?.sourcePath,
277
+ targetSourceLine: entry?.sourceLine,
278
+ targetSnippet: entry?.sourceSnippet ?? pendingSuggest.elementSnippet,
279
+ range: {
280
+ anchorText: pendingSuggest.anchorText,
281
+ originalText: pendingSuggest.anchorText,
282
+ suggestedText: input.suggestedText,
283
+ rationale: input.rationale || undefined,
284
+ },
285
+ body: input.body,
286
+ author: input.author,
287
+ })
288
+ setItems((prev) => [...prev, item])
289
+ setActiveId(item.id)
290
+ setPendingSuggest(null)
291
+ setPendingSelection(null)
292
+ // Clear the page selection so the tooltip doesn't reopen
293
+ try {
294
+ document.getSelection()?.removeAllRanges()
295
+ } catch {}
296
+ setError(null)
297
+ } catch (err) {
298
+ setError(err instanceof Error ? err.message : String(err))
299
+ }
300
+ },
301
+ [page, pendingSuggest, manifest],
302
+ )
303
+
304
+ const handleResolve = useCallback(async (id: string) => {
305
+ try {
306
+ const item = await setNoteStatus(page, id, 'resolved')
307
+ setItems((prev) => prev.map((i) => (i.id === id ? item : i)))
308
+ } catch (err) {
309
+ setError(err instanceof Error ? err.message : String(err))
310
+ }
311
+ }, [page])
312
+
313
+ const handleReopen = useCallback(async (id: string) => {
314
+ try {
315
+ const item = await setNoteStatus(page, id, 'open')
316
+ setItems((prev) => prev.map((i) => (i.id === id ? item : i)))
317
+ } catch (err) {
318
+ setError(err instanceof Error ? err.message : String(err))
319
+ }
320
+ }, [page])
321
+
322
+ const handleDelete = useCallback(async (id: string) => {
323
+ try {
324
+ await deleteNote(page, id)
325
+ setItems((prev) => prev.filter((i) => i.id !== id))
326
+ if (activeId === id) setActiveId(null)
327
+ } catch (err) {
328
+ setError(err instanceof Error ? err.message : String(err))
329
+ }
330
+ }, [page, activeId])
331
+
332
+ const handleApply = useCallback(async (id: string) => {
333
+ setApplyingId(id)
334
+ try {
335
+ const result = await applyNote(page, id)
336
+ // On both success and 409 the server returns an updated item
337
+ setItems((prev) => prev.map((i) => (i.id === id ? result.item : i)))
338
+ if (result.error) {
339
+ setError(result.error)
340
+ } else {
341
+ setError(null)
342
+ }
343
+ } catch (err) {
344
+ setError(err instanceof Error ? err.message : String(err))
345
+ } finally {
346
+ setApplyingId(null)
347
+ }
348
+ }, [page])
349
+
350
+ const handleSelectionComment = useCallback(() => {
351
+ if (!pendingSelection) return
352
+ // Convert the selection into a comment on the parent element
353
+ setPendingPick({
354
+ cmsId: pendingSelection.cmsId,
355
+ rect: pendingSelection.elementRect,
356
+ snippet: pendingSelection.anchorText,
357
+ })
358
+ setPendingSelection(null)
359
+ }, [pendingSelection])
360
+
361
+ const handleSelectionSuggest = useCallback(() => {
362
+ if (!pendingSelection) return
363
+ setPendingSuggest(pendingSelection)
364
+ setPendingSelection(null)
365
+ }, [pendingSelection])
366
+
367
+ return (
368
+ <div class="notes-root">
369
+ <Toolbar
370
+ page={page}
371
+ count={items.length}
372
+ picking={picking}
373
+ onTogglePick={() => {
374
+ setPicking((p) => !p)
375
+ setPendingPick(null)
376
+ setPendingSuggest(null)
377
+ }}
378
+ onExit={() => exitReviewMode(urlFlag)}
379
+ />
380
+ <Sidebar
381
+ page={page}
382
+ items={items}
383
+ activeId={activeId}
384
+ picking={picking}
385
+ error={error}
386
+ staleIds={staleIds}
387
+ applyingId={applyingId}
388
+ onFocus={setActiveId}
389
+ onResolve={handleResolve}
390
+ onReopen={handleReopen}
391
+ onDelete={handleDelete}
392
+ onApply={handleApply}
393
+ />
394
+ {picking && hoverRect ? <ElementHighlight rect={hoverRect.rect} /> : null}
395
+ {activeRect ? <ElementHighlight rect={activeRect} persistent /> : null}
396
+ {pendingSelection && !pendingPick && !pendingSuggest
397
+ ? (
398
+ <SelectionTooltip
399
+ rect={pendingSelection.rect}
400
+ onComment={handleSelectionComment}
401
+ onSuggest={handleSelectionSuggest}
402
+ />
403
+ )
404
+ : null}
405
+ {pendingPick
406
+ ? (
407
+ <CommentPopover
408
+ rect={pendingPick.rect}
409
+ snippet={pendingPick.snippet}
410
+ defaultAuthor={author}
411
+ onCancel={() => setPendingPick(null)}
412
+ onSubmit={handleCreateComment}
413
+ />
414
+ )
415
+ : null}
416
+ {pendingSuggest
417
+ ? (
418
+ <SuggestPopover
419
+ rect={pendingSuggest.rect}
420
+ originalText={pendingSuggest.anchorText}
421
+ defaultAuthor={author}
422
+ onCancel={() => {
423
+ setPendingSuggest(null)
424
+ try {
425
+ document.getSelection()?.removeAllRanges()
426
+ } catch {}
427
+ }}
428
+ onSubmit={handleCreateSuggestion}
429
+ />
430
+ )
431
+ : null}
432
+ </div>
433
+ )
434
+ }
@@ -0,0 +1,96 @@
1
+ /** @jsxImportSource preact */
2
+ import { useEffect, useRef, useState } from 'preact/hooks'
3
+
4
+ interface CommentPopoverProps {
5
+ rect: { x: number; y: number; width: number; height: number }
6
+ snippet?: string
7
+ defaultAuthor: string
8
+ onCancel: () => void
9
+ onSubmit: (body: string, author: string) => void | Promise<void>
10
+ }
11
+
12
+ /**
13
+ * Popover anchored to the right of the picked element. Submits a free-form
14
+ * comment body with an author name (persisted in localStorage by the App).
15
+ *
16
+ * Stops propagation on its own clicks so the App's outside-click handler
17
+ * doesn't immediately close it.
18
+ */
19
+ export function CommentPopover({ rect, snippet, defaultAuthor, onCancel, onSubmit }: CommentPopoverProps) {
20
+ const [body, setBody] = useState('')
21
+ const [author, setAuthor] = useState(defaultAuthor)
22
+ const [submitting, setSubmitting] = useState(false)
23
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
24
+
25
+ useEffect(() => {
26
+ textareaRef.current?.focus()
27
+ }, [])
28
+
29
+ const handleSubmit = async () => {
30
+ if (!body.trim() || submitting) return
31
+ setSubmitting(true)
32
+ try {
33
+ await onSubmit(body.trim(), author.trim() || 'Anonymous')
34
+ } finally {
35
+ setSubmitting(false)
36
+ }
37
+ }
38
+
39
+ const handleKey = (e: KeyboardEvent) => {
40
+ if (e.key === 'Escape') {
41
+ e.preventDefault()
42
+ onCancel()
43
+ } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
44
+ e.preventDefault()
45
+ handleSubmit()
46
+ }
47
+ }
48
+
49
+ // Position to the right of the element when there's room, otherwise left.
50
+ const sidebarWidth = 360
51
+ const popoverWidth = 320
52
+ const margin = 12
53
+ const viewportW = window.innerWidth - sidebarWidth
54
+ let left = rect.x + rect.width + margin
55
+ if (left + popoverWidth > viewportW) {
56
+ left = Math.max(margin, rect.x - popoverWidth - margin)
57
+ }
58
+ const top = Math.max(56, Math.min(rect.y, window.innerHeight - 260))
59
+
60
+ return (
61
+ <div
62
+ class="notes-popover"
63
+ style={{ left: `${left}px`, top: `${top}px` }}
64
+ onClick={(e) => e.stopPropagation()}
65
+ onKeyDown={handleKey}
66
+ >
67
+ <h4 class="notes-popover__title">Add comment</h4>
68
+ {snippet
69
+ ? <div class="notes-popover__snippet">{snippet}</div>
70
+ : null}
71
+ <textarea
72
+ ref={textareaRef}
73
+ placeholder="Leave a note for the agency..."
74
+ value={body}
75
+ onInput={(e) => setBody((e.target as HTMLTextAreaElement).value)}
76
+ />
77
+ <input
78
+ type="text"
79
+ placeholder="Your name"
80
+ value={author}
81
+ onInput={(e) => setAuthor((e.target as HTMLInputElement).value)}
82
+ />
83
+ <div class="notes-popover__row">
84
+ <span class="notes-sidebar__hint">⌘+Enter to save</span>
85
+ <div style={{ display: 'flex', gap: '6px' }}>
86
+ <button class="notes-btn notes-btn--ghost" onClick={onCancel} disabled={submitting}>
87
+ Cancel
88
+ </button>
89
+ <button class="notes-btn notes-btn--primary" onClick={handleSubmit} disabled={!body.trim() || submitting}>
90
+ {submitting ? 'Saving...' : 'Save'}
91
+ </button>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ )
96
+ }