import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react' import { parentPathOf } from './pathutil' import { CHANGELOG_V030 } from './changelog' import { LOCAL_VERSION, checkUpdate, getAutoCheck, readCache, setAutoCheck as storeAutoCheck, setSkipVersion, type UpdateInfo, type UpdateStatus } from './updateCheck' import { Terminal } from 'xterm' import MarkdownIt from 'markdown-it' import hljs from 'highlight.js/lib/core' import hljsTypescript from 'highlight.js/lib/languages/typescript' import hljsJavascript from 'highlight.js/lib/languages/javascript' import hljsCss from 'highlight.js/lib/languages/css' import hljsJson from 'highlight.js/lib/languages/json' hljs.registerLanguage('typescript', hljsTypescript) hljs.registerLanguage('javascript', hljsJavascript) hljs.registerLanguage('css', hljsCss) hljs.registerLanguage('json', hljsJson) /** * dsh-worktable 乐高式工作区 M1:通用分栏引擎(PRD §13)。 * 布局模型:标题栏 + 顶部通栏行(可选) + 主行内容窗 + 聊天窗(官方会话视图区整体, * 贴右或贴左,由 chatSide 决定;marginLeft/marginRight + marginTop 组合挤法)。 * 内容三态:null(未指派 → 6 选 1 选择器)/ iframe / builtin(浏览器/资源管理器/SCM/任务/终端)。 * 窗位调整:标题栏拖拽换位(同行或跨行);工具栏 ⇄ 切换聊天窗左右。 * 会话切换重新锚定不关闭;宽度按 layoutId 持久化 dsh.worktable.split.v2; * 内容与 chatSide 的变更经 onSpecMutated 回调交给工作台持久化(布局条目)。 */ export type BuiltinType = 'browser' | 'anim' | 'explorer' | 'scm' | 'tasks' | 'terminal' | 'custom' | 'console' export type SplitContent = | { kind: 'iframe'; url: string; title?: string } | { kind: 'builtin'; type: BuiltinType; url?: string } | { kind: 'file'; path: string } /** 一个内容标签页 */ export type PaneTab = { id: string; title: string; content: SplitContent } export type SplitPane = { id: string title: string min: number /** 向后兼容:单内容声明(打开时归一化为一个标签页) */ content?: SplitContent | null /** 标签页模型:内容标签列表(空 = 未指派,显示 6 选 1 选择器) */ tabs?: PaneTab[] /** 激活的标签下标 */ active?: number /** 头部折叠:true = 隐藏窗格标题与标签栏(内容占满;随 spec 持久化) */ collapsed?: boolean } export type LayoutSpec = { id: string title: string top: SplitPane[] | null main: SplitPane[] /** 左列整高内容窗(可选;存在时右侧列 = top 行 + 底部聊天,chatSide 固定 right) */ left?: SplitPane | null leftWidth?: { default: number; min: number; max: number } chatWidth: { default: number; min: number; max: number } topHeight?: { default: number; min: number; max: number } /** 聊天窗贴边位置:'right'(右列/右下,默认)| 'left'(左列/左下) */ chatSide?: 'left' | 'right' /** 布局条目图标(emoji;工作台侧栏展示,点击可换) */ icon?: string /** 聊天窗通高(整列):为 true 时聊天占整条右/左列,内容区(含 top 行)全部排在其另一侧 */ chatFullHeight?: boolean /** 顶行首次打开时高度占可用高度比例(0~1;0.5 = 上下等分);拖动后由存档值覆盖 */ topHeightRatio?: number } type Geom = { left: number; top: number; right: number; bottom: number } type PaneRow = 'left' | 'top' | 'main' type SplitState = { active: boolean spec: LayoutSpec | null geom: Geom | null chatW: number topH: number leftW: number paneWs: number[] topWs: number[] leftWs: number[] root: HTMLElement | null header: HTMLElement | null viewArea: HTMLElement | null savedMarginLeft: string savedMarginRight: string savedMarginTop: string /** 打开前会话根上 --dsh-chat-user-width 的内联值(关闭时原样恢复;0.1.1 上该变量闲置无害) */ savedWidthVar: string observer: ResizeObserver | null fallback: MutationObserver | null yieldObserver: MutationObserver | null lastMarginLeft: string lastMarginRight: string lastMarginTop: string onSpecMutated: ((spec: LayoutSpec) => void) | null listeners: Set<() => void> open(spec: LayoutSpec): boolean close(): void syncAnchor(): void refreshGeom(): void applyMargin(): void setChatW(w: number): void setTopH(h: number): void setLeftW(w: number): void setPaneW(i: number, w: number): void setTopW(i: number, w: number): void setPaneContent(row: PaneRow, i: number, content: SplitContent | null): void openTab(row: PaneRow, i: number, content: SplitContent): void /** 锁定窗格:清空原有标签,把内容作为该窗唯一的固定标签(挂载产物的「锁死」语义) */ lockPane(row: PaneRow, i: number, content: SplitContent): void closeTab(row: PaneRow, i: number, tabId: string): void setActiveTab(row: PaneRow, i: number, tabId: string): void toggleCollapsed(row: PaneRow, i: number): void moveTab(fromRow: PaneRow, fromI: number, tabId: string, toRow: PaneRow, toI: number): void swapPanes(aRow: PaneRow, aI: number, bRow: PaneRow, bI: number): void setChatSide(side: 'left' | 'right'): void persist(): void subscribe(fn: () => void): () => void notify(): void } const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi) const GAP = 1 // 窗格之间真实布局间隙:1px(分隔细线即间隙,底色贴线) const DIVIDER = 4 // 分隔条热区宽度:透明覆盖层,细线画在热区中间与两侧内容贴齐 const BAR_H = 26 const PERSIST_KEY = 'dsh.worktable.split.v2' /** 内置内容窗图标 */ const BUILTIN_ICONS: Record = { browser: '🌐', anim: '🎬', explorer: '📁', scm: '🔀', tasks: '✅', terminal: '▸_', custom: '✨', console: '🖥️', } const BUILTIN_LABEL_KEYS: Record = { browser: 'pane.browser', anim: 'pane.anim', explorer: 'pane.explorer', scm: 'pane.scm', tasks: 'pane.tasks', terminal: 'pane.terminal', custom: 'pane.custom', console: 'pane.console', } function tabTitleOf(content: SplitContent): string { if (content.kind === 'builtin') return T(BUILTIN_LABEL_KEYS[content.type]) if (content.kind === 'file') return basenameOf(content.path) if (content.kind === 'iframe' && content.title) return content.title try { const u = new URL(content.url) return u.hostname || content.url } catch { return content.url } } /** 取路径最后一段作为标签标题 */ function basenameOf(p: string): string { const parts = String(p).replace(/[\\/]+$/, '').split(/[\\/]/) return parts[parts.length - 1] || String(p) } /** 内容同一性(openTab 去重:同窗内同内容只保留一个标签,再次打开切过去) */ function sameContent(a: SplitContent, b: SplitContent): boolean { if (a.kind === 'iframe' && b.kind === 'iframe') return a.url === b.url if (a.kind === 'file' && b.kind === 'file') return a.path === b.path if (a.kind === 'builtin' && b.kind === 'builtin') return a.type === b.type return false } /** 分栏 UI 文案提供者(由工作台注入 locale t) */ let uiT: ((key: string, params?: Record) => string) | null = null export function setSplitT(fn: ((key: string, params?: Record) => string) | null) { uiT = fn } const T = (key: string, params?: Record): string => (uiT ? uiT(key, params) : key) /** 工作区环境(由工作台注入:当前会话作用域与后台任务列表) */ export type SplitScope = { sessionId: string; cwd: string } export type SplitJob = { id: string kind: string label: string status: string detail?: string startedAt: number finishedAt?: number } /** 控制室卡片数据(index.tsx 组装;纯读镜像,零轮询零 Token) */ export type ConsoleCardData = { id: string name: string icon: string /** 三态 + 空闲:need(待决黄) > done(完成绿) > busy(工作中蓝) > idle */ status: 'idle' | 'busy' | 'need' | 'done' /** 正在运行时的本轮已用毫秒;非运行态 null */ runtimeMs: number | null /** 子代理数量 */ kids: number /** 最近一条消息预览(单行文本,可能为空) */ preview: string /** 是否绑定对话 */ bound: boolean /** 是否为「工作台」自己(卡片点击无操作) */ self: boolean /** 完成/待决且未被确认:卡片发对应色光(点击确认后熄灭) */ glow: boolean } type SplitEnv = { fillHostInput: (text: string) => boolean getScope: () => SplitScope | null getJobs: () => SplitJob[] getSubagents: () => any[] /** 自定义窗口:项目列表 + 会话列表 + 当前项目 + 新建会话 / 发送到已有会话 */ custom?: { getProjects: () => { id: string; name: string }[] currentProjectId: () => string | null getSessions: () => Promise<{ groups: { title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]; current: string }> submit: (projectId: string, projectName: string, requirement: string) => Promise sendToSession: (sessionId: string, projectName: string, requirement: string) => Promise } /** 控制室:卡片数据订阅 + 打开项目 / 跳绑定对话 + 主题 */ console?: { subscribe: (fn: () => void) => () => void getCards: () => ConsoleCardData[] onOpen: (id: string) => void onJump: (id: string) => void /** 点发光卡片:确认(熄灭光)再打开 */ onAck: (id: string) => void /** 冷会话消息预热(打开控制室时拉最近消息) */ refreshPreviews: () => void /** 创建卡片:打开「添加项目」流程(同侧栏 +) */ onAdd: () => void getTheme: () => 'dark' | 'light' | 'system' setTheme: (th: 'dark' | 'light' | 'system') => void getCols: () => number setCols: (n: number) => void getShape: () => 'square' | 'circle' setShape: (s: 'square' | 'circle') => void getBg: () => 'plain' | 'glow' | 'photo' setBg: (m: 'plain' | 'glow' | 'photo') => void getPhotoLib: () => Promise<{ list: { id: string; kind: 'photo' | 'video'; url: string }[]; activeId: string | null }> addPhoto: (blob: Blob) => Promise<{ id: string; kind: 'photo' | 'video'; url: string }> setPhotoId: (id: string) => void getPhotoHsl: (id: string) => { h: number; s: number; l: number } setPhotoHsl: (id: string, v: { h: number; s: number; l: number }) => void removePhoto: (id: string) => Promise reorderPhotos: (ids: string[]) => Promise getPhotoGrid: () => boolean setPhotoGrid: (v: boolean) => void getGridOpacity: () => number setGridOpacity: (v: number) => void getCardBlur: () => number setCardBlur: (v: number) => void getPlainBlur: () => number setPlainBlur: (v: number) => void getGlowBlur: () => number setGlowBlur: (v: number) => void getPlainGrid: () => number setPlainGrid: (v: number) => void getGlowGrid: () => number setGlowGrid: (v: number) => void getGlowSpeed: () => number setGlowSpeed: (v: number) => void getPlainHsl: () => { h: number; s: number; l: number } setPlainHsl: (v: { h: number; s: number; l: number }) => void getGlowHsl: () => { h: number; s: number; l: number } setGlowHsl: (v: { h: number; s: number; l: number }) => void getPhotoHsl: () => { h: number; s: number; l: number } setPhotoHsl: (v: { h: number; s: number; l: number }) => void } } let splitEnv: SplitEnv | null = null export function setSplitEnv(env: SplitEnv | null) { splitEnv = env } async function postJson(url: string, body: unknown): Promise { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }) if (!res.ok) throw new Error('HTTP ' + res.status) return res.json() } /** 拖拽换位暂存 */ let dragPane: { row: PaneRow; index: number } | null = null /** 标签拖拽暂存(跨窗移动) */ let dragTab: { row: PaneRow; index: number; tabId: string } | null = null /** 标签拖放目标(吸附高亮;模块级,PaneBody 与窗容器共用) */ let dropTarget: { row: PaneRow; index: number } | null = null let dropTargetListeners: Set<() => void> = new Set() function setDropTarget(t: { row: PaneRow; index: number } | null) { if (dropTarget === t) return dropTarget = t for (const fn of dropTargetListeners) fn() } /** ——「指哪打哪」标注引擎:按钮 → 蓝色冒泡光标 → 点击弹输入框 → ✓ 打包进对话框(不发送) —— */ type AnnotState = { on: boolean started: boolean drawing: boolean px: number py: number bx0: number by0: number bx1: number by1: number ex: number ey: number draft: string resp: string stat: string } let annotState: AnnotState = { on: false, started: false, drawing: false, px: 0, py: 0, bx0: 0, by0: 0, bx1: 0, by1: 0, ex: 0, ey: 0, draft: '', resp: '', stat: '' } const annotListeners = new Set<() => void>() function setAnnot(patch: Partial) { annotState = { ...annotState, ...patch } for (const fn of annotListeners) fn() } function subscribeAnnot(fn: () => void) { annotListeners.add(fn); return () => { annotListeners.delete(fn) } } /** 进入瞄准模式(resp = 窗口编号等上下文,由调用方拼好) */ function startAnnot(resp: string) { document.body.classList.add('dsh-wt-annotating') setAnnot({ on: true, started: false, drawing: false, resp, stat: '', draft: '' }) } function cancelAnnot() { document.body.classList.remove('dsh-wt-annotating') setAnnot({ on: false, started: false, drawing: false }) } /** 窗口身份(约定:左栏 → 顶行 → 主行,从 1 起;附窗格标题与内容类型/URL,让接收方无需猜"这是哪个界面") */ function windowLabelOf(row: PaneRow, index: number): string { const spec = splitStore.spec if (!spec) return '窗口?' let num = spec.left ? 2 : 1 if (row === 'left') num = 1 else if (row === 'top') num = num + index else { num += (spec.top?.length ?? 0) + index } let label = '窗口' + num const pane = row === 'left' ? spec.left : row === 'top' ? spec.top?.[index] : spec.main?.[index] const title = pane?.title if (title) label += '「' + title + '」' const tab = pane?.tabs?.[pane.active ?? 0] const c = tab?.content if (c) { try { if (c.kind === 'iframe' && c.url) label += '(网页 ' + new URL(c.url).hostname + ')' else if (c.kind === 'builtin') { label += '(内置·' + c.type try { if (c.url) label += ' ' + new URL(c.url).hostname } catch {} label += ')' } else if (c.kind === 'file') label += '(文件)' } catch {} } return label } /** 被点元素上下文(标签 + 类名 + 文字) */ function ctxOf(t: EventTarget | null): string { const el = t instanceof Element ? t : null if (!el || el === document.documentElement || el === document.body) return '' const tag = el.tagName.toLowerCase() const cls = (el.getAttribute('class') || '').split(/\s+/).filter(Boolean).slice(0, 4).join('.') const text = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60) let out = '<' + tag + (cls ? ' .' + cls : '') + '>' if (text) out += '「' + text + '」' try { const cs = getComputedStyle(el); out += '(字号 ' + cs.fontSize + ')' } catch {} return out } /** 框选 payload v3.1:主目标 = 框中心点【光标下的字符】所在的 Text 节点(caretPositionFromPoint, * 标签即使不是独立元素也能取到真正的词/段);样式取该 Text 节点的父元素(真实渲染值); * 整行 = 最近的高度≤44px 祖先的内容(供「这行字是什么」类问题);候选 = 相交的独立短文本。 */ function boxPayload(x0: number, y0: number, x1: number, y1: number): { primary: { text: string; fontSize: string; lineHeight: string; color: string } | null; line: string; candidates: string[]; limited: boolean; src: string } { const L = Math.min(x0, x1) const T = Math.min(y0, y1) const R = Math.max(x0, x1) const B = Math.max(y0, y1) const cx = (L + R) / 2 const cy = (T + B) / 2 let primary: { text: string; fontSize: string; lineHeight: string; color: string } | null = null let line = '' let candidates: string[] = [] let limited = false let src = '' // 在某 document 内取字(rect = 该 doc 视口在顶层页面的偏移) const readDoc = (doc: Document, ox: number, oy: number): boolean => { let ok = false let textParent: HTMLElement | null = null try { const pd = doc as any const px = cx - ox const py = cy - oy let pos: any = null try { if (pd.caretPositionFromPoint) pos = pd.caretPositionFromPoint(px, py) } catch {} if (!pos) { try { if (pd.caretRangeFromPoint) pos = pd.caretRangeFromPoint(px, py) } catch {} } const textNode: Node | null = pos ? (pos.offsetNode ?? pos.startContainer ?? null) : null if (textNode) { const parent = textNode.parentElement if (parent) { textParent = parent const text = (textNode.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60) if (text) { const cs = getComputedStyle(parent) primary = { text, fontSize: cs.fontSize, lineHeight: cs.lineHeight, color: cs.color } ok = true } } } } catch {} // 整行 if (textParent) { try { let el: HTMLElement | null = textParent while (el && el !== doc.body) { const r = el.getBoundingClientRect() if (r.height >= 8 && r.height <= 44 && r.width > 0) { const t = (el.innerText || '').replace(/\s+/g, ' ').trim() if (t) { line = t.slice(0, 120); break } } el = el.parentElement } } catch {} } // 候选 const seen = new Set() if (primary) seen.add(primary.text) const cs: string[] = [] try { for (const el of Array.from(doc.querySelectorAll('*'))) { const tag = el.tagName.toLowerCase() if (tag === 'script' || tag === 'style' || tag === 'svg' || tag === 'path' || tag === 'br' || tag === 'template' || tag === 'iframe') continue const rect = el.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) continue if (rect.right + ox < L || rect.left + ox > R || rect.bottom + oy < T || rect.top + oy > B) continue const text = (el.textContent || '').trim().replace(/\s+/g, ' ') if (text.length < 1 || text.length > 30) continue if (seen.has(text)) continue seen.add(text) cs.push(text) if (cs.length >= 4) break } } catch {} if (ok || cs.length > 0) candidates = cs return ok } // 1) 顶层文档 readDoc(document, 0, 0) // 2) 顶层取不到 → 中心点所在 iframe 下钻(同源可读;跨域标记受限于 src) if (!primary) { try { for (const iframe of Array.from(document.querySelectorAll('iframe'))) { try { const r = iframe.getBoundingClientRect() if (r.width <= 0 || r.height <= 0) continue if (cx < r.left || cx > r.right || cy < r.top || cy > r.bottom) continue src = iframe.getAttribute('src') ?? '' let inner: Document | null = null try { inner = iframe.contentDocument } catch { inner = null } if (inner) readDoc(inner, r.left, r.top) else limited = true break } catch { continue } } } catch {} } return { primary, line, candidates, limited, src } } /** 更新方法:复制给 AI 的升级指令(插件不自更新;升级由用户或其 Agent 执行 + 重启) */ const UPGRADE_CMD = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"' const UPGRADE_AI = '帮我升级 dsh-worktable:执行 ' + UPGRADE_CMD + ',完成后提醒我重启 dsh web 并刷新页面' async function copyTextSafe(text: string): Promise { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true } } catch {} try { const ta = document.createElement('textarea') ta.value = text document.body.appendChild(ta) ta.select() const ok = document.execCommand('copy') document.body.removeChild(ta) return ok } catch { return false } } /** 注入宿主对话框输入框(不发送);失败返回 false */ function fillHostInput(text: string): boolean { return splitEnv?.fillHostInput(text) ?? false } /** ✓ 确认:打包并注入,不发送 */ function confirmAnnot() { const s = annotState const text = '📌 标注-' + s.resp + '\n' + (s.stat || '') + '\n要求:' + (s.draft.trim() || '(未填写)') + '\n【工作台标注·已定位,直接作答,无需再问;仅当确实无法从标注定位时才问一次。数据缺失或标注标注读取受限时,如实说明无法确定,禁止编造数值;可建议提供截图或使用视觉模型会话】' const ok = fillHostInput(text) if (!ok) { try { navigator.clipboard?.writeText(text).catch(() => { try { window.prompt('标注已生成,请手动复制:', text) } catch {} }) } catch { try { window.prompt('标注已生成,请手动复制:', text) } catch {} } } cancelAnnot() } /** 标注浮层:瞄准气泡 + 输入编辑器(每个工作区渲染一次;fixed 定位) */ function AnnotationOverlay() { const [, setTick] = useState(0) useEffect(() => subscribeAnnot(() => setTick((t) => t + 1)), []) const s = annotState useEffect(() => { if (!s.on || s.started) return let sx = 0 let sy = 0 let dragging = false const isUi = (e: MouseEvent) => e.target instanceof Element && e.target.closest('.dsh-wt_annotUi') != null const onMove = (e: MouseEvent) => { if (!annotState.on || annotState.started) return if (annotState.drawing) setAnnot({ bx1: e.clientX, by1: e.clientY, px: e.clientX, py: e.clientY }) else setAnnot({ px: e.clientX, py: e.clientY }) } const onDown = (e: MouseEvent) => { if (!annotState.on || annotState.started || isUi(e)) return e.preventDefault() e.stopImmediatePropagation() sx = e.clientX sy = e.clientY dragging = true setAnnot({ drawing: true, bx0: sx, by0: sy, bx1: sx, by1: sy }) } const onUp = (e: MouseEvent) => { if (!annotState.on || annotState.started || !dragging) return e.preventDefault() e.stopImmediatePropagation() dragging = false const w = Math.abs(e.clientX - sx) const h = Math.abs(e.clientY - sy) const paneEl = (e.target instanceof Element ? e.target.closest('.dsh-wt_pane') : null) as HTMLElement | null let stat = '' if (Math.max(w, h) >= 8) { // 框选:中心/宽高 + 窗口内百分比 + 框内可见文字聚合 const L = Math.min(sx, e.clientX) const T = Math.min(sy, e.clientY) const R = Math.max(sx, e.clientX) const B = Math.max(sy, e.clientY) stat = '框选屏幕 (' + Math.round(L) + ',' + Math.round(T) + ') → (' + Math.round(R) + ',' + Math.round(B) + ') 宽高 (' + Math.round(w) + '×' + Math.round(h) + 'px)' if (paneEl) { const pr = paneEl.getBoundingClientRect() const rx1 = (((L - pr.left) / Math.max(1, pr.width)) * 100).toFixed(1) const ry1 = (((T - pr.top) / Math.max(1, pr.height)) * 100).toFixed(1) const rx2 = (((R - pr.left) / Math.max(1, pr.width)) * 100).toFixed(1) const ry2 = (((B - pr.top) / Math.max(1, pr.height)) * 100).toFixed(1) stat += ',窗口内 (' + rx1 + '%,' + ry1 + '%)→(' + rx2 + '%,' + ry2 + '%)' } const hit = boxPayload(L, T, R, B) if (hit.primary) stat += ',主目标:' + hit.primary.text + '(字号 ' + hit.primary.fontSize + ',行高 ' + hit.primary.lineHeight + ')' if (hit.line) stat += ';整行:' + hit.line if (hit.limited) stat += ';读取受限:跨域页面内容不可见(浏览器安全限制),窗口身份见上(' + (hit.src ? hit.src : '无URL') + ')' if (hit.candidates.length > 0) stat += ';候选:' + hit.candidates.map((t, i) => '[' + (i + 1) + ']' + t).join(' ') } else { stat = '屏幕坐标 (' + Math.round(e.clientX) + ', ' + Math.round(e.clientY) + ')' if (paneEl) { const r = paneEl.getBoundingClientRect() const rx = (((e.clientX - r.left) / Math.max(1, r.width)) * 100).toFixed(1) const ry = (((e.clientY - r.top) / Math.max(1, r.height)) * 100).toFixed(1) stat += ',窗口内 (' + rx + '%, ' + ry + '%)' } const ctx = ctxOf(e.target) if (ctx) stat += ',元素 ' + ctx } document.body.classList.remove('dsh-wt-annotating') const ex = Math.max(8, Math.min(e.clientX + 10, window.innerWidth - 288)) const ey = Math.max(8, Math.min(e.clientY + 14, window.innerHeight - 148)) setAnnot({ started: true, drawing: false, ex, ey, stat }) } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') cancelAnnot() } window.addEventListener('mousemove', onMove, true) window.addEventListener('mousedown', onDown, true) window.addEventListener('mouseup', onUp, true) window.addEventListener('keydown', onKey, true) return () => { window.removeEventListener('mousemove', onMove, true) window.removeEventListener('mousedown', onDown, true) window.removeEventListener('mouseup', onUp, true) window.removeEventListener('keydown', onKey, true) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [s.on, s.started]) // 首次进入标注:右侧气泡提示「拖动也可框选」,仅显示一次 const [hintOnce] = useState(() => { try { return localStorage.getItem('dsh.worktable.annotHint.v1') !== '1' } catch { return false } }) const [hintVisible, setHintVisible] = useState(false) useEffect(() => { if (!s.on || !hintOnce) return setHintVisible(true) try { localStorage.setItem('dsh.worktable.annotHint.v1', '1') } catch {} const t = window.setTimeout(() => setHintVisible(false), 4200) return () => window.clearTimeout(t) }, [s.on, hintOnce]) if (!s.on) return null {hintVisible && (
{T('annot.hint')}
)} if (s.drawing) { const L = Math.min(s.bx0, s.bx1) const T = Math.min(s.by0, s.by1) const R = Math.max(s.bx0, s.bx1) const B = Math.max(s.by0, s.by1) return
} if (!s.started) { return (
) } return (