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.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/cordis.patch.yml +16 -0
- package/lib/client.js +2143 -0
- package/lib/index.js +6 -0
- package/lib/types/client/annotate/anchor.d.ts +57 -0
- package/lib/types/client/annotate/chip.d.ts +11 -0
- package/lib/types/client/annotate/draft.d.ts +29 -0
- package/lib/types/client/annotate/format.d.ts +52 -0
- package/lib/types/client/annotate/index.d.ts +2 -0
- package/lib/types/client/annotate/model.d.ts +59 -0
- package/lib/types/client/annotate/overlay.d.ts +27 -0
- package/lib/types/client/annotate/selection.d.ts +49 -0
- package/lib/types/client/bridge.d.ts +19 -0
- package/lib/types/client/index.d.ts +12 -0
- package/lib/types/client/locales.d.ts +77 -0
- package/lib/types/client/sidechat/SideChatPanel.d.ts +2 -0
- package/lib/types/client/sidechat/composer.d.ts +26 -0
- package/lib/types/client/sidechat/index.d.ts +2 -0
- package/lib/types/client/sidechat/model.d.ts +101 -0
- package/lib/types/client/sidechat/open.d.ts +16 -0
- package/lib/types/client/sidechat/slash.d.ts +12 -0
- package/lib/types/context-types.d.ts +335 -0
- package/lib/types/index.d.ts +9 -0
- package/package.json +96 -0
- package/src/client/annotate/anchor.ts +158 -0
- package/src/client/annotate/annotate.module.css +301 -0
- package/src/client/annotate/chip.tsx +115 -0
- package/src/client/annotate/css.d.ts +9 -0
- package/src/client/annotate/draft.ts +67 -0
- package/src/client/annotate/format.ts +83 -0
- package/src/client/annotate/index.tsx +66 -0
- package/src/client/annotate/model.ts +138 -0
- package/src/client/annotate/overlay.tsx +531 -0
- package/src/client/annotate/selection.ts +203 -0
- package/src/client/bridge.ts +19 -0
- package/src/client/index.tsx +22 -0
- package/src/client/locales.ts +175 -0
- package/src/client/sidechat/SideChatPanel.tsx +366 -0
- package/src/client/sidechat/composer.ts +129 -0
- package/src/client/sidechat/css-modules.d.ts +5 -0
- package/src/client/sidechat/index.tsx +56 -0
- package/src/client/sidechat/model.ts +331 -0
- package/src/client/sidechat/open.ts +66 -0
- package/src/client/sidechat/sidechat.module.css +230 -0
- package/src/client/sidechat/slash.ts +84 -0
- package/src/context-types.ts +369 -0
- 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
|
+
}
|