@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.
- package/README.md +211 -0
- package/dist/overlay.js +1367 -0
- package/package.json +51 -0
- package/src/apply/apply-suggestion.ts +157 -0
- package/src/dev/api-handlers.ts +215 -0
- package/src/dev/middleware.ts +65 -0
- package/src/dev/request-utils.ts +71 -0
- package/src/index.ts +2 -0
- package/src/integration.ts +168 -0
- package/src/overlay/App.tsx +434 -0
- package/src/overlay/components/CommentPopover.tsx +96 -0
- package/src/overlay/components/DiffPreview.tsx +29 -0
- package/src/overlay/components/ElementHighlight.tsx +33 -0
- package/src/overlay/components/SelectionTooltip.tsx +48 -0
- package/src/overlay/components/Sidebar.tsx +70 -0
- package/src/overlay/components/SidebarItem.tsx +104 -0
- package/src/overlay/components/StaleWarning.tsx +19 -0
- package/src/overlay/components/SuggestPopover.tsx +139 -0
- package/src/overlay/components/Toolbar.tsx +38 -0
- package/src/overlay/env.d.ts +4 -0
- package/src/overlay/index.tsx +71 -0
- package/src/overlay/lib/cms-bridge.ts +33 -0
- package/src/overlay/lib/dom-walker.ts +61 -0
- package/src/overlay/lib/manifest-fetch.ts +35 -0
- package/src/overlay/lib/notes-fetch.ts +121 -0
- package/src/overlay/lib/range-anchor.ts +87 -0
- package/src/overlay/lib/url-mode.ts +43 -0
- package/src/overlay/styles.css +526 -0
- package/src/overlay/types.ts +66 -0
- package/src/storage/id-gen.ts +32 -0
- package/src/storage/json-store.ts +196 -0
- package/src/storage/slug.ts +35 -0
- package/src/storage/types.ts +100 -0
- package/src/tsconfig.json +6 -0
- 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
|
+
}
|