dsh-sidenote 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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/cordis.patch.yml +16 -0
  4. package/lib/client.js +2143 -0
  5. package/lib/index.js +6 -0
  6. package/lib/types/client/annotate/anchor.d.ts +57 -0
  7. package/lib/types/client/annotate/chip.d.ts +11 -0
  8. package/lib/types/client/annotate/draft.d.ts +29 -0
  9. package/lib/types/client/annotate/format.d.ts +52 -0
  10. package/lib/types/client/annotate/index.d.ts +2 -0
  11. package/lib/types/client/annotate/model.d.ts +59 -0
  12. package/lib/types/client/annotate/overlay.d.ts +27 -0
  13. package/lib/types/client/annotate/selection.d.ts +49 -0
  14. package/lib/types/client/bridge.d.ts +19 -0
  15. package/lib/types/client/index.d.ts +12 -0
  16. package/lib/types/client/locales.d.ts +77 -0
  17. package/lib/types/client/sidechat/SideChatPanel.d.ts +2 -0
  18. package/lib/types/client/sidechat/composer.d.ts +26 -0
  19. package/lib/types/client/sidechat/index.d.ts +2 -0
  20. package/lib/types/client/sidechat/model.d.ts +101 -0
  21. package/lib/types/client/sidechat/open.d.ts +16 -0
  22. package/lib/types/client/sidechat/slash.d.ts +12 -0
  23. package/lib/types/context-types.d.ts +335 -0
  24. package/lib/types/index.d.ts +9 -0
  25. package/package.json +96 -0
  26. package/src/client/annotate/anchor.ts +158 -0
  27. package/src/client/annotate/annotate.module.css +301 -0
  28. package/src/client/annotate/chip.tsx +115 -0
  29. package/src/client/annotate/css.d.ts +9 -0
  30. package/src/client/annotate/draft.ts +67 -0
  31. package/src/client/annotate/format.ts +83 -0
  32. package/src/client/annotate/index.tsx +66 -0
  33. package/src/client/annotate/model.ts +138 -0
  34. package/src/client/annotate/overlay.tsx +531 -0
  35. package/src/client/annotate/selection.ts +203 -0
  36. package/src/client/bridge.ts +19 -0
  37. package/src/client/index.tsx +22 -0
  38. package/src/client/locales.ts +175 -0
  39. package/src/client/sidechat/SideChatPanel.tsx +366 -0
  40. package/src/client/sidechat/composer.ts +129 -0
  41. package/src/client/sidechat/css-modules.d.ts +5 -0
  42. package/src/client/sidechat/index.tsx +56 -0
  43. package/src/client/sidechat/model.ts +331 -0
  44. package/src/client/sidechat/open.ts +66 -0
  45. package/src/client/sidechat/sidechat.module.css +230 -0
  46. package/src/client/sidechat/slash.ts +84 -0
  47. package/src/context-types.ts +369 -0
  48. package/src/index.ts +13 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The annotation store: per-session numbered selection annotations. Pure
