feat: M1 通用分栏引擎(split.tsx + openSplit)+ 文档同步
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { css } from './styles'
|
||||
import { NS, zh, en, type WorktableKey } from './locales'
|
||||
import { splitStore, SplitWorkspace, type LayoutSpec } from './split'
|
||||
|
||||
/**
|
||||
* dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。
|
||||
@@ -272,6 +273,11 @@ function WorktableSection(props: any) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** 分栏工作区入口(M1 引擎):项目卡片调用 openSplit(spec) 打开声明式布局 */
|
||||
const openSplit = useCallback((spec: LayoutSpec) => {
|
||||
splitStore.open(spec)
|
||||
}, [])
|
||||
|
||||
// ── 有效排序 ──
|
||||
// 手动:持久化 order(过滤已卸载 id)→ 新注册 id 追加尾部;
|
||||
// 最近:有 lastUsed 的按时间降序在前,其余按手动序在后。
|
||||
@@ -298,6 +304,7 @@ function WorktableSection(props: any) {
|
||||
nameOverrides: projects.nameOverrides,
|
||||
reportMeta,
|
||||
reportUsed,
|
||||
openSplit,
|
||||
}
|
||||
|
||||
// ── 拖动(≡ 手柄,与 v1 相同)──
|
||||
@@ -720,6 +727,13 @@ export function apply(ctx: any) {
|
||||
syncIds()
|
||||
ctx.effect(() => disposeSubscribe, 'dsh-worktable: project registry watch')
|
||||
|
||||
// 分栏工作区浮层(M1 通用引擎,shell.overlay 座位)
|
||||
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay',
|
||||
id: 'dsh-worktable-split',
|
||||
order: 100,
|
||||
}, SplitWorkspace), 'dsh-worktable: split workspace overlay')
|
||||
|
||||
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
||||
name: 'sidebar.footer.action',
|
||||
id: 'dsh-worktable',
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* dsh-worktable 乐高式工作区 M1:通用分栏引擎(PRD §13)。
|
||||
* 布局模型:标题栏 + 顶部通栏行(可选)+ 主行(内容窗,从左到右)+ 右下聊天窗(官方会话视图区整体)。
|
||||
* 聊天窗几何 = marginLeft + marginTop 组合挤法;会话切换重新锚定(§12.4,切会话不关闭)。
|
||||
* 宽度持久化:dsh.worktable.split.v1 = { [layoutId]: { chatW, topH, paneWs, topWs } }。
|
||||
*/
|
||||
|
||||
export type SplitContent = { kind: 'iframe'; url: string }
|
||||
|
||||
export type SplitPane = { id: string; title: string; min: number; content: SplitContent }
|
||||
|
||||
export type LayoutSpec = {
|
||||
id: string
|
||||
title: string
|
||||
/** 顶部通栏行(可选;每项为内容窗,横跨整列宽) */
|
||||
top: SplitPane[] | null
|
||||
/** 主行内容窗(从左到右;聊天窗恒在最后、由引擎自动追加) */
|
||||
main: SplitPane[]
|
||||
chatWidth: { default: number; min: number; max: number }
|
||||
/** 顶部行高度(存在 top 行时生效) */
|
||||
topHeight?: { default: number; min: number; max: number }
|
||||
}
|
||||
|
||||
type Geom = { left: number; top: number; right: number; bottom: number }
|
||||
|
||||
type SplitState = {
|
||||
active: boolean
|
||||
spec: LayoutSpec | null
|
||||
geom: Geom | null
|
||||
chatW: number
|
||||
topH: number
|
||||
paneWs: number[]
|
||||
topWs: number[]
|
||||
root: HTMLElement | null
|
||||
header: HTMLElement | null
|
||||
viewArea: HTMLElement | null
|
||||
savedMarginLeft: string
|
||||
savedMarginTop: string
|
||||
observer: ResizeObserver | null
|
||||
fallback: MutationObserver | null
|
||||
listeners: Set<() => void>
|
||||
open(spec: LayoutSpec): boolean
|
||||
close(): void
|
||||
syncAnchor(): void
|
||||
refreshGeom(): void
|
||||
applyMargin(): void
|
||||
setChatW(w: number): void
|
||||
setTopH(h: number): void
|
||||
setPaneW(i: number, w: number): void
|
||||
setTopW(i: number, w: number): 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 DIVIDER = 6
|
||||
const BAR_H = 26
|
||||
const PERSIST_KEY = 'dsh.worktable.split.v1'
|
||||
|
||||
/** 找到会话根容器:data-phase 元素中排除输入框、取含子元素者;优先 phase=active;无活动会话返回 null */
|
||||
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 >= 2
|
||||
return candidates.find((el) => ok(el) && el.dataset.phase === 'active')
|
||||
?? candidates.find(ok)
|
||||
?? null
|
||||
}
|
||||
|
||||
function loadSaved(layoutId: string): { chatW: number; topH: number; paneWs: number[]; topWs: 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,
|
||||
paneWs: Array.isArray(s.paneWs) ? s.paneWs : [],
|
||||
topWs: Array.isArray(s.topWs) ? s.topWs : [],
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function persistSaved(layoutId: string, s: { chatW: number; topH: number; paneWs: number[]; topWs: 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 {}
|
||||
}
|
||||
|
||||
export const splitStore: SplitState = {
|
||||
active: false,
|
||||
spec: null,
|
||||
geom: null,
|
||||
chatW: 320,
|
||||
topH: 200,
|
||||
paneWs: [],
|
||||
topWs: [],
|
||||
root: null,
|
||||
header: null,
|
||||
viewArea: null,
|
||||
savedMarginLeft: '',
|
||||
savedMarginTop: '',
|
||||
observer: null,
|
||||
fallback: null,
|
||||
listeners: new Set(),
|
||||
|
||||
open(spec) {
|
||||
if (this.active) this.close()
|
||||
const root = findConversationRoot()
|
||||
if (!root || root.dataset.phase !== 'active') return false
|
||||
const header = root.children[0] as HTMLElement | undefined
|
||||
const viewArea = root.children[1] as HTMLElement | undefined
|
||||
if (!header || !viewArea) return false
|
||||
this.spec = spec
|
||||
const main = spec.main ?? []
|
||||
const top = spec.top ?? []
|
||||
const saved = loadSaved(spec.id)
|
||||
this.chatW = saved && saved.chatW >= 0 ? saved.chatW : spec.chatWidth.default
|
||||
this.topH = saved && saved.topH >= 0 ? saved.topH : (spec.topHeight?.default ?? 200)
|
||||
this.paneWs = saved && saved.paneWs.length === main.length ? [...saved.paneWs] : main.map((p) => p.min)
|
||||
this.topWs = saved && saved.topWs.length === top.length ? [...saved.topWs] : top.map((p) => p.min)
|
||||
this.root = root
|
||||
this.header = header
|
||||
this.viewArea = viewArea
|
||||
this.savedMarginLeft = viewArea.style.marginLeft
|
||||
this.savedMarginTop = viewArea.style.marginTop
|
||||
this.refreshGeom()
|
||||
this.applyMargin()
|
||||
this.observer = new ResizeObserver(() => {
|
||||
const r = this.root
|
||||
if (!(r && r.isConnected && r.dataset.phase === 'active')) {
|
||||
this.syncAnchor()
|
||||
return
|
||||
}
|
||||
this.refreshGeom()
|
||||
this.applyMargin()
|
||||
this.notify()
|
||||
})
|
||||
this.observer.observe(root)
|
||||
// 兜底:会话根被替换/phase 变化时 RO 可能不再回调,用 body 级 MutationObserver 驱动重锚定
|
||||
this.fallback = new MutationObserver(() => {
|
||||
const r = this.root
|
||||
if (r && r.isConnected && r.dataset.phase === 'active') return
|
||||
this.syncAnchor()
|
||||
})
|
||||
this.fallback.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-phase'] })
|
||||
this.active = true
|
||||
this.notify()
|
||||
return true
|
||||
},
|
||||
|
||||
/** 会话根失效(切换会话)时重新锚定:左侧内容保持不关闭;无会话才关闭 */
|
||||
syncAnchor() {
|
||||
if (!this.active) return
|
||||
const next = findConversationRoot()
|
||||
if (!next) {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
if (next.dataset.phase !== 'active') return // 过渡态:保持等待(phase 变化会再次触发)
|
||||
if (next === this.root) {
|
||||
this.refreshGeom()
|
||||
this.applyMargin()
|
||||
this.notify()
|
||||
return
|
||||
}
|
||||
const header = next.children[0] as HTMLElement | undefined
|
||||
const viewArea = next.children[1] as HTMLElement | undefined
|
||||
if (!header || !viewArea) {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
// 恢复旧视图区 margin(若仍连接),锚定到新会话根
|
||||
if (this.viewArea && this.viewArea.isConnected && this.viewArea !== viewArea) {
|
||||
this.viewArea.style.marginLeft = this.savedMarginLeft
|
||||
this.viewArea.style.marginTop = this.savedMarginTop
|
||||
}
|
||||
this.root = next
|
||||
this.header = header
|
||||
this.viewArea = viewArea
|
||||
this.savedMarginLeft = viewArea.style.marginLeft
|
||||
this.savedMarginTop = viewArea.style.marginTop
|
||||
this.observer?.disconnect()
|
||||
this.observer.observe(next)
|
||||
this.refreshGeom()
|
||||
this.applyMargin()
|
||||
this.notify()
|
||||
},
|
||||
|
||||
refreshGeom() {
|
||||
const root = this.root
|
||||
const header = this.header
|
||||
if (!root || !header) return
|
||||
const rr = root.getBoundingClientRect()
|
||||
const hr = header.getBoundingClientRect()
|
||||
this.geom = { left: rr.left, top: hr.bottom, 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 hasTop = !!(spec.top && spec.top.length > 0)
|
||||
const chatW = clamp(this.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
|
||||
const topH = hasTop
|
||||
? clamp(this.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
|
||||
: 0
|
||||
viewArea.style.marginLeft = Math.max(0, colW - chatW) + 'px'
|
||||
viewArea.style.marginTop = (BAR_H + topH) + 'px'
|
||||
},
|
||||
|
||||
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) * DIVIDER
|
||||
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()
|
||||
},
|
||||
|
||||
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) * DIVIDER)
|
||||
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) * DIVIDER)
|
||||
const next = this.topWs.slice()
|
||||
next[i] = clamp(Math.round(w), lo, hi)
|
||||
this.topWs = next
|
||||
this.persist()
|
||||
this.notify()
|
||||
},
|
||||
|
||||
persist() {
|
||||
if (!this.spec) return
|
||||
persistSaved(this.spec.id, { chatW: this.chatW, topH: this.topH, paneWs: this.paneWs, topWs: this.topWs })
|
||||
},
|
||||
|
||||
close() {
|
||||
if (this.viewArea) {
|
||||
this.viewArea.style.marginLeft = this.savedMarginLeft
|
||||
this.viewArea.style.marginTop = this.savedMarginTop
|
||||
}
|
||||
this.observer?.disconnect()
|
||||
this.observer = null
|
||||
this.fallback?.disconnect()
|
||||
this.fallback = null
|
||||
this.root = null
|
||||
this.header = null
|
||||
this.viewArea = null
|
||||
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()
|
||||
},
|
||||
}
|
||||
|
||||
/** 分配各窗宽度(最后一个拿余量) */
|
||||
function allocate(panes: SplitPane[], ws: number[], total: number) {
|
||||
const out: { pane: SplitPane; left: number; width: number }[] = []
|
||||
const gapTotal = Math.max(0, panes.length - 1) * DIVIDER
|
||||
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 + DIVIDER
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** 通用分隔线拖拽(chat/top/pane/topPane) */
|
||||
function makeDividerHandler(kind: '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 === '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 * DIVIDER
|
||||
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 * DIVIDER
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/** 分栏工作区浮层(shell.overlay 座位;订阅 splitStore 快照渲染) */
|
||||
function SplitWorkspace() {
|
||||
const [snap, setSnap] = useState({
|
||||
active: splitStore.active,
|
||||
spec: splitStore.spec,
|
||||
geom: splitStore.geom,
|
||||
chatW: splitStore.chatW,
|
||||
topH: splitStore.topH,
|
||||
paneWs: [...splitStore.paneWs],
|
||||
topWs: [...splitStore.topWs],
|
||||
})
|
||||
|
||||
useEffect(() => splitStore.subscribe(() => {
|
||||
setSnap({
|
||||
active: splitStore.active,
|
||||
spec: splitStore.spec,
|
||||
geom: splitStore.geom,
|
||||
chatW: splitStore.chatW,
|
||||
topH: splitStore.topH,
|
||||
paneWs: [...splitStore.paneWs],
|
||||
topWs: [...splitStore.topWs],
|
||||
})
|
||||
}), [])
|
||||
|
||||
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])
|
||||
|
||||
if (!snap.active || !snap.spec || !snap.geom) return null
|
||||
const g = snap.geom
|
||||
const spec = snap.spec
|
||||
const top = spec.top ?? []
|
||||
const main = spec.main ?? []
|
||||
const hasTop = top.length > 0
|
||||
const colW = g.right - g.left
|
||||
const rowH = g.bottom - g.top
|
||||
const chatW = clamp(snap.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
|
||||
const topH = hasTop
|
||||
? clamp(snap.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
|
||||
: 0
|
||||
const contentW = Math.max(0, colW - chatW)
|
||||
|
||||
const topItems = allocate(top, snap.topWs, colW)
|
||||
const mainItems = allocate(main, snap.paneWs, contentW)
|
||||
|
||||
const barTop = g.top
|
||||
const bodyTop = barTop + BAR_H + topH
|
||||
const paneBottom = g.bottom
|
||||
const mainH = paneBottom - bodyTop
|
||||
const topY = barTop + BAR_H
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 标题栏 */}
|
||||
<div className="dsh-wt_splitBar" style={{ position: 'fixed', left: g.left, top: barTop, width: hasTop ? colW : contentW, zIndex: 70 }}>
|
||||
<span className="dsh-wt_splitTitle">{spec.title}</span>
|
||||
<button type="button" className="dsh-wt_splitClose" aria-label="退出分栏(Esc)" onClick={() => splitStore.close()}>✕</button>
|
||||
</div>
|
||||
{/* 顶部通栏行 */}
|
||||
{hasTop && topItems.map((it) => (
|
||||
<div key={it.pane.id} className="dsh-wt_pane" style={{ position: 'fixed', left: g.left + it.left, top: topY, width: it.width, height: topH, zIndex: 68 }}>
|
||||
<div className="dsh-wt_paneBar"><span className="dsh-wt_paneTitle">{it.pane.title}</span></div>
|
||||
<iframe className="dsh-wt_paneFrame" src={it.pane.content.url} title={it.pane.title} />
|
||||
</div>
|
||||
))}
|
||||
{/* 主行内容窗 */}
|
||||
{mainItems.map((it) => (
|
||||
<div key={it.pane.id} className="dsh-wt_pane" style={{ position: 'fixed', left: g.left + it.left, top: bodyTop, width: it.width, height: mainH, zIndex: 68 }}>
|
||||
<div className="dsh-wt_paneBar"><span className="dsh-wt_paneTitle">{it.pane.title}</span></div>
|
||||
<iframe className="dsh-wt_paneFrame" src={it.pane.content.url} title={it.pane.title} />
|
||||
</div>
|
||||
))}
|
||||
{/* 顶部/主行水平分隔线 */}
|
||||
{hasTop && (
|
||||
<div
|
||||
className="dsh-wt_splitDivider dsh-wt_splitDividerH"
|
||||
role="separator"
|
||||
title="拖动调整上下分区"
|
||||
style={{ position: 'fixed', left: g.left, top: bodyTop - DIVIDER / 2, width: colW, 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: g.left + 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: g.left + 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="拖动调整聊天宽度"
|
||||
style={{ position: 'fixed', left: g.right - chatW - DIVIDER / 2, top: bodyTop, width: DIVIDER, height: mainH, zIndex: 72 }}
|
||||
onPointerDown={makeDividerHandler('chat')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { SplitWorkspace }
|
||||
@@ -70,4 +70,16 @@ export const css = [
|
||||
'.dsh-wt_projects{display:flex;flex-direction:column;gap:4px}',
|
||||
'.dsh-wt_projects[data-managing=true]{opacity:.55;pointer-events:none}',
|
||||
'.dsh-wt_empty{padding:8px 10px;border:1px dashed var(--dsw-alias-border-l1,#262b36);border-radius:8px;color:var(--dsw-alias-label-tertiary,#6e7683);font-size:11px;text-align:center}',
|
||||
// 分栏工作区(M1 通用引擎)
|
||||
'.dsh-wt_splitBar{display:flex;align-items:center;gap:10px;height:26px;padding:0 10px;box-sizing:border-box;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-bg-base,#0b0e14)}',
|
||||
'.dsh-wt_splitTitle{flex:1;min-width:0;font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e8eb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||
'.dsh-wt_splitClose{flex:none;display:flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:none;border-radius:4px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font-size:12px;cursor:pointer}',
|
||||
'.dsh-wt_splitClose:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
|
||||
'.dsh-wt_pane{display:flex;flex-direction:column;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-bg-base,#0b0e14);overflow:hidden}',
|
||||
'.dsh-wt_paneBar{flex:none;display:flex;align-items:center;height:22px;padding:0 8px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02))}',
|
||||
'.dsh-wt_paneTitle{flex:1;min-width:0;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#9aa4b2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||
'.dsh-wt_paneFrame{flex:1;width:100%;border:0;background:#010409;min-height:0}',
|
||||
'.dsh-wt_splitDivider{background:var(--dsw-alias-border-l2,#3a4150);cursor:col-resize;touch-action:none;box-sizing:border-box}',
|
||||
'.dsh-wt_splitDividerH{cursor:row-resize}',
|
||||
'.dsh-wt_splitDivider:hover,.dsh-wt_splitDividerH:hover{background:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
|
||||
].join('\n')
|
||||
|
||||
Reference in New Issue
Block a user