3211 lines
147 KiB
TypeScript
3211 lines
147 KiB
TypeScript
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<BuiltinType, string> = {
|
||
browser: '🌐',
|
||
anim: '🎬',
|
||
explorer: '📁',
|
||
scm: '🔀',
|
||
tasks: '✅',
|
||
terminal: '▸_',
|
||
custom: '✨',
|
||
console: '🖥️',
|
||
}
|
||
|
||
const BUILTIN_LABEL_KEYS: Record<BuiltinType, string> = {
|
||
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, string>) => string) | null = null
|
||
export function setSplitT(fn: ((key: string, params?: Record<string, string>) => string) | null) {
|
||
uiT = fn
|
||
}
|
||
const T = (key: string, params?: Record<string, string>): 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<void>
|
||
sendToSession: (sessionId: string, projectName: string, requirement: string) => Promise<void>
|
||
}
|
||
/** 控制室:卡片数据订阅 + 打开项目 / 跳绑定对话 + 主题 */
|
||
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<void>
|
||
reorderPhotos: (ids: string[]) => Promise<void>
|
||
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<any> {
|
||
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 = { ...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<string>()
|
||
if (primary) seen.add(primary.text)
|
||
const cs: string[] = []
|
||
try {
|
||
for (const el of Array.from(doc.querySelectorAll<HTMLElement>('*'))) {
|
||
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<HTMLIFrameElement>('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<boolean> {
|
||
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<boolean>(() => { 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 && (
|
||
<div className="dsh-wt_annotUi dsh-wt_annotHint">{T('annot.hint')}</div>
|
||
)}
|
||
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 <div className="dsh-wt_annotUi dsh-wt_annotSel" style={{ left: L, top: T, width: R - L, height: B - T }} />
|
||
}
|
||
if (!s.started) {
|
||
return (
|
||
<div className="dsh-wt_annotUi dsh-wt_annotBubble" style={{ left: s.px, top: s.py }} aria-hidden>
|
||
<svg width="12" height="12" viewBox="0 0 16 16"><path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" /></svg>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="dsh-wt_annotUi dsh-wt_annotEditor" style={{ left: s.ex, top: s.ey }}>
|
||
<textarea
|
||
className="dsh-wt_annotInput"
|
||
autoFocus
|
||
value={s.draft}
|
||
placeholder={T('annot.placeholder')}
|
||
onChange={(e) => setAnnot({ draft: e.target.value })}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); confirmAnnot() }
|
||
if (e.key === 'Escape') cancelAnnot()
|
||
}}
|
||
/>
|
||
<div className="dsh-wt_annotBtns">
|
||
<button type="button" className="dsh-wt_annotOk" title={T('annot.ok')} aria-label={T('annot.ok')} onClick={confirmAnnot}>✓</button>
|
||
<button type="button" className="dsh-wt_annotNo" title={T('annot.cancel')} aria-label={T('annot.cancel')} onClick={cancelAnnot}>✕</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 找到会话根容器:data-phase 元素中排除输入框、取含子元素者。
|
||
* DSH 0.1.1-rc.2 与 0.1.2-rc.1 的会话阶段一致:active(有消息)/ hero(无会话或空会话)/ settling(过渡)。
|
||
* 优先 active,其次 hero(空会话/无会话时也能打开分栏),settling 兜底(锚定后由观察器修正)。 */
|
||
function findConversationRoot(): HTMLElement | null {
|
||
const candidates = Array.from(document.querySelectorAll<HTMLElement>('[data-phase]'))
|
||
const ok = (el: HTMLElement) => el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT'
|
||
&& el.children.length >= 1 && !/input/i.test(String(el.className))
|
||
const rank = (el: HTMLElement) => (el.dataset.phase === 'active' ? 0 : el.dataset.phase === 'hero' ? 1 : 2)
|
||
let best: HTMLElement | null = null
|
||
let bestRank = Infinity
|
||
for (const el of candidates) {
|
||
if (!ok(el)) continue
|
||
const r = rank(el)
|
||
if (r < bestRank) { bestRank = r; best = el }
|
||
}
|
||
return best
|
||
}
|
||
|
||
/** 从首个槽位元素里解析「可见头部」:
|
||
* - 0.1.1:children[0] 就是 <header> 本身(可见时自身有高度)。
|
||
* - 0.1.2:children[0] 是零高的槽位包装(display:contents 式),真实头部渲染在其内部——
|
||
* 此时向内找第一个可见后代(文档序 = 头部自身,先于其子元素)。
|
||
* - 空会话(headerHidden,整棵子树 display:none):找不到任何可见元素 → null(顶部 = 根顶部)。 */
|
||
function visibleHeaderIn(first: HTMLElement): HTMLElement | null {
|
||
if (first.getBoundingClientRect().height > 0) return first
|
||
for (const el of Array.from(first.querySelectorAll<HTMLElement>('*'))) {
|
||
const r = el.getBoundingClientRect()
|
||
if (r.height > 0 && r.width > 0) return el
|
||
}
|
||
return null
|
||
}
|
||
|
||
/** 从会话根解析分栏锚点(兼容 0.1.1 与 0.1.2 两种 DOM 结构):
|
||
* - 0.1.1 与 0.1.2 会话打开:root.children = [头部(或头部包装), 内容滚动区](0.1.2 多一层 body 包装,不影响取 children[1])。
|
||
* - 0.1.2 无会话:root.children = [body](无头部元素)。
|
||
* - 空会话(blank):头部带 headerHidden 样式(高度为 0),按「无头部」处理(顶部 = 根顶部)。
|
||
* header 仅当存在第二个子元素且能解析出可见头部时生效;viewArea = 第二个子元素,只有一个时 = 第一个。 */
|
||
function resolveAnchor(root: HTMLElement): { header: HTMLElement | null; viewArea: HTMLElement } {
|
||
const first = root.children[0] as HTMLElement | undefined
|
||
const second = root.children[1] as HTMLElement | undefined
|
||
const header = !!second && !!first ? visibleHeaderIn(first) : null
|
||
return { header, viewArea: second ?? first! }
|
||
}
|
||
|
||
function loadSaved(layoutId: string): { chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[]; leftWs: number[] } | null {
|
||
try {
|
||
const raw = localStorage.getItem(PERSIST_KEY)
|
||
if (!raw) return null
|
||
const s = JSON.parse(raw)?.[layoutId]
|
||
if (!s || typeof s !== 'object') return null
|
||
return {
|
||
chatW: Number.isFinite(s.chatW) ? s.chatW : -1,
|
||
topH: Number.isFinite(s.topH) ? s.topH : -1,
|
||
leftW: Number.isFinite(s.leftW) ? s.leftW : -1,
|
||
paneWs: Array.isArray(s.paneWs) ? s.paneWs : [],
|
||
topWs: Array.isArray(s.topWs) ? s.topWs : [],
|
||
leftWs: Array.isArray(s.leftWs) ? s.leftWs : [],
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function persistSaved(layoutId: string, s: { chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[]; leftWs: number[] }) {
|
||
try {
|
||
const raw = localStorage.getItem(PERSIST_KEY)
|
||
const all = raw ? JSON.parse(raw) : {}
|
||
all[layoutId] = s
|
||
localStorage.setItem(PERSIST_KEY, JSON.stringify(all))
|
||
} catch {}
|
||
}
|
||
|
||
/** 共享互斥协议:其他接入本协议的分栏引擎声明占用时,本引擎让位(同一时刻仅一个分栏工作区) */
|
||
window.addEventListener('dsh:split-claim', ((e: any) => {
|
||
const id = e?.detail?.id
|
||
if (splitStore.active && id && id !== splitStore.spec?.id) splitStore.close()
|
||
}) as EventListener)
|
||
|
||
export const splitStore: SplitState = {
|
||
active: false,
|
||
spec: null,
|
||
geom: null,
|
||
chatW: 320,
|
||
topH: 200,
|
||
leftW: 260,
|
||
paneWs: [],
|
||
topWs: [],
|
||
leftWs: [],
|
||
root: null,
|
||
header: null,
|
||
viewArea: null,
|
||
savedMarginLeft: '',
|
||
savedMarginRight: '',
|
||
savedMarginTop: '',
|
||
savedWidthVar: '',
|
||
observer: null,
|
||
fallback: null,
|
||
yieldObserver: null,
|
||
lastMarginLeft: '',
|
||
lastMarginRight: '',
|
||
lastMarginTop: '',
|
||
onSpecMutated: null,
|
||
listeners: new Set(),
|
||
|
||
open(spec) {
|
||
if (this.active) {
|
||
// 反选:同一布局再点 = 关闭;不同布局 = 替换(先关旧的)
|
||
if (this.spec?.id === spec.id) {
|
||
this.close()
|
||
return true
|
||
}
|
||
this.close()
|
||
}
|
||
// 跨插件互操作桥:自带分栏实现的入驻插件(未接入共享引擎)打开时,运行时点击其关闭按钮让位
|
||
try {
|
||
const taClose = document.querySelector<HTMLElement>('.ta_splitClose')
|
||
taClose?.click()
|
||
} catch {}
|
||
// 声明占用:接入共享协议的其他引擎收到后让位
|
||
try {
|
||
window.dispatchEvent(new CustomEvent('dsh:split-claim', { detail: { id: spec.id } }))
|
||
} catch {}
|
||
const root = findConversationRoot()
|
||
if (!root) return false
|
||
const { header, viewArea } = resolveAnchor(root)
|
||
this.spec = { ...spec, chatSide: spec.chatSide === 'left' ? 'left' : 'right' }
|
||
// 向后兼容归一化:单内容声明 → 一个标签页
|
||
const normalize = (p: SplitPane): SplitPane => {
|
||
if (p.tabs && p.tabs.length > 0) return p
|
||
if (p.content) {
|
||
return { ...p, content: null, tabs: [{ id: 't1', title: tabTitleOf(p.content), content: p.content }], active: 0 }
|
||
}
|
||
return { ...p, content: null, tabs: [], active: 0 }
|
||
}
|
||
if (spec.top) this.spec.top = spec.top.map(normalize)
|
||
if (spec.left) this.spec.left = normalize(spec.left)
|
||
this.spec.main = (spec.main ?? []).map(normalize)
|
||
const main = this.spec.main ?? []
|
||
const top = spec.top ?? []
|
||
const left = spec.left ?? null
|
||
const saved = loadSaved(spec.id)
|
||
const hasChatW = !!saved && saved.chatW >= 0
|
||
const hasTopH = !!saved && saved.topH >= 0
|
||
const hasLeftW = !!saved && saved.leftW >= 0
|
||
const hasPaneWs = !!saved && saved.paneWs.length === main.length
|
||
const hasTopWs = !!saved && saved.topWs.length === top.length
|
||
const hasLeftWs = !!saved && saved.leftWs.length === (left ? 1 : 0)
|
||
this.chatW = hasChatW ? saved!.chatW : spec.chatWidth.default
|
||
this.topH = hasTopH ? saved!.topH : (spec.topHeight?.default ?? 200)
|
||
this.leftW = hasLeftW ? saved!.leftW : (spec.leftWidth?.default ?? 260)
|
||
this.paneWs = hasPaneWs ? [...saved!.paneWs] : main.map((p) => p.min)
|
||
this.topWs = hasTopWs ? [...saved!.topWs] : top.map((p) => p.min)
|
||
this.leftWs = hasLeftWs ? [...saved!.leftWs] : (left ? [left.min] : [])
|
||
this.root = root
|
||
this.header = header
|
||
this.viewArea = viewArea
|
||
this.savedWidthVar = root.style.getPropertyValue('--dsh-chat-user-width')
|
||
this.savedMarginLeft = viewArea.style.marginLeft
|
||
this.savedMarginRight = viewArea.style.marginRight
|
||
this.savedMarginTop = viewArea.style.marginTop
|
||
this.refreshGeom()
|
||
// 存档 sanitize:chatW 等尺寸按本布局规格钳制(防老版本/脏存值越界,如 1067 > max 600)
|
||
if (this.geom) {
|
||
const minContent = main.reduce((a: number, p: SplitPane) => a + p.min, 0) + Math.max(0, main.length - 1) * GAP
|
||
const hi = Math.max(spec.chatWidth.min, (this.geom.right - this.geom.left) - minContent)
|
||
this.chatW = clamp(Math.round(this.chatW), spec.chatWidth.min, hi)
|
||
}
|
||
// 均衡默认:无存档尺寸时按当前可用空间比例分配,
|
||
// 不再出现“其余窗全部贴 min、最后一个吃掉全部余量”的悬殊观感。
|
||
const g0 = this.geom
|
||
if (g0) {
|
||
const colW0 = g0.right - g0.left
|
||
const rowH0 = g0.bottom - g0.top
|
||
if (!hasChatW) {
|
||
const hi = Math.max(spec.chatWidth.min, colW0 - 60)
|
||
this.chatW = clamp(Math.round(colW0 * 0.3), spec.chatWidth.min, hi)
|
||
}
|
||
if (left && !hasLeftW) {
|
||
const lo = spec.leftWidth?.min ?? 160
|
||
this.leftW = clamp(Math.round(colW0 * 0.38), lo, Math.max(lo, colW0 - 260))
|
||
}
|
||
if (top.length > 0 && !hasTopH) {
|
||
const lo = spec.topHeight?.min ?? 80
|
||
const ratio = spec.topHeightRatio ?? 0.35
|
||
this.topH = clamp(Math.round((rowH0 - BAR_H) * ratio), lo, Math.max(lo, rowH0 - BAR_H - 80))
|
||
}
|
||
if (!hasPaneWs) {
|
||
const contentW = Math.max(0, colW0 - this.chatW)
|
||
const avail = Math.max(main.length * 120, contentW - Math.max(0, main.length - 1) * GAP)
|
||
const share = Math.round(avail / main.length)
|
||
this.paneWs = main.map((p) => Math.max(p.min, share))
|
||
}
|
||
if (!hasTopWs) {
|
||
// chatFull 时顶行只占内容侧(扣除聊天列宽)
|
||
const chatW0 = spec.chatFullHeight === true ? this.chatW : 0
|
||
const rowW = Math.max(0, colW0 - chatW0 - (left ? this.leftW : 0))
|
||
const avail = Math.max(top.length * 120, rowW - Math.max(0, top.length - 1) * GAP)
|
||
const share = Math.round(avail / top.length)
|
||
this.topWs = top.map((p) => Math.max(p.min, share))
|
||
}
|
||
if (left && !hasLeftWs) this.leftWs = [Math.max(left.min, this.leftW)]
|
||
}
|
||
this.applyMargin()
|
||
this.observer = new ResizeObserver(() => {
|
||
const r = this.root
|
||
if (!(r && r.isConnected)) { this.syncAnchor(); return }
|
||
const anchor = resolveAnchor(r)
|
||
if (anchor.header !== this.header || anchor.viewArea !== this.viewArea) { this.syncAnchor(); return }
|
||
this.refreshGeom()
|
||
this.applyMargin()
|
||
this.notify()
|
||
})
|
||
this.observer.observe(root)
|
||
// 兜底:会话根被替换/子结构变化(如 hero→active 头部出现)时 RO 可能不再回调,
|
||
// 用 body 级 MutationObserver 驱动重锚定;锚点未变时静默(避免流式消息期间高频刷新)
|
||
this.fallback = new MutationObserver(() => {
|
||
const r = this.root
|
||
if (!(r && r.isConnected)) { this.syncAnchor(); return }
|
||
const anchor = resolveAnchor(r)
|
||
if (anchor.header !== this.header || anchor.viewArea !== this.viewArea) this.syncAnchor()
|
||
})
|
||
this.fallback.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-phase'] })
|
||
// 让位观察器:会话视图区 margin 被外部改写(其他未接入协议的分栏引擎接管)时关闭自身
|
||
this.yieldObserver = new MutationObserver(() => {
|
||
if (!this.active || !this.viewArea) return
|
||
if (this.viewArea.style.marginLeft !== this.lastMarginLeft
|
||
|| this.viewArea.style.marginRight !== this.lastMarginRight
|
||
|| this.viewArea.style.marginTop !== this.lastMarginTop) {
|
||
this.close()
|
||
}
|
||
})
|
||
this.yieldObserver.observe(viewArea, { attributes: true, attributeFilter: ['style'] })
|
||
this.active = true
|
||
this.notify()
|
||
return true
|
||
},
|
||
|
||
/** 会话根失效(切换会话 / hero↔active 结构变化)时重新锚定:左侧内容保持不关闭;无会话根才关闭 */
|
||
syncAnchor() {
|
||
if (!this.active) return
|
||
const next = findConversationRoot()
|
||
if (!next) {
|
||
this.close()
|
||
return
|
||
}
|
||
const ph = next.dataset.phase
|
||
if (ph !== 'active' && ph !== 'hero') return // settling 等过渡态:保持等待(phase 变化会再次触发)
|
||
const anchor = resolveAnchor(next)
|
||
if (next === this.root && anchor.header === this.header && anchor.viewArea === this.viewArea) {
|
||
// 同锚点:头部可能由隐藏变可见(hero→active),刷新几何与挤位
|
||
this.refreshGeom()
|
||
this.applyMargin()
|
||
this.notify()
|
||
return
|
||
}
|
||
const viewArea = anchor.viewArea
|
||
const oldRoot = this.root
|
||
const oldViewArea = this.viewArea
|
||
// 恢复旧锚点(若仍连接),锚定到新会话根
|
||
if (oldViewArea && oldViewArea.isConnected && oldViewArea !== viewArea) {
|
||
oldViewArea.style.marginLeft = this.savedMarginLeft
|
||
oldViewArea.style.marginRight = this.savedMarginRight
|
||
oldViewArea.style.marginTop = this.savedMarginTop
|
||
}
|
||
if (oldRoot && oldRoot.isConnected && oldRoot !== next) {
|
||
if (this.savedWidthVar) oldRoot.style.setProperty('--dsh-chat-user-width', this.savedWidthVar)
|
||
else oldRoot.style.removeProperty('--dsh-chat-user-width')
|
||
}
|
||
this.root = next
|
||
this.header = anchor.header
|
||
this.viewArea = viewArea
|
||
// 仅锚点元素真正更换时才记录「打开前」原值——同元素重锚(hero→active 头部出现)
|
||
// 必须保留最初存档,否则关闭时会把已应用的 margin/变量误还原成「新原值」
|
||
if (oldRoot !== next) {
|
||
this.savedWidthVar = next.style.getPropertyValue('--dsh-chat-user-width')
|
||
}
|
||
if (oldViewArea !== viewArea) {
|
||
this.savedMarginLeft = viewArea.style.marginLeft
|
||
this.savedMarginRight = viewArea.style.marginRight
|
||
this.savedMarginTop = viewArea.style.marginTop
|
||
}
|
||
this.observer?.disconnect()
|
||
this.observer.observe(next)
|
||
this.refreshGeom()
|
||
this.applyMargin()
|
||
this.notify()
|
||
},
|
||
|
||
refreshGeom() {
|
||
const root = this.root
|
||
if (!root) return
|
||
const rr = root.getBoundingClientRect()
|
||
const hr = this.header ? this.header.getBoundingClientRect() : null
|
||
// 头部隐藏(blank 会话)或无头部(0.1.2 无会话)时以根顶部为分栏上沿
|
||
const top = hr && hr.height > 0 ? hr.bottom : rr.top
|
||
this.geom = { left: rr.left, top, right: rr.right, bottom: rr.bottom }
|
||
},
|
||
|
||
applyMargin() {
|
||
const viewArea = this.viewArea
|
||
const g = this.geom
|
||
const spec = this.spec
|
||
if (!viewArea || !g || !spec) return
|
||
const colW = g.right - g.left
|
||
const rowH = g.bottom - g.top
|
||
const hasLeft = !!spec.left
|
||
const hasTop = !!(spec.top && spec.top.length > 0)
|
||
const chatW = clamp(this.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
|
||
// 会话内容宽 clamp(680px, …, 920px) 不随挤出的聊天列收缩 → 显式钉到挤出的列宽
|
||
// (close/重锚定时原样恢复;0.1.1 上该 CSS 变量闲置,设置无害)
|
||
if (this.root && this.root.isConnected) {
|
||
this.root.style.setProperty('--dsh-chat-user-width', Math.round(chatW) + 'px')
|
||
}
|
||
const topH = hasTop
|
||
? clamp(this.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
|
||
: 0
|
||
const leftW = hasLeft
|
||
? clamp(this.leftW, spec.leftWidth?.min ?? 160, Math.max(spec.leftWidth?.min ?? 160, colW - 260))
|
||
: 0
|
||
const chatFull = spec.chatFullHeight === true
|
||
const gap = Math.max(0, colW - chatW) + 'px'
|
||
const mt = (BAR_H + (hasTop && !chatFull ? topH : 0)) + 'px'
|
||
const chatLeft = !hasLeft && spec.chatSide === 'left'
|
||
this.lastMarginLeft = hasLeft ? leftW + 'px' : (chatLeft ? '' : gap)
|
||
this.lastMarginRight = hasLeft ? '' : (chatLeft ? gap : '')
|
||
this.lastMarginTop = mt
|
||
viewArea.style.marginLeft = this.lastMarginLeft
|
||
viewArea.style.marginRight = this.lastMarginRight
|
||
viewArea.style.marginTop = mt
|
||
},
|
||
|
||
setChatW(w) {
|
||
const g = this.geom
|
||
const spec = this.spec
|
||
if (!g || !spec) return
|
||
const colW = g.right - g.left
|
||
const main = spec.main ?? []
|
||
const minContent = main.reduce((a, p) => a + p.min, 0) + Math.max(0, main.length - 1) * GAP
|
||
const hi = Math.max(spec.chatWidth.min, colW - minContent)
|
||
this.chatW = clamp(Math.round(w), spec.chatWidth.min, hi)
|
||
this.applyMargin()
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
setTopH(h) {
|
||
const g = this.geom
|
||
const spec = this.spec
|
||
if (!g || !spec) return
|
||
const rowH = g.bottom - g.top
|
||
const lo = spec.topHeight?.min ?? 80
|
||
const hi = Math.max(lo, rowH - BAR_H - 80)
|
||
this.topH = clamp(Math.round(h), lo, hi)
|
||
this.applyMargin()
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
setLeftW(w) {
|
||
const g = this.geom
|
||
const spec = this.spec
|
||
if (!g || !spec || !spec.left) return
|
||
const colW = g.right - g.left
|
||
const lo = spec.leftWidth?.min ?? 160
|
||
const hi = Math.max(lo, colW - 260)
|
||
this.leftW = clamp(Math.round(w), lo, hi)
|
||
this.applyMargin()
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
setPaneW(i, w) {
|
||
const g = this.geom
|
||
const spec = this.spec
|
||
if (!g || !spec) return
|
||
const main = spec.main ?? []
|
||
if (i < 0 || i >= main.length) return
|
||
const colW = g.right - g.left
|
||
const chatW = clamp(this.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
|
||
const contentW = Math.max(0, colW - chatW)
|
||
const othersMin = main.reduce((a, p, k) => a + (k === i ? 0 : p.min), 0)
|
||
const lo = main[i].min
|
||
const hi = Math.max(lo, contentW - othersMin - Math.max(0, main.length - 1) * GAP)
|
||
const next = this.paneWs.slice()
|
||
next[i] = clamp(Math.round(w), lo, hi)
|
||
this.paneWs = next
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
setTopW(i, w) {
|
||
const g = this.geom
|
||
const spec = this.spec
|
||
if (!g || !spec) return
|
||
const top = spec.top ?? []
|
||
if (i < 0 || i >= top.length) return
|
||
const colW = g.right - g.left
|
||
const othersMin = top.reduce((a, p, k) => a + (k === i ? 0 : p.min), 0)
|
||
const lo = top[i].min
|
||
const hi = Math.max(lo, colW - othersMin - Math.max(0, top.length - 1) * GAP)
|
||
const next = this.topWs.slice()
|
||
next[i] = clamp(Math.round(w), lo, hi)
|
||
this.topWs = next
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
setPaneContent(row, i, content) {
|
||
if (content) this.openTab(row, i, content)
|
||
},
|
||
|
||
lockPane(row, i, content) {
|
||
const spec = this.spec
|
||
if (!spec) return
|
||
const tab: PaneTab = { id: 't' + Date.now().toString(36), title: tabTitleOf(content), content, active: 0 }
|
||
const mutate = (pane: SplitPane): SplitPane => ({ ...pane, content: null, tabs: [tab], active: 0 })
|
||
if (row === 'left') {
|
||
if (!spec.left || i !== 0) return
|
||
this.spec = { ...spec, left: mutate(spec.left) }
|
||
} else if (row === 'top') {
|
||
const top = [...(spec.top ?? [])]
|
||
if (!top[i]) return
|
||
top[i] = mutate(top[i])
|
||
this.spec = { ...spec, top }
|
||
} else {
|
||
const main = [...spec.main]
|
||
if (!main[i]) return
|
||
main[i] = mutate(main[i])
|
||
this.spec = { ...spec, main }
|
||
}
|
||
this.onSpecMutated?.(this.spec)
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
/** 更新指定标签页的内容(浏览器/动画窗地址栏回车时回写),并持久化到布局条目 */
|
||
setTabContent(row, index, tabId, content) {
|
||
const spec = this.spec
|
||
if (!spec) return
|
||
const mutate = (pane: SplitPane): SplitPane => {
|
||
const tabs = (pane.tabs ?? []).map((t) => (t.id === tabId ? { ...t, content, title: tabTitleOf(content) } : t))
|
||
return { ...pane, tabs }
|
||
}
|
||
if (row === 'left') {
|
||
if (spec.left && index === 0) this.spec = { ...spec, left: mutate(spec.left) }
|
||
} else if (row === 'top') {
|
||
const top = [...(spec.top ?? [])]
|
||
if (top[index]) { top[index] = mutate(top[index]); this.spec = { ...spec, top } }
|
||
} else {
|
||
const main = [...spec.main]
|
||
if (main[index]) { main[index] = mutate(main[index]); this.spec = { ...spec, main } }
|
||
}
|
||
this.onSpecMutated?.(this.spec)
|
||
this.notify()
|
||
},
|
||
|
||
openTab(row, i, content) {
|
||
const spec = this.spec
|
||
if (!spec) return
|
||
const mutate = (pane: SplitPane): SplitPane => {
|
||
const tabs = [...(pane.tabs ?? [])]
|
||
// 去重:同内容已有标签 → 直接激活
|
||
const existing = tabs.findIndex((t) => sameContent(t.content, content))
|
||
if (existing >= 0) return { ...pane, content: null, tabs, active: existing }
|
||
const tab: PaneTab = { id: 't' + Date.now().toString(36), title: tabTitleOf(content), content }
|
||
tabs.push(tab)
|
||
return { ...pane, content: null, tabs, active: tabs.length - 1 }
|
||
}
|
||
if (row === 'left') {
|
||
if (!spec.left || i !== 0) return
|
||
this.spec = { ...spec, left: mutate(spec.left) }
|
||
} else if (row === 'top') {
|
||
const top = [...(spec.top ?? [])]
|
||
if (!top[i]) return
|
||
top[i] = mutate(top[i])
|
||
this.spec = { ...spec, top }
|
||
} else {
|
||
const main = [...spec.main]
|
||
if (!main[i]) return
|
||
main[i] = mutate(main[i])
|
||
this.spec = { ...spec, main }
|
||
}
|
||
this.onSpecMutated?.(this.spec)
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
closeTab(row, i, tabId) {
|
||
const spec = this.spec
|
||
if (!spec) return
|
||
const mutate = (pane: SplitPane): SplitPane => {
|
||
const tabs = (pane.tabs ?? []).filter((t) => t.id !== tabId)
|
||
return { ...pane, tabs, active: 0 }
|
||
}
|
||
if (row === 'left') {
|
||
if (!spec.left || i !== 0) return
|
||
this.spec = { ...spec, left: mutate(spec.left) }
|
||
} else if (row === 'top') {
|
||
const top = [...(spec.top ?? [])]
|
||
if (!top[i]) return
|
||
top[i] = mutate(top[i])
|
||
this.spec = { ...spec, top }
|
||
} else {
|
||
const main = [...spec.main]
|
||
if (!main[i]) return
|
||
main[i] = mutate(main[i])
|
||
this.spec = { ...spec, main }
|
||
}
|
||
this.onSpecMutated?.(this.spec)
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
moveTab(fromRow, fromI, tabId, toRow, toI) {
|
||
const spec = this.spec
|
||
if (!spec) return
|
||
if (fromRow === toRow && fromI === toI) return
|
||
const top = [...(spec.top ?? [])]
|
||
const main = [...spec.main]
|
||
const left = spec.left ? { ...spec.left } : null
|
||
const arrOf = (row: PaneRow): SplitPane[] => (row === 'left' ? (left ? [left] : []) : row === 'top' ? top : main)
|
||
const fromArr = arrOf(fromRow)
|
||
const toArr = arrOf(toRow)
|
||
const fromPane = fromArr[fromI]
|
||
const toPane = toArr[toI]
|
||
if (!fromPane || !toPane) return
|
||
const tab = (fromPane.tabs ?? []).find((t) => t.id === tabId)
|
||
if (!tab) return
|
||
const fromTabs = (fromPane.tabs ?? []).filter((t) => t.id !== tabId)
|
||
const toTabs = [...(toPane.tabs ?? []), tab]
|
||
const setPane = (row: PaneRow, i: number, pane: SplitPane) => {
|
||
if (row === 'left') spec.left = pane
|
||
else if (row === 'top') top[i] = pane
|
||
else main[i] = pane
|
||
}
|
||
setPane(fromRow, fromI, { ...fromPane, tabs: fromTabs, active: 0 })
|
||
setPane(toRow, toI, { ...toPane, tabs: toTabs, active: toTabs.length - 1 })
|
||
this.spec = { ...spec, left: left ?? null, top: top.length > 0 ? top : null, main }
|
||
this.onSpecMutated?.(this.spec)
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
setActiveTab(row, i, tabId) {
|
||
const spec = this.spec
|
||
if (!spec) return
|
||
const mutate = (pane: SplitPane): SplitPane => {
|
||
const idx = (pane.tabs ?? []).findIndex((t) => t.id === tabId)
|
||
if (idx < 0) return pane
|
||
return { ...pane, active: idx }
|
||
}
|
||
if (row === 'left') {
|
||
if (!spec.left || i !== 0) return
|
||
this.spec = { ...spec, left: mutate(spec.left) }
|
||
} else if (row === 'top') {
|
||
const top = [...(spec.top ?? [])]
|
||
if (!top[i]) return
|
||
top[i] = mutate(top[i])
|
||
this.spec = { ...spec, top }
|
||
} else {
|
||
const main = [...spec.main]
|
||
if (!main[i]) return
|
||
main[i] = mutate(main[i])
|
||
this.spec = { ...spec, main }
|
||
}
|
||
this.onSpecMutated?.(this.spec)
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
toggleCollapsed(row, i) {
|
||
const spec = this.spec
|
||
if (!spec) return
|
||
const mutate = (pane: SplitPane): SplitPane => ({ ...pane, collapsed: !pane.collapsed })
|
||
if (row === 'left') {
|
||
if (!spec.left || i !== 0) return
|
||
this.spec = { ...spec, left: mutate(spec.left) }
|
||
} else if (row === 'top') {
|
||
const top = [...(spec.top ?? [])]
|
||
if (!top[i]) return
|
||
top[i] = mutate(top[i])
|
||
this.spec = { ...spec, top }
|
||
} else {
|
||
const main = [...spec.main]
|
||
if (!main[i]) return
|
||
main[i] = mutate(main[i])
|
||
this.spec = { ...spec, main }
|
||
}
|
||
this.onSpecMutated?.(this.spec)
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
swapPanes(aRow, aI, bRow, bI) {
|
||
const spec = this.spec
|
||
if (!spec) return
|
||
const top = [...(spec.top ?? [])]
|
||
const main = [...spec.main]
|
||
const left = spec.left ? { ...spec.left } : null
|
||
const arrOf = (row: PaneRow): SplitPane[] => (row === 'left' ? (left ? [left] : []) : row === 'top' ? top : main)
|
||
const setOf = (row: PaneRow, i: number, pane: SplitPane) => {
|
||
if (row === 'left') spec.left = pane
|
||
else if (row === 'top') top[i] = pane
|
||
else main[i] = pane
|
||
}
|
||
const a = arrOf(aRow)[aI]
|
||
const b = arrOf(bRow)[bI]
|
||
if (!a || !b) return
|
||
setOf(aRow, aI, b)
|
||
setOf(bRow, bI, a)
|
||
const wsOf = (row: PaneRow): number[] => (row === 'left' ? this.leftWs : row === 'top' ? this.topWs : this.paneWs)
|
||
const setWs = (row: PaneRow, i: number, v: number) => {
|
||
if (row === 'left') { const n = this.leftWs.slice(); n[i] = v; this.leftWs = n }
|
||
else if (row === 'top') { const n = this.topWs.slice(); n[i] = v; this.topWs = n }
|
||
else { const n = this.paneWs.slice(); n[i] = v; this.paneWs = n }
|
||
}
|
||
const aW = wsOf(aRow)[aI]
|
||
const bW = wsOf(bRow)[bI]
|
||
setWs(aRow, aI, bW)
|
||
setWs(bRow, bI, aW)
|
||
this.spec = { ...spec, left: left ?? null, top: top.length > 0 ? top : null, main }
|
||
this.onSpecMutated?.(this.spec)
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
setChatSide(side) {
|
||
const spec = this.spec
|
||
if (!spec) return
|
||
if (spec.left) return // 左列布局:聊天固定右下
|
||
this.spec = { ...spec, chatSide: side }
|
||
this.onSpecMutated?.(this.spec)
|
||
this.applyMargin()
|
||
this.persist()
|
||
this.notify()
|
||
},
|
||
|
||
persist() {
|
||
if (!this.spec) return
|
||
persistSaved(this.spec.id, { chatW: this.chatW, topH: this.topH, leftW: this.leftW, paneWs: this.paneWs, topWs: this.topWs, leftWs: this.leftWs })
|
||
},
|
||
|
||
close() {
|
||
if (this.root && this.root.isConnected) {
|
||
if (this.savedWidthVar) this.root.style.setProperty('--dsh-chat-user-width', this.savedWidthVar)
|
||
else this.root.style.removeProperty('--dsh-chat-user-width')
|
||
}
|
||
if (this.viewArea) {
|
||
this.viewArea.style.marginLeft = this.savedMarginLeft
|
||
this.viewArea.style.marginRight = this.savedMarginRight
|
||
this.viewArea.style.marginTop = this.savedMarginTop
|
||
}
|
||
this.observer?.disconnect()
|
||
this.observer = null
|
||
this.fallback?.disconnect()
|
||
this.fallback = null
|
||
this.yieldObserver?.disconnect()
|
||
this.yieldObserver = null
|
||
this.root = null
|
||
this.header = null
|
||
this.viewArea = null
|
||
this.savedWidthVar = ''
|
||
this.geom = null
|
||
this.spec = null
|
||
this.active = false
|
||
this.notify()
|
||
},
|
||
|
||
subscribe(fn) {
|
||
this.listeners.add(fn)
|
||
return () => this.listeners.delete(fn)
|
||
},
|
||
|
||
notify() {
|
||
for (const fn of this.listeners) fn()
|
||
},
|
||
}
|
||
|
||
/** 跨插件互操作桥:自带分栏实现的插件其浮层(.ta_split)出现 = 其引擎打开 → 本引擎让位。
|
||
* 不改动对方插件代码,仅在 DOM 层观察其浮层挂载。 */
|
||
if (typeof document !== 'undefined' && document.body) {
|
||
const taObserver = new MutationObserver(() => {
|
||
if (!splitStore.active) return
|
||
if (document.querySelector('.ta_split')) splitStore.close()
|
||
})
|
||
taObserver.observe(document.body, { childList: true, subtree: true })
|
||
}
|
||
|
||
/** 分配各窗宽度(最后一个拿余量) */
|
||
function allocate(panes: SplitPane[], ws: number[], total: number) {
|
||
const out: { pane: SplitPane; left: number; width: number }[] = []
|
||
const gapTotal = Math.max(0, panes.length - 1) * GAP
|
||
const avail = Math.max(0, total - gapTotal)
|
||
let x = 0
|
||
panes.forEach((p, i) => {
|
||
const w = i === panes.length - 1 ? Math.max(0, avail - x) : ws[i]
|
||
out.push({ pane: p, left: x, width: w })
|
||
x += w + GAP
|
||
})
|
||
return out
|
||
}
|
||
|
||
/** 通用分隔线拖拽(chat/top/pane/topPane) */
|
||
function makeDividerHandler(kind: 'left' | 'chat' | 'top' | 'pane' | 'topPane', index?: number) {
|
||
return (e: any) => {
|
||
e.preventDefault()
|
||
const target = e.currentTarget as HTMLElement
|
||
try { target.setPointerCapture(e.pointerId) } catch {}
|
||
const onMove = (ev: PointerEvent) => {
|
||
const g = splitStore.geom
|
||
if (!g) return
|
||
if (kind === 'left') {
|
||
splitStore.setLeftW(ev.clientX - g.left)
|
||
} else if (kind === 'chat') {
|
||
splitStore.setChatW(g.right - ev.clientX)
|
||
} else if (kind === 'top') {
|
||
splitStore.setTopH(ev.clientY - g.top - BAR_H)
|
||
} else if (kind === 'pane' && index != null) {
|
||
const prefix = splitStore.paneWs.slice(0, index).reduce((a, b) => a + b, 0) + index * GAP
|
||
splitStore.setPaneW(index, ev.clientX - (g.left + prefix))
|
||
} else if (kind === 'topPane' && index != null) {
|
||
const prefix = splitStore.topWs.slice(0, index).reduce((a, b) => a + b, 0) + index * GAP
|
||
splitStore.setTopW(index, ev.clientX - (g.left + prefix))
|
||
}
|
||
}
|
||
const onUp = () => {
|
||
target.removeEventListener('pointermove', onMove)
|
||
target.removeEventListener('pointerup', onUp)
|
||
target.removeEventListener('pointercancel', onUp)
|
||
}
|
||
target.addEventListener('pointermove', onMove)
|
||
target.addEventListener('pointerup', onUp)
|
||
target.addEventListener('pointercancel', onUp)
|
||
}
|
||
}
|
||
|
||
/** 浏览器内置窗:地址栏 + 前往;刷新统一在标签栏最左(重挂载 iframe,跨域也可靠) */
|
||
function BrowserPane(props: { row: PaneRow; index: number; tabId: string; content: SplitContent; reloadKey: number }) {
|
||
const initial = props.content?.url || 'https://example.com'
|
||
const [url, setUrl] = useState(initial)
|
||
const [src, setSrc] = useState(initial)
|
||
const go = () => {
|
||
const u = url.trim()
|
||
const ok = /^(\/|https?:\/\/)/i.test(u) ? u : 'about:blank'
|
||
setSrc(ok)
|
||
if (ok !== 'about:blank') {
|
||
// 地址回写:刷新/重开布局时保持当前网址
|
||
splitStore.setTabContent(props.row, props.index, props.tabId, { kind: 'builtin', type: 'browser', url: ok })
|
||
}
|
||
}
|
||
return (
|
||
<>
|
||
<div className="dsh-wt_browserBar">
|
||
<input
|
||
className="dsh-wt_browserInput"
|
||
value={url}
|
||
placeholder="https://"
|
||
onChange={(e) => setUrl(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') go() }}
|
||
/>
|
||
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
|
||
</div>
|
||
<iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src} title="browser" />
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** iframe 内容标签(网页/站点产物):刷新统一在标签栏最左(重挂载整页刷新,跨域可靠) */
|
||
function IframePane(props: { url: string; title?: string; reloadKey: number }) {
|
||
return <iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={props.url} title={props.title ?? ''} />
|
||
}
|
||
|
||
/** 动画播放窗:iframe 壳 + 地址栏(站内自带项目/场景列表、播放、画幅切换、导出等全部控件) */
|
||
function AnimPane(props: { row: PaneRow; index: number; tabId: string; content: SplitContent; reloadKey: number }) {
|
||
const initial = props.content?.url || ''
|
||
const [url, setUrl] = useState(initial)
|
||
const [src, setSrc] = useState(initial || 'about:blank')
|
||
const go = () => {
|
||
const u = url.trim()
|
||
const ok = /^(\/|https?:\/\/)/i.test(u) ? u : 'about:blank'
|
||
setSrc(ok)
|
||
if (ok !== 'about:blank') {
|
||
splitStore.setTabContent(props.row, props.index, props.tabId, { kind: 'builtin', type: 'anim', url: ok })
|
||
}
|
||
}
|
||
return (
|
||
<>
|
||
<div className="dsh-wt_browserBar">
|
||
<input
|
||
className="dsh-wt_browserInput"
|
||
value={url}
|
||
placeholder={T('pane.animUrlPh')}
|
||
onChange={(e) => setUrl(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') go() }}
|
||
/>
|
||
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
|
||
</div>
|
||
<iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src} title="anim" />
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 主题图标(描边风格):dark=月 / light=日 / system=电脑;主按钮与下拉共用 */
|
||
function ThemeIcon({ mode, size }: { mode: 'dark' | 'light' | 'system'; size?: number }) {
|
||
const s = size ?? 18
|
||
if (mode === 'dark') return <svg width={s} height={s} viewBox="0 0 16 16" aria-hidden><path d="M14 8.53A6 6 0 1 1 7.47 2 4.67 4.67 0 0 0 14 8.53z" fill="none" stroke="currentColor" strokeWidth="0.9" strokeLinejoin="round" /></svg>
|
||
if (mode === 'light') return <svg width={s} height={s} viewBox="0 0 16 16" aria-hidden><circle cx="8" cy="8" r="3.1" fill="none" stroke="currentColor" strokeWidth="0.9" /><path d="M8 1.6v1.7M8 12.7v1.7M1.6 8h1.7M12.7 8h1.7M3.5 3.5l1.2 1.2M11.3 11.3l1.2 1.2M12.5 3.5l-1.2 1.2M4.7 11.3l-1.2 1.2" fill="none" stroke="currentColor" strokeWidth="0.9" strokeLinecap="round" /></svg>
|
||
return <svg width={s} height={s} viewBox="0 0 16 16" aria-hidden><rect x="2" y="3.2" width="12" height="8.8" rx="1.6" fill="none" stroke="currentColor" strokeWidth="0.9" /><path d="M5.4 14.4h5.2M8 12v2.4" fill="none" stroke="currentColor" strokeWidth="0.9" strokeLinecap="round" /></svg>
|
||
}
|
||
|
||
/** 两行滑杆图标(设置入口):2 条轨道 + 2 个旋钮,描边风格 */
|
||
function SliderIcon({ size }: { size?: number }) {
|
||
const s = size ?? 14
|
||
return (
|
||
<svg width={s} height={s} viewBox="0 0 16 16" aria-hidden>
|
||
<path d="M2.5 5.6h11M2.5 10.4h11" fill="none" stroke="currentColor" strokeWidth="1.1" />
|
||
<circle cx="10.4" cy="5.6" r="1.8" fill="currentColor" />
|
||
<circle cx="5.8" cy="10.4" r="1.8" fill="currentColor" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
/** HSL 数值普通输入框:始终为常规边框输入框,输入提交时自动钳制到 0..max */
|
||
function HslValInput(props: { value: number; max: number; min?: number; onCommit: (n: number) => void }) {
|
||
const lo = props.min ?? 0
|
||
const [draft, setDraft] = useState(String(props.value))
|
||
const [focused, setFocused] = useState(false)
|
||
useEffect(() => { if (!focused) setDraft(String(props.value)) }, [props.value, focused])
|
||
const commit = () => {
|
||
let n = parseInt(draft, 10)
|
||
if (!Number.isFinite(n)) n = props.value
|
||
n = Math.min(Math.max(n, lo), props.max)
|
||
props.onCommit(n)
|
||
}
|
||
return (
|
||
<input
|
||
className="dsh-wt_hslInput"
|
||
type="number"
|
||
min={lo}
|
||
max={props.max}
|
||
value={focused ? draft : String(props.value)}
|
||
onFocus={() => { setFocused(true); setDraft(String(props.value)) }}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onBlur={() => { setFocused(false); commit() }}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
|
||
if (e.key === 'Escape') { setDraft(String(props.value)); (e.target as HTMLInputElement).blur() }
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
/** 纯色背景默认值(与主题关联):深色 = 原深蓝黑 #0a0d13;浅色 = 白 #eef1f5;网格线由 --wt-grid 主题变量自动切换 */
|
||
const PLAIN_HSL_DARK = { h: -140, s: 31, l: 6 }
|
||
const PLAIN_HSL_LIGHT = { h: -146, s: 26, l: 95 }
|
||
|
||
/** 循环视频背景:首尾帧交叉渐变——双轨重叠过渡(尾帧淡出→头帧淡入),消除循环「卡一下」。
|
||
* 两轨共用同一源;备用轨仅在交接窗口播放,其余时间暂停(解码负担仅 ~1.2s/循环)。 */
|
||
function ConsoleVideo(props: { src: string; style?: CSSProperties }) {
|
||
const aRef = useRef<HTMLVideoElement | null>(null)
|
||
const bRef = useRef<HTMLVideoElement | null>(null)
|
||
const roleRef = useRef<'a' | 'b'>('a')
|
||
const firedRef = useRef(false)
|
||
useEffect(() => {
|
||
const role = () => (roleRef.current === 'a' ? aRef.current : bRef.current)
|
||
const spare = () => (roleRef.current === 'a' ? bRef.current : aRef.current)
|
||
firedRef.current = false
|
||
let raf = 0
|
||
let stopped = false
|
||
const onEnded = () => {
|
||
const m = role()
|
||
const s = spare()
|
||
if (!m || !s) return
|
||
// 主轨播完:备用轨已成为新主轨(淡入完成);旧主轨复位为备用轨
|
||
roleRef.current = roleRef.current === 'a' ? 'b' : 'a'
|
||
firedRef.current = false
|
||
try {
|
||
m.currentTime = 0
|
||
m.pause()
|
||
m.style.opacity = '0'
|
||
m.getAnimations().forEach((an) => an.cancel())
|
||
} catch {}
|
||
try {
|
||
const nm = role()
|
||
if (nm) nm.style.opacity = '1'
|
||
} catch {}
|
||
}
|
||
const tick = () => {
|
||
if (stopped) return
|
||
const m = role()
|
||
const s = spare()
|
||
if (m && s && !m.paused && m.readyState >= 2 && !firedRef.current) {
|
||
const d = m.duration
|
||
if (Number.isFinite(d) && d > 0) {
|
||
const fade = Math.min(2.0, Math.max(1.2, d * 0.18))
|
||
if (m.currentTime >= Math.max(0, d - fade)) {
|
||
firedRef.current = true
|
||
try {
|
||
try { s.currentTime = 0 } catch {}
|
||
const startFade = () => {
|
||
try {
|
||
try { m.getAnimations().forEach((an) => an.cancel()) } catch {}
|
||
try { s.getAnimations().forEach((an) => an.cancel()) } catch {}
|
||
s.play().catch(() => {})
|
||
s.animate([{ opacity: 0 }, { opacity: 1 }], { duration: fade * 1000, easing: 'linear', fill: 'forwards' })
|
||
m.animate([{ opacity: 1 }, { opacity: 0 }], { duration: fade * 1000, easing: 'linear', fill: 'forwards' })
|
||
} catch {}
|
||
}
|
||
if (s.readyState >= 2) startFade()
|
||
else s.addEventListener('canplay', startFade, { once: true })
|
||
} catch {}
|
||
}
|
||
}
|
||
}
|
||
raf = requestAnimationFrame(tick)
|
||
}
|
||
raf = requestAnimationFrame(tick)
|
||
aRef.current?.addEventListener('ended', onEnded)
|
||
bRef.current?.addEventListener('ended', onEnded)
|
||
return () => {
|
||
stopped = true
|
||
cancelAnimationFrame(raf)
|
||
aRef.current?.removeEventListener('ended', onEnded)
|
||
bRef.current?.removeEventListener('ended', onEnded)
|
||
}
|
||
}, [])
|
||
return (
|
||
<>
|
||
<video ref={aRef} className="dsh-wt_consoleMedia" src={props.src} muted autoPlay playsInline style={props.style} />
|
||
<video ref={bRef} className="dsh-wt_consoleMedia" src={props.src} muted playsInline style={{ ...props.style, opacity: 0 }} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 控制室面板:项目卡片网格(每行 3 张、超出换行);数据由工作台组装推送(纯读镜像)。
|
||
* 主题:dark/light 直接生效;system = 跟随宿主 html 的 color-scheme(DSH 深色/白色/跟随系统都会反映到它) */
|
||
function ConsolePane() {
|
||
const [, setTick] = useState(0)
|
||
const [now, setNow] = useState(() => Date.now())
|
||
const [cols, setColsState] = useState<number>(() => splitEnv?.console?.getCols?.() ?? 3)
|
||
const [shape, setShapeState] = useState<'square' | 'circle'>(() => splitEnv?.console?.getShape?.() ?? 'square')
|
||
const [bg, setBgState] = useState<'plain' | 'glow' | 'photo'>(() => splitEnv?.console?.getBg?.() ?? 'glow')
|
||
const [bgPhoto, setBgPhoto] = useState<string>('')
|
||
const [photoId, setPhotoIdLocal] = useState<string>('')
|
||
const [photoList, setPhotoList] = useState<{ id: string; kind: 'photo' | 'video'; url: string }[]>([])
|
||
const [bgVideo, setBgVideo] = useState<string>('')
|
||
const [photoGrid, setPhotoGridState] = useState<boolean>(() => splitEnv?.console?.getPhotoGrid?.() ?? true)
|
||
const [gridOpacity, setGridOpacityState] = useState<number>(() => splitEnv?.console?.getGridOpacity?.() ?? 8)
|
||
const [cardBlur, setCardBlurState] = useState<number>(() => splitEnv?.console?.getCardBlur?.() ?? 8)
|
||
const [plainBlur, setPlainBlurState] = useState<number>(() => splitEnv?.console?.getPlainBlur?.() ?? 0)
|
||
const [glowBlur, setGlowBlurState] = useState<number>(() => splitEnv?.console?.getGlowBlur?.() ?? 8)
|
||
const [plainGrid, setPlainGridState] = useState<number>(() => splitEnv?.console?.getPlainGrid?.() ?? 5)
|
||
const [glowGrid, setGlowGridState] = useState<number>(() => splitEnv?.console?.getGlowGrid?.() ?? 8)
|
||
const [glowSpeed, setGlowSpeedState] = useState<number>(() => splitEnv?.console?.getGlowSpeed?.() ?? 50)
|
||
const [annOpen, setAnnOpen] = useState(false)
|
||
const [updStatus, setUpdStatus] = useState<UpdateStatus>(() => readCache().status)
|
||
const [updInfo, setUpdInfo] = useState<UpdateInfo | null>(() => readCache().info)
|
||
const [autoCheckOn, setAutoCheckOn] = useState<boolean>(() => getAutoCheck())
|
||
const updBusyRef = useRef(false)
|
||
const photoUrlRef = useRef<string>('')
|
||
const [openMenu, setOpenMenu] = useState<string | null>(null)
|
||
const [bgEdit, setBgEdit] = useState<'plain' | 'glow' | 'photo' | null>(null)
|
||
const [plainHsl, setPlainHslState] = useState<{ h: number; s: number; l: number }>(() => {
|
||
const saved = splitEnv?.console?.getPlainHsl?.()
|
||
if (saved) return saved
|
||
return splitEnv?.console?.getTheme?.() === 'light' ? { ...PLAIN_HSL_LIGHT } : { ...PLAIN_HSL_DARK }
|
||
})
|
||
const [glowHsl, setGlowHslState] = useState<{ h: number; s: number; l: number }>(() => splitEnv?.console?.getGlowHsl?.() ?? { h: 0, s: 100, l: 100 })
|
||
const [photoHsl, setPhotoHslState] = useState<{ h: number; s: number; l: number }>({ h: 0, s: 100, l: 100 })
|
||
const gridRef = useRef<HTMLDivElement | null>(null)
|
||
const firstRectsRef = useRef<Map<string, { x: number; y: number }> | null>(null)
|
||
const onCols = (n: number) => {
|
||
const grid = gridRef.current
|
||
if (grid) {
|
||
const rects = new Map<string, { x: number; y: number }>()
|
||
grid.querySelectorAll('.dsh-wt_consoleCard').forEach((el) => {
|
||
const key = (el as HTMLElement).dataset.flip ?? ''
|
||
const r = el.getBoundingClientRect()
|
||
rects.set(key, { x: r.left, y: r.top })
|
||
})
|
||
firstRectsRef.current = rects
|
||
}
|
||
setColsState(n)
|
||
splitEnv?.console?.setCols?.(n)
|
||
}
|
||
const onShape = (s: 'square' | 'circle') => {
|
||
setShapeState(s)
|
||
splitEnv?.console?.setShape?.(s)
|
||
}
|
||
/** 选择本地图片文件并入库 */
|
||
const pickPhotoFile = () => {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = 'image/*,video/*'
|
||
input.onchange = () => {
|
||
const file = input.files?.[0]
|
||
if (!file) return
|
||
onFilePhoto(file)
|
||
}
|
||
input.click()
|
||
}
|
||
/** 新增媒体(照片/视频):原始 Blob 存 IndexedDB(零压缩),显示用对象 URL;自动设为当前 */
|
||
const onFilePhoto = async (file: Blob) => {
|
||
let id = ''
|
||
let url = ''
|
||
let kind: 'photo' | 'video' = file.type && file.type.indexOf('video/') === 0 ? 'video' : 'photo'
|
||
const rec = await (splitEnv?.console?.addPhoto?.(file) ?? Promise.resolve(null)).catch(() => null)
|
||
if (rec) { id = rec.id; url = rec.url; kind = rec.kind } else {
|
||
url = URL.createObjectURL(file)
|
||
}
|
||
photoUrlRef.current = url
|
||
setPhotoIdLocal(id)
|
||
if (id) setPhotoList((prev) => [{ id, kind, url }, ...prev])
|
||
if (kind === 'video') { setBgPhoto(''); setBgVideo(url) } else { setBgVideo(''); setBgPhoto(url) }
|
||
setBgState('photo')
|
||
splitEnv?.console?.setBg?.('photo')
|
||
}
|
||
/** 删除一条媒体:当前使用的那条被删 → 切到最新一条;删光 → 回到流光背景 */
|
||
const removePhotoById = async (p: { id: string; kind: 'photo' | 'video'; url: string }) => {
|
||
try { await splitEnv?.console?.removePhoto?.(p.id) } catch {}
|
||
try { URL.revokeObjectURL(p.url) } catch {}
|
||
const nextList = photoList.filter((x) => x.id !== p.id)
|
||
setPhotoList(nextList)
|
||
if (photoId === p.id) {
|
||
const next = nextList[0] ?? null
|
||
if (next) {
|
||
setPhotoIdLocal(next.id)
|
||
splitEnv?.console?.setPhotoId?.(next.id)
|
||
photoUrlRef.current = next.url
|
||
setBgVideo(next.kind === 'video' ? next.url : '')
|
||
setBgPhoto(next.kind === 'video' ? '' : next.url)
|
||
setBgState('photo')
|
||
splitEnv?.console?.setBg?.('photo')
|
||
} else {
|
||
setPhotoIdLocal('')
|
||
photoUrlRef.current = ''
|
||
setBgPhoto('')
|
||
setBgVideo('')
|
||
setBgState('glow')
|
||
splitEnv?.console?.setBg?.('glow')
|
||
}
|
||
}
|
||
}
|
||
const togglePhotoGrid = () => {
|
||
const next = !photoGrid
|
||
setPhotoGridState(next)
|
||
splitEnv?.console?.setPhotoGrid?.(next)
|
||
}
|
||
const onGridOpacity = (n: number) => {
|
||
const v = Math.min(Math.max(Math.round(n), 0), 30)
|
||
setGridOpacityState(v)
|
||
splitEnv?.console?.setGridOpacity?.(v)
|
||
}
|
||
const onCardBlur = (n: number) => {
|
||
const v = Math.min(Math.max(Math.round(n), 0), 20)
|
||
setCardBlurState(v)
|
||
splitEnv?.console?.setCardBlur?.(v)
|
||
}
|
||
const onModeBlur = (kind: 'plain' | 'glow' | 'photo', n: number) => {
|
||
const v = Math.min(Math.max(Math.round(n), 0), 20)
|
||
if (kind === 'plain') { setPlainBlurState(v); splitEnv?.console?.setPlainBlur?.(v) }
|
||
else if (kind === 'glow') { setGlowBlurState(v); splitEnv?.console?.setGlowBlur?.(v) }
|
||
else { setCardBlurState(v); splitEnv?.console?.setCardBlur?.(v) }
|
||
}
|
||
const onModeGrid = (kind: 'plain' | 'glow', n: number) => {
|
||
const v = Math.min(Math.max(Math.round(n), 0), 30)
|
||
if (kind === 'plain') { setPlainGridState(v); splitEnv?.console?.setPlainGrid?.(v) }
|
||
else { setGlowGridState(v); splitEnv?.console?.setGlowGrid?.(v) }
|
||
}
|
||
const onGlowSpeed = (n: number) => {
|
||
const v = Math.min(Math.max(Math.round(n), 0), 100)
|
||
setGlowSpeedState(v)
|
||
splitEnv?.console?.setGlowSpeed?.(v)
|
||
}
|
||
const runUpdateCheck = async (force: boolean) => {
|
||
if (updBusyRef.current) return
|
||
updBusyRef.current = true
|
||
setUpdStatus('checking')
|
||
try {
|
||
const r = await checkUpdate(force)
|
||
setUpdStatus(r.status)
|
||
setUpdInfo(r.info)
|
||
} finally {
|
||
updBusyRef.current = false
|
||
}
|
||
}
|
||
const [updCopied, setUpdCopied] = useState(false)
|
||
const onCopyUpgrade = async () => {
|
||
const ok = await copyTextSafe(UPGRADE_AI)
|
||
if (ok) { setUpdCopied(true); window.setTimeout(() => setUpdCopied(false), 2200) }
|
||
}
|
||
const onSkipVersion = () => {
|
||
if (updInfo) { setSkipVersion(updInfo.latest); setUpdInfo(null); setUpdStatus('uptodate') }
|
||
}
|
||
const onAutoCheckToggle = () => {
|
||
const next = !autoCheckOn
|
||
setAutoCheckOn(next)
|
||
storeAutoCheck(next)
|
||
if (next) void runUpdateCheck(false)
|
||
}
|
||
/** 抓手拖拽排序:按住抓手上下移动 → 行实时重排,松手持久化 */
|
||
const dragRowRef = useRef<string | null>(null)
|
||
const dragFlipRef = useRef<Map<string, { x: number; y: number }> | null>(null)
|
||
const lastTargetRef = useRef<number>(-1)
|
||
const [dragRowId, setDragRowId] = useState<string | null>(null)
|
||
// FLIP:落位提交后,其他行从旧位置平滑让位(260ms 缓出)
|
||
useLayoutEffect(() => {
|
||
const first = dragFlipRef.current
|
||
if (!first) return
|
||
dragFlipRef.current = null
|
||
document.querySelectorAll<HTMLElement>('.dsh-wt_photoRow').forEach((el) => {
|
||
const key = el.dataset.rid ?? ''
|
||
const f = first.get(key)
|
||
if (!f) return
|
||
const r = el.getBoundingClientRect()
|
||
const dx = f.x - r.left
|
||
const dy = f.y - r.top
|
||
if (dx || dy) el.animate([{ transform: 'translate(' + dx + 'px,' + dy + 'px)' }, { transform: 'none' }], { duration: 260, easing: 'cubic-bezier(.25,.6,.3,1)' })
|
||
})
|
||
}, [photoList])
|
||
const onHandleDown = (id: string, e: { button: number; preventDefault: () => void; stopPropagation: () => void }) => {
|
||
if (e.button !== 0) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
dragRowRef.current = id
|
||
lastTargetRef.current = photoList.findIndex((x) => x.id === id)
|
||
setDragRowId(id)
|
||
const onMove = (ev: PointerEvent) => {
|
||
if (dragRowRef.current !== id) return
|
||
const rows = Array.from(document.querySelectorAll<HTMLElement>('.dsh-wt_photoRow'))
|
||
if (rows.length === 0) return
|
||
// 槽位制:按固定行高算最近槽位;边界 ±0.12 行高为死区,未决定性越过前不切换
|
||
const first = rows[0].getBoundingClientRect()
|
||
const h = first.height
|
||
const rel = (ev.clientY - first.top) / h
|
||
let t = Math.max(0, Math.min(rows.length - 1, Math.floor(rel + 0.5)))
|
||
const frac = rel - Math.floor(rel)
|
||
if (Math.abs(frac - 0.5) < 0.12) t = lastTargetRef.current
|
||
if (t === lastTargetRef.current) return
|
||
const from = photoList.findIndex((x) => x.id === id)
|
||
if (from < 0) return
|
||
const rects = new Map<string, { x: number; y: number }>()
|
||
rows.forEach((el) => { const k = el.dataset.rid ?? ''; const r = el.getBoundingClientRect(); rects.set(k, { x: r.left, y: r.top }) })
|
||
const next = [...photoList]
|
||
const moved = next.splice(from, 1)[0]
|
||
const to = Math.max(0, Math.min(t, next.length))
|
||
next.splice(to, 0, moved)
|
||
dragFlipRef.current = rects
|
||
setPhotoList(next)
|
||
lastTargetRef.current = t
|
||
}
|
||
const onUp = () => {
|
||
dragRowRef.current = null
|
||
setDragRowId(null)
|
||
window.removeEventListener('pointermove', onMove)
|
||
window.removeEventListener('pointerup', onUp)
|
||
const ids = photoList.map((x) => x.id)
|
||
void splitEnv?.console?.reorderPhotos?.(ids).catch(() => {})
|
||
}
|
||
window.addEventListener('pointermove', onMove)
|
||
window.addEventListener('pointerup', onUp)
|
||
}
|
||
/** 在媒体库中选中某条 */
|
||
const selectPhoto = (p: { id: string; kind: 'photo' | 'video'; url: string }) => {
|
||
setPhotoIdLocal(p.id)
|
||
splitEnv?.console?.setPhotoId?.(p.id)
|
||
photoUrlRef.current = p.url
|
||
if (p.kind === 'video') { setBgPhoto(''); setBgVideo(p.url) } else { setBgVideo(''); setBgPhoto(p.url) }
|
||
setBgState('photo')
|
||
splitEnv?.console?.setBg?.('photo')
|
||
}
|
||
const onBg = (m: 'plain' | 'glow' | 'photo') => {
|
||
if (m === 'photo') {
|
||
// 直接切换到当前媒体;没有媒体时才进入二级菜单引导上传
|
||
if (photoList.length === 0) { setBgEdit('photo'); return }
|
||
setBgState('photo')
|
||
splitEnv?.console?.setBg?.('photo')
|
||
return
|
||
}
|
||
setBgState(m)
|
||
splitEnv?.console?.setBg?.(m)
|
||
}
|
||
const editHsl = (kind: 'plain' | 'glow' | 'photo', patch: Partial<{ h: number; s: number; l: number }>) => {
|
||
if (kind === 'plain') {
|
||
const next = { ...plainHsl, ...patch }
|
||
setPlainHslState(next)
|
||
splitEnv?.console?.setPlainHsl?.(next)
|
||
} else if (kind === 'glow') {
|
||
const next = { ...glowHsl, ...patch }
|
||
setGlowHslState(next)
|
||
splitEnv?.console?.setGlowHsl?.(next)
|
||
} else {
|
||
const next = { ...photoHsl, ...patch }
|
||
setPhotoHslState(next)
|
||
if (photoId) splitEnv?.console?.setPhotoHsl?.(photoId, next)
|
||
}
|
||
}
|
||
const resetHsl = (kind: 'plain' | 'glow' | 'photo') => {
|
||
// 恢复初始:连同贴片模糊(B)/网格不透明度(T)一起复位(纯色 B=0,流光/自定义 B=8)
|
||
const blurDef = kind === 'plain' ? 0 : 8
|
||
if (kind === 'plain') {
|
||
setPlainBlurState(blurDef); splitEnv?.console?.setPlainBlur?.(blurDef)
|
||
setPlainGridState(5); splitEnv?.console?.setPlainGrid?.(5)
|
||
} else if (kind === 'glow') {
|
||
setGlowBlurState(blurDef); splitEnv?.console?.setGlowBlur?.(blurDef)
|
||
setGlowGridState(8); splitEnv?.console?.setGlowGrid?.(8)
|
||
setGlowSpeedState(50); splitEnv?.console?.setGlowSpeed?.(50)
|
||
} else {
|
||
setCardBlurState(blurDef); splitEnv?.console?.setCardBlur?.(blurDef)
|
||
setGridOpacityState(8); splitEnv?.console?.setGridOpacity?.(8)
|
||
}
|
||
if (kind === 'plain') {
|
||
const def = resolvedTheme === 'light' ? { ...PLAIN_HSL_LIGHT } : { ...PLAIN_HSL_DARK }
|
||
setPlainHslState(def)
|
||
splitEnv?.console?.setPlainHsl?.(def)
|
||
} else if (kind === 'glow') {
|
||
setGlowHslState({ h: 0, s: 100, l: 100 })
|
||
splitEnv?.console?.setGlowHsl?.({ h: 0, s: 100, l: 100 })
|
||
} else {
|
||
setPhotoHslState({ h: 0, s: 100, l: 100 })
|
||
if (photoId) splitEnv?.console?.setPhotoHsl?.(photoId, { h: 0, s: 100, l: 100 })
|
||
// 照片网格线开关一并复位为默认「开」
|
||
setPhotoGridState(true); splitEnv?.console?.setPhotoGrid?.(true)
|
||
}
|
||
}
|
||
useLayoutEffect(() => {
|
||
const first = firstRectsRef.current
|
||
if (!first) return
|
||
const grid = gridRef.current
|
||
if (!grid) return
|
||
grid.querySelectorAll('.dsh-wt_consoleCard').forEach((el) => {
|
||
const key = (el as HTMLElement).dataset.flip ?? ''
|
||
const f = first.get(key)
|
||
if (!f) return
|
||
const r = el.getBoundingClientRect()
|
||
const dx = f.x - r.left
|
||
const dy = f.y - r.top
|
||
if (dx || dy) {
|
||
el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration: 450, easing: 'cubic-bezier(.22,.61,.36,1)' })
|
||
}
|
||
})
|
||
firstRectsRef.current = null
|
||
}, [cols])
|
||
const [themeMode, setThemeMode] = useState<'dark' | 'light' | 'system'>(() => splitEnv?.console?.getTheme?.() ?? 'system')
|
||
const [sysDark, setSysDark] = useState(() => {
|
||
try { return window.matchMedia('(prefers-color-scheme: dark)').matches } catch { return true }
|
||
})
|
||
useEffect(() => {
|
||
const env = splitEnv?.console
|
||
if (!env) return
|
||
const bump = () => { setTick((t) => t + 1); setThemeMode(env.getTheme?.() ?? 'system') }
|
||
const un = env.subscribe(bump)
|
||
bump()
|
||
return un
|
||
}, [])
|
||
// 跟随系统:监听 OS 深浅切换(仅 themeMode==='system' 时影响渲染)
|
||
useEffect(() => {
|
||
let mq: MediaQueryList | null = null
|
||
try { mq = window.matchMedia('(prefers-color-scheme: dark)') } catch {}
|
||
if (!mq) return
|
||
const f = (e: any) => setSysDark(!!e?.matches)
|
||
try { mq.addEventListener('change', f) } catch { try { (mq as any).addListener(f) } catch {} }
|
||
return () => { try { mq.removeEventListener('change', f) } catch { try { (mq as any).removeListener(f) } catch {} } }
|
||
}, [])
|
||
// 打开控制室 = 预热所有绑定会话的最近消息(冷会话走宿主 history 只读通道)
|
||
useEffect(() => {
|
||
splitEnv?.console?.refreshPreviews?.()
|
||
}, [])
|
||
// 自动检查更新(节流一天;手动按钮始终 force)
|
||
useEffect(() => { if (getAutoCheck()) void runUpdateCheck(false) }, [])
|
||
// 背景照片库:IndexedDB 异步加载记录 → 对象 URL;无记录则空
|
||
useEffect(() => {
|
||
let alive = true
|
||
splitEnv?.console?.getPhotoLib?.().then((lib) => {
|
||
if (!alive) return
|
||
setPhotoList(lib.list)
|
||
const active = lib.list.find((p) => p.id === lib.activeId) ?? lib.list[0] ?? null
|
||
if (active) {
|
||
setPhotoIdLocal(active.id)
|
||
photoUrlRef.current = active.url
|
||
setBgVideo(active.kind === 'video' ? active.url : '')
|
||
setBgPhoto(active.kind === 'video' ? '' : active.url)
|
||
}
|
||
}).catch(() => {})
|
||
return () => { alive = false }
|
||
}, [])
|
||
// 当前照片切换 → 载入该照片记住的 HSL 调节值
|
||
useEffect(() => {
|
||
if (!photoId) return
|
||
setPhotoHslState(splitEnv?.console?.getPhotoHsl?.(photoId) ?? { h: 0, s: 100, l: 100 })
|
||
}, [photoId])
|
||
// 面板卸载时释放对象 URL
|
||
useEffect(() => () => { if (photoUrlRef.current) { try { URL.revokeObjectURL(photoUrlRef.current) } catch {} } }, [])
|
||
// 运行时长的分钟级刷新:每秒重渲染一次(仅控制室开着时存在)
|
||
useEffect(() => {
|
||
const iv = window.setInterval(() => setNow(Date.now()), 1000)
|
||
return () => window.clearInterval(iv)
|
||
}, [])
|
||
void now
|
||
const env = splitEnv?.console
|
||
const cards = env ? env.getCards() : []
|
||
const resolvedTheme: 'dark' | 'light' = (() => {
|
||
if (themeMode !== 'system') return themeMode
|
||
try {
|
||
const cs = getComputedStyle(document.documentElement).colorScheme || ''
|
||
if (cs.includes('dark') && !cs.includes('light')) return 'dark'
|
||
if (cs.includes('light') && !cs.includes('dark')) return 'light'
|
||
} catch {}
|
||
return sysDark ? 'dark' : 'light'
|
||
})()
|
||
const setTheme = (th: 'dark' | 'light' | 'system') => { setThemeMode(th); env?.setTheme?.(th) }
|
||
// 主题联动:切换主题时,若纯色还是另一主题的默认值 → 自动跟随新主题默认;自定义过则保持
|
||
const prevPlainThemeRef = useRef<'dark' | 'light' | null>(null)
|
||
useEffect(() => {
|
||
const prev = prevPlainThemeRef.current
|
||
prevPlainThemeRef.current = resolvedTheme
|
||
if (!prev || prev === resolvedTheme) return
|
||
const isDarkDef = plainHsl.h === PLAIN_HSL_DARK.h && plainHsl.s === PLAIN_HSL_DARK.s && plainHsl.l === PLAIN_HSL_DARK.l
|
||
const isLightDef = plainHsl.h === PLAIN_HSL_LIGHT.h && plainHsl.s === PLAIN_HSL_LIGHT.s && plainHsl.l === PLAIN_HSL_LIGHT.l
|
||
const next = resolvedTheme === 'light' && isDarkDef ? { ...PLAIN_HSL_LIGHT }
|
||
: resolvedTheme === 'dark' && isLightDef ? { ...PLAIN_HSL_DARK } : null
|
||
if (next) {
|
||
setPlainHslState(next)
|
||
splitEnv?.console?.setPlainHsl?.(next)
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [resolvedTheme])
|
||
const fmtDur = (ms: number) => {
|
||
const s = Math.max(0, Math.floor(ms / 1000))
|
||
const h = Math.floor(s / 3600)
|
||
const m = Math.floor((s % 3600) / 60)
|
||
const ss = s % 60
|
||
return h > 0 ? h + ':' + String(m).padStart(2, '0') + ':' + String(ss).padStart(2, '0') : m + ':' + String(ss).padStart(2, '0')
|
||
}
|
||
const statusLabel: Record<string, string> = { idle: T('console.idle'), busy: T('console.busy'), need: T('console.need'), done: T('console.done') }
|
||
const themeOpts: { mode: 'dark' | 'light' | 'system'; key: string }[] = [
|
||
{ mode: 'dark', key: 'console.themeDark' },
|
||
{ mode: 'light', key: 'console.themeLight' },
|
||
{ mode: 'system', key: 'console.themeSystem' },
|
||
]
|
||
return (
|
||
<div className="dsh-wt_console" data-wt-theme={resolvedTheme} data-wt-shape={shape} data-wt-bg={bg} data-wt-grid={bg === 'photo' && !photoGrid ? 'off' : 'on'} style={{ ...(bg === 'plain' ? { ['--wt-bg' as any]: 'hsl(' + plainHsl.h + ', ' + plainHsl.s + '%, ' + plainHsl.l + '%)', ['--wt-gridPlain' as any]: 'rgba(255,255,255,' + (plainGrid / 100) + ')', ['--wt-gridPlainLight' as any]: 'rgba(27,31,36,' + (plainGrid / 100) + ')' } : {}), ...(bg === 'glow' ? { ['--wt-gridGlow' as any]: 'rgba(255,255,255,' + (glowGrid / 100) + ')', ['--wt-gridGlowLight' as any]: 'rgba(27,31,36,' + (glowGrid / 100) + ')', ['--wt-glowScale' as any]: glowSpeed === 0 ? '100000000' : String(50 / glowSpeed) } : {}), ...(bg === 'photo' ? { ['--wt-gridPhoto' as any]: 'rgba(255,255,255,' + (gridOpacity / 100) + ')' } : {}), ['--wt-cardBlur' as any]: (bg === 'plain' ? plainBlur : bg === 'glow' ? glowBlur : cardBlur) + 'px' }}>
|
||
<span className="dsh-wt_consoleBg" aria-hidden style={bg === 'glow' && (glowHsl.h !== 0 || glowHsl.s !== 100 || glowHsl.l !== 100) ? { filter: 'hue-rotate(' + glowHsl.h + 'deg) saturate(' + glowHsl.s + '%) brightness(' + glowHsl.l + '%)' } : undefined}>
|
||
{bg === 'photo' && (bgVideo
|
||
? <ConsoleVideo src={bgVideo} style={photoHsl.h !== 0 || photoHsl.s !== 100 || photoHsl.l !== 100 ? { filter: 'hue-rotate(' + photoHsl.h + 'deg) saturate(' + photoHsl.s + '%) brightness(' + photoHsl.l + '%)' } : undefined} />
|
||
: bgPhoto ? <img className="dsh-wt_consoleMedia" src={bgPhoto} alt="" style={photoHsl.h !== 0 || photoHsl.s !== 100 || photoHsl.l !== 100 ? { filter: 'hue-rotate(' + photoHsl.h + 'deg) saturate(' + photoHsl.s + '%) brightness(' + photoHsl.l + '%)' } : undefined} /> : null)}
|
||
{bg === 'glow' && (<><i className="dsh-wt_blob dsh-wt_blob1" /><i className="dsh-wt_blob dsh-wt_blob2" /><i className="dsh-wt_blob dsh-wt_blob3" /><i className="dsh-wt_blob dsh-wt_blob4" /></>)}</span>
|
||
{openMenu !== null && <div className="dsh-wt_dropMask" onClick={() => setOpenMenu(null)} />}
|
||
<div className="dsh-wt_consoleScroll">
|
||
{annOpen ? (
|
||
<div className="dsh-wt_announceCtr">
|
||
<div className="dsh-wt_announce">
|
||
<div className="dsh-wt_announceHead">
|
||
<span className="dsh-wt_announceVer">{T('annot.curVer')} v{LOCAL_VERSION}</span>
|
||
<button type="button" className="dsh-wt_announceBtn" disabled={updStatus === 'checking'} onClick={() => runUpdateCheck(true)}>{updStatus === 'checking' ? T('annot.checking') : T('annot.checkNow')}</button>
|
||
<span className="dsh-wt_announceAuto">{T('annot.autoCheck')}</span>
|
||
<button type="button" className={'dsh-wt_switch' + (autoCheckOn ? ' dsh-wt_switchOn' : '')} aria-pressed={autoCheckOn} aria-label={T('annot.autoCheck')} onClick={onAutoCheckToggle}><span className="dsh-wt_switchKnob" /></button>
|
||
</div>
|
||
{updInfo && (
|
||
<div className="dsh-wt_announceUpdate">
|
||
<span className="dsh-wt_announceNewVer">{T('annot.newVer')} v{updInfo.latest}</span>
|
||
<button type="button" className={'dsh-wt_announceUpg' + (updCopied ? ' dsh-wt_announceUpgOk' : '')} onClick={onCopyUpgrade}>{updCopied ? T('annot.copied') : T('annot.copyUpgrade')}</button>
|
||
<button type="button" className="dsh-wt_announceSkip dsh-wt_announceSkipGo" onClick={() => { try { window.open(updInfo.url, '_blank') } catch {} }}>{T('annot.gotoRelease')}</button>
|
||
<button type="button" className="dsh-wt_announceSkip" onClick={onSkipVersion}>{T('annot.skipVer')}</button>
|
||
<div className="dsh-wt_announceHow">{T('annot.howUpdate')}</div>
|
||
</div>
|
||
)}
|
||
{updStatus === 'failed' && <div className="dsh-wt_announceStatus">{T('annot.checkFail')}</div>}
|
||
{updStatus === 'uptodate' && !updInfo && <div className="dsh-wt_announceStatus">{T('annot.latest')}</div>}
|
||
<div className="dsh-wt_announceBody">{CHANGELOG_V030}</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div ref={gridRef} className="dsh-wt_consoleGrid" style={{ ['--wt-cols' as any]: cols }}>
|
||
{cards.map((c) => (
|
||
<div
|
||
key={c.id}
|
||
data-flip={c.id}
|
||
role={c.self ? undefined : 'button'}
|
||
tabIndex={c.self ? -1 : 0}
|
||
className={'dsh-wt_consoleCard' + (c.self ? ' dsh-wt_consoleCardSelf' : '')
|
||
+ (c.status === 'busy' ? ' dsh-wt_consoleCard-busy' : '')
|
||
+ (c.glow && c.status === 'done' ? ' dsh-wt_consoleCard-glowDone' : '')
|
||
+ (c.glow && c.status === 'need' ? ' dsh-wt_consoleCard-glowNeed' : '')}
|
||
title={c.name}
|
||
onClick={() => {
|
||
if (c.self || !env) return
|
||
if (c.glow) env.onAck?.(c.id) // 点发光卡片:先确认熄光,再进入
|
||
env.onOpen(c.id)
|
||
}}
|
||
>
|
||
<div className="dsh-wt_consoleCardHead">
|
||
<span className="dsh-wt_consoleIcon" aria-hidden>{c.icon}</span>
|
||
<span className="dsh-wt_consoleName">{c.name}</span>
|
||
</div>
|
||
<div className="dsh-wt_consoleDivider" aria-hidden />
|
||
<div className="dsh-wt_consoleStatusRow">
|
||
<span className={'dsh-wt_consoleStatus dsh-wt_consoleStatus-' + c.status}>{statusLabel[c.status]}</span>
|
||
{c.runtimeMs != null && <span className="dsh-wt_consoleRuntime">{fmtDur(c.runtimeMs)}</span>}
|
||
</div>
|
||
{c.status === 'busy' && <span className="dsh-wt_consoleSweep" aria-hidden />}
|
||
<div className={'dsh-wt_consolePreview' + (c.preview ? '' : ' dsh-wt_consolePreviewNone')} title={c.preview}>
|
||
{c.preview || (c.bound ? T('console.noPreview') : T('console.unboundShort'))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{/* 创建卡片:永远最后一位;点击 = 侧栏工作台「添加项目」同款流程 */}
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
className="dsh-wt_consoleCard dsh-wt_consoleAdd"
|
||
title={T('console.addProject')}
|
||
onClick={() => env?.onAdd?.()}
|
||
>
|
||
<span className="dsh-wt_consoleAddPlus" aria-hidden>+</span>
|
||
<span className="dsh-wt_consoleAddLabel">{T('console.addProject')}</span>
|
||
</div>
|
||
{cards.length === 0 && <div className="dsh-wt_consoleEmpty">{T('console.empty')}</div>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="dsh-wt_consoleDockWrap">
|
||
<div className="dsh-wt_consoleDock">
|
||
<button type="button" className={'dsh-wt_dockBtn' + (openMenu === 'theme' ? ' dsh-wt_dockBtnOn' : '')} title={T('console.themeLabel')} aria-label={T('console.themeLabel')} onClick={() => setOpenMenu(openMenu === 'theme' ? null : 'theme')}><ThemeIcon mode={themeMode} /></button>
|
||
<button type="button" className={'dsh-wt_dockBtn' + (openMenu === 'shape' ? ' dsh-wt_dockBtnOn' : '')} title={T('console.shapeLabel')} aria-label={T('console.shapeLabel')} onClick={() => setOpenMenu(openMenu === 'shape' ? null : 'shape')}><svg width="20" height="20" viewBox="0 0 16 16" aria-hidden><rect x="2.2" y="2.2" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="0.9" /><circle cx="10.6" cy="10.6" r="3.8" fill="none" stroke="currentColor" strokeWidth="0.9" /></svg></button>
|
||
<button type="button" className={'dsh-wt_dockBtn' + (openMenu === 'bg' ? ' dsh-wt_dockBtnOn' : '')} title={T('console.bgLabel')} aria-label={T('console.bgLabel')} onClick={() => { setOpenMenu(openMenu === 'bg' ? null : 'bg'); setBgEdit(null) }}><svg width="18" height="18" viewBox="0 0 16 16" aria-hidden><rect x="2.2" y="3.2" width="11.6" height="9.6" rx="1.6" fill="none" stroke="currentColor" strokeWidth="0.9" /><circle cx="5.9" cy="6.7" r="1.05" fill="none" stroke="currentColor" strokeWidth="0.8" /><path d="M3.4 11.4l3-3 2.3 2.3 1.9-1.9 3 2.6" fill="none" stroke="currentColor" strokeWidth="0.9" /></svg></button>
|
||
<button type="button" className={'dsh-wt_dockBtn' + (openMenu === 'cols' ? ' dsh-wt_dockBtnOn' : '')} title={T('console.colsLabel')} aria-label={T('console.colsLabel')} onClick={() => setOpenMenu(openMenu === 'cols' ? null : 'cols')}><svg width="20" height="20" viewBox="0 0 16 16" aria-hidden><rect x="2.2" y="3.4" width="2.7" height="9.2" rx="0.9" fill="none" stroke="currentColor" strokeWidth="0.9" /><rect x="6.65" y="3.4" width="2.7" height="9.2" rx="0.9" fill="none" stroke="currentColor" strokeWidth="0.9" /><rect x="11.1" y="3.4" width="2.7" height="9.2" rx="0.9" fill="none" stroke="currentColor" strokeWidth="0.9" /></svg></button>
|
||
<button type="button" className={'dsh-wt_dockBtn' + (annOpen ? ' dsh-wt_dockBtnOn' : '')} title={T('annot.updateTitle')} aria-label={T('annot.updateTitle')} onClick={() => { setOpenMenu(null); setAnnOpen((v) => !v) }}><svg width="18" height="18" viewBox="0 0 16 16" aria-hidden><path d="M2.6 7c0-2.9 2.4-4.9 5.4-4.9s5.4 2 5.4 4.9v2.9l1.2 1.7H1.4l1.2-1.7z" fill="none" stroke="currentColor" strokeWidth="0.9" strokeLinejoin="round" /><path d="M6.2 12.9c.3.7 1 1.2 1.8 1.2s1.5-.5 1.8-1.2" fill="none" stroke="currentColor" strokeWidth="0.9" strokeLinecap="round" /></svg>{updInfo && <span className="dsh-wt_dockBadge" aria-hidden />}</button>
|
||
</div>
|
||
{openMenu === 'theme' && (
|
||
<div className="dsh-wt_drop">
|
||
{themeOpts.map((o) => (
|
||
<button key={o.mode} type="button" className={'dsh-wt_dropItem' + (themeMode === o.mode ? ' dsh-wt_dropItemOn' : '')} onClick={() => setTheme(o.mode)}><ThemeIcon mode={o.mode} size={13} />{T(o.key)}</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{openMenu === 'shape' && (
|
||
<div className="dsh-wt_drop">
|
||
<button type="button" className={'dsh-wt_dropItem' + (shape === 'square' ? ' dsh-wt_dropItemOn' : '')} onClick={() => onShape('square')}><svg width="13" height="13" viewBox="0 0 16 16" aria-hidden><rect x="4" y="4" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.5" /></svg>{T('console.shapeSquare')}</button>
|
||
<button type="button" className={'dsh-wt_dropItem' + (shape === 'circle' ? ' dsh-wt_dropItemOn' : '')} onClick={() => onShape('circle')}><svg width="13" height="13" viewBox="0 0 16 16" aria-hidden><circle cx="8" cy="8" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.5" /></svg>{T('console.shapeCircle')}</button>
|
||
</div>
|
||
)}
|
||
{openMenu === 'bg' && (
|
||
bgEdit === null ? (
|
||
<div className="dsh-wt_drop">
|
||
<div className="dsh-wt_dropRow">
|
||
<button type="button" className={'dsh-wt_dropItem' + (bg === 'plain' ? ' dsh-wt_dropItemOn' : '')} onClick={() => onBg('plain')}><svg width="13" height="13" viewBox="0 0 16 16" aria-hidden><rect x="3" y="3" width="10" height="10" rx="2" fill="none" stroke="currentColor" strokeWidth="1.2" /></svg>{T('console.bgPlain')}</button>
|
||
<button type="button" className="dsh-wt_dropGear" title={T('console.bgEdit')} aria-label={T('console.bgEdit')} onClick={() => setBgEdit('plain')}><SliderIcon /></button>
|
||
</div>
|
||
<div className="dsh-wt_dropRow">
|
||
<button type="button" className={'dsh-wt_dropItem' + (bg === 'glow' ? ' dsh-wt_dropItemOn' : '')} onClick={() => onBg('glow')}><svg width="13" height="13" viewBox="0 0 16 16" aria-hidden><path d="M2.4 5.6c2-1.5 3.9-1.5 5.6 0 1.9 1.6 3.6 1.6 5.6 0M2.4 10.4c2-1.5 3.9-1.5 5.6 0 1.9 1.6 3.6 1.6 5.6 0" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" /></svg>{T('console.bgGlow')}</button>
|
||
<button type="button" className="dsh-wt_dropGear" title={T('console.bgEdit')} aria-label={T('console.bgEdit')} onClick={() => setBgEdit('glow')}><SliderIcon /></button>
|
||
</div>
|
||
<div className="dsh-wt_dropRow">
|
||
<button type="button" className={'dsh-wt_dropItem' + (bg === 'photo' ? ' dsh-wt_dropItemOn' : '')} onClick={() => onBg('photo')}><svg width="13" height="13" viewBox="0 0 16 16" aria-hidden><rect x="2.5" y="3.5" width="11" height="9" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.2" /><circle cx="5.8" cy="6.6" r="1.1" fill="currentColor" /><path d="M3.2 11.2l2.8-2.8 2.2 2.2 1.8-1.8 2.8 2.4" fill="none" stroke="currentColor" strokeWidth="1.2" /></svg>{T('console.bgCustom')}</button>
|
||
<button type="button" className="dsh-wt_dropGear" title={T('console.bgEdit')} aria-label={T('console.bgEdit')} onClick={() => setBgEdit('photo')}><SliderIcon /></button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className={'dsh-wt_drop' + (bgEdit === 'photo' ? ' dsh-wt_dropWide' : '')}>
|
||
<div className="dsh-wt_hslHead">
|
||
<button type="button" className="dsh-wt_hslBack" title={T('console.bgEditBack')} aria-label={T('console.bgEditBack')} onClick={() => setBgEdit(null)}><svg width="12" height="12" viewBox="0 0 16 16" aria-hidden><path d="M10.2 3.2 5.4 8l4.8 4.8" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg></button>
|
||
<span className="dsh-wt_hslTitle">{bgEdit === 'plain' ? T('console.bgEditPlain') : bgEdit === 'glow' ? T('console.bgEditGlow') : T('console.bgEditPhoto')}</span>
|
||
</div>
|
||
{bgEdit === 'photo' && (
|
||
<>
|
||
<div className="dsh-wt_dropRow">
|
||
<button type="button" className="dsh-wt_dropItem" onClick={pickPhotoFile}><svg width="13" height="13" viewBox="0 0 16 16" aria-hidden><rect x="2.5" y="3.5" width="11" height="9" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.2" /><circle cx="5.8" cy="6.6" r="1.1" fill="currentColor" /><path d="M3.2 11.2l2.8-2.8 2.2 2.2 1.8-1.8 2.8 2.4" fill="none" stroke="currentColor" strokeWidth="1.2" /></svg>{T('console.bgPhoto')}</button>
|
||
<div className="dsh-wt_gridHalf">
|
||
<span className="dsh-wt_gridLabel">{T('console.bgGridLabel')}</span>
|
||
<button type="button" className={'dsh-wt_switch' + (photoGrid ? ' dsh-wt_switchOn' : '')} aria-pressed={photoGrid} aria-label={T('console.bgGridLabel')} onClick={togglePhotoGrid}><span className="dsh-wt_switchKnob" /></button>
|
||
</div>
|
||
</div>
|
||
{photoList.length > 0 ? (
|
||
photoList.slice(0, 4).map((p) => (
|
||
<div key={p.id} data-rid={p.id} className={'dsh-wt_dropRow dsh-wt_photoRow' + (photoId === p.id ? ' dsh-wt_photoRowOn' : '')} onClick={() => selectPhoto(p)}>
|
||
<span className="dsh-wt_thumbWrap">
|
||
{p.kind === 'video'
|
||
? <video className="dsh-wt_photoThumb" src={p.url} muted playsInline preload="metadata" />
|
||
: <img className="dsh-wt_photoThumb" src={p.url} alt="" />}
|
||
<span className="dsh-wt_thumbBadge" aria-hidden>{p.kind === 'video'
|
||
? <svg width="8" height="8" viewBox="0 0 16 16"><path d="M4.5 3.8l8 4.2-8 4.2z" fill="currentColor" /></svg>
|
||
: <svg width="8" height="8" viewBox="0 0 16 16"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.5" /><path d="M3.4 11l2.6-2.6 2.1 2.1 1.7-1.7 2.8 2.4" fill="none" stroke="currentColor" strokeWidth="1.5" /></svg>}</span>
|
||
</span>
|
||
<span className="dsh-wt_photoName">{photoId === p.id ? T('console.bgPhotoCurrent') : T('console.bgPhotoUse')}</span>
|
||
<button type="button" className={'dsh-wt_dragHandle' + (dragRowId === p.id ? ' dsh-wt_dragHandleOn' : '')} title={T('console.bgMediaDrag')} aria-label={T('console.bgMediaDrag')} onPointerDown={(e) => onHandleDown(p.id, e)}><svg width="10" height="12" viewBox="0 0 10 12" aria-hidden><circle cx="2.5" cy="2" r="1.1" fill="currentColor" /><circle cx="7.5" cy="2" r="1.1" fill="currentColor" /><circle cx="2.5" cy="6" r="1.1" fill="currentColor" /><circle cx="7.5" cy="6" r="1.1" fill="currentColor" /><circle cx="2.5" cy="10" r="1.1" fill="currentColor" /><circle cx="7.5" cy="10" r="1.1" fill="currentColor" /></svg></button>
|
||
<button type="button" className="dsh-wt_dropTrash" title={T('console.bgPhotoDelete')} aria-label={T('console.bgPhotoDelete')} onClick={(e) => { e.stopPropagation(); removePhotoById(p) }}><svg width="11" height="11" viewBox="0 0 16 16" aria-hidden><path d="M2.5 4.2h11M6.5 4.2V2.9c0-.6.4-1 .9-1h1.2c.5 0 .9.4.9 1v1.3M4.2 4.2l.5 8.1c0 .7.5 1.2 1.2 1.2h4.2c.7 0 1.2-.5 1.2-1.2l.5-8.1" fill="none" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" /></svg></button>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="dsh-wt_photoEmpty">{T('console.bgPhotoNone')}</div>
|
||
)}
|
||
<div className="dsh-wt_hslDivider" />
|
||
</>
|
||
)}
|
||
{(() => {
|
||
const v = bgEdit === 'plain' ? plainHsl : bgEdit === 'glow' ? glowHsl : photoHsl
|
||
const sMax = bgEdit === 'plain' ? 100 : 200
|
||
const lMax = bgEdit === 'plain' ? 100 : 200
|
||
return (<>
|
||
{bgEdit === 'glow' && (
|
||
<div className="dsh-wt_hslRow" data-tip={T('console.bgTipSpeed')}><span className="dsh-wt_hslLabel">S</span><input className="dsh-wt_hslSlider" type="range" min={0} max={100} step={5} value={glowSpeed} onChange={(e) => onGlowSpeed(Number(e.target.value))} /><HslValInput value={glowSpeed} min={0} max={100} onCommit={onGlowSpeed} /></div>
|
||
)}
|
||
<div className="dsh-wt_hslRow" data-tip={T('console.bgTipB')}><span className="dsh-wt_hslLabel">B</span><input className="dsh-wt_hslSlider" type="range" min={0} max={20} step={1} value={bgEdit === 'plain' ? plainBlur : bgEdit === 'glow' ? glowBlur : cardBlur} onChange={(e) => onModeBlur(bgEdit, Number(e.target.value))} /><HslValInput value={bgEdit === 'plain' ? plainBlur : bgEdit === 'glow' ? glowBlur : cardBlur} min={0} max={20} onCommit={(n) => onModeBlur(bgEdit, n)} /></div>
|
||
<div className="dsh-wt_hslRow" data-tip={T('console.bgTipT')}><span className="dsh-wt_hslLabel">T</span><input className="dsh-wt_hslSlider" type="range" min={0} max={30} step={1} value={bgEdit === 'plain' ? plainGrid : bgEdit === 'glow' ? glowGrid : gridOpacity} onChange={(e) => bgEdit === 'photo' ? onGridOpacity(Number(e.target.value)) : onModeGrid(bgEdit, Number(e.target.value))} /><HslValInput value={bgEdit === 'plain' ? plainGrid : bgEdit === 'glow' ? glowGrid : gridOpacity} min={0} max={30} onCommit={(n) => bgEdit === 'photo' ? onGridOpacity(n) : onModeGrid(bgEdit, n)} /></div>
|
||
<div className="dsh-wt_hslRow" data-tip={T('console.bgTipH')}><span className="dsh-wt_hslLabel">H</span><input className="dsh-wt_hslSlider" type="range" min={-180} max={180} step={1} value={v.h} onChange={(e) => editHsl(bgEdit, { h: Number(e.target.value) })} /><HslValInput value={v.h} min={-180} max={180} onCommit={(n) => editHsl(bgEdit, { h: n })} /></div>
|
||
<div className="dsh-wt_hslRow" data-tip={T('console.bgTipS')}><span className="dsh-wt_hslLabel">S</span><input className="dsh-wt_hslSlider" type="range" min={0} max={sMax} step={1} value={v.s} onChange={(e) => editHsl(bgEdit, { s: Number(e.target.value) })} /><HslValInput value={v.s} max={sMax} onCommit={(n) => editHsl(bgEdit, { s: n })} /></div>
|
||
<div className="dsh-wt_hslRow" data-tip={T('console.bgTipL')}><span className="dsh-wt_hslLabel">L</span><input className="dsh-wt_hslSlider" type="range" min={0} max={lMax} step={1} value={v.l} onChange={(e) => editHsl(bgEdit, { l: Number(e.target.value) })} /><HslValInput value={v.l} max={lMax} onCommit={(n) => editHsl(bgEdit, { l: n })} /></div>
|
||
<button type="button" className="dsh-wt_hslReset" onClick={() => resetHsl(bgEdit)}>{T('console.bgEditReset')}</button>
|
||
</>)
|
||
})()}
|
||
</div>
|
||
)
|
||
)}
|
||
{openMenu === 'cols' && (
|
||
<div className="dsh-wt_drop">
|
||
<div className="dsh-wt_hbar">
|
||
<span className="dsh-wt_hbarFill" style={{ width: (8 + (cols - 1) * 21) + '%' }} />
|
||
{[1, 2, 3, 4, 5].map((v) => (
|
||
<button key={v} type="button" className="dsh-wt_hbarLine" style={{ left: (8 + (v - 1) * 21) + '%' }} aria-label={String(v)} title={String(v)} onClick={() => onCols(v)}></button>
|
||
))}
|
||
<span className="dsh-wt_hbarKnob" style={{ left: (8 + (cols - 1) * 21) + '%' }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 文件夹图标(重绘 SVG,与 better-sidebar 同款风格) */
|
||
function FolderIcon() {
|
||
return (
|
||
<svg className="dsh-wt_treeIcon" width="13" height="13" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||
<path d="M1.75 3.25A1.75 1.75 0 0 1 3.5 1.5h2.63a1.75 1.75 0 0 1 1.34.66l.62.79a1.75 1.75 0 0 0 1.34.66H12.5a1.75 1.75 0 0 1 1.75 1.75v7.39A1.75 1.75 0 0 1 12.5 14.5h-9a1.75 1.75 0 0 1-1.75-1.75V3.25Z" fill="var(--dsw-alias-state-accent-primary,#4f8ef7)" opacity="0.9" />
|
||
<path d="M1.75 5.75h12.5v7a1.75 1.75 0 0 1-1.75 1.75h-9a1.75 1.75 0 0 1-1.75-1.75v-7Z" fill="var(--dsw-alias-state-accent-primary,#4f8ef7)" opacity="0.4" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
/** 文件图标(重绘 SVG) */
|
||
function FileIcon() {
|
||
return (
|
||
<svg className="dsh-wt_treeIcon" width="13" height="13" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||
<path d="M4 1.5h5.25a1 1 0 0 1 .71.29l3.25 3.25a1 1 0 0 1 .29.71V13.5a1 1 0 0 1-1 1h-8.5a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1Z" fill="var(--dsw-alias-fill-l1,rgba(255,255,255,.06))" stroke="var(--dsw-alias-label-secondary,#9aa4b2)" strokeWidth="1.1" />
|
||
<path d="M9.25 1.5V4.75h3.25" fill="none" stroke="var(--dsw-alias-label-secondary,#9aa4b2)" strokeWidth="1.1" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
/** 资源管理器窗:树形展开(懒加载子目录;刷新/上一级均可用;.html 点击开浏览器标签) */
|
||
function ExplorerPane(props: { row: PaneRow; index: number }) {
|
||
const cacheRef = useRef<Record<string, any[]>>({})
|
||
const expandedRef = useRef<Set<string>>(new Set())
|
||
const [rootPath, setRootPath] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [, setTick] = useState(0)
|
||
const rerender = () => setTick((t) => t + 1)
|
||
|
||
const fetchDir = useCallback(async (path: string, force = false) => {
|
||
if (!force && cacheRef.current[path]) return { path, entries: cacheRef.current[path] }
|
||
try {
|
||
const d = await postJson('/api/worktable/fs', {
|
||
path,
|
||
sessionId: splitEnv?.getScope()?.sessionId ?? '',
|
||
cwd: splitEnv?.getScope()?.cwd ?? '',
|
||
})
|
||
const entries: any[] = d.entries ?? []
|
||
cacheRef.current[d.path] = entries
|
||
setError(d.error ? String(d.error) : '')
|
||
return { path: d.path, entries }
|
||
} catch (e) {
|
||
setError(String(e))
|
||
return { path, entries: [] }
|
||
} finally {
|
||
rerender()
|
||
}
|
||
}, [])
|
||
|
||
const initRoot = useCallback(async () => {
|
||
const r = await fetchDir(splitEnv?.getScope()?.cwd ?? '')
|
||
setRootPath(r.path)
|
||
rerender()
|
||
}, [fetchDir])
|
||
|
||
useEffect(() => { initRoot() }, [initRoot])
|
||
|
||
const toggle = (path: string) => {
|
||
if (expandedRef.current.has(path)) expandedRef.current.delete(path)
|
||
else { expandedRef.current.add(path); fetchDir(path) }
|
||
rerender()
|
||
}
|
||
|
||
const refresh = () => {
|
||
cacheRef.current = {}
|
||
expandedRef.current.clear()
|
||
setError('')
|
||
initRoot()
|
||
}
|
||
|
||
const goUp = () => {
|
||
if (!rootPath) return
|
||
const parent = parentPathOf(rootPath)
|
||
if (parent === rootPath) return
|
||
cacheRef.current = {}
|
||
expandedRef.current.clear()
|
||
setError('')
|
||
fetchDir(parent).then((r) => { setRootPath(r.path); rerender() })
|
||
}
|
||
|
||
const renderLevel = (path: string, depth: number): any[] => {
|
||
const entries = cacheRef.current[path]
|
||
if (!entries) return []
|
||
const nodes: any[] = []
|
||
for (const e of entries) {
|
||
const isOpen = expandedRef.current.has(e.path)
|
||
nodes.push(
|
||
<div key={e.path}>
|
||
<button
|
||
type="button"
|
||
className="dsh-wt_treeRow"
|
||
style={{ paddingLeft: 8 + depth * 14 }}
|
||
onClick={() => {
|
||
if (e.isDir) { toggle(e.path); return }
|
||
if (/\.html?$/i.test(e.name)) {
|
||
// 目录级静态托管:相对引用(./assets/...)在所在目录下解析,页面可完整渲染
|
||
const dir = parentPathOf(e.path)
|
||
splitStore.openTab(props.row, props.index, {
|
||
kind: 'iframe',
|
||
url: '/api/worktable/site/' + encodeURIComponent(dir) + '/' + encodeURIComponent(e.name),
|
||
title: e.name,
|
||
})
|
||
} else if (/\.(md|markdown|mdown|txt|log|tsx|ts|jsx|js|css|json|pdf|png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(e.name)) {
|
||
splitStore.openTab(props.row, props.index, { kind: 'file', path: e.path })
|
||
} else {
|
||
setError(T('pane.openLater'))
|
||
}
|
||
}}
|
||
>
|
||
<span className={'dsh-wt_treeArrow' + (e.isDir && isOpen ? ' dsh-wt_treeArrowOpen' : '')} aria-hidden>{e.isDir ? '▸' : ''}</span>
|
||
{e.isDir ? <FolderIcon /> : <FileIcon />}
|
||
<span className="dsh-wt_treeName">{e.name}</span>
|
||
</button>
|
||
{e.isDir && isOpen && renderLevel(e.path, depth + 1)}
|
||
</div>,
|
||
)
|
||
}
|
||
return nodes
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="dsh-wt_subBar">
|
||
<button type="button" className="dsh-wt_subBtn" title="上一级" onClick={goUp}>⬆</button>
|
||
<button type="button" className="dsh-wt_subBtn" title="刷新" onClick={refresh}>↻</button>
|
||
<span className="dsh-wt_subPath">{rootPath || '…'}</span>
|
||
</div>
|
||
<div className="dsh-wt_subList">
|
||
{error && <div className="dsh-wt_subEmpty">{error}</div>}
|
||
{!error && cacheRef.current[rootPath]?.length === 0 && <div className="dsh-wt_subEmpty">—</div>}
|
||
{renderLevel(rootPath, 0)}
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 源代码管理窗(服务端 /api/worktable/git) */
|
||
function GitPane() {
|
||
const [snap, setSnap] = useState<{ isRepo: boolean; branch?: string; entries: any[] } | null>(null)
|
||
const [error, setError] = useState('')
|
||
const load = useCallback(() => {
|
||
postJson('/api/worktable/git', {
|
||
sessionId: splitEnv?.getScope()?.sessionId ?? '',
|
||
cwd: splitEnv?.getScope()?.cwd ?? '',
|
||
})
|
||
.then(setSnap)
|
||
.catch((e) => setError(String(e)))
|
||
}, [])
|
||
useEffect(() => { load() }, [load])
|
||
return (
|
||
<>
|
||
<div className="dsh-wt_subBar">
|
||
<button type="button" className="dsh-wt_subBtn" title="刷新" onClick={load}>↻</button>
|
||
<span className="dsh-wt_subPath">{snap?.isRepo ? ('⎇ ' + snap.branch) : ''}</span>
|
||
</div>
|
||
<div className="dsh-wt_subList">
|
||
{error && <div className="dsh-wt_subEmpty">{error}</div>}
|
||
{!error && snap && !snap.isRepo && <div className="dsh-wt_subEmpty">{T('pane.gitNotRepo')}</div>}
|
||
{!error && snap?.isRepo && snap.entries.length === 0 && <div className="dsh-wt_subEmpty">{T('pane.gitClean')}</div>}
|
||
{!error && snap?.isRepo && snap.entries.map((e, i) => (
|
||
<div key={i} className="dsh-wt_subRow dsh-wt_subRowStatic">
|
||
<span className={'dsh-wt_gitXY dsh-wt_gitXY' + (e.xy.includes('A') || e.xy.includes('M') ? 'Mod' : 'New')}>{e.xy.trim()}</span>
|
||
<span className="dsh-wt_subName">{e.path}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 任务管理窗:后台任务 + 子代理(Agent 情况;2s 刷新) */
|
||
function JobsPane() {
|
||
const [, setTick] = useState(0)
|
||
useEffect(() => {
|
||
const timer = window.setInterval(() => setTick((t) => t + 1), 2000)
|
||
return () => window.clearInterval(timer)
|
||
}, [])
|
||
const jobs = splitEnv?.getJobs?.() ?? []
|
||
const subagents = splitEnv?.getSubagents?.() ?? []
|
||
return (
|
||
<div className="dsh-wt_subList">
|
||
<div className="dsh-wt_subSection">{T('pane.jobsTitle')}</div>
|
||
{jobs.length === 0 && <div className="dsh-wt_subEmpty">{T('pane.jobsEmpty')}</div>}
|
||
{jobs.map((j) => (
|
||
<div key={j.id} className="dsh-wt_subRow dsh-wt_subRowStatic">
|
||
<span className={'dsh-wt_jobDot dsh-wt_jobDot-' + j.status} aria-hidden>●</span>
|
||
<span className="dsh-wt_subName">{j.label}</span>
|
||
<span className="dsh-wt_subTag">{j.kind}</span>
|
||
</div>
|
||
))}
|
||
<div className="dsh-wt_subSection">{T('pane.subagents')}</div>
|
||
{subagents.length === 0 && <div className="dsh-wt_subEmpty">{T('pane.subagentsEmpty')}</div>}
|
||
{subagents.map((s: any, i: number) => (
|
||
<div key={s?.id ?? i} className="dsh-wt_subRow dsh-wt_subRowStatic" style={{ paddingLeft: 8 + (s?.depth ?? 0) * 12 }}>
|
||
<span className={'dsh-wt_jobDot dsh-wt_jobDot-' + (s?.status ?? 'stopping')} aria-hidden>●</span>
|
||
<span className="dsh-wt_subName">{s?.label ?? s?.title ?? s?.name ?? '—'}</span>
|
||
{s?.status && <span className="dsh-wt_subTag">{s.status}</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 终端窗(WS /api/worktable/term + node-pty) */
|
||
function TerminalPane() {
|
||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||
const [failed, setFailed] = useState('')
|
||
useEffect(() => {
|
||
const el = hostRef.current
|
||
if (!el) return
|
||
let term: any = null
|
||
let ws: WebSocket | null = null
|
||
let disposed = false
|
||
try {
|
||
term = new Terminal({
|
||
cursorBlink: true,
|
||
fontFamily: 'Cascadia Code, Cascadia Mono, Consolas, Menlo, monospace',
|
||
fontSize: 12,
|
||
convertEol: true,
|
||
theme: { background: '#010409' },
|
||
})
|
||
} catch {
|
||
setFailed(T('pane.termFail'))
|
||
return
|
||
}
|
||
term.open(el)
|
||
// 强制自动换行(DECAWM on):超长行在窗口宽度处换行,不被截断
|
||
try { term.write('\x1b[?7h') } catch {}
|
||
const focusTerm = () => { try { term.focus() } catch {} }
|
||
focusTerm()
|
||
el.addEventListener('pointerdown', focusTerm)
|
||
const scope = splitEnv?.getScope?.()
|
||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||
const url = proto + '//' + location.host + '/api/worktable/term?sessionId=' + encodeURIComponent(scope?.sessionId ?? '') + '&cwd=' + encodeURIComponent(scope?.cwd ?? '') + '&cols=80&rows=24'
|
||
try {
|
||
ws = new WebSocket(url)
|
||
} catch {
|
||
term.dispose()
|
||
setFailed(T('pane.termFail'))
|
||
return
|
||
}
|
||
ws.onopen = () => { focusTerm(); try { term.write('\x1b[?7h') } catch {} }
|
||
ws.onmessage = (ev) => { try { term.write(String(ev.data)) } catch {} }
|
||
ws.onclose = () => { if (!disposed) { try { term.write('\r\n[连接已关闭]') } catch {} } }
|
||
ws.onerror = () => { if (!disposed) setFailed(T('pane.termFail')) }
|
||
term.onData((d: string) => { if (ws && ws.readyState === 1) ws.send(d) })
|
||
const ro = new ResizeObserver(() => {
|
||
if (typeof term.fit === 'function') {
|
||
try { term.fit() } catch {}
|
||
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||
}
|
||
})
|
||
ro.observe(el)
|
||
return () => {
|
||
disposed = true
|
||
ro.disconnect()
|
||
try { ws?.close() } catch {}
|
||
try { term.dispose() } catch {}
|
||
}
|
||
}, [])
|
||
if (failed) {
|
||
return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{failed}</span></div>
|
||
}
|
||
return <div ref={hostRef} className="dsh-wt_termHost" />
|
||
}
|
||
|
||
const mdRenderer = new MarkdownIt({ linkify: true })
|
||
|
||
const IMAGE_EXTS = /[.](png|jpe?g|gif|webp|svg|bmp|ico)$/i
|
||
const MD_EXTS = /[.](md|markdown|mdown)$/i
|
||
|
||
/** 本地文件预览:PDF 走原生 iframe(Chrome 内置阅读器)、图片居中、MD 渲染、其余纯文本 */
|
||
function FileViewer(props: { path: string }) {
|
||
const ext = (props.path.split('.').pop() || '').toLowerCase()
|
||
const fileUrl = '/api/worktable/file?path=' + encodeURIComponent(props.path)
|
||
if (ext === 'pdf') {
|
||
return <iframe className="dsh-wt_paneFrame" src={fileUrl} title={basenameOf(props.path)} />
|
||
}
|
||
if (IMAGE_EXTS.test('.' + ext)) {
|
||
return (
|
||
<div className="dsh-wt_imgView">
|
||
<img src={fileUrl} alt={basenameOf(props.path)} />
|
||
</div>
|
||
)
|
||
}
|
||
return <TextViewer path={props.path} fileUrl={fileUrl} isMd={MD_EXTS.test('.' + ext)} />
|
||
}
|
||
|
||
/** 代码文件语言映射(预览语法着色) */
|
||
const CODE_LANGS: Record<string, string> = { ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', css: 'css', json: 'json' }
|
||
const CODE_EXTS = /[.](tsx|ts|jsx|js|css|json)$/i
|
||
|
||
function escapeHtml(s: string): string {
|
||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
}
|
||
|
||
function codeHtml(text: string, ext: string): string {
|
||
const lang = CODE_LANGS[ext] ?? ''
|
||
if (lang) {
|
||
try { return hljs.highlight(text, { language: lang }).value } catch { return escapeHtml(text) }
|
||
}
|
||
return escapeHtml(text)
|
||
}
|
||
|
||
/** 文本预览(fetch 原文 → MD 渲染 / 代码高亮 / <pre> 等宽展示);全部文本类型支持编辑/预览切换并可保存回磁盘 */
|
||
function TextViewer(props: { path: string; fileUrl: string; isMd: boolean }) {
|
||
const [text, setText] = useState<string | null>(null)
|
||
const [error, setError] = useState('')
|
||
const [mode, setMode] = useState<'preview' | 'edit'>('preview')
|
||
const [draft, setDraft] = useState('')
|
||
const [saving, setSaving] = useState(false)
|
||
const [saveFail, setSaveFail] = useState(false)
|
||
useEffect(() => {
|
||
let dead = false
|
||
setText(null)
|
||
setError('')
|
||
fetch(props.fileUrl)
|
||
.then((r) => {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status)
|
||
return r.text()
|
||
})
|
||
.then((t) => { if (!dead) setText(t) })
|
||
.catch((e) => { if (!dead) setError(String(e)) })
|
||
return () => { dead = true }
|
||
}, [props.fileUrl])
|
||
const enterEdit = () => { setDraft(text ?? ''); setSaveFail(false); setMode('edit') }
|
||
const save = async () => {
|
||
setSaving(true)
|
||
setSaveFail(false)
|
||
try {
|
||
const r = await fetch('/api/worktable/write', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ path: props.path, content: draft }),
|
||
})
|
||
if (!r.ok) throw new Error('HTTP ' + r.status)
|
||
setText(draft)
|
||
setMode('preview')
|
||
} catch {
|
||
setSaveFail(true)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
if (error) {
|
||
return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{T('file.fail')}:{error}</span></div>
|
||
}
|
||
if (text == null) {
|
||
return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{T('file.loading')}</span></div>
|
||
}
|
||
const ext = (props.path.split('.').pop() || '').toLowerCase()
|
||
const isCode = CODE_EXTS.test('.' + ext)
|
||
return (
|
||
<>
|
||
<div className="dsh-wt_mdBar">
|
||
<button type="button" className={'dsh-wt_mdBtn' + (mode === 'preview' ? ' dsh-wt_mdBtnOn' : '')} onClick={() => setMode('preview')}>{T('file.preview')}</button>
|
||
<button type="button" className={'dsh-wt_mdBtn' + (mode === 'edit' ? ' dsh-wt_mdBtnOn' : '')} onClick={enterEdit}>{T('file.edit')}</button>
|
||
{mode === 'edit' && (
|
||
<button type="button" className="dsh-wt_mdSave" disabled={saving} onClick={save}>{saving ? '…' : T('file.save')}</button>
|
||
)}
|
||
{saveFail && <span className="dsh-wt_mdMsg">{T('file.saveFail')}</span>}
|
||
</div>
|
||
{mode === 'edit'
|
||
? <textarea className="dsh-wt_mdEdit" value={draft} spellCheck={false} onChange={(e) => setDraft(e.target.value)} />
|
||
: props.isMd
|
||
? (
|
||
<div className="dsh-wt_fileView">
|
||
<div
|
||
className="dsh-wt_md"
|
||
dangerouslySetInnerHTML={{ __html: mdRenderer.render(text) }}
|
||
onClick={(e: any) => {
|
||
const a = e.target && e.target.closest ? (e.target.closest('a') as HTMLAnchorElement | null) : null
|
||
if (!a) return
|
||
e.preventDefault()
|
||
const href = a.getAttribute('href') || ''
|
||
if (/^(https?:|mailto:)/i.test(href)) window.open(href, '_blank', 'noopener')
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
: isCode
|
||
? (
|
||
<div className="dsh-wt_fileView">
|
||
<pre className="dsh-wt_code"><code dangerouslySetInnerHTML={{ __html: codeHtml(text, ext) }} /></pre>
|
||
</div>
|
||
)
|
||
: <div className="dsh-wt_fileView"><pre className="dsh-wt_txt">{text}</pre></div>}
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 自制下拉列表(原生 select 无法美化):文件夹分组标题 + 1px 细分隔线 + 选项列表 */
|
||
function SelectPop(props: {
|
||
value: string | null
|
||
groups: { title: string; items: { id: string; label: string; isCurrent?: boolean }[] }[]
|
||
placeholder?: string
|
||
onChange: (id: string) => void
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const flat = props.groups.flatMap((g) => g.items)
|
||
const selected = flat.find((i) => i.id === props.value)
|
||
return (
|
||
<div className="dsh-wt_select">
|
||
<button type="button" className="dsh-wt_selectBtn" onClick={() => setOpen((v) => !v)}>
|
||
<span className={'dsh-wt_selectVal' + (selected ? '' : ' dsh-wt_selectPh')}>{selected?.label ?? props.placeholder ?? ''}</span>
|
||
<span className="dsh-wt_selectCaret" aria-hidden>▾</span>
|
||
</button>
|
||
{open && (
|
||
<div className="dsh-wt_selectList">
|
||
{props.groups.map((g, gi) => (
|
||
<Fragment key={g.title || 'g' + gi}>
|
||
{g.title && (
|
||
<>
|
||
<div className="dsh-wt_selectDivider" />
|
||
<div className="dsh-wt_selectGroup">📁 {g.title}</div>
|
||
</>
|
||
)}
|
||
{g.items.map((it) => (
|
||
<button
|
||
key={it.id}
|
||
type="button"
|
||
className={'dsh-wt_selectItem' + (it.id === props.value ? ' dsh-wt_selectItemOn' : '')}
|
||
onClick={() => { props.onChange(it.id); setOpen(false) }}
|
||
>
|
||
<span className="dsh-wt_selectItemTitle">{it.label}</span>
|
||
{it.isCurrent && <span className="dsh-wt_selectCurrent">{T('custom.sessionCurrent')}</span>}
|
||
</button>
|
||
))}
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 自定义窗口:居中对话框。两种模式:新建专属会话 / 发送到已有会话(默认当前会话)。 */
|
||
function CustomPane(props: { paneTitle?: string }) {
|
||
const paneTitle = props.paneTitle ?? ''
|
||
try { (window as any).__dshLastCustomPaneTitle = paneTitle } catch {}
|
||
const custom = splitEnv?.custom
|
||
const [requirement, setRequirement] = useState('')
|
||
const [projectId, setProjectId] = useState<string | null>(null)
|
||
const [projects, setProjects] = useState<{ id: string; name: string }[]>(() => custom?.getProjects?.() ?? [])
|
||
const [mode, setMode] = useState<'new' | 'existing'>('existing')
|
||
const [sessionGroups, setSessionGroups] = useState<{ title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]>([])
|
||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||
// 分组(宿主工作区)三态:未分组 / 现有组 / 新建组
|
||
const [wsGroups, setWsGroups] = useState<{ id: string; title: string; path: string }[]>([])
|
||
const [groupMode, setGroupMode] = useState<'none' | 'existing' | 'new'>('none')
|
||
const [groupId, setGroupId] = useState<string | null>(null)
|
||
const [newGroupParent, setNewGroupParent] = useState('')
|
||
const [newGroupName, setNewGroupName] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [done, setDone] = useState(false)
|
||
const [fail, setFail] = useState('')
|
||
const [bindNote, setBindNote] = useState<'auto' | 'kept' | 'none'>('none')
|
||
// 默认项目 = 当前工作区所属项目;默认会话 = 当前会话;默认分组 = 当前会话所在工作区
|
||
useEffect(() => {
|
||
const list = custom?.getProjects?.() ?? []
|
||
setProjects(list)
|
||
const cur = custom?.currentProjectId?.() ?? null
|
||
setProjectId(cur && list.some((p) => p.id === cur) ? cur : (list[0]?.id ?? null))
|
||
setWsGroups((custom?.getWorkspaces?.() ?? []).map((w) => ({ id: w.id, title: w.title, path: w.path })))
|
||
custom?.getSessions?.().then((res) => {
|
||
setSessionGroups(res.groups)
|
||
const flat = res.groups.flatMap((g) => g.sessions)
|
||
setSessionId(flat.find((s) => s.isCurrent)?.id ?? flat[0]?.id ?? null)
|
||
const curId = flat.find((s) => s.isCurrent)?.id
|
||
const home = curId ? (custom?.getWorkspaces?.() ?? []).find((w) => (w.sessionIds ?? []).includes(curId)) : null
|
||
if (home) { setGroupMode('existing'); setGroupId(home.id) }
|
||
}).catch(() => { setSessionGroups([]) })
|
||
}, [custom])
|
||
const submit = async () => {
|
||
const text = requirement.trim()
|
||
if (!text || !projectId || !custom) return
|
||
setBusy(true)
|
||
setFail('')
|
||
try {
|
||
const proj = projects.find((p) => p.id === projectId)
|
||
const pname = proj?.name ?? projectId
|
||
if (mode === 'new') {
|
||
let group: any = { kind: 'none' }
|
||
if (groupMode === 'existing' && groupId) group = { kind: 'existing', workspaceId: groupId }
|
||
else if (groupMode === 'new') {
|
||
if (!newGroupParent.trim() || !newGroupName.trim()) { setFail(T('custom.groupNeedPath')); return }
|
||
group = { kind: 'new', parent: newGroupParent.trim(), name: newGroupName.trim() }
|
||
}
|
||
const sid = await custom.submit(projectId, pname, text, group, paneTitle)
|
||
setBindNote((custom.autoBind?.(sid) ?? 'none') as any)
|
||
} else {
|
||
if (!sessionId) return
|
||
await custom.sendToSession(sessionId, projectId, pname, text, paneTitle)
|
||
setBindNote((custom.autoBind?.(sessionId) ?? 'none') as any)
|
||
}
|
||
setDone(true)
|
||
} catch (e) {
|
||
setFail(String(e))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
if (!custom) {
|
||
return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{T('pane.wip')}</span></div>
|
||
}
|
||
if (done) {
|
||
return (
|
||
<div className="dsh-wt_customBox">
|
||
<span className="dsh-wt_customDone" aria-hidden>✅</span>
|
||
<p className="dsh-wt_customDoneText">{mode === 'new' ? T('custom.done') : T('custom.sent')}</p>
|
||
<p className="dsh-wt_customDoneHint">{T('custom.doneHint')}</p>
|
||
{bindNote !== 'none' && (
|
||
<p className="dsh-wt_customDoneBind">{bindNote === 'auto' ? T('custom.autoBound') : T('custom.keptBinding')}</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="dsh-wt_customBox">
|
||
<div className="dsh-wt_customCard">
|
||
<span className="dsh-wt_customTitle">✨ {T('custom.title')}</span>
|
||
<div className="dsh-wt_customModes">
|
||
<button type="button" className={'dsh-wt_customModeBtn' + (mode === 'existing' ? ' dsh-wt_customModeBtnOn' : '')} onClick={() => setMode('existing')}>{T('custom.modeSend')}</button>
|
||
<button type="button" className={'dsh-wt_customModeBtn' + (mode === 'new' ? ' dsh-wt_customModeBtnOn' : '')} onClick={() => setMode('new')}>{T('custom.modeNew')}</button>
|
||
</div>
|
||
<p className="dsh-wt_customHint">{mode === 'new' ? T('custom.hint') : T('custom.hintSend')}</p>
|
||
<textarea
|
||
className="dsh-wt_customInput"
|
||
autoFocus
|
||
placeholder={T('custom.placeholder')}
|
||
value={requirement}
|
||
onChange={(e) => setRequirement(e.target.value)}
|
||
/>
|
||
{mode === 'existing' && (
|
||
<div className="dsh-wt_customRow">
|
||
<span className="dsh-wt_customLabel">{T('custom.session')}</span>
|
||
<SelectPop
|
||
value={sessionId}
|
||
groups={sessionGroups.map((g) => ({
|
||
title: g.title,
|
||
items: g.sessions.map((s) => ({ id: s.id, label: s.title, isCurrent: s.isCurrent })),
|
||
}))}
|
||
placeholder={T('custom.session')}
|
||
onChange={setSessionId}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="dsh-wt_customRow">
|
||
<span className="dsh-wt_customLabel">{T('custom.project')}</span>
|
||
<SelectPop
|
||
value={projectId}
|
||
groups={[{ title: '', items: projects.map((p) => ({ id: p.id, label: p.name })) }]}
|
||
placeholder={T('custom.project')}
|
||
onChange={setProjectId}
|
||
/>
|
||
</div>
|
||
{mode === 'new' && (
|
||
<div className="dsh-wt_customRow">
|
||
<span className="dsh-wt_customLabel">{T('custom.group')}</span>
|
||
<SelectPop
|
||
value={groupMode === 'none' ? '__none' : groupMode === 'new' ? '__new' : groupId}
|
||
groups={[{
|
||
title: '',
|
||
items: [
|
||
{ id: '__none', label: T('custom.groupNone') },
|
||
...wsGroups.map((w) => ({ id: w.id, label: w.title })),
|
||
{ id: '__new', label: T('custom.groupNew') },
|
||
],
|
||
}]}
|
||
placeholder={T('custom.group')}
|
||
onChange={(id) => {
|
||
if (id === '__none') setGroupMode('none')
|
||
else if (id === '__new') setGroupMode('new')
|
||
else { setGroupMode('existing'); setGroupId(id) }
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
{mode === 'new' && groupMode === 'new' && (
|
||
<>
|
||
<div className="dsh-wt_customRow">
|
||
<span className="dsh-wt_customLabel">{T('custom.groupNewParent')}</span>
|
||
<input
|
||
className="dsh-wt_customPathInput"
|
||
placeholder={T('custom.groupNewParentPh')}
|
||
value={newGroupParent}
|
||
onChange={(e) => setNewGroupParent(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="dsh-wt_customRow">
|
||
<span className="dsh-wt_customLabel">{T('custom.groupNewName')}</span>
|
||
<input
|
||
className="dsh-wt_customPathInput"
|
||
placeholder={T('custom.groupNewNamePh')}
|
||
value={newGroupName}
|
||
onChange={(e) => setNewGroupName(e.target.value)}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="dsh-wt_customSend"
|
||
disabled={busy || !requirement.trim() || !projectId || (mode === 'existing' && !sessionId) || (mode === 'new' && groupMode === 'new' && (!newGroupParent.trim() || !newGroupName.trim()))}
|
||
onClick={submit}
|
||
>{busy ? '…' : (mode === 'new' ? T('custom.send') : T('custom.sendToSession'))}</button>
|
||
{fail && <p className="dsh-wt_customFail">{T('custom.fail')}:{fail}</p>}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 单个标签页的内容渲染 */
|
||
function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number; paneTitle?: string; reloadKey: number }) {
|
||
const content = props.tab.content
|
||
if (content.kind === 'iframe') {
|
||
return <IframePane url={content.url} title={content.title ?? props.tab.title} reloadKey={props.reloadKey} />
|
||
}
|
||
if (content.kind === 'file') {
|
||
return <FileViewer path={content.path} />
|
||
}
|
||
if (content.type === 'browser') return <BrowserPane row={props.row} index={props.index} tabId={props.tab.id} content={content} reloadKey={props.reloadKey} />
|
||
if (content.type === 'anim') return <AnimPane row={props.row} index={props.index} tabId={props.tab.id} content={content} reloadKey={props.reloadKey} />
|
||
if (content.type === 'console') return <ConsolePane />
|
||
if (content.type === 'explorer') return <ExplorerPane row={props.row} index={props.index} />
|
||
if (content.type === 'scm') return <GitPane />
|
||
if (content.type === 'tasks') return <JobsPane />
|
||
if (content.type === 'terminal') return <TerminalPane />
|
||
if (content.type === 'custom') return <CustomPane paneTitle={props.paneTitle ?? ''} />
|
||
return (
|
||
<div className="dsh-wt_paneWip">
|
||
<span className="dsh-wt_paneWipIcon" aria-hidden>{BUILTIN_ICONS[content.type]}</span>
|
||
<span className="dsh-wt_paneWipText">{T('pane.wip')}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 需要标签栏刷新按钮的内容类型:网页类(iframe / 浏览器 / 动画)统一在标签最左放 ↻ */
|
||
function refreshableTab(t: PaneTab): boolean {
|
||
const c = t.content
|
||
return c.kind === 'iframe' || (c.kind === 'builtin' && (c.type === 'browser' || c.type === 'anim'))
|
||
}
|
||
|
||
/** 窗内容:标签页模型(无标签 = 6 选 1 选择器;标签可切换/关闭,关完回到选择器)
|
||
* 网页类标签最左侧固定一个 ↻ 刷新按钮(标签名之前),点击重挂载该标签内容。 */
|
||
function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
|
||
const { pane, row, index } = props
|
||
const tabs = pane.tabs ?? []
|
||
const active = Math.min(pane.active ?? 0, Math.max(0, tabs.length - 1))
|
||
const [reloadKeys, setReloadKeys] = useState<Record<string, number>>({})
|
||
if (tabs.length === 0) {
|
||
return <PanePicker row={row} index={index} />
|
||
}
|
||
const refreshTab = (t: PaneTab) => {
|
||
setReloadKeys((m) => ({ ...m, [t.id]: (m[t.id] ?? 0) + 1 }))
|
||
splitStore.setActiveTab(row, index, t.id)
|
||
}
|
||
// 唯一标签是控制室 → 整个标签栏不渲染(不可关、不可换,标签栏无信息价值;去掉顶部多余标题)
|
||
const singleConsole = tabs.length === 1 && tabs[0].content?.kind === 'builtin' && tabs[0].content.type === 'console'
|
||
return (
|
||
<>
|
||
{!singleConsole && !pane.collapsed && (
|
||
<div className="dsh-wt_tabBar">
|
||
{tabs.map((t, i) => {
|
||
// 控制室标签不可关闭:关掉后窗格变选择器,用户回不到控制室(不可逆操作)
|
||
const locked = t.content?.kind === 'builtin' && t.content.type === 'console'
|
||
return (
|
||
<span
|
||
key={t.id}
|
||
className={'dsh-wt_tab' + (i === active ? ' dsh-wt_tabOn' : '')}
|
||
title={t.title}
|
||
draggable={!locked}
|
||
onDragStart={(e: any) => { dragTab = { row, index, tabId: t.id }; try { e.dataTransfer.effectAllowed = 'move' } catch {} }}
|
||
onDragEnd={() => { dragTab = null; setDropTarget(null) }}
|
||
onClick={() => splitStore.setActiveTab(row, index, t.id)}
|
||
>
|
||
{refreshableTab(t) && (
|
||
<button
|
||
type="button"
|
||
className="dsh-wt_tabRefresh"
|
||
title={T('pane.refresh')}
|
||
aria-label={T('pane.refresh')}
|
||
onClick={(e) => { e.stopPropagation(); refreshTab(t) }}
|
||
>↻</button>
|
||
)}
|
||
<span className="dsh-wt_tabTitle">{t.title}</span>
|
||
{!locked && (
|
||
<button
|
||
type="button"
|
||
className="dsh-wt_tabClose"
|
||
title={T('pane.closeTab')}
|
||
onClick={(e) => { e.stopPropagation(); splitStore.closeTab(row, index, t.id) }}
|
||
>✕</button>
|
||
)}
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
<PaneTabBody tab={tabs[active]} row={row} index={index} paneTitle={pane.title} reloadKey={reloadKeys[tabs[active].id] ?? 0} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 未指派内容:4 选 1 选择器。按钮固定大小、整体居中;
|
||
* 按窗位宽高比自适应排列:宽窗横排 4 连 / 方窗 2×2 / 竖窗竖排。 */
|
||
function PanePicker(props: { row: PaneRow; index: number }) {
|
||
const [mode, setMode] = useState<'row' | 'grid' | 'col'>('grid')
|
||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||
useEffect(() => {
|
||
const el = hostRef.current
|
||
if (!el) return
|
||
const update = () => {
|
||
const w = el.clientWidth
|
||
const h = el.clientHeight
|
||
const aspect = h > 0 ? w / h : 1
|
||
setMode(aspect > 1.4 ? 'row' : aspect > 0.72 ? 'grid' : 'col')
|
||
}
|
||
update()
|
||
const ro = new ResizeObserver(update)
|
||
ro.observe(el)
|
||
return () => ro.disconnect()
|
||
}, [])
|
||
const pick = (content: SplitContent) => splitStore.openTab(props.row, props.index, content)
|
||
return (
|
||
<div ref={hostRef} className={'dsh-wt_panePicker dsh-wt_panePicker-' + mode}>
|
||
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'browser' })}>
|
||
<span aria-hidden>🌐</span>{T('pane.browser')}
|
||
</button>
|
||
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'anim' })}>
|
||
<span aria-hidden>🎬</span>{T('pane.anim')}
|
||
</button>
|
||
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'explorer' })}>
|
||
<span aria-hidden>📁</span>{T('pane.explorer')}
|
||
</button>
|
||
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'terminal' })}>
|
||
<span aria-hidden>▸_</span>{T('pane.terminal')}
|
||
</button>
|
||
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'custom' })}>
|
||
<span aria-hidden>✨</span>{T('pane.custom')}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type PoolItem = { spec: LayoutSpec; chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[] }
|
||
|
||
/** 单个工作区渲染层(geom 为 null 时用 0 几何渲染,外层 display:none 保活,保留网页/MD 滚动/激活标签等状态) */
|
||
function WorkspaceLayer(props: { spec: LayoutSpec; geom: Geom | null; chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[] }) {
|
||
const g = props.geom ?? { left: 0, top: 0, right: 0, bottom: 0 }
|
||
const spec = props.spec
|
||
const top = spec.top ?? []
|
||
const main = spec.main ?? []
|
||
const hasLeft = !!spec.left
|
||
const hasTop = top.length > 0
|
||
const chatLeft = !hasLeft && spec.chatSide === 'left'
|
||
const colW = g.right - g.left
|
||
const rowH = g.bottom - g.top
|
||
const chatW = clamp(props.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
|
||
const topH = hasTop
|
||
? clamp(props.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
|
||
: 0
|
||
const leftW = hasLeft
|
||
? clamp(props.leftW, spec.leftWidth?.min ?? 160, Math.max(spec.leftWidth?.min ?? 160, colW - 260))
|
||
: 0
|
||
const chatFull = spec.chatFullHeight === true
|
||
const contentW = Math.max(0, colW - chatW)
|
||
const contentX = hasLeft ? g.left + leftW : (chatLeft ? g.left + chatW : g.left)
|
||
const topRowX = hasLeft ? g.left + leftW : contentX
|
||
const topRowW = hasLeft ? Math.max(0, colW - leftW) : (chatFull ? contentW : colW)
|
||
|
||
const topItems = allocate(top, props.topWs, topRowW)
|
||
const mainItems = allocate(main, props.paneWs, contentW)
|
||
const leftItem = spec.left ? { pane: spec.left, left: 0, width: leftW } : null
|
||
|
||
const barTop = g.top
|
||
const bodyTop = barTop + BAR_H + topH
|
||
const paneBottom = g.bottom
|
||
const mainH = paneBottom - bodyTop
|
||
const topY = barTop + BAR_H
|
||
|
||
const renderPane = (it: { pane: SplitPane; left: number; width: number }, row: PaneRow, index: number, x: number, y: number, h: number) => {
|
||
const singleConsole = (it.pane.tabs ?? []).length === 1 && it.pane.tabs![0].content?.kind === 'builtin' && it.pane.tabs![0].content.type === 'console'
|
||
return (
|
||
<div
|
||
key={it.pane.id}
|
||
className="dsh-wt_pane"
|
||
data-drop-hover={dropTarget && dropTarget.row === row && dropTarget.index === index ? 'true' : undefined}
|
||
style={{ position: 'fixed', left: x + it.left, top: y, width: it.width, height: h, zIndex: 68 }}
|
||
onDragOver={(e: any) => {
|
||
if (!dragTab) return
|
||
e.preventDefault()
|
||
setDropTarget({ row, index })
|
||
}}
|
||
onDragLeave={(e: any) => {
|
||
if (e.currentTarget && !e.currentTarget.contains(e.relatedTarget)) setDropTarget(null)
|
||
}}
|
||
onDrop={(e: any) => {
|
||
e.preventDefault()
|
||
const s = dragTab
|
||
dragTab = null
|
||
setDropTarget(null)
|
||
if (s && (s.row !== row || s.index !== index)) {
|
||
splitStore.moveTab(s.row, s.index, s.tabId, row, index)
|
||
}
|
||
}}
|
||
>
|
||
{!it.pane.collapsed && (
|
||
<div
|
||
className="dsh-wt_paneBar"
|
||
title={T('split.dragSwap')}
|
||
draggable
|
||
onDragStart={(e: any) => { dragPane = { row, index }; try { e.dataTransfer.effectAllowed = 'move' } catch {} }}
|
||
onDragOver={(e: any) => e.preventDefault()}
|
||
onDrop={(e: any) => {
|
||
e.preventDefault()
|
||
const s = dragPane
|
||
if (s && (s.row !== row || s.index !== index)) splitStore.swapPanes(s.row, s.index, row, index)
|
||
dragPane = null
|
||
}}
|
||
onDragEnd={() => { dragPane = null }}
|
||
>
|
||
<span className="dsh-wt_paneTitle">{it.pane.title}</span>
|
||
</div>
|
||
)}
|
||
<PaneBody pane={it.pane} row={row} index={index} />
|
||
{!singleConsole && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className="dsh-wt_annotBtn"
|
||
title={T('annot.label')}
|
||
aria-label={T('annot.label')}
|
||
onClick={() => startAnnot(windowLabelOf(row, index))}
|
||
>
|
||
<svg viewBox="0 0 16 16" aria-hidden><path d="M2 5.6c0-1.2 1-2.2 2.2-2.2h7.6c1.2 0 2.2 1 2.2 2.2v3.6c0 1.2-1 2.2-2.2 2.2H7.5L5 13.6l.3-2.4H4.2c-1.2 0-2.2-1-2.2-2.2z" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" /></svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={'dsh-wt_collapseBtn' + (it.pane.collapsed ? ' dsh-wt_collapseBtnCollapsed' : '')}
|
||
title={it.pane.collapsed ? T('pane.expand') : T('pane.collapse')}
|
||
aria-label={it.pane.collapsed ? T('pane.expand') : T('pane.collapse')}
|
||
onClick={() => splitStore.toggleCollapsed(row, index)}
|
||
>
|
||
{it.pane.collapsed ? (
|
||
<svg viewBox="0 0 16 16" aria-hidden><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||
) : (
|
||
<svg viewBox="0 0 16 16" aria-hidden><path d="M4 10l4-4 4 4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||
)}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* 标题栏 */}
|
||
<div className="dsh-wt_splitBar" style={{ position: 'fixed', left: g.left, top: barTop, width: hasLeft || chatFull ? contentW : (hasTop ? colW : contentW), zIndex: 70 }}>
|
||
{spec.id !== 'wt-console' && <span className="dsh-wt_splitTitle">{spec.title}</span>}
|
||
{!hasLeft && (
|
||
<button
|
||
type="button"
|
||
className="dsh-wt_splitFlip"
|
||
title={T('split.flip')}
|
||
onClick={() => splitStore.setChatSide(chatLeft ? 'right' : 'left')}
|
||
>⇄</button>
|
||
)}
|
||
<button type="button" className="dsh-wt_splitClose" aria-label="退出分栏(Esc)" onClick={() => splitStore.close()}>✕</button>
|
||
</div>
|
||
{/* 左列整高内容窗 */}
|
||
{leftItem && renderPane(leftItem, 'left', 0, g.left, barTop + BAR_H, g.bottom - barTop - BAR_H)}
|
||
{/* 顶部通栏行(左列布局时为右侧列顶行) */}
|
||
{hasTop && topItems.map((it, i) => renderPane(it, 'top', i, topRowX, topY, topH))}
|
||
{/* 主行内容窗 */}
|
||
{mainItems.map((it, i) => renderPane(it, 'main', i, contentX, bodyTop, mainH))}
|
||
{/* 顶部/主行水平分隔线 */}
|
||
{hasTop && (
|
||
<div
|
||
className="dsh-wt_splitDivider dsh-wt_splitDividerH"
|
||
role="separator"
|
||
title="拖动调整上下分区"
|
||
style={{ position: 'fixed', left: topRowX, top: bodyTop - DIVIDER / 2, width: topRowW, height: DIVIDER, zIndex: 72 }}
|
||
onPointerDown={makeDividerHandler('top')}
|
||
/>
|
||
)}
|
||
{/* 顶部行内垂直分隔线 */}
|
||
{hasTop && topItems.slice(0, -1).map((it, i) => (
|
||
<div
|
||
key={'tv' + it.pane.id}
|
||
className="dsh-wt_splitDivider"
|
||
role="separator"
|
||
title="拖动调整宽度"
|
||
style={{ position: 'fixed', left: topRowX + it.left + it.width - DIVIDER / 2, top: topY, width: DIVIDER, height: topH, zIndex: 72 }}
|
||
onPointerDown={makeDividerHandler('topPane', i)}
|
||
/>
|
||
))}
|
||
{/* 主行内容窗垂直分隔线 */}
|
||
{mainItems.slice(0, -1).map((it, i) => (
|
||
<div
|
||
key={'v' + it.pane.id}
|
||
className="dsh-wt_splitDivider"
|
||
role="separator"
|
||
title="拖动调整宽度"
|
||
style={{ position: 'fixed', left: contentX + it.left + it.width - DIVIDER / 2, top: bodyTop, width: DIVIDER, height: mainH, zIndex: 72 }}
|
||
onPointerDown={makeDividerHandler('pane', i)}
|
||
/>
|
||
))}
|
||
{/* 聊天分隔线(左列布局 = 左/右列边界;其余 = 内容与聊天之间) */}
|
||
<div
|
||
className="dsh-wt_splitDivider"
|
||
role="separator"
|
||
title={hasLeft ? '拖动调整左右列宽' : '拖动调整聊天宽度'}
|
||
style={{
|
||
position: 'fixed',
|
||
left: (hasLeft ? g.left + leftW : (chatLeft ? g.left + chatW : g.right - chatW)) - DIVIDER / 2,
|
||
top: hasLeft || chatFull ? barTop + BAR_H : bodyTop,
|
||
width: DIVIDER,
|
||
height: hasLeft || chatFull ? g.bottom - barTop - BAR_H : mainH,
|
||
zIndex: 72,
|
||
}}
|
||
onPointerDown={makeDividerHandler(hasLeft ? 'left' : 'chat')}
|
||
/>
|
||
<AnnotationOverlay />
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 分栏工作区浮层(shell.overlay 座位;订阅 splitStore 快照渲染)。
|
||
* 切换项目时旧工作区不销毁:全部挂载在池中、仅当前可见(display:none 保活),
|
||
* 网页子页面/滚动位置、MD 滚动位置、激活标签等在切回时原样保留。 */
|
||
function SplitWorkspace() {
|
||
const [snap, setSnap] = useState({
|
||
active: splitStore.active,
|
||
spec: splitStore.spec,
|
||
geom: splitStore.geom,
|
||
chatW: splitStore.chatW,
|
||
topH: splitStore.topH,
|
||
leftW: splitStore.leftW,
|
||
paneWs: [...splitStore.paneWs],
|
||
topWs: [...splitStore.topWs],
|
||
})
|
||
const poolRef = useRef<Map<string, PoolItem>>(new Map())
|
||
const [, setPoolTick] = useState(0)
|
||
|
||
useEffect(() => splitStore.subscribe(() => {
|
||
const spec = splitStore.spec
|
||
if (splitStore.active && spec) {
|
||
poolRef.current.set(spec.id, {
|
||
spec,
|
||
chatW: splitStore.chatW,
|
||
topH: splitStore.topH,
|
||
leftW: splitStore.leftW,
|
||
paneWs: [...splitStore.paneWs],
|
||
topWs: [...splitStore.topWs],
|
||
})
|
||
// 保活池上限 6 个(LRU:删最老的),避免长时间使用内存膨胀
|
||
while (poolRef.current.size > 6) {
|
||
const first = poolRef.current.keys().next().value
|
||
if (first != null) poolRef.current.delete(first)
|
||
}
|
||
}
|
||
setSnap({
|
||
active: splitStore.active,
|
||
spec: splitStore.spec,
|
||
geom: splitStore.geom,
|
||
chatW: splitStore.chatW,
|
||
topH: splitStore.topH,
|
||
leftW: splitStore.leftW,
|
||
paneWs: [...splitStore.paneWs],
|
||
topWs: [...splitStore.topWs],
|
||
})
|
||
setPoolTick((t) => t + 1)
|
||
}), [])
|
||
|
||
useEffect(() => {
|
||
if (!snap.active) return
|
||
const onKey = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') splitStore.close()
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [snap.active])
|
||
|
||
// 标签拖放高亮 → 重渲染
|
||
const [, setDropTick] = useState(0)
|
||
useEffect(() => {
|
||
const fn = () => setDropTick((t) => t + 1)
|
||
dropTargetListeners.add(fn)
|
||
return () => { dropTargetListeners.delete(fn) }
|
||
}, [])
|
||
|
||
const activeId = snap.active && snap.spec ? snap.spec.id : null
|
||
const entries = Array.from(poolRef.current.entries())
|
||
if (entries.length === 0) return null
|
||
return (
|
||
<>
|
||
{entries.map(([id, item]) => {
|
||
const isActive = id === activeId
|
||
return (
|
||
<div key={id} style={isActive ? undefined : { visibility: 'hidden' as const }}>
|
||
<WorkspaceLayer
|
||
spec={item.spec}
|
||
geom={isActive ? snap.geom : null}
|
||
chatW={isActive ? snap.chatW : item.chatW}
|
||
topH={isActive ? snap.topH : item.topH}
|
||
leftW={isActive ? snap.leftW : item.leftW}
|
||
paneWs={isActive ? snap.paneWs : item.paneWs}
|
||
topWs={isActive ? snap.topWs : item.topWs}
|
||
/>
|
||
</div>
|
||
)
|
||
})}
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 调试出口(自动化验证用;必须在 store 定义之后) */
|
||
try { (window as any).__dshWorktable = { splitStore } } catch {}
|
||
|
||
export { SplitWorkspace }
|