/**
* 输入区镜像层 chip 的呈现与交互层(席 K):打标、样式注入、hover 面板、✕ 移除角标。
* 是 installSelectionChipHoverTitles 的全面升级替代(旧函数由整合层下线,本文件不改它)。
*
* 布局零影响铁律:镜像层与 textarea 逐字符对齐,任何占布局/改文字度量的样式都会让
* 输入框排版错位——注入样式只准用 background / box-shadow(spread 模拟描边)/ color /
* border-radius;面板与角标都是 document.body 下 position:fixed 的单例浮层,不入镜像层。
*/
/**
* 宿主 InputState 的结构子集。类型包(@deepseek-ai/* 0.1.0-rc.6)未导出该类型,
* 字段按 SPEC 真机实测定义;occurrences 按 offset 升序。整合层传入的真实快照
* 只要结构上含这些字段即可(TypeScript 结构类型)。
*/
export interface InputOccurrence {
occurrenceId: number
source: string
ref: string
offset: number
label: string
clipboardText: string
invalid?: boolean
}
export interface InputState {
draft: string
draftRev: number
phase?: string
occurrences?: InputOccurrence[]
}
export interface ChipPresentationDeps {
/** 当前会话 InputState 快照;无会话时 undefined */
getInputState(): InputState | undefined
/** 订阅 input state 变化;返回退订函数 */
subscribeInputState(listener: () => void): () => void
/** 整合层会接到席 C 的原语;席 K 只调用 */
removeOccurrence(occurrenceId: number): boolean
replaceOccurrenceRef(occurrenceId: number, newRef: string): boolean
onToast(text: string): void
/** 面板标题旁展示的文稿名解析(选区 chip 用);拿不到给 undefined */
getDocTitle?(): string | undefined
}
/** 本插件的两类 chip 来源(与席 C 的 source 常量一致,本文件自洽不跨席 import)。 */
const SELECTION_SOURCE = 'qingagent-selection'
const ANNOTATION_SOURCE = 'qingagent-annotation'
import { findOccurrenceProjection } from './annotationReference.js'
/** 打标属性与浮层 id:宿主重绘丢标是常态,打标幂等、可重入。 */
const CHIP_ATTR = 'data-qing-chip'
const OCC_ATTR = 'data-qing-occ'
/** 覆盖层显示文案(图标+「批注/选段:内容」):底层投影文字被透明化,::after 按此渲染。 */
const DISPLAY_ATTR = 'data-qing-display'
/** SVG 图标(用户裁定按 SVG 画):左双引号(两勾)=选段;钢笔=批注。
* mask 方案让图标吃 CSS background-color,主题两态都清晰。 */
export const QUOTE_ICON_SVG = "url(\"data:image/svg+xml," + encodeURIComponent(
'',
) + "\")"
export const PEN_ICON_SVG = "url(\"data:image/svg+xml," + encodeURIComponent(
'',
) + "\")"
/** 跨行标记:投影字符流被 overflow-wrap:anywhere 拆成两段(U+2060 也拦不住)时,
* absolute 覆盖层只能盖第一段——退化为底层原样文字(内容已是干净短 label),
* ::before 同色块遮掉投影首字符 @,背景每段完整克隆。 */
const WRAPPED_ATTR = 'data-qing-wrapped'
const PANEL_ATTR = 'data-qing-chip-panel'
const BADGE_ATTR = 'data-qing-chip-badge'
const STYLE_ID = 'qingagent-chip-presentation-style'
/** hover 面板时序:命中后 80ms 显示;离开(chip 与浮层都不含鼠标)350ms 关闭。 */
export const CHIP_PANEL_SHOW_DELAY = 80
export const CHIP_PANEL_HIDE_DELAY = 350
/** 文案冻结(SPEC):不得自造别的。 */
const TOAST_INPUT_UNAVAILABLE = '输入框当前不可用,请稍后重试'
const TOAST_CUT_COPIED = '已复制,请手动删除'
/** 镜像层内 chip 元素的修饰类(子元素/状态类),配对时须排除。 */
const CHIP_MODIFIER_CLASSES = ['chipLabel', 'chipIcon', 'chipInvalid']
/** 收集镜像层 chip 元素,按 DOM 顺序(与 occurrences 的 offset 升序一一对应)。 */
function collectChipElements(): HTMLElement[] {
const chips: HTMLElement[] = []
for (const backdrop of document.querySelectorAll('[class*="backdrop"]')) {
for (const el of backdrop.querySelectorAll('[class*="chip"]')) {
if (!(el instanceof HTMLElement)) continue
// className 是构建 hash 前缀,只能子串排除修饰类。
if (CHIP_MODIFIER_CLASSES.some((modifier) => el.className.includes(modifier))) continue
chips.push(el)
}
}
return chips
}
/** 「修改方向」初值只留改法本身:模型爱写「替换为:"…"」一类前缀与包裹引号,
* 评测口径(与客户端一致)要求初值不含前缀、不带引号包裹;确定性剥除比约束模型可靠。 */
export function normalizeDirectionText(text: string): string {
let value = text.trim()
value = value.replace(/^(?:替换为|建议改为|改写为|修改为|改为)[::]?\s*/u, '')
const wrappers: Array<[string, string]> = [['"', '"'], ['“', '”'], ['「', '」'], ['『', '』'], ["'", "'"]]
for (const [open, close] of wrappers) {
if (value.length > 1 && value.startsWith(open) && value.endsWith(close)) {
value = value.slice(open.length, value.length - close.length).trim()
break
}
}
return value || text.trim()
}
/** 覆盖层文案:批注=✎ 笔图标,选段=“中文弯双引号(用户裁定);内容取 ref 的完整
* 引文/修改方向,超宽由 CSS ellipsis 兜底(宽度自适应,不预截断)。ref 解析不出时
* 回落到底层 label(剥 dedupe 圈号)。 */
export function chipDisplayText(
kind: 'selection' | 'annotation',
label: string,
ref?: string,
): string {
const fallback = label.replaceAll('\u2060', '').replace(/^[①-⑳]|^#\d+/u, '')
if (kind === 'annotation') {
const body = (ref ?? '').replace(/\s+/gu, ' ').trim()
.replace(/^按批注修改[:\uFF1A]/u, '')
.replace(/[((]原文[:\uFF1A]『[\s\S]*』[))]\s*$/u, '')
.trim()
const direction = /^[\s\S]{1,60}?——([\s\S]+)$/u.exec(body)?.[1]?.trim() ?? body
return `批注:${direction || fallback}`
}
const quote = /[:\uFF1A]\s*「([\s\S]*)」\s*$/u.exec(ref ?? '')?.[1]?.replace(/\s+/gu, ' ').trim()
return `选段:${quote || fallback}`
}
function occurrenceKind(occurrence: InputOccurrence): 'selection' | 'annotation' | undefined {
if (occurrence.source === SELECTION_SOURCE) return 'selection'
if (occurrence.source === ANNOTATION_SOURCE) return 'annotation'
return undefined
}
/**
* 安装打标+样式+hover 面板+移除交互;返回卸载函数(移除样式、面板 DOM、监听器)。
* 可重复安装/卸载:所有副作用都在卸载函数里清干净。
*/
export function installChipPresentation(deps: ChipPresentationDeps): () => void {
if (typeof document === 'undefined') return () => {}
// ---------------------------------------------------------------- 样式注入
// 只允许不占布局的属性:background / box-shadow(spread 描边)/ color / border-radius。
// 裸 `@` 是草稿真实字符,保持原样,绝不动它。
const style = document.createElement('style')
style.id = STYLE_ID
style.textContent = [
`[${CHIP_ATTR}="selection"]{`,
// 用户裁定:选区 chip 是「带一定颜色的块」;文字色跟随宿主主题(写死浅字在亮色主题下看不清)。
'background:rgba(200,169,106,.38);',
'box-shadow:0 0 0 1px rgba(200,169,106,.75);',
'color:var(--dsw-alias-label-primary, inherit);',
'border-radius:0;',
'}',
`[${CHIP_ATTR}="annotation"]{`,
'background:rgba(186,92,38,.38);',
'box-shadow:0 0 0 1px rgba(186,92,38,.8);',
'color:var(--dsw-alias-label-primary, inherit);',
'border-radius:0;',
'}',
// 跨行时背景/描边每段完整克隆,不再一段有边一段裸奔。
`[${CHIP_ATTR}]{`,
'-webkit-box-decoration-break:clone;',
'box-decoration-break:clone;',
'}',
// 覆盖层显示(用户裁定:图标+「批注/选段:内容」12 号字):底层投影文字透明化,
// ::after 绝对定位覆盖——与宿主 chipLabel 同机制,不占布局、不改底层字符度量。
`[${CHIP_ATTR}][${DISPLAY_ATTR}]{`,
'position:relative;',
'color:transparent !important;',
'}',
`[${CHIP_ATTR}][${DISPLAY_ATTR}] *{`,
'color:transparent !important;',
'}',
`[${CHIP_ATTR}][${DISPLAY_ATTR}]::after{`,
`content:attr(${DISPLAY_ATTR});`,
// block + translateY 几何居中:flex 会吃掉 text-overflow(用户实测截断无省略号);
// -1px 校正 CJK 字面视觉重心略低(用户实测偏下一点)。
'position:absolute;', 'left:0;', 'right:0;', 'top:calc(50% - 1px);',
'transform:translateY(-50%);', 'display:block;',
'padding:0 20px 0 22px;', 'box-sizing:border-box;',
'font-size:12px;', 'line-height:normal;',
'color:var(--dsw-alias-label-primary, #ece4d4);',
'white-space:nowrap;', 'overflow:hidden;', 'text-overflow:ellipsis;',
'}',
// SVG 图标:mask + background-color,不吃 chip 的 transparent 文字色。
`[${CHIP_ATTR}][${DISPLAY_ATTR}]:not([${WRAPPED_ATTR}])::before{`,
"content:'';", 'position:absolute;', 'left:6px;', 'top:calc(50% - 1px);',
'width:12px;', 'height:12px;', 'transform:translateY(-50%);',
'background:var(--dsw-alias-label-primary, #ece4d4);',
`-webkit-mask:${QUOTE_ICON_SVG} center / 12px 12px no-repeat;`,
`mask:${QUOTE_ICON_SVG} center / 12px 12px no-repeat;`,
'}',
`[${CHIP_ATTR}="annotation"][${DISPLAY_ATTR}]:not([${WRAPPED_ATTR}])::before{`,
`-webkit-mask-image:${PEN_ICON_SVG};`,
`mask-image:${PEN_ICON_SVG};`,
'}',
// 划选扫过 chip 时,透明的底层投影字符会以选区样式显形放大(用户实测);
// 选中反馈只保留整块背景,不显字。
`[${CHIP_ATTR}][${DISPLAY_ATTR}]::selection{ color:transparent; }`,
`[${CHIP_ATTR}][${DISPLAY_ATTR}] *::selection{ color:transparent; }`,
// 跨行退化:底层原样文字显形(内容=干净短 label),关闭覆盖层;
// 投影首字符 @ 用同背景色块盖住(首段左缘固定宽),观感只留内容。
`[${CHIP_ATTR}][${WRAPPED_ATTR}]{`,
'color:var(--dsw-alias-label-primary, inherit) !important;',
'}',
// 子元素也要显形:上面 [DISPLAY] * 的透明规则同特异性,靠声明顺序在此翻盘。
`[${CHIP_ATTR}][${WRAPPED_ATTR}] *{`,
'color:var(--dsw-alias-label-primary, inherit) !important;',
'}',
`[${CHIP_ATTR}][${WRAPPED_ATTR}]::after{ display:none; }`,
// 跨行时 @ 总在上一行行尾独占一小段(它与 label 间是天然断点)——用纯底色块
// 把这 15px 残段整个隐形化,内容主体从下一行干净起头。
`[${CHIP_ATTR}][${WRAPPED_ATTR}]::before{`,
"content:'';", 'position:absolute;', 'left:0;', 'top:0;', 'bottom:0;', 'width:16px;',
'background:var(--dsw-specific-input-major, #1b1c1f);',
'}',
`[${CHIP_ATTR}][${WRAPPED_ATTR}]::selection{ color:var(--dsw-alias-label-primary, inherit); }`,
].join('')
document.head.appendChild(style)
// ---------------------------------------------------------------- 打标
// 差量打标:只改需要改的属性,本身零变更时幂等(否则 MutationObserver 会自激)。
const retag = () => {
const occurrences = (deps.getInputState()?.occurrences ?? [])
.slice()
.sort((a, b) => a.offset - b.offset)
const chips = collectChipElements()
const desired = new Map()
// 宿主新版镜像层自带 data-occurrence(真机实证):有则精确配对,残缺投影/乱序时不会错标;
// 旧版缺该属性时退回按 DOM 序与 offset 序对齐。
const byHostOcc = new Map()
for (const el of chips) {
const hostOcc = el.getAttribute('data-occurrence')
if (hostOcc !== null) byHostOcc.set(hostOcc, el)
}
if (byHostOcc.size > 0) {
for (const occurrence of occurrences) {
const el = byHostOcc.get(String(occurrence.occurrenceId))
const kind = el ? occurrenceKind(occurrence) : undefined
if (el && kind) desired.set(el, { kind, occurrenceId: occurrence.occurrenceId })
}
} else {
const count = Math.min(chips.length, occurrences.length)
for (let index = 0; index < count; index += 1) {
const kind = occurrenceKind(occurrences[index]!)
if (kind) {
desired.set(chips[index]!, { kind, occurrenceId: occurrences[index]!.occurrenceId })
}
}
}
const occurrenceById = new Map(occurrences.map((occurrence) => [occurrence.occurrenceId, occurrence]))
for (const el of document.querySelectorAll(`[${CHIP_ATTR}]`)) {
if (!(el instanceof HTMLElement)) continue
const want = desired.get(el)
if (!want
|| el.getAttribute(CHIP_ATTR) !== want.kind
|| el.getAttribute(OCC_ATTR) !== String(want.occurrenceId)) {
el.removeAttribute(CHIP_ATTR)
el.removeAttribute(OCC_ATTR)
el.removeAttribute(DISPLAY_ATTR)
}
}
for (const [el, want] of desired) {
if (el.getAttribute(CHIP_ATTR) !== want.kind) el.setAttribute(CHIP_ATTR, want.kind)
if (el.getAttribute(OCC_ATTR) !== String(want.occurrenceId)) {
el.setAttribute(OCC_ATTR, String(want.occurrenceId))
}
const source = occurrenceById.get(want.occurrenceId)
const display = chipDisplayText(want.kind, source?.label ?? '', source?.ref)
if (el.getAttribute(DISPLAY_ATTR) !== display) el.setAttribute(DISPLAY_ATTR, display)
}
// 宿主重渲可能把 hover 中的 chip 换掉:旧节点已不在文档里就收浮层。
if (hoverChip && !hoverChip.isConnected) hideOverlays()
scheduleWrapCheck()
}
// 投影字符流在行尾会被 overflow-wrap:anywhere 拆成两段(textarea 与镜像同规则,
// 数据层就跨行);absolute 覆盖层只画在第一段上,第二段成透明空块(用户实测)。
// 跨行的 chip 撤覆盖层退化为底层原样文字——宁可原样大字,不可空白。
let wrapCheckQueued = false
function scheduleWrapCheck(): void {
if (wrapCheckQueued || typeof requestAnimationFrame !== 'function') return
wrapCheckQueued = true
requestAnimationFrame(() => {
wrapCheckQueued = false
for (const el of document.querySelectorAll(`[${CHIP_ATTR}]`)) {
if (!(el instanceof HTMLElement)) continue
const wrapped = el.getClientRects().length > 1
if (wrapped !== el.hasAttribute(WRAPPED_ATTR)) {
if (wrapped) el.setAttribute(WRAPPED_ATTR, '1')
else el.removeAttribute(WRAPPED_ATTR)
}
}
})
}
// ---------------------------------------------------------------- 浮层单例
const panel = document.createElement('div')
panel.setAttribute(PANEL_ATTR, '1')
// 跟随宿主明暗主题(dsw 令牌;写死深色在浅色主题下是一坨黑块——用户实测点名)。
panel.style.cssText = [
'position:fixed', 'z-index:100600', 'display:none', 'width:360px', 'box-sizing:border-box',
'padding:12px', 'border-radius:0',
'background:var(--dsw-alias-bg-layer-3, #2e2a24)',
'color:var(--dsw-alias-label-primary, #ece4d4)',
'border:1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.25))',
'box-shadow:var(--dsw-shadow-lv2, 0 8px 24px rgba(0,0,0,.35))',
'font-size:12px', 'line-height:18px',
].join(';')
// 自绘瞬时提示:宿主 notify 的 notice 呈现依赖输入机状态,cut 降级等场景用户
// 实测看不到(评测 r5 席3 4.5)——改为插件自绘,2.5 秒自动消失,主题令牌自适应。
const toastEl = document.createElement('div')
toastEl.setAttribute(PANEL_ATTR, '1')
toastEl.style.cssText = [
'position:fixed', 'z-index:100602', 'display:none', 'left:50%', 'bottom:96px',
'transform:translateX(-50%)', 'max-width:70vw', 'padding:7px 14px', 'border-radius:0',
'background:var(--dsw-alias-bg-layer-3, #2e2a24)',
'color:var(--dsw-alias-label-primary, #ece4d4)',
'border:1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.25))',
'box-shadow:var(--dsw-shadow-lv2, 0 8px 24px rgba(0,0,0,.35))',
'font-size:12px', 'line-height:18px', 'white-space:nowrap', 'pointer-events:none',
].join(';')
let toastTimer: ReturnType | undefined
const showToast = (text: string) => {
toastEl.textContent = text
toastEl.style.display = 'block'
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toastEl.style.display = 'none' }, 2_500)
// 宿主通道保留:输入机状态允许时 notice 同步出现,双保险不冲突。
deps.onToast(text)
}
const badge = document.createElement('div')
badge.setAttribute(BADGE_ATTR, '1')
badge.textContent = '✕'
badge.style.cssText = [
'position:fixed', 'z-index:100601', 'display:none',
'width:16px', 'height:16px', 'border-radius:0',
'background:var(--dsw-alias-bg-layer-3, #26282c)',
'color:var(--dsw-alias-label-secondary, #b9b3a8)',
'border:1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.3))',
'font-size:10px', 'line-height:14px', 'text-align:center',
'cursor:pointer', 'user-select:none',
'box-shadow:var(--dsw-shadow-lv2, 0 2px 8px rgba(0,0,0,.3))',
].join(';')
const panelButton = (text: string, primary: boolean): HTMLButtonElement => {
const button = document.createElement('button')
button.type = 'button'
button.textContent = text
button.style.cssText = [
'padding:4px 14px', 'border-radius:0', 'cursor:pointer', 'font-size:12px', 'line-height:18px',
primary
? 'border:1px solid #b3541e;background:#b3541e;color:#f5efdf'
: 'border:1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.35));background:transparent;color:var(--dsw-alias-label-primary, #ece4d4)',
].join(';')
return button
}
// ---------------------------------------------------------------- hover 状态机
let hoverChip: HTMLElement | null = null
let showTimer: ReturnType | undefined
let hideTimer: ReturnType | undefined
const clearTimers = () => {
if (showTimer !== undefined) { clearTimeout(showTimer); showTimer = undefined }
if (hideTimer !== undefined) { clearTimeout(hideTimer); hideTimer = undefined }
}
function hideOverlays() {
clearTimers()
hoverChip = null
panel.style.display = 'none'
badge.style.display = 'none'
}
/** 命中 chip 变化(含初次命中):立即上 ✕ 角标,80ms 后开面板。 */
function scheduleShow(chip: HTMLElement) {
if (showTimer !== undefined) clearTimeout(showTimer)
showTimer = setTimeout(() => {
showTimer = undefined
if (hoverChip !== chip || !chip.isConnected) return
showPanel(chip)
}, CHIP_PANEL_SHOW_DELAY)
}
function scheduleHide() {
if (hideTimer !== undefined) return
hideTimer = setTimeout(() => {
hideTimer = undefined
hideOverlays()
}, CHIP_PANEL_HIDE_DELAY)
}
function positionBadge(rect: DOMRect) {
badge.style.display = 'block'
// 角标骑在 chip 右上角:中心对齐右上顶点。
// 用户裁定:✕ 不做浮动角标,贴在 chip 内部最右侧,垂直居中。
badge.style.left = `${Math.round(rect.right - 18)}px`
badge.style.top = `${Math.round(rect.top + (rect.height - 16) / 2)}px`
}
function showPanel(chip: HTMLElement) {
const occurrenceId = Number(chip.getAttribute(OCC_ATTR))
const occurrence = deps.getInputState()?.occurrences
?.find((candidate) => candidate.occurrenceId === occurrenceId)
const kind = chip.getAttribute(CHIP_ATTR)
if (!occurrence || (kind !== 'selection' && kind !== 'annotation')) return
panel.textContent = ''
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:baseline;gap:8px;margin-bottom:8px;font-weight:600'
const title = document.createElement('span')
title.textContent = kind === 'selection' ? '选段内容' : '批注修改'
header.appendChild(title)
if (kind === 'selection') {
// 文稿名从 chip 自己的 ref 解析(payload 自证):取当前稿标题在切稿后会张冠李戴
// (评测 0822-r10:内容是通知稿,标题显示成刚切去的读书笔记)。
const refTitle = /^\[选段\]《([\s\S]*)》(?:第\d+段)?[:\uFF1A]\s*「/.exec(occurrence.ref)?.[1]?.trim()
const docTitle = refTitle || deps.getDocTitle?.()?.trim()
if (docTitle) {
const doc = document.createElement('span')
doc.textContent = `《${docTitle}》`
doc.style.cssText = 'font-weight:400;opacity:.65'
header.appendChild(doc)
}
}
panel.appendChild(header)
let editArea: HTMLTextAreaElement | undefined
if (kind === 'selection') {
const body = document.createElement('div')
// ref 形如「[选段]《文稿名》:「引文」」;面板 header 已展示文稿名,正文只放引文全文。
// 引文必须锚定冒号后的「:文稿名自身可含「」(《「燃力纤」…》),贪婪匹配会从
// 书名里的第一个「切起,把锚点裁残(评测 0822-r2 实证)。
const quoteMatch = /[:\uFF1A]\s*「([\s\S]*)」\s*$/.exec(occurrence.ref)
body.textContent = quoteMatch?.[1] ?? occurrence.ref
body.style.cssText = [
'max-height:200px', 'overflow:auto', 'margin-bottom:10px',
'white-space:pre-wrap', 'word-break:break-word', 'opacity:.9',
].join(';')
panel.appendChild(body)
} else {
// 结构化(用户裁定):原文只读,输入框只留「修改方向」。
// 指令真源格式:按批注修改:{方向}(原文:『{引文}』);引文缺省时整段视为方向。
const parsed = /^按批注修改[::]([\s\S]*?)(?:[((]原文[::]『([\s\S]*)』[))])?\s*$/u.exec(occurrence.ref)
const action = parsed?.[1]?.trim() ?? occurrence.ref
const quote = parsed?.[2]
// 真源 action = 「{问题摘要}——{改法}」:摘要只读展示,输入框只留改法(修改方向)。
const dashSplit = /^([\s\S]{1,60}?)——([\s\S]*)$/u.exec(action)
const summary = dashSplit?.[1]?.trim()
const direction = normalizeDirectionText(dashSplit?.[2]?.trim() ?? action)
const fieldLabel = (text: string): HTMLElement => {
const label = document.createElement('div')
label.textContent = text
label.style.cssText = 'font-size:11px;opacity:.65;margin-bottom:4px'
return label
}
if (summary) {
panel.appendChild(fieldLabel('问题'))
const summaryLine = document.createElement('div')
summaryLine.textContent = summary
summaryLine.style.cssText = 'margin-bottom:8px;font-weight:600'
panel.appendChild(summaryLine)
}
if (quote) {
panel.appendChild(fieldLabel('原文'))
const quoteBlock = document.createElement('div')
quoteBlock.textContent = quote
quoteBlock.style.cssText = [
'max-height:120px', 'overflow:auto', 'margin-bottom:10px', 'padding:6px 8px',
'white-space:pre-wrap', 'word-break:break-word', 'opacity:.85',
'border-left:2px solid rgba(186,92,38,.6)',
'background:var(--dsw-alias-bg-layer-1, rgba(128,128,128,.08))',
].join(';')
panel.appendChild(quoteBlock)
}
panel.appendChild(fieldLabel('修改方向'))
editArea = document.createElement('textarea')
editArea.value = direction
editArea.style.cssText = [
'width:100%', 'box-sizing:border-box', 'height:72px', 'resize:none',
'padding:7px 8px', 'border-radius:0', 'margin-bottom:10px',
'background:var(--dsw-alias-bg-layer-1, rgba(255,255,255,.06))',
'color:var(--dsw-alias-label-primary, #ece4d4)',
'border:1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.35))',
'font-size:12px', 'line-height:18px', 'outline:none',
].join(';')
panel.appendChild(editArea)
// 确认时按真源格式重组完整指令(问题摘要与原文保持不动)。
editArea.dataset.qingQuote = quote ?? ''
editArea.dataset.qingSummary = summary ?? ''
}
const footer = document.createElement('div')
footer.style.cssText = 'display:flex;justify-content:flex-end;gap:8px'
const removeButton = panelButton('移除', false)
removeButton.addEventListener('click', () => {
if (deps.removeOccurrence(occurrence.occurrenceId)) hideOverlays()
else showToast(TOAST_INPUT_UNAVAILABLE)
})
footer.appendChild(removeButton)
if (kind === 'annotation') {
// 用户裁定:按钮叫「修改」;默认置灰不可点(hover 提示「请先修改内容」),
// 「修改方向」内容与初值不一致时点亮,点击即替换重组。
const confirmButton = panelButton('修改', true)
const initialDirection = (editArea?.value ?? '').trim()
const setEnabled = (enabled: boolean) => {
confirmButton.disabled = !enabled
confirmButton.title = enabled ? '' : '请先修改内容'
confirmButton.style.cursor = enabled ? 'pointer' : 'not-allowed'
confirmButton.style.opacity = enabled ? '1' : '.45'
confirmButton.style.background = enabled ? '#b3541e' : 'transparent'
confirmButton.style.color = enabled
? '#f5efdf'
: 'var(--dsw-alias-label-secondary, #b9b3a8)'
confirmButton.style.borderColor = enabled
? '#b3541e'
: 'var(--dsw-alias-border-l1, rgba(128,128,128,.35))'
}
setEnabled(false)
editArea?.addEventListener('input', () => {
setEnabled((editArea?.value ?? '').trim() !== initialDirection)
})
confirmButton.addEventListener('click', () => {
if (confirmButton.disabled) return
const direction = editArea?.value?.trim() ?? ''
const quote = editArea?.dataset.qingQuote
const summary = editArea?.dataset.qingSummary
const action = summary ? `${summary}——${direction}` : direction
const rebuilt = direction
? `按批注修改:${action}${quote ? `(原文:『${quote}』)` : ''}`
: occurrence.ref
if (deps.replaceOccurrenceRef(occurrence.occurrenceId, rebuilt)) {
hideOverlays()
} else {
showToast(TOAST_INPUT_UNAVAILABLE)
}
})
footer.appendChild(confirmButton)
}
panel.appendChild(footer)
// 定位:chip 上方 8px,视口顶部放不下则翻转到下方;水平钳在视口内。
const rect = chip.getBoundingClientRect()
panel.style.display = 'block'
const width = panel.offsetWidth || 360
const height = panel.offsetHeight || 0
panel.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`
let top = rect.top - height - 8
if (top < 8) top = rect.bottom + 8
if (height > 0 && top + height > window.innerHeight - 8) {
top = Math.max(8, window.innerHeight - height - 8)
}
panel.style.top = `${top}px`
}
/** mousemove 坐标命中:镜像层 pointer-events:none,事件全落在 textarea 上。 */
const hitChip = (event: MouseEvent): HTMLElement | null => {
// 逐 fragment 命中:跨行 chip 的 boundingRect 是两行的并集大矩形,会把相邻 chip
// 的悬浮区整个罩住(用户实测第 4 枚 hover 不到)。
for (const el of document.querySelectorAll(`[${CHIP_ATTR}]`)) {
const fragments = el.getClientRects()
const rects = fragments.length > 0 ? [...fragments] : [el.getBoundingClientRect()]
for (const rect of rects) {
if (event.clientX >= rect.left && event.clientX <= rect.right
&& event.clientY >= rect.top && event.clientY <= rect.bottom) {
return el as HTMLElement
}
}
}
return null
}
// ✕ 定位锚点:内容末尾所在的最后一个 fragment(单行=唯一 rect;跨行不再悬在并集矩形外)。
const lastFragmentRect = (chip: HTMLElement): DOMRect => {
const rects = chip.getClientRects()
return rects.length > 0 ? rects[rects.length - 1]! : chip.getBoundingClientRect()
}
const onMouseMove = (event: MouseEvent) => {
const target = event.target as Element | null
// 鼠标在面板/角标上:浮层保持,不计离开。
if (target?.closest?.(`[${PANEL_ATTR}], [${BADGE_ATTR}]`)) {
if (hideTimer !== undefined) { clearTimeout(hideTimer); hideTimer = undefined }
return
}
if (!target?.closest?.('textarea')) {
if (hoverChip) scheduleHide()
return
}
const chip = hitChip(event)
if (!chip) {
if (hoverChip) scheduleHide()
return
}
if (hideTimer !== undefined) { clearTimeout(hideTimer); hideTimer = undefined }
positionBadge(lastFragmentRect(chip))
if (chip !== hoverChip) {
hoverChip = chip
scheduleShow(chip)
}
}
// ✕ 角标:mousedown 拦截(阻止落焦到 textarea 的默认行为),removeOccurrence 失败走 toast。
const onBadgeMouseDown = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!hoverChip) return
const occurrenceId = Number(hoverChip.getAttribute(OCC_ATTR))
if (deps.removeOccurrence(occurrenceId)) hideOverlays()
else showToast(TOAST_INPUT_UNAVAILABLE)
}
badge.addEventListener('mousedown', onBadgeMouseDown)
// 点击空白处立即收浮层(不挡正常输入:仅关闭,不拦截事件)。
const onMouseDown = (event: MouseEvent) => {
const target = event.target as Element | null
if (target?.closest?.(`[${PANEL_ATTR}], [${BADGE_ATTR}]`)) return
if (hoverChip) hideOverlays()
}
// ---------------------------------------------------------------- 装配与卸载
document.body.append(panel, badge, toastEl)
document.addEventListener('mousemove', onMouseMove, { capture: true, passive: true })
document.addEventListener('mousedown', onMouseDown, { capture: true })
// ---------------------------------------------------------------- chip 原子化(用户裁定)
// chip 是整体:光标不允许落进投影文本内部;Backspace/Delete 命中时整体删除,不许拆字。
const ourProjections = (): Array<{ occurrenceId: number; start: number; end: number; clipboardText: string }> => {
const state = deps.getInputState()
if (!state) return []
const result: Array<{ occurrenceId: number; start: number; end: number; clipboardText: string }> = []
for (const occurrence of state.occurrences ?? []) {
if (!occurrenceKind(occurrence)) continue
const projection = findOccurrenceProjection(state as never, occurrence.occurrenceId)
if (projection) result.push({
occurrenceId: occurrence.occurrenceId,
clipboardText: occurrence.clipboardText,
...projection,
})
}
return result.sort((left, right) => left.start - right.start)
}
const composerOf = (target: EventTarget | null): HTMLTextAreaElement | null =>
target instanceof HTMLTextAreaElement && !target.closest(`[${PANEL_ATTR}]`) ? target : null
const onSelectionChange = () => {
const textarea = composerOf(document.activeElement)
if (!textarea || textarea.selectionStart !== textarea.selectionEnd) return
const pos = textarea.selectionStart
for (const projection of ourProjections()) {
if (pos > projection.start && pos < projection.end) {
const snap = pos - projection.start <= projection.end - pos ? projection.start : projection.end
textarea.setSelectionRange(snap, snap)
return
}
}
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Backspace' && event.key !== 'Delete') return
const textarea = composerOf(event.target)
if (!textarea || textarea.selectionStart !== textarea.selectionEnd) return
const pos = textarea.selectionStart
for (const projection of ourProjections()) {
const hit = event.key === 'Backspace'
? pos > projection.start && pos <= projection.end
: pos >= projection.start && pos < projection.end
if (hit) {
event.preventDefault()
event.stopPropagation()
if (!deps.removeOccurrence(projection.occurrenceId)) showToast(TOAST_INPUT_UNAVAILABLE)
return
}
}
}
const onClipboard = (event: ClipboardEvent) => {
const textarea = composerOf(event.target)
const state = deps.getInputState()
// occurrence offset 只对当前 InputState.draft 有效;宿主状态尚未追上 textarea 时
// 放回原生复制,避免按旧坐标展开错误 occurrence。
if (!textarea || !state || textarea.value !== state.draft || !event.clipboardData) return
const selectionStart = textarea.selectionStart
const selectionEnd = textarea.selectionEnd
if (selectionStart === selectionEnd) return
const intersecting = ourProjections().filter((projection) =>
selectionStart < projection.end && selectionEnd > projection.start)
if (intersecting.length === 0) return
let cursor = selectionStart
const parts: string[] = []
for (const projection of intersecting) {
const plainEnd = Math.min(selectionEnd, Math.max(cursor, projection.start))
if (plainEnd > cursor) parts.push(state.draft.slice(cursor, plainEnd))
parts.push(projection.clipboardText)
cursor = Math.min(selectionEnd, Math.max(cursor, projection.end))
}
if (cursor < selectionEnd) parts.push(state.draft.slice(cursor, selectionEnd))
event.clipboardData.setData('text/plain', parts.join(''))
event.preventDefault()
// 原生 cut 的删除会被 preventDefault 一并拦下;保守降级为复制,避免宿主 diff
// 在跨 chip 选区里误伤相邻 occurrence 的完整载荷。
if (event.type === 'cut') showToast(TOAST_CUT_COPIED)
}
document.addEventListener('selectionchange', onSelectionChange)
document.addEventListener('keydown', onKeyDown, { capture: true })
document.addEventListener('copy', onClipboard, { capture: true })
document.addEventListener('cut', onClipboard, { capture: true })
const unsubscribeInputState = deps.subscribeInputState(retag)
const observer = new MutationObserver((mutations) => {
// 浮层自身的增删不触发重打标(打标是差量的,理论上不会自激,这里再省一轮)。
for (const mutation of mutations) {
const nodes = [...mutation.addedNodes, ...mutation.removedNodes]
const onlyOverlays = nodes.length > 0 && nodes.every((node) =>
node instanceof Element && Boolean(node.closest(`[${PANEL_ATTR}], [${BADGE_ATTR}]`)))
if (!onlyOverlays) {
retag()
return
}
}
})
observer.observe(document.body, { childList: true, subtree: true })
// 输入框宽度变化会改变折行形态:变窄要退化、变宽要恢复覆盖层,都走幂等 retag。
window.addEventListener('resize', retag)
retag()
return () => {
observer.disconnect()
window.removeEventListener('resize', retag)
unsubscribeInputState()
document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('keydown', onKeyDown, { capture: true })
document.removeEventListener('copy', onClipboard, { capture: true })
document.removeEventListener('cut', onClipboard, { capture: true })
document.removeEventListener('mousemove', onMouseMove, { capture: true })
document.removeEventListener('mousedown', onMouseDown, { capture: true })
clearTimers()
if (toastTimer) clearTimeout(toastTimer)
panel.remove()
badge.remove()
toastEl.remove()
style.remove()
hoverChip = null
for (const el of document.querySelectorAll(`[${CHIP_ATTR}]`)) {
el.removeAttribute(CHIP_ATTR)
el.removeAttribute(OCC_ATTR)
}
}
}