@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,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,4 @@
1
+ declare module '*.css?inline' {
2
+ const css: string
3
+ export default css
4
+ }
@@ -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
+ }