3
+ * (DOM-free, React-free) so the whole lifecycle is unit-testable. One instance
4
+ * per plugin activation; page-level lifecycle — nothing is persisted.
5
+ *
6
+ * Numbering: a per-session monotonic counter assigns each annotation its
7
+ * display number at creation (①②… rendered as plain digits). Numbers are
8
+ * never reused and never re-packed on delete — 删除不重排.
9
+ *
10
+ * State machine per annotation: 'active' (counts in the composer chip, rides
11
+ * the managed draft prefix) → 'sent' (the message it was attached to went
12
+ * out; badge stays on the message flow but the chip no longer counts it).
13
+ */
14
+ export interface Annotation {
15
+ /** Store-unique identity (monotonic across sessions). */
16
+ readonly id: number
17
+ /** Per-session display number assigned at creation; never reused. */
18
+ readonly number: number
19
+ readonly sessionId: string
20
+ /** `data-chat-anchor-key` of the owning message (re-anchor key). */
21
+ readonly anchorKey: string | undefined
22
+ /** Truncated quote text (what the model and the chip see). */
23
+ readonly text: string
24
+ /** Full selection text (re-anchor needle; untruncated). */
25
+ readonly anchorText: string
26
+ /** 0-based ordinal of the quote's occurrence inside the anchor element. */
27
+ readonly occurrence: number
28
+ /** User-written note; '' means 纯引用. */
29
+ readonly note: string
30
+ readonly state: 'active' | 'sent'
31
+ readonly createdAt: number
32
+ }
33
+
34
+ export interface AnnotationDraft {
35
+ readonly sessionId: string
36
+ readonly anchorKey: string | undefined
37
+ readonly text: string
38
+ readonly anchorText: string
39
+ readonly occurrence: number
40
+ readonly note: string
41
+ }
42
+
43
+ export interface AnnotationStore {
44
+ /** Monotonic version, referentially stable between mutations (uSES-ready). */
45
+ getSnapshot(): number
46
+ subscribe(fn: () => void): () => void
47
+ /** Create an active annotation; returns it (id/number assigned). */
48
+ add(draft: AnnotationDraft): Annotation
49
+ setNote(id: number, note: string): void
50
+ remove(id: number): void
51
+ /** Flip every active annotation of the session to 'sent' (send edge). */
52
+ markSessionSent(sessionId: string): void
53
+ get(id: number): Annotation | undefined
54
+ /** All annotations of a session in creation order (active + sent). */
55
+ list(sessionId: string): readonly Annotation[]
56
+ listActive(sessionId: string): readonly Annotation[]
57
+ countActive(sessionId: string): number
58
+ /** Sessions currently holding any annotation (draft-sync fan-out). */
59
+ sessions(): readonly string[]
60
+ }
61
+
62
+ export function createAnnotationStore(now: () => number = () => Date.now()): AnnotationStore {
63
+ let annotations: Annotation[] = []
64
+ let nextId = 1
65
+ let version = 0
66
+ /** Per-session next display number. */
67
+ const counters = new Map<string, number>()
68
+ const listeners = new Set<() => void>()
69
+
70
+ const notify = (): void => {
71
+ version += 1
72
+ for (const fn of [...listeners]) fn()
73
+ }
74
+
75
+ const replace = (id: number, patch: Partial<Annotation>): void => {
76
+ annotations = annotations.map(a => (a.id === id ? { ...a, ...patch } : a))
77
+ notify()
78
+ }
79
+
80
+ return {
81
+ getSnapshot: () => version,
82
+ subscribe(fn: () => void): () => void {
83
+ listeners.add(fn)
84
+ return () => { listeners.delete(fn) }
85
+ },
86
+ add(draft: AnnotationDraft): Annotation {
87
+ const number = (counters.get(draft.sessionId) ?? 0) + 1
88
+ counters.set(draft.sessionId, number)
89
+ const annotation: Annotation = {
90
+ id: nextId,
91
+ number,
92
+ sessionId: draft.sessionId,
93
+ anchorKey: draft.anchorKey,
94
+ text: draft.text,
95
+ anchorText: draft.anchorText,
96
+ occurrence: draft.occurrence,
97
+ note: draft.note,
98
+ state: 'active',
99
+ createdAt: now(),
100
+ }
101
+ nextId += 1
102
+ annotations = [...annotations, annotation]
103
+ notify()
104
+ return annotation
105
+ },
106
+ setNote(id: number, note: string): void {
107
+ if (!annotations.some(a => a.id === id)) return
108
+ replace(id, { note })
109
+ },
110
+ remove(id: number): void {
111
+ if (!annotations.some(a => a.id === id)) return
112
+ annotations = annotations.filter(a => a.id !== id)
113
+ notify()
114
+ },
115
+ markSessionSent(sessionId: string): void {
116
+ if (!annotations.some(a => a.sessionId === sessionId && a.state === 'active')) return
117
+ annotations = annotations.map(a => (
118
+ a.sessionId === sessionId && a.state === 'active' ? { ...a, state: 'sent' } : a
119
+ ))
120
+ notify()
121
+ },
122
+ get(id: number): Annotation | undefined {
123
+ return annotations.find(a => a.id === id)
124
+ },
125
+ list(sessionId: string): readonly Annotation[] {
126
+ return annotations.filter(a => a.sessionId === sessionId)
127
+ },
128
+ listActive(sessionId: string): readonly Annotation[] {
129
+ return annotations.filter(a => a.sessionId === sessionId && a.state === 'active')
130
+ },
131
+ countActive(sessionId: string): number {
132
+ return annotations.reduce((n, a) => n + (a.sessionId === sessionId && a.state === 'active' ? 1 : 0), 0)
133
+ },
134
+ sessions(): readonly string[] {
135
+ return [...new Set(annotations.map(a => a.sessionId))]
136
+ },
137
+ }
138
+ }
@@ -0,0 +1,531 @@
1
+ /**
2
+ * The annotation overlay (Workitem 02): an independent React root appended to
3
+ * `document.body` (marked `data-dsh-sidenote` so the selection listener
4
+ * excludes it) rendering three fixed-position layers over the conversation:
5
+ *
6
+ * - the selection toolbar (Add to conversation / Ask in side chat),
7
+ * - the numbered badge layer (锚定在选区矩形右缘、选区首行高度) plus the
8
+ * 激活态高亮 (editor open only — 关闭后高亮消退只留角标),
9
+ * - the annotation editor popover (新建态 ✓ / 重开态 🗑 取消 保存).
10
+ *
11
+ * Every layer is pointer-safe (toolbar buttons preventDefault their mousedown
12
+ * so the selection survives until the click commits) and failure-safe (an
13
+ * error boundary around the tree; layout math is wrapped in anchor.ts).
14
+ */
15
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
16
+ import { Component } from 'react'
17
+ import type { ReactNode } from 'react'
18
+ import { IconCheckOutline16, IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
19
+ import type { Context } from '../../context-types.ts'
20
+ import { sideChatBridge } from '../bridge.ts'
21
+ import { t, useLocaleTick } from '../locales.ts'
22
+ import { badgeAnchorOf, highlightRectsOf, resolveRange, spreadBadgePoint } from './anchor.ts'
23
+ import { buildSideChatQuote } from './format.ts'
24
+ import type { Annotation, AnnotationStore } from './model.ts'
25
+ import type { SelectionController, SelectionSnapshot } from './selection.ts'
26
+ import css from './annotate.module.css'
27
+
28
+ /** Editor state: which annotation is being edited, and where the popover sits. */
29
+ interface EditorState {
30
+ readonly annotationId: number
31
+ readonly mode: 'new' | 'edit'
32
+ readonly x: number
33
+ readonly y: number
34
+ }
35
+
36
+ /** 「Ask in side chat」的注解收集态:先弹编辑器(与「Add to conversation」一致,
37
+ * 允许空注解),保存后才经 bridge 注入侧边聊天草稿——不产生主对话注释
38
+ * (无高亮/角标/chip,两个去向互斥)。 */
39
+ interface SideDraftState {
40
+ readonly snapshot: SelectionSnapshot
41
+ readonly x: number
42
+ readonly y: number
43
+ }
44
+
45
+ interface OverlayProps {
46
+ readonly ctx: Context
47
+ readonly store: AnnotationStore
48
+ readonly controller: SelectionController
49
+ }
50
+
51
+ /** Crash guard: a render failure must never take the host page down. */
52
+ export class AnnotateErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
53
+ override state = { failed: false }
54
+ static getDerivedStateFromError(): { failed: boolean } {
55
+ return { failed: true }
56
+ }
57
+ override componentDidCatch(error: unknown): void {
58
+ console.error('[dsh-sidenote] annotate overlay crashed:', error)
59
+ }
60
+ override render(): ReactNode {
61
+ return this.state.failed ? null : this.props.children
62
+ }
63
+ }
64
+
65
+ /** Badge anchor of a freshly captured selection (editor placement on create). */
66
+ function selectionBadgePoint(snapshot: SelectionSnapshot): { x: number; y: number } {
67
+ const anchor = badgeAnchorOf(snapshot.range)
68
+ if (anchor === null) {
69
+ const rect = snapshot.range.getBoundingClientRect()
70
+ return { x: rect.right, y: rect.top + 10 }
71
+ }
72
+ return { x: anchor.right, y: anchor.centerY }
73
+ }
74
+
75
+ export function AnnotateOverlay(props: OverlayProps): ReactNode {
76
+ return (
77
+ <AnnotateErrorBoundary>
78
+ <AnnotateOverlayInner {...props} />
79
+ </AnnotateErrorBoundary>
80
+ )
81
+ }
82
+
83
+ function AnnotateOverlayInner({ ctx, store, controller }: OverlayProps): ReactNode {
84
+ useLocaleTick()
85
+ const selectionState = useSyncExternalStore(
86
+ useCallback((cb: () => void) => controller.subscribe(cb), [controller]),
87
+ () => controller.getSnapshot(),
88
+ )
89
+ // Store version (bumped per mutation) and session list drive re-renders.
90
+ useSyncExternalStore(
91
+ useCallback((cb: () => void) => store.subscribe(cb), [store]),
92
+ () => store.getSnapshot(),
93
+ )
94
+ const sessionList = useSyncExternalStore(
95
+ useCallback((cb: () => void) => ctx.sessions.list.subscribe(cb), [ctx]),
96
+ () => ctx.sessions.list.getSnapshot(),
97
+ )
98
+ const currentSessionId = sessionList.current ?? ''
99
+ const [editor, setEditor] = useState<EditorState | null>(null)
100
+ const [sideDraft, setSideDraft] = useState<SideDraftState | null>(null)
101
+ // Bumped by scroll/resize/DOM mutation so badge/highlight geometry follows.
102
+ const [, setGeometryTick] = useState(0)
103
+ const rangeCache = useRef(new Map<number, Range>())
104
+
105
+ useEffect(() => {
106
+ let raf = 0
107
+ let debounce = 0
108
+ const bumpNow = (): void => {
109
+ if (raf !== 0) return
110
+ raf = window.requestAnimationFrame(() => {
111
+ raf = 0
112
+ setGeometryTick(tick => tick + 1)
113
+ })
114
+ }
115
+ // MutationObserver 挂在整棵 document 上:宿主页面的周期更新(相对时间等)
116
+ // 会持续触发——按 100ms 尾沿去抖,否则角标层每帧重渲、按钮永远「不稳定」
117
+ // (真实点击与 Playwright 的稳定性判定都会受影响)。
118
+ const bumpDebounced = (): void => {
119
+ if (debounce !== 0) window.clearTimeout(debounce)
120
+ debounce = window.setTimeout(() => {
121
+ debounce = 0
122
+ bumpNow()
123
+ }, 100)
124
+ }
125
+ const observer = new MutationObserver(bumpDebounced)
126
+ try {
127
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true })
128
+ } catch {
129
+ // document.body missing (pre-mount teardown) — badges just stay put.
130
+ }
131
+ window.addEventListener('resize', bumpNow)
132
+ document.addEventListener('scroll', bumpNow, true)
133
+ return () => {
134
+ observer.disconnect()
135
+ window.removeEventListener('resize', bumpNow)
136
+ document.removeEventListener('scroll', bumpNow, true)
137
+ if (raf !== 0) window.cancelAnimationFrame(raf)
138
+ if (debounce !== 0) window.clearTimeout(debounce)
139
+ }
140
+ }, [])
141
+
142
+ const selection = selectionState.selection
143
+
144
+ const addToConversation = (snapshot: SelectionSnapshot): void => {
145
+ const anchor = selectionBadgePoint(snapshot)
146
+ const annotation = store.add({
147
+ sessionId: snapshot.sessionId,
148
+ anchorKey: snapshot.anchorKey,
149
+ text: snapshot.text,
150
+ anchorText: snapshot.anchorText,
151
+ occurrence: snapshot.occurrence,
152
+ note: '',
153
+ })
154
+ const cached = snapshot.range.cloneRange()
155
+ rangeCache.current.set(annotation.id, cached)
156
+ setEditor({ annotationId: annotation.id, mode: 'new', x: anchor.x, y: anchor.y })
157
+ controller.clear()
158
+ window.getSelection()?.removeAllRanges()
159
+ }
160
+
161
+ const askInSideChat = (snapshot: SelectionSnapshot): void => {
162
+ // WI-03 联动:先弹注解编辑器收集注解(可空),保存后经 bridge 注入侧边
163
+ // 聊天草稿;本路径不产生主对话注释(无高亮/角标/chip)。
164
+ const anchor = selectionBadgePoint(snapshot)
165
+ setSideDraft({ snapshot, x: anchor.x, y: anchor.y })
166
+ controller.clear()
167
+ window.getSelection()?.removeAllRanges()
168
+ }
169
+
170
+ const commitSideDraft = (note: string): boolean => {
171
+ if (sideDraft === null) return false
172
+ const ok = sideChatBridge.current?.askInSideChat(
173
+ sideDraft.snapshot.sessionId,
174
+ buildSideChatQuote(sideDraft.snapshot.text, note),
175
+ ) === true
176
+ if (ok) setSideDraft(null)
177
+ return ok
178
+ }
179
+
180
+ const reopenEditor = (annotation: Annotation, point: { x: number; y: number }): void => {
181
+ setEditor({ annotationId: annotation.id, mode: 'edit', x: point.x, y: point.y })
182
+ }
183
+
184
+ const closeEditor = (): void => setEditor(null)
185
+
186
+ const editingAnnotation = editor === null ? undefined : store.get(editor.annotationId)
187
+
188
+ return (
189
+ <>
190
+ {selection !== null && editor === null && sideDraft === null && (
191
+ <SelectionToolbar
192
+ snapshot={selection}
193
+ sideChatAvailable={sideChatBridge.current !== null}
194
+ onAdd={() => { addToConversation(selection) }}
195
+ onAsk={() => { askInSideChat(selection) }}
196
+ />
197
+ )}
198
+ <BadgeLayer
199
+ store={store}
200
+ sessionId={currentSessionId}
201
+ cache={rangeCache.current}
202
+ editingId={editor?.annotationId ?? null}
203
+ onOpen={reopenEditor}
204
+ />
205
+ {editingAnnotation !== undefined && editor !== null && (
206
+ <AnnotationEditor
207
+ annotation={editingAnnotation}
208
+ mode={editor.mode}
209
+ x={editor.x}
210
+ y={editor.y}
211
+ onSave={(note) => {
212
+ store.setNote(editingAnnotation.id, note)
213
+ closeEditor()
214
+ }}
215
+ onDelete={() => {
216
+ store.remove(editingAnnotation.id)
217
+ rangeCache.current.delete(editingAnnotation.id)
218
+ closeEditor()
219
+ }}
220
+ onCancel={() => {
221
+ // 新建态取消 = 放弃整条注释;重开态取消 = 保留原注解。
222
+ if (editor.mode === 'new') {
223
+ store.remove(editingAnnotation.id)
224
+ rangeCache.current.delete(editingAnnotation.id)
225
+ }
226
+ closeEditor()
227
+ }}
228
+ />
229
+ )}
230
+ {sideDraft !== null && (
231
+ <SideChatNoteEditor
232
+ x={sideDraft.x}
233
+ y={sideDraft.y}
234
+ onSave={commitSideDraft}
235
+ onCancel={() => { setSideDraft(null) }}
236
+ />
237
+ )}
238
+ </>
239
+ )
240
+ }
241
+
242
+ /** 「Ask in side chat」的注解编辑器(新建态同构:输入框 + ✓,允许空注解)。
243
+ * bridge 失败时不关编辑器、给出内联错误——用户写好的注解不丢。 */
244
+ function SideChatNoteEditor(props: {
245
+ x: number
246
+ y: number
247
+ onSave: (note: string) => boolean
248
+ onCancel: () => void
249
+ }): ReactNode {
250
+ useLocaleTick()
251
+ const [note, setNote] = useState('')
252
+ const [failed, setFailed] = useState(false)
253
+ const rootRef = useRef<HTMLDivElement | null>(null)
254
+
255
+ useEffect(() => {
256
+ const onKeyDown = (event: KeyboardEvent): void => {
257
+ if (event.key === 'Escape') {
258
+ event.stopPropagation()
259
+ props.onCancel()
260
+ }
261
+ }
262
+ const onMouseDown = (event: MouseEvent): void => {
263
+ const root = rootRef.current
264
+ if (root === null || !(event.target instanceof Node)) return
265
+ if (root.contains(event.target)) return
266
+ if (event.target instanceof Element && event.target.closest('[data-dsh-sidenote]') !== null) return
267
+ props.onCancel()
268
+ }
269
+ document.addEventListener('keydown', onKeyDown, true)
270
+ document.addEventListener('mousedown', onMouseDown, true)
271
+ return () => {
272
+ document.removeEventListener('keydown', onKeyDown, true)
273
+ document.removeEventListener('mousedown', onMouseDown, true)
274
+ }
275
+ // eslint-disable-next-line react-hooks/exhaustive-deps
276
+ }, [])
277
+
278
+ const width = 320
279
+ const left = Math.max(8, Math.min(window.innerWidth - width - 8, props.x + 16))
280
+ const top = Math.max(8, Math.min(window.innerHeight - 120, props.y - 20))
281
+ const save = (): void => {
282
+ // 失败保持打开并提示(草稿不丢);成功由父组件关闭。
283
+ setFailed(!props.onSave(note))
284
+ }
285
+
286
+ return (
287
+ <div ref={rootRef} className={css.editorNew} style={{ left, top, width }}>
288
+ <input
289
+ className={css.editorInput}
290
+ value={note}
291
+ placeholder={t('sideNotePlaceholder')}
292
+ aria-label={t('sideNoteAria')}
293
+ autoFocus
294
+ onChange={(event) => { setNote(event.target.value); setFailed(false) }}
295
+ onKeyDown={(event) => {
296
+ if (event.key === 'Enter') {
297
+ event.preventDefault()
298
+ save()
299
+ }
300
+ }}
301
+ />
302
+ <button
303
+ type="button"
304
+ className={css.confirmButton}
305
+ title={t('confirmTitle')}
306
+ aria-label={t('confirmAskAria')}
307
+ onClick={save}
308
+ >
309
+ <IconCheckOutline16 size={14} />
310
+ </button>
311
+ {failed && <div className={css.editorError}>{t('openSideFailed')}</div>}
312
+ </div>
313
+ )
314
+ }
315
+
316
+ /** The floating two-button toolbar above a validated selection. */
317
+ function SelectionToolbar(props: {
318
+ snapshot: SelectionSnapshot
319
+ sideChatAvailable: boolean
320
+ onAdd: () => void
321
+ onAsk: () => void
322
+ }): ReactNode {
323
+ useLocaleTick()
324
+ const { rect } = props.snapshot
325
+ const left = Math.max(8, Math.min(window.innerWidth - 8, rect.left + rect.width / 2))
326
+ const top = Math.max(4, rect.top - 10)
327
+ return (
328
+ <div
329
+ className={css.toolbar}
330
+ style={{ left, top }}
331
+ role="toolbar"
332
+ aria-label={t('toolbarAria')}
333
+ >
334
+ <button
335
+ type="button"
336
+ className={css.toolbarButton}
337
+ onMouseDown={(event) => { event.preventDefault() }}
338
+ onClick={(event) => {
339
+ event.preventDefault()
340
+ event.stopPropagation()
341
+ props.onAdd()
342
+ }}
343
+ >
344
+ {t('addToConversation')}
345
+ </button>
346
+ {props.sideChatAvailable && (
347
+ <button
348
+ type="button"
349
+ className={css.toolbarButton}
350
+ onMouseDown={(event) => { event.preventDefault() }}
351
+ onClick={(event) => {
352
+ event.preventDefault()
353
+ event.stopPropagation()
354
+ props.onAsk()
355
+ }}
356
+ >
357
+ {t('askInSideChat')}
358
+ </button>
359
+ )}
360
+ </div>
361
+ )
362
+ }
363
+
364
+ /** The badge layer (one numbered circle per annotation) + the active highlight. */
365
+ function BadgeLayer(props: {
366
+ store: AnnotationStore
367
+ sessionId: string
368
+ cache: Map<number, Range>
369
+ editingId: number | null
370
+ onOpen: (annotation: Annotation, point: { x: number; y: number }) => void
371
+ }): ReactNode {
372
+ const annotations = props.sessionId === '' ? [] : props.store.list(props.sessionId)
373
+ const badges: ReactNode[] = []
374
+ const placed: { x: number; y: number }[] = []
375
+ let highlight: ReactNode = null
376
+ for (const annotation of annotations) {
377
+ const range = resolveRange(annotation, props.cache)
378
+ if (range === null) continue
379
+ if (annotation.id === props.editingId) {
380
+ // 高亮仅激活态呈现:编辑器打开时被选文本保持高亮。
381
+ const rects = highlightRectsOf(range)
382
+ highlight = rects.map((rect, index) => (
383
+ <div
384
+ key={`hl-${index}`}
385
+ className={css.highlight}
386
+ style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
387
+ />
388
+ ))
389
+ }
390
+ const anchor = badgeAnchorOf(range)
391
+ if (anchor === null) continue
392
+ // 锚点滚出视口的角标不渲染(fixed 定位否则会漂浮在无关内容上方)。
393
+ if (anchor.centerY < 0 || anchor.centerY > window.innerHeight || anchor.right < 0 || anchor.right > window.innerWidth) continue
394
+ // 同一选区/相邻行的多个角标会落在同一点位——错开保证每个都可点击。
395
+ const point = spreadBadgePoint({ x: anchor.right, y: anchor.centerY }, placed)
396
+ placed.push(point)
397
+ badges.push(
398
+ <button
399
+ key={annotation.id}
400
+ type="button"
401
+ className={css.badge}
402
+ style={{ left: point.x + 6, top: point.y }}
403
+ title={annotation.note === '' ? annotation.text : `${annotation.text}\n${t('noteLine', { note: annotation.note })}`}
404
+ onClick={(event) => {
405
+ event.preventDefault()
406
+ event.stopPropagation()
407
+ props.onOpen(annotation, point)
408
+ }}
409
+ >
410
+ {annotation.number}
411
+ </button>,
412
+ )
413
+ }
414
+ return (
415
+ <>
416
+ {highlight}
417
+ {badges}
418
+ </>
419
+ )
420
+ }
421
+
422
+ /**
423
+ * The annotation editor popover. 新建态: input + ✓ 确认 (允许空注解直接保存;
424
+ * 点击外部/Esc 取消且无显式取消按钮). 重开态: 已有注解 + 🗑 删除 + 取消/保存.
425
+ */
426
+ function AnnotationEditor(props: {
427
+ annotation: Annotation
428
+ mode: 'new' | 'edit'
429
+ x: number
430
+ y: number
431
+ onSave: (note: string) => void
432
+ onDelete: () => void
433
+ onCancel: () => void
434
+ }): ReactNode {
435
+ useLocaleTick()
436
+ const [note, setNote] = useState(props.annotation.note)
437
+ const rootRef = useRef<HTMLDivElement | null>(null)
438
+
439
+ useEffect(() => {
440
+ const onKeyDown = (event: KeyboardEvent): void => {
441
+ if (event.key === 'Escape') {
442
+ event.stopPropagation()
443
+ props.onCancel()
444
+ }
445
+ }
446
+ const onMouseDown = (event: MouseEvent): void => {
447
+ const root = rootRef.current
448
+ if (root === null || !(event.target instanceof Node)) return
449
+ if (root.contains(event.target)) return
450
+ // 本插件自身的 DOM(角标/工具条)不算「外部」:点击角标由它自己的
451
+ // click 处理器接管编辑器,不能先被外部点击取消掉。
452
+ if (event.target instanceof Element && event.target.closest('[data-dsh-sidenote]') !== null) return
453
+ props.onCancel()
454
+ }
455
+ document.addEventListener('keydown', onKeyDown, true)
456
+ document.addEventListener('mousedown', onMouseDown, true)
457
+ return () => {
458
+ document.removeEventListener('keydown', onKeyDown, true)
459
+ document.removeEventListener('mousedown', onMouseDown, true)
460
+ }
461
+ // eslint-disable-next-line react-hooks/exhaustive-deps
462
+ }, [])
463
+
464
+ const width = props.mode === 'new' ? 320 : 340
465
+ const left = Math.max(8, Math.min(window.innerWidth - width - 8, props.x + 16))
466
+ const top = Math.max(8, Math.min(window.innerHeight - 120, props.y - 20))
467
+
468
+ const save = (): void => { props.onSave(note) }
469
+
470
+ if (props.mode === 'new') {
471
+ return (
472
+ <div ref={rootRef} className={css.editorNew} style={{ left, top, width }}>
473
+ <input
474
+ className={css.editorInput}
475
+ value={note}
476
+ placeholder={t('notePlaceholder')}
477
+ autoFocus
478
+ onChange={(event) => { setNote(event.target.value) }}
479
+ onKeyDown={(event) => {
480
+ if (event.key === 'Enter') {
481
+ event.preventDefault()
482
+ save()
483
+ }
484
+ }}
485
+ />
486
+ <button
487
+ type="button"
488
+ className={css.confirmButton}
489
+ title={t('confirmTitle')}
490
+ aria-label={t('saveNoteAria')}
491
+ onClick={save}
492
+ >
493
+ <IconCheckOutline16 size={14} />
494
+ </button>
495
+ </div>
496
+ )
497
+ }
498
+
499
+ return (
500
+ <div ref={rootRef} className={css.editorEdit} style={{ left, top, width }}>
501
+ <textarea
502
+ className={css.editorTextarea}
503
+ value={note}
504
+ placeholder={t('notePlaceholder')}
505
+ rows={3}
506
+ autoFocus
507
+ onChange={(event) => { setNote(event.target.value) }}
508
+ onKeyDown={(event) => {
509
+ if (event.key === 'Enter' && !event.shiftKey) {
510
+ event.preventDefault()
511
+ save()
512
+ }
513
+ }}
514
+ />
515
+ <div className={css.editorFooter}>
516
+ <button
517
+ type="button"
518
+ className={css.deleteButton}
519
+ title={t('deleteNote')}
520
+ aria-label={t('deleteNote')}
521
+ onClick={props.onDelete}
522
+ >
523
+ <IconTrashOutline16 size={14} />
524
+ </button>
525
+ <span className={css.editorSpacer} />
526
+ <button type="button" className={css.cancelButton} onClick={props.onCancel}>{t('cancel')}</button>
527
+ <button type="button" className={css.saveButton} onClick={save}>{t('save')}</button>
528
+ </div>
529
+ </div>
530
+ )
531
+ }