@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,29 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
|
|
3
|
+
interface DiffPreviewProps {
|
|
4
|
+
original: string
|
|
5
|
+
suggested: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Inline strikethrough + insertion diff for the sidebar suggestion card.
|
|
10
|
+
*
|
|
11
|
+
* v0.1 keeps it dead simple: original on top with strikethrough, suggested
|
|
12
|
+
* underneath with an insertion mark. We don't try to highlight character-
|
|
13
|
+
* level changes — the reviewer already saw the whole substring when they
|
|
14
|
+
* made the suggestion, and the agency mostly cares about the new wording.
|
|
15
|
+
*/
|
|
16
|
+
export function DiffPreview({ original, suggested }: DiffPreviewProps) {
|
|
17
|
+
return (
|
|
18
|
+
<div class="notes-diff">
|
|
19
|
+
<div class="notes-diff__row notes-diff__row--del">
|
|
20
|
+
<span class="notes-diff__marker">−</span>
|
|
21
|
+
<span class="notes-strikethrough">{original}</span>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="notes-diff__row notes-diff__row--ins">
|
|
24
|
+
<span class="notes-diff__marker">+</span>
|
|
25
|
+
<span>{suggested}</span>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
|
|
3
|
+
interface Rect {
|
|
4
|
+
x: number
|
|
5
|
+
y: number
|
|
6
|
+
width: number
|
|
7
|
+
height: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ElementHighlightProps {
|
|
11
|
+
rect: Rect | null
|
|
12
|
+
persistent?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A non-interactive ring drawn over a target element. The Preact root sits
|
|
17
|
+
* inside a shadow DOM mounted to `<body>`, so we use viewport-fixed
|
|
18
|
+
* positioning to align with the target's `getBoundingClientRect()`.
|
|
19
|
+
*/
|
|
20
|
+
export function ElementHighlight({ rect, persistent }: ElementHighlightProps) {
|
|
21
|
+
if (!rect) return null
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
class={`notes-highlight ${persistent ? 'notes-highlight--persistent' : ''}`}
|
|
25
|
+
style={{
|
|
26
|
+
left: `${rect.x}px`,
|
|
27
|
+
top: `${rect.y}px`,
|
|
28
|
+
width: `${rect.width}px`,
|
|
29
|
+
height: `${rect.height}px`,
|
|
30
|
+
}}
|
|
31
|
+
/>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
|
|
3
|
+
interface SelectionTooltipProps {
|
|
4
|
+
rect: { x: number; y: number; width: number; height: number }
|
|
5
|
+
onComment: () => void
|
|
6
|
+
onSuggest: () => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Floating tooltip that appears just above the user's text selection.
|
|
11
|
+
* Two actions: leave a comment on the parent element, or open the
|
|
12
|
+
* suggest popover with the selection as the range anchor.
|
|
13
|
+
*
|
|
14
|
+
* Positioning: centered horizontally over the selection, 36px above its
|
|
15
|
+
* top edge (or below it if there's no room).
|
|
16
|
+
*/
|
|
17
|
+
export function SelectionTooltip({ rect, onComment, onSuggest }: SelectionTooltipProps) {
|
|
18
|
+
const tooltipWidth = 220
|
|
19
|
+
const tooltipHeight = 36
|
|
20
|
+
const margin = 8
|
|
21
|
+
|
|
22
|
+
let left = rect.x + rect.width / 2 - tooltipWidth / 2
|
|
23
|
+
left = Math.max(margin, Math.min(left, window.innerWidth - tooltipWidth - margin - 360))
|
|
24
|
+
|
|
25
|
+
let top = rect.y - tooltipHeight - margin
|
|
26
|
+
if (top < 56) {
|
|
27
|
+
// Below the selection if there's not enough room above
|
|
28
|
+
top = rect.y + rect.height + margin
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
class="notes-selection-tooltip"
|
|
34
|
+
style={{ left: `${left}px`, top: `${top}px`, width: `${tooltipWidth}px` }}
|
|
35
|
+
onMouseDown={(e) => {
|
|
36
|
+
// Prevent the click from clearing the user's text selection
|
|
37
|
+
e.preventDefault()
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
<button class="notes-btn notes-btn--ghost" onClick={onComment}>
|
|
41
|
+
💬 Comment
|
|
42
|
+
</button>
|
|
43
|
+
<button class="notes-btn notes-btn--primary" onClick={onSuggest}>
|
|
44
|
+
✏️ Suggest edit
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { NoteItem } from '../types'
|
|
3
|
+
import { SidebarItem } from './SidebarItem'
|
|
4
|
+
|
|
5
|
+
interface SidebarProps {
|
|
6
|
+
page: string
|
|
7
|
+
items: NoteItem[]
|
|
8
|
+
activeId: string | null
|
|
9
|
+
picking: boolean
|
|
10
|
+
error: string | null
|
|
11
|
+
staleIds: Set<string>
|
|
12
|
+
applyingId: string | null
|
|
13
|
+
onFocus: (id: string) => void
|
|
14
|
+
onResolve: (id: string) => void
|
|
15
|
+
onReopen: (id: string) => void
|
|
16
|
+
onDelete: (id: string) => void
|
|
17
|
+
onApply: (id: string) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Sidebar(
|
|
21
|
+
{ page, items, activeId, picking, error, staleIds, applyingId, onFocus, onResolve, onReopen, onDelete, onApply }: SidebarProps,
|
|
22
|
+
) {
|
|
23
|
+
const open = items.filter((i) => i.status !== 'resolved' && i.status !== 'applied')
|
|
24
|
+
const closed = items.filter((i) => i.status === 'resolved' || i.status === 'applied')
|
|
25
|
+
const renderItem = (item: NoteItem) => (
|
|
26
|
+
<SidebarItem
|
|
27
|
+
key={item.id}
|
|
28
|
+
item={item}
|
|
29
|
+
active={item.id === activeId}
|
|
30
|
+
stale={staleIds.has(item.id)}
|
|
31
|
+
applying={applyingId === item.id}
|
|
32
|
+
onFocus={() => onFocus(item.id)}
|
|
33
|
+
onResolve={() => onResolve(item.id)}
|
|
34
|
+
onReopen={() => onReopen(item.id)}
|
|
35
|
+
onDelete={() => onDelete(item.id)}
|
|
36
|
+
onApply={() => onApply(item.id)}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
return (
|
|
40
|
+
<aside class="notes-sidebar">
|
|
41
|
+
<header class="notes-sidebar__header">
|
|
42
|
+
<h3 class="notes-sidebar__title">Review notes</h3>
|
|
43
|
+
<div class="notes-sidebar__meta">
|
|
44
|
+
{open.length} open · {closed.length} resolved · <code>{page}</code>
|
|
45
|
+
</div>
|
|
46
|
+
</header>
|
|
47
|
+
<div class="notes-sidebar__list">
|
|
48
|
+
{error ? <div class="notes-banner">{error}</div> : null}
|
|
49
|
+
{items.length === 0
|
|
50
|
+
? (
|
|
51
|
+
<div class="notes-sidebar__empty">
|
|
52
|
+
{picking
|
|
53
|
+
? 'Click any text or element on the page to add a comment.'
|
|
54
|
+
: 'Select text on the page to suggest an edit, or click "Pick element" to comment.'}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
: null}
|
|
58
|
+
{open.map(renderItem)}
|
|
59
|
+
{closed.length > 0
|
|
60
|
+
? (
|
|
61
|
+
<>
|
|
62
|
+
<div class="notes-sidebar__meta" style={{ marginTop: '4px' }}>Resolved</div>
|
|
63
|
+
{closed.map(renderItem)}
|
|
64
|
+
</>
|
|
65
|
+
)
|
|
66
|
+
: null}
|
|
67
|
+
</div>
|
|
68
|
+
</aside>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { NoteItem } from '../types'
|
|
3
|
+
import { DiffPreview } from './DiffPreview'
|
|
4
|
+
import { StaleWarning } from './StaleWarning'
|
|
5
|
+
|
|
6
|
+
interface SidebarItemProps {
|
|
7
|
+
item: NoteItem
|
|
8
|
+
active: boolean
|
|
9
|
+
stale: boolean
|
|
10
|
+
applying: boolean
|
|
11
|
+
onFocus: () => void
|
|
12
|
+
onResolve: () => void
|
|
13
|
+
onReopen: () => void
|
|
14
|
+
onDelete: () => void
|
|
15
|
+
onApply: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatTime(iso: string): string {
|
|
19
|
+
try {
|
|
20
|
+
const d = new Date(iso)
|
|
21
|
+
const now = new Date()
|
|
22
|
+
const diffMs = now.getTime() - d.getTime()
|
|
23
|
+
const diffMin = Math.floor(diffMs / 60000)
|
|
24
|
+
if (diffMin < 1) return 'just now'
|
|
25
|
+
if (diffMin < 60) return `${diffMin}m ago`
|
|
26
|
+
const diffHr = Math.floor(diffMin / 60)
|
|
27
|
+
if (diffHr < 24) return `${diffHr}h ago`
|
|
28
|
+
const diffDays = Math.floor(diffHr / 24)
|
|
29
|
+
if (diffDays < 7) return `${diffDays}d ago`
|
|
30
|
+
return d.toLocaleDateString()
|
|
31
|
+
} catch {
|
|
32
|
+
return iso
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One note card in the sidebar list. Renders both comment and suggestion
|
|
38
|
+
* shapes from a unified data model: comments show the body; suggestions
|
|
39
|
+
* show the inline diff and (if any) a rationale + body.
|
|
40
|
+
*/
|
|
41
|
+
export function SidebarItem({ item, active, stale, applying, onFocus, onResolve, onReopen, onDelete, onApply }: SidebarItemProps) {
|
|
42
|
+
const isResolved = item.status === 'resolved' || item.status === 'applied'
|
|
43
|
+
const isApplied = item.status === 'applied'
|
|
44
|
+
const isSuggestion = item.type === 'suggestion' && item.range
|
|
45
|
+
const canApply = isSuggestion && !isApplied && !stale
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
class={`notes-item ${active ? 'notes-item--active' : ''} ${isResolved ? 'notes-item--resolved' : ''}`}
|
|
49
|
+
onMouseEnter={onFocus}
|
|
50
|
+
>
|
|
51
|
+
<div class="notes-item__head">
|
|
52
|
+
<div>
|
|
53
|
+
<span class={`notes-item__badge notes-item__badge--${item.type}`}>
|
|
54
|
+
{item.type}
|
|
55
|
+
</span>
|
|
56
|
+
{isResolved
|
|
57
|
+
? <span class="notes-item__badge notes-item__badge--resolved">{item.status}</span>
|
|
58
|
+
: null}
|
|
59
|
+
<span class="notes-item__author">{item.author}</span>
|
|
60
|
+
</div>
|
|
61
|
+
<span class="notes-item__time">{formatTime(item.createdAt)}</span>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{stale && isSuggestion ? <StaleWarning /> : null}
|
|
65
|
+
|
|
66
|
+
{isSuggestion && item.range
|
|
67
|
+
? (
|
|
68
|
+
<>
|
|
69
|
+
<DiffPreview original={item.range.originalText} suggested={item.range.suggestedText} />
|
|
70
|
+
{item.range.rationale
|
|
71
|
+
? (
|
|
72
|
+
<div class="notes-item__rationale">
|
|
73
|
+
<span class="notes-item__rationale-label">Why:</span> {item.range.rationale}
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
: null}
|
|
77
|
+
{item.body ? <div class="notes-item__body">{item.body}</div> : null}
|
|
78
|
+
</>
|
|
79
|
+
)
|
|
80
|
+
: (
|
|
81
|
+
<>
|
|
82
|
+
{item.targetSnippet
|
|
83
|
+
? <div class="notes-item__snippet">{item.targetSnippet}</div>
|
|
84
|
+
: null}
|
|
85
|
+
<div class="notes-item__body">{item.body}</div>
|
|
86
|
+
</>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
<div class="notes-item__actions">
|
|
90
|
+
{canApply
|
|
91
|
+
? (
|
|
92
|
+
<button class="notes-btn notes-btn--primary" onClick={onApply} disabled={applying}>
|
|
93
|
+
{applying ? 'Applying...' : 'Apply'}
|
|
94
|
+
</button>
|
|
95
|
+
)
|
|
96
|
+
: null}
|
|
97
|
+
{isResolved
|
|
98
|
+
? <button class="notes-btn notes-btn--ghost" onClick={onReopen}>Reopen</button>
|
|
99
|
+
: <button class="notes-btn" onClick={onResolve}>Resolve</button>}
|
|
100
|
+
<button class="notes-btn notes-btn--ghost notes-btn--danger" onClick={onDelete}>Delete</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
|
|
3
|
+
interface StaleWarningProps {
|
|
4
|
+
reason?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Small badge shown on suggestion cards whose anchor text no longer
|
|
9
|
+
* appears inside the target element. Phase 5 will add a "re-attach" CTA;
|
|
10
|
+
* for v0.1 we just surface the situation so the agency can act on it.
|
|
11
|
+
*/
|
|
12
|
+
export function StaleWarning({ reason }: StaleWarningProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div class="notes-stale" title={reason}>
|
|
15
|
+
<span class="notes-stale__icon">⚠</span>
|
|
16
|
+
<span>Anchor text not found on this page</span>
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
|
|
4
|
+
interface SuggestPopoverProps {
|
|
5
|
+
rect: { x: number; y: number; width: number; height: number }
|
|
6
|
+
originalText: string
|
|
7
|
+
defaultAuthor: string
|
|
8
|
+
onCancel: () => void
|
|
9
|
+
onSubmit: (input: { suggestedText: string; rationale: string; body: string; author: string }) => void | Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Google Docs-style suggestion popover. Shows the original text crossed out
|
|
14
|
+
* (read-only), then a textarea pre-filled with the same text the reviewer
|
|
15
|
+
* can edit, an optional rationale, and a one-line note. The author input
|
|
16
|
+
* mirrors CommentPopover so the user only types their name once per session.
|
|
17
|
+
*/
|
|
18
|
+
export function SuggestPopover({ rect, originalText, defaultAuthor, onCancel, onSubmit }: SuggestPopoverProps) {
|
|
19
|
+
const [suggested, setSuggested] = useState(originalText)
|
|
20
|
+
const [rationale, setRationale] = useState('')
|
|
21
|
+
const [body, setBody] = useState('')
|
|
22
|
+
const [author, setAuthor] = useState(defaultAuthor)
|
|
23
|
+
const [submitting, setSubmitting] = useState(false)
|
|
24
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
// Focus the suggested text input and select-all so the reviewer can
|
|
28
|
+
// just start typing the replacement.
|
|
29
|
+
const ta = textareaRef.current
|
|
30
|
+
if (ta) {
|
|
31
|
+
ta.focus()
|
|
32
|
+
ta.select()
|
|
33
|
+
}
|
|
34
|
+
}, [])
|
|
35
|
+
|
|
36
|
+
const dirty = suggested.trim() !== originalText.trim()
|
|
37
|
+
|
|
38
|
+
const handleSubmit = async () => {
|
|
39
|
+
if (!dirty || submitting) return
|
|
40
|
+
setSubmitting(true)
|
|
41
|
+
try {
|
|
42
|
+
await onSubmit({
|
|
43
|
+
suggestedText: suggested,
|
|
44
|
+
rationale: rationale.trim(),
|
|
45
|
+
body: body.trim(),
|
|
46
|
+
author: author.trim() || 'Anonymous',
|
|
47
|
+
})
|
|
48
|
+
} finally {
|
|
49
|
+
setSubmitting(false)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
54
|
+
if (e.key === 'Escape') {
|
|
55
|
+
e.preventDefault()
|
|
56
|
+
onCancel()
|
|
57
|
+
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
handleSubmit()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sidebarWidth = 360
|
|
64
|
+
const popoverWidth = 360
|
|
65
|
+
const margin = 12
|
|
66
|
+
const viewportW = window.innerWidth - sidebarWidth
|
|
67
|
+
let left = rect.x + rect.width + margin
|
|
68
|
+
if (left + popoverWidth > viewportW) {
|
|
69
|
+
left = Math.max(margin, rect.x - popoverWidth - margin)
|
|
70
|
+
if (left < margin) left = margin
|
|
71
|
+
}
|
|
72
|
+
const top = Math.max(56, Math.min(rect.y, window.innerHeight - 360))
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
class="notes-popover notes-popover--suggest"
|
|
77
|
+
style={{ left: `${left}px`, top: `${top}px`, width: `${popoverWidth}px` }}
|
|
78
|
+
onClick={(e) => e.stopPropagation()}
|
|
79
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
80
|
+
onKeyDown={handleKey}
|
|
81
|
+
>
|
|
82
|
+
<h4 class="notes-popover__title">Suggest edit</h4>
|
|
83
|
+
<div class="notes-popover__original">
|
|
84
|
+
<span class="notes-popover__label">Original</span>
|
|
85
|
+
<span class="notes-strikethrough">{originalText}</span>
|
|
86
|
+
</div>
|
|
87
|
+
<div>
|
|
88
|
+
<label class="notes-popover__label" for="nua-suggest-text">Replacement</label>
|
|
89
|
+
<textarea
|
|
90
|
+
ref={textareaRef}
|
|
91
|
+
id="nua-suggest-text"
|
|
92
|
+
value={suggested}
|
|
93
|
+
onInput={(e) => setSuggested((e.target as HTMLTextAreaElement).value)}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
<div>
|
|
97
|
+
<label class="notes-popover__label" for="nua-suggest-rationale">Why? (optional)</label>
|
|
98
|
+
<input
|
|
99
|
+
type="text"
|
|
100
|
+
id="nua-suggest-rationale"
|
|
101
|
+
placeholder="Stronger framing, fixes typo, ..."
|
|
102
|
+
value={rationale}
|
|
103
|
+
onInput={(e) => setRationale((e.target as HTMLInputElement).value)}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
<div>
|
|
107
|
+
<label class="notes-popover__label" for="nua-suggest-body">Note (optional)</label>
|
|
108
|
+
<input
|
|
109
|
+
type="text"
|
|
110
|
+
id="nua-suggest-body"
|
|
111
|
+
placeholder="Anything else for the agency"
|
|
112
|
+
value={body}
|
|
113
|
+
onInput={(e) => setBody((e.target as HTMLInputElement).value)}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
<input
|
|
117
|
+
type="text"
|
|
118
|
+
placeholder="Your name"
|
|
119
|
+
value={author}
|
|
120
|
+
onInput={(e) => setAuthor((e.target as HTMLInputElement).value)}
|
|
121
|
+
/>
|
|
122
|
+
<div class="notes-popover__row">
|
|
123
|
+
<span class="notes-sidebar__hint">{dirty ? '⌘+Enter to save' : 'Edit the replacement to enable save'}</span>
|
|
124
|
+
<div style={{ display: 'flex', gap: '6px' }}>
|
|
125
|
+
<button class="notes-btn notes-btn--ghost" onClick={onCancel} disabled={submitting}>
|
|
126
|
+
Cancel
|
|
127
|
+
</button>
|
|
128
|
+
<button
|
|
129
|
+
class="notes-btn notes-btn--primary"
|
|
130
|
+
onClick={handleSubmit}
|
|
131
|
+
disabled={!dirty || submitting}
|
|
132
|
+
>
|
|
133
|
+
{submitting ? 'Saving...' : 'Save suggestion'}
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
|
|
3
|
+
interface ToolbarProps {
|
|
4
|
+
page: string
|
|
5
|
+
count: number
|
|
6
|
+
picking: boolean
|
|
7
|
+
onTogglePick: () => void
|
|
8
|
+
onExit: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Top bar for notes review mode. Shows the brand mark, current page path,
|
|
13
|
+
* note count, a "Pick element" button (to enter the click-to-comment flow),
|
|
14
|
+
* and an "Exit review" button that drops the cookie + reloads.
|
|
15
|
+
*/
|
|
16
|
+
export function Toolbar({ page, count, picking, onTogglePick, onExit }: ToolbarProps) {
|
|
17
|
+
return (
|
|
18
|
+
<div class="notes-toolbar">
|
|
19
|
+
<div class="notes-toolbar__brand">
|
|
20
|
+
<span class="notes-toolbar__dot" />
|
|
21
|
+
<span>Notes</span>
|
|
22
|
+
<span class="notes-toolbar__page">{page} · {count} {count === 1 ? 'item' : 'items'}</span>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="notes-toolbar__actions">
|
|
25
|
+
<button
|
|
26
|
+
class={`notes-btn ${picking ? 'notes-btn--primary' : ''}`}
|
|
27
|
+
onClick={onTogglePick}
|
|
28
|
+
title="Click any text or element on the page to leave a comment"
|
|
29
|
+
>
|
|
30
|
+
{picking ? 'Cancel pick' : 'Pick element'}
|
|
31
|
+
</button>
|
|
32
|
+
<button class="notes-btn notes-btn--ghost" onClick={onExit} title="Leave review mode">
|
|
33
|
+
Exit
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import { render } from 'preact'
|
|
3
|
+
import { App } from './App'
|
|
4
|
+
import { enableCmsBridge } from './lib/cms-bridge'
|
|
5
|
+
import { isReviewMode, setReviewModeCookie } from './lib/url-mode'
|
|
6
|
+
import OVERLAY_STYLES from './styles.css?inline'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Notes overlay entry. Loaded as a virtual ESM module from the page bundle.
|
|
10
|
+
*
|
|
11
|
+
* - If the URL flag is absent and the cookie isn't set, do nothing. CMS works
|
|
12
|
+
* as normal and the overlay never mounts.
|
|
13
|
+
* - Otherwise, persist the cookie so navigation stays in review mode, hide
|
|
14
|
+
* the CMS chrome via the bridge stylesheet, and mount the Preact app
|
|
15
|
+
* inside a shadow DOM so our CSS doesn't leak into the host page.
|
|
16
|
+
*
|
|
17
|
+
* The build-time URL flag is read from `window.__NuaNotesConfig` which the
|
|
18
|
+
* Astro integration injects in `astro:config:setup` before this module loads.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface NuaNotesConfig {
|
|
22
|
+
urlFlag?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
declare global {
|
|
26
|
+
interface Window {
|
|
27
|
+
__NuaNotesConfig?: NuaNotesConfig
|
|
28
|
+
__nuasiteNotesMounted?: boolean
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function init(): void {
|
|
33
|
+
if (window.__nuasiteNotesMounted) return
|
|
34
|
+
|
|
35
|
+
const config = window.__NuaNotesConfig ?? {}
|
|
36
|
+
const urlFlag = config.urlFlag ?? 'nua-notes'
|
|
37
|
+
|
|
38
|
+
if (!isReviewMode(urlFlag)) return
|
|
39
|
+
window.__nuasiteNotesMounted = true
|
|
40
|
+
|
|
41
|
+
// Cement the cookie so subsequent navigation keeps review mode without
|
|
42
|
+
// re-appending `?nua-notes` to every link.
|
|
43
|
+
setReviewModeCookie()
|
|
44
|
+
enableCmsBridge()
|
|
45
|
+
|
|
46
|
+
const host = document.createElement('div')
|
|
47
|
+
host.id = 'nua-notes-host'
|
|
48
|
+
host.setAttribute('data-nua-notes-host', '')
|
|
49
|
+
host.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;z-index:2147483600;'
|
|
50
|
+
document.body.appendChild(host)
|
|
51
|
+
|
|
52
|
+
const shadow = host.attachShadow({ mode: 'open' })
|
|
53
|
+
|
|
54
|
+
const styleEl = document.createElement('style')
|
|
55
|
+
styleEl.textContent = OVERLAY_STYLES
|
|
56
|
+
shadow.appendChild(styleEl)
|
|
57
|
+
|
|
58
|
+
const root = document.createElement('div')
|
|
59
|
+
root.id = 'nua-notes-root'
|
|
60
|
+
shadow.appendChild(root)
|
|
61
|
+
|
|
62
|
+
render(<App urlFlag={urlFlag} />, root)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof window !== 'undefined') {
|
|
66
|
+
if (document.readyState === 'loading') {
|
|
67
|
+
document.addEventListener('DOMContentLoaded', init, { once: true })
|
|
68
|
+
} else {
|
|
69
|
+
init()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mode-exclusivity bridge with `@nuasite/cms`.
|
|
3
|
+
*
|
|
4
|
+
* When notes review mode is active we hide the CMS editor chrome via a
|
|
5
|
+
* single injected `<style>` tag in the host document. CMS uses a shadow DOM
|
|
6
|
+
* mounted on `#cms-app-host`, so hiding that element + suppressing pointer
|
|
7
|
+
* events on the data-cms-id markers is enough to make the two UIs not
|
|
8
|
+
* collide. There's no postMessage handshake yet — Phase 5 may add one.
|
|
9
|
+
*
|
|
10
|
+
* `enable()` is idempotent. `disable()` removes the style if it was added.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const STYLE_ID = 'nua-notes-cms-bridge'
|
|
14
|
+
|
|
15
|
+
const HIDE_CMS_CSS = `
|
|
16
|
+
#cms-app-host { display: none !important; }
|
|
17
|
+
[data-nuasite-cms] { display: none !important; }
|
|
18
|
+
/* Allow notes to handle clicks on annotated elements without CMS interfering */
|
|
19
|
+
[data-cms-id] { cursor: default !important; }
|
|
20
|
+
`
|
|
21
|
+
|
|
22
|
+
export function enableCmsBridge(): void {
|
|
23
|
+
if (document.getElementById(STYLE_ID)) return
|
|
24
|
+
const style = document.createElement('style')
|
|
25
|
+
style.id = STYLE_ID
|
|
26
|
+
style.textContent = HIDE_CMS_CSS
|
|
27
|
+
document.head.appendChild(style)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function disableCmsBridge(): void {
|
|
31
|
+
const existing = document.getElementById(STYLE_ID)
|
|
32
|
+
if (existing) existing.remove()
|
|
33
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-node walker scoped to a single element.
|
|
3
|
+
*
|
|
4
|
+
* Used by `range-anchor.ts` to locate suggestion anchors after page reloads
|
|
5
|
+
* (and Phase 3 selection capture). The walker treats the element's text
|
|
6
|
+
* content as a single string and remembers offsets back into the underlying
|
|
7
|
+
* text nodes so we can build a DOM `Range` from a substring match.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface TextNodeOffset {
|
|
11
|
+
node: Text
|
|
12
|
+
start: number
|
|
13
|
+
end: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Collect every text node inside `el` (in document order) along with the
|
|
18
|
+
* cumulative character offset where each node starts within the joined text.
|
|
19
|
+
*/
|
|
20
|
+
export function collectTextNodes(el: Element): { joined: string; nodes: TextNodeOffset[] } {
|
|
21
|
+
const nodes: TextNodeOffset[] = []
|
|
22
|
+
let joined = ''
|
|
23
|
+
const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
|
24
|
+
let cur = walker.nextNode() as Text | null
|
|
25
|
+
while (cur) {
|
|
26
|
+
const text = cur.nodeValue ?? ''
|
|
27
|
+
nodes.push({ node: cur, start: joined.length, end: joined.length + text.length })
|
|
28
|
+
joined += text
|
|
29
|
+
cur = walker.nextNode() as Text | null
|
|
30
|
+
}
|
|
31
|
+
return { joined, nodes }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Map an absolute character offset back to a `(node, offsetInNode)` pair.
|
|
36
|
+
* Returns null if the offset is outside the element.
|
|
37
|
+
*/
|
|
38
|
+
export function offsetToNode(nodes: TextNodeOffset[], offset: number): { node: Text; offset: number } | null {
|
|
39
|
+
for (const n of nodes) {
|
|
40
|
+
if (offset >= n.start && offset <= n.end) {
|
|
41
|
+
return { node: n.node, offset: offset - n.start }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build a DOM `Range` covering `[start, end)` (in joined-text coordinates)
|
|
49
|
+
* inside the element. Returns null if either endpoint is out of bounds.
|
|
50
|
+
*/
|
|
51
|
+
export function rangeFromOffsets(el: Element, start: number, end: number): Range | null {
|
|
52
|
+
const { joined, nodes } = collectTextNodes(el)
|
|
53
|
+
if (start < 0 || end > joined.length || start >= end) return null
|
|
54
|
+
const startPos = offsetToNode(nodes, start)
|
|
55
|
+
const endPos = offsetToNode(nodes, end)
|
|
56
|
+
if (!startPos || !endPos) return null
|
|
57
|
+
const range = el.ownerDocument.createRange()
|
|
58
|
+
range.setStart(startPos.node, startPos.offset)
|
|
59
|
+
range.setEnd(endPos.node, endPos.offset)
|
|
60
|
+
return range
|
|
61
|
+
}
|