feat: + 面板可视化布局选择 + 窗内6选1内容 + 聊天左右切换与窗位拖换
This commit is contained in:
@@ -1,7 +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'
|
||||
import { splitStore, SplitWorkspace, setSplitT, type LayoutSpec, type SplitPane } from './split'
|
||||
|
||||
/**
|
||||
* dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。
|
||||
@@ -72,20 +72,16 @@ const PRESET_DEFS = [
|
||||
{ id: 'grid', topCount: 2, contentCount: 1 },
|
||||
] as const
|
||||
|
||||
function presetTotal(def: { topCount: number; contentCount: number }): number {
|
||||
return def.topCount + def.contentCount
|
||||
}
|
||||
|
||||
function buildLayout(presetId: string, name: string, urls: string[]): LayoutSpec {
|
||||
function buildLayout(presetId: string, name: string): LayoutSpec {
|
||||
const def = PRESET_DEFS.find((d) => d.id === presetId) ?? PRESET_DEFS[0]
|
||||
const mk = (i: number, url: string) => ({
|
||||
const mk = (i: number): SplitPane => ({
|
||||
id: 'p' + (i + 1),
|
||||
title: '内容' + (i + 1),
|
||||
min: 200,
|
||||
content: { kind: 'iframe' as const, url },
|
||||
content: null,
|
||||
})
|
||||
const top = Array.from({ length: def.topCount }, (_, i) => mk(i, urls[i] ?? 'about:blank'))
|
||||
const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.topCount + i, urls[def.topCount + i] ?? 'about:blank'))
|
||||
const top = Array.from({ length: def.topCount }, (_, i) => mk(i))
|
||||
const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.topCount + i))
|
||||
return {
|
||||
id: 'layout-' + Date.now().toString(36),
|
||||
title: name,
|
||||
@@ -93,9 +89,37 @@ function buildLayout(presetId: string, name: string, urls: string[]): LayoutSpec
|
||||
main,
|
||||
chatWidth: { default: 360, min: 240, max: 600 },
|
||||
topHeight: { default: 200, min: 120, max: 480 },
|
||||
chatSide: 'right',
|
||||
}
|
||||
}
|
||||
|
||||
/** 布局缩略图(迷你窗格示意;聊天窗蓝色 💬) */
|
||||
function presetThumb(defId: string) {
|
||||
const cell = (chat: boolean, key: string) => (
|
||||
<span key={key} className={'dsh-wt_thumbCell' + (chat ? ' dsh-wt_thumbChat' : '')}>{chat ? '💬' : ''}</span>
|
||||
)
|
||||
if (defId === '2h') {
|
||||
return <span className="dsh-wt_thumb"><span className="dsh-wt_thumbRow">{cell(false, 'a')}{cell(true, 'b')}</span></span>
|
||||
}
|
||||
if (defId === '3h') {
|
||||
return <span className="dsh-wt_thumb"><span className="dsh-wt_thumbRow">{cell(false, 'a')}{cell(false, 'b')}{cell(true, 'c')}</span></span>
|
||||
}
|
||||
if (defId === 't2') {
|
||||
return (
|
||||
<span className="dsh-wt_thumb">
|
||||
<span className="dsh-wt_thumbRow">{cell(false, 'a')}</span>
|
||||
<span className="dsh-wt_thumbRow">{cell(false, 'b')}{cell(true, 'c')}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="dsh-wt_thumb">
|
||||
<span className="dsh-wt_thumbRow">{cell(false, 'a')}{cell(false, 'b')}</span>
|
||||
<span className="dsh-wt_thumbRow">{cell(false, 'c')}{cell(true, 'd')}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const DEFAULT_VIEW: ViewState = {
|
||||
query: '',
|
||||
searchOpen: false,
|
||||
@@ -207,7 +231,6 @@ function WorktableSection(props: any) {
|
||||
const [scError, setScError] = useState(false)
|
||||
const [wsPreset, setWsPreset] = useState<string>('2h')
|
||||
const [wsName, setWsName] = useState('')
|
||||
const [wsUrls, setWsUrls] = useState<string[]>(['', '', ''])
|
||||
const [wsError, setWsError] = useState(false)
|
||||
const [float, setFloat] = useState<FloatRect | null>(() =>
|
||||
view.dock === 'float' && view.floatTop != null ? { top: view.floatTop } : null,
|
||||
@@ -252,6 +275,20 @@ function WorktableSection(props: any) {
|
||||
setActiveSplitId(splitStore.active && splitStore.spec ? splitStore.spec.id : null)
|
||||
}), [])
|
||||
|
||||
// 引擎内 spec 变更(窗内容/聊天位置/窗位互换)→ 回写布局条目持久化
|
||||
useEffect(() => {
|
||||
splitStore.onSpecMutated = (spec) => {
|
||||
persistProjects((prev) => ({ ...prev, layouts: prev.layouts.map((l) => (l.id === spec.id ? spec : l)) }))
|
||||
}
|
||||
return () => { splitStore.onSpecMutated = null }
|
||||
}, [])
|
||||
|
||||
// 分栏引擎 UI 文案(窗选择器等)
|
||||
useEffect(() => {
|
||||
setSplitT((k) => t(k as WorktableKey))
|
||||
return () => setSplitT(null)
|
||||
}, [t])
|
||||
|
||||
// 侧边栏折叠/展开:保持原停靠位置(不再回弹 footer);折叠态由项目图标框承载。
|
||||
// 折叠且浮动时:等折叠动画结束(320/750ms 双次重测,取收敛后的几何)再以 fixed 定位
|
||||
// 图标框到拖前高度——避免在过渡帧上测得展开态宽度导致图标框偏离窄栏中心。
|
||||
@@ -513,13 +550,10 @@ function WorktableSection(props: any) {
|
||||
// ── 布局(新建工作区) ──
|
||||
const saveLayout = () => {
|
||||
const name = wsName.trim()
|
||||
const def = PRESET_DEFS.find((d) => d.id === wsPreset) ?? PRESET_DEFS[0]
|
||||
const total = presetTotal(def)
|
||||
const urls = wsUrls.slice(0, total).map((u) => u.trim())
|
||||
if (!name || urls.some((u) => !/^(\/|https?:\/\/)/i.test(u))) { setWsError(true); return }
|
||||
const layout = buildLayout(wsPreset, name, urls)
|
||||
if (!name) { setWsError(true); return }
|
||||
const layout = buildLayout(wsPreset, name)
|
||||
persistProjects((prev) => ({ ...prev, layouts: [...prev.layouts, layout] }))
|
||||
setWsName(''); setWsUrls(['', '', '']); setWsError(false)
|
||||
setWsName(''); setWsError(false)
|
||||
setAddOpen(false)
|
||||
openSplit(layout)
|
||||
reportUsed(layout.id)
|
||||
@@ -695,11 +729,28 @@ function WorktableSection(props: any) {
|
||||
|
||||
{addOpen && (
|
||||
<div className="dsh-wt_menu dsh-wt_add">
|
||||
<span className="dsh-wt_menuLabel">{t('add.guideTitle')}</span>
|
||||
<p className="dsh-wt_addText">{t('add.guideBody')}</p>
|
||||
<a className="dsh-wt_menuItem dsh-wt_addLink" href={MARKET_URL} target="_blank" rel="noreferrer noopener">
|
||||
{t('add.market')} ↗
|
||||
</a>
|
||||
<span className="dsh-wt_menuLabel">{t('add.chooseLayout')}</span>
|
||||
<div className="dsh-wt_presets">
|
||||
{PRESET_DEFS.map((def) => (
|
||||
<button
|
||||
key={def.id}
|
||||
type="button"
|
||||
className="dsh-wt_preset"
|
||||
data-on={wsPreset === def.id ? 'true' : 'false'}
|
||||
onClick={() => { setWsPreset(def.id); setWsError(false) }}
|
||||
>
|
||||
{presetThumb(def.id)}
|
||||
<span className="dsh-wt_presetLabel">{t('preset.' + def.id)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dsh-wt_addForm">
|
||||
<input type="text" placeholder={t('add.layoutNamePh')} value={wsName}
|
||||
onChange={(e) => { setWsName(e.target.value); setWsError(false) }} />
|
||||
<button type="button" className="dsh-wt_addBtn" onClick={saveLayout}>{t('add.layoutSave')}</button>
|
||||
</div>
|
||||
{wsError && <p className="dsh-wt_addError">{t('add.layoutInvalid')}</p>}
|
||||
<div className="dsh-wt_menuSep" />
|
||||
<span className="dsh-wt_menuLabel">{t('add.shortcutTitle')}</span>
|
||||
<p className="dsh-wt_addText">{t('add.shortcutDesc')}</p>
|
||||
<div className="dsh-wt_addForm">
|
||||
@@ -712,40 +763,6 @@ function WorktableSection(props: any) {
|
||||
<button type="button" className="dsh-wt_addBtn" onClick={addShortcut}>{t('add.shortcutAdd')}</button>
|
||||
</div>
|
||||
{scError && <p className="dsh-wt_addError">{t('add.shortcutInvalid')}</p>}
|
||||
<div className="dsh-wt_menuSep" />
|
||||
<span className="dsh-wt_menuLabel">{t('add.workspaceTitle')}</span>
|
||||
<p className="dsh-wt_addText">{t('add.workspaceDesc')}</p>
|
||||
<div className="dsh-wt_presets">
|
||||
{PRESET_DEFS.map((def) => (
|
||||
<button
|
||||
key={def.id}
|
||||
type="button"
|
||||
className="dsh-wt_preset"
|
||||
data-on={wsPreset === def.id ? 'true' : 'false'}
|
||||
onClick={() => { setWsPreset(def.id); setWsError(false) }}
|
||||
>
|
||||
{t('preset.' + def.id)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dsh-wt_addForm">
|
||||
<input type="text" placeholder={t('add.layoutNamePh')} value={wsName}
|
||||
onChange={(e) => { setWsName(e.target.value); setWsError(false) }} />
|
||||
{Array.from({ length: presetTotal(PRESET_DEFS.find((d) => d.id === wsPreset) ?? PRESET_DEFS[0]) }, (_, i) => (
|
||||
<input
|
||||
key={i}
|
||||
type="text"
|
||||
placeholder={t('add.paneUrlPh')}
|
||||
value={wsUrls[i] ?? ''}
|
||||
onChange={(e) => {
|
||||
setWsUrls((prev) => { const next = [...prev]; next[i] = e.target.value; return next })
|
||||
setWsError(false)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<button type="button" className="dsh-wt_addBtn" onClick={saveLayout}>{t('add.layoutSave')}</button>
|
||||
</div>
|
||||
{wsError && <p className="dsh-wt_addError">{t('add.layoutInvalid')}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -41,17 +41,26 @@ export const zh = {
|
||||
'shortcut.badge': '本地',
|
||||
'layout.badge': '布局',
|
||||
'layout.desc': '{n} 内容窗 · 对话',
|
||||
'add.workspaceTitle': '新建工作区',
|
||||
'add.workspaceDesc': '选一个拓扑,填好各窗内容,保存为工作台布局(其中一窗恒为对话)。',
|
||||
'add.chooseLayout': '选择布局模式',
|
||||
'add.layoutNamePh': '布局名称',
|
||||
'add.paneUrlPh': '内容地址(/xxx/ 或 https://)',
|
||||
'add.layoutSave': '保存并打开',
|
||||
'add.layoutInvalid': '请填写布局名称,且每个内容地址以 / 或 http(s) 开头',
|
||||
'add.layoutSave': '进入工作区',
|
||||
'add.layoutInvalid': '请填写布局名称',
|
||||
'preset.2h': '左右两栏',
|
||||
'preset.3h': '三栏横排',
|
||||
'preset.t2': '上一下二',
|
||||
'preset.grid': '井字四栏',
|
||||
'manage.deleteLayout': '删除布局',
|
||||
'pane.browser': '浏览器',
|
||||
'pane.explorer': '资源管理器',
|
||||
'pane.scm': '源代码管理',
|
||||
'pane.tasks': '任务管理',
|
||||
'pane.terminal': '终端',
|
||||
'pane.custom': '自定义',
|
||||
'pane.customUrlPh': '地址(/xxx/ 或 https://)',
|
||||
'pane.open': '打开',
|
||||
'pane.wip': '开发中(后续版本接入)',
|
||||
'split.flip': '切换对话窗位置(左⇄右)',
|
||||
'split.dragSwap': '拖动标题栏与其他窗交换位置',
|
||||
'empty': '暂无项目',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
@@ -95,17 +104,26 @@ export const en = {
|
||||
'shortcut.badge': 'Local',
|
||||
'layout.badge': 'Layout',
|
||||
'layout.desc': '{n} content panes · chat',
|
||||
'add.workspaceTitle': 'New workspace',
|
||||
'add.workspaceDesc': 'Pick a topology, fill in the pane contents, save as a worktable layout (one pane is always the chat).',
|
||||
'add.chooseLayout': 'Choose a layout',
|
||||
'add.layoutNamePh': 'Layout name',
|
||||
'add.paneUrlPh': 'Content URL (/xxx/ or https://)',
|
||||
'add.layoutSave': 'Save & open',
|
||||
'add.layoutInvalid': 'Enter a layout name, and each content URL must start with / or http(s)',
|
||||
'add.layoutSave': 'Enter workspace',
|
||||
'add.layoutInvalid': 'Enter a layout name',
|
||||
'preset.2h': 'Side by side',
|
||||
'preset.3h': 'Three columns',
|
||||
'preset.t2': 'Top + two',
|
||||
'preset.grid': '2×2 grid',
|
||||
'manage.deleteLayout': 'Delete layout',
|
||||
'pane.browser': 'Browser',
|
||||
'pane.explorer': 'Explorer',
|
||||
'pane.scm': 'Source control',
|
||||
'pane.tasks': 'Tasks',
|
||||
'pane.terminal': 'Terminal',
|
||||
'pane.custom': 'Custom',
|
||||
'pane.customUrlPh': 'URL (/xxx/ or https://)',
|
||||
'pane.open': 'Open',
|
||||
'pane.wip': 'Coming in a later version',
|
||||
'split.flip': 'Flip chat side (left ⇄ right)',
|
||||
'split.dragSwap': 'Drag the title bar to swap panes',
|
||||
'empty': 'No projects yet',
|
||||
} satisfies Record<WorktableKey, string>
|
||||
|
||||
|
||||
+243
-28
@@ -2,29 +2,37 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* dsh-worktable 乐高式工作区 M1:通用分栏引擎(PRD §13)。
|
||||
* 布局模型:标题栏 + 顶部通栏行(可选)+ 主行(内容窗,从左到右)+ 右下聊天窗(官方会话视图区整体)。
|
||||
* 聊天窗几何 = marginLeft + marginTop 组合挤法;会话切换重新锚定(§12.4,切会话不关闭)。
|
||||
* 宽度持久化:dsh.worktable.split.v1 = { [layoutId]: { chatW, topH, paneWs, topWs } }。
|
||||
* 布局模型:标题栏 + 顶部通栏行(可选) + 主行内容窗 + 聊天窗(官方会话视图区整体,
|
||||
* 贴右或贴左,由 chatSide 决定;marginLeft/marginRight + marginTop 组合挤法)。
|
||||
* 内容三态:null(未指派 → 6 选 1 选择器)/ iframe / builtin(浏览器/资源管理器/SCM/任务/终端)。
|
||||
* 窗位调整:标题栏拖拽换位(同行或跨行);工具栏 ⇄ 切换聊天窗左右。
|
||||
* 会话切换重新锚定不关闭;宽度按 layoutId 持久化 dsh.worktable.split.v1;
|
||||
* 内容与 chatSide 的变更经 onSpecMutated 回调交给工作台持久化(布局条目)。
|
||||
*/
|
||||
|
||||
export type SplitContent = { kind: 'iframe'; url: string }
|
||||
export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal'
|
||||
|
||||
export type SplitPane = { id: string; title: string; min: number; content: SplitContent }
|
||||
export type SplitContent =
|
||||
| { kind: 'iframe'; url: string }
|
||||
| { kind: 'builtin'; type: BuiltinType }
|
||||
|
||||
export type SplitPane = { id: string; title: string; min: number; content: SplitContent | null }
|
||||
|
||||
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 }
|
||||
/** 聊天窗贴边位置:'right'(右列/右下,默认)| 'left'(左列/左下) */
|
||||
chatSide?: 'left' | 'right'
|
||||
}
|
||||
|
||||
type Geom = { left: number; top: number; right: number; bottom: number }
|
||||
|
||||
type PaneRow = 'top' | 'main'
|
||||
|
||||
type SplitState = {
|
||||
active: boolean
|
||||
spec: LayoutSpec | null
|
||||
@@ -37,12 +45,15 @@ type SplitState = {
|
||||
header: HTMLElement | null
|
||||
viewArea: HTMLElement | null
|
||||
savedMarginLeft: string
|
||||
savedMarginRight: string
|
||||
savedMarginTop: 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
|
||||
@@ -53,6 +64,9 @@ type SplitState = {
|
||||
setTopH(h: number): void
|
||||
setPaneW(i: number, w: number): void
|
||||
setTopW(i: number, w: number): void
|
||||
setPaneContent(row: PaneRow, i: number, content: SplitContent | null): void
|
||||
swapPanes(aRow: PaneRow, aI: number, bRow: PaneRow, bI: number): void
|
||||
setChatSide(side: 'left' | 'right'): void
|
||||
persist(): void
|
||||
subscribe(fn: () => void): () => void
|
||||
notify(): void
|
||||
@@ -63,6 +77,25 @@ const DIVIDER = 6
|
||||
const BAR_H = 26
|
||||
const PERSIST_KEY = 'dsh.worktable.split.v1'
|
||||
|
||||
/** 内置内容窗图标 */
|
||||
const BUILTIN_ICONS: Record<BuiltinType, string> = {
|
||||
browser: '🌐',
|
||||
explorer: '📁',
|
||||
scm: '🔀',
|
||||
tasks: '✅',
|
||||
terminal: '▸_',
|
||||
}
|
||||
|
||||
/** 分栏 UI 文案提供者(由工作台注入 locale t) */
|
||||
let uiT: ((key: string) => string) | null = null
|
||||
export function setSplitT(fn: ((key: string) => string) | null) {
|
||||
uiT = fn
|
||||
}
|
||||
const T = (key: string): string => (uiT ? uiT(key) : key)
|
||||
|
||||
/** 拖拽换位暂存 */
|
||||
let dragPane: { row: PaneRow; index: number } | null = null
|
||||
|
||||
/** 找到会话根容器:data-phase 元素中排除输入框、取含子元素者;优先 phase=active;无活动会话返回 null */
|
||||
function findConversationRoot(): HTMLElement | null {
|
||||
const candidates = Array.from(document.querySelectorAll<HTMLElement>('[data-phase]'))
|
||||
@@ -116,12 +149,15 @@ export const splitStore: SplitState = {
|
||||
header: null,
|
||||
viewArea: null,
|
||||
savedMarginLeft: '',
|
||||
savedMarginRight: '',
|
||||
savedMarginTop: '',
|
||||
observer: null,
|
||||
fallback: null,
|
||||
yieldObserver: null,
|
||||
lastMarginLeft: '',
|
||||
lastMarginRight: '',
|
||||
lastMarginTop: '',
|
||||
onSpecMutated: null,
|
||||
listeners: new Set(),
|
||||
|
||||
open(spec) {
|
||||
@@ -147,7 +183,7 @@ export const splitStore: SplitState = {
|
||||
const header = root.children[0] as HTMLElement | undefined
|
||||
const viewArea = root.children[1] as HTMLElement | undefined
|
||||
if (!header || !viewArea) return false
|
||||
this.spec = spec
|
||||
this.spec = { ...spec, chatSide: spec.chatSide === 'left' ? 'left' : 'right' }
|
||||
const main = spec.main ?? []
|
||||
const top = spec.top ?? []
|
||||
const saved = loadSaved(spec.id)
|
||||
@@ -159,6 +195,7 @@ export const splitStore: SplitState = {
|
||||
this.header = header
|
||||
this.viewArea = viewArea
|
||||
this.savedMarginLeft = viewArea.style.marginLeft
|
||||
this.savedMarginRight = viewArea.style.marginRight
|
||||
this.savedMarginTop = viewArea.style.marginTop
|
||||
this.refreshGeom()
|
||||
this.applyMargin()
|
||||
@@ -183,7 +220,9 @@ export const splitStore: SplitState = {
|
||||
// 让位观察器:会话视图区 margin 被外部改写(其他未接入协议的分栏引擎接管)时关闭自身
|
||||
this.yieldObserver = new MutationObserver(() => {
|
||||
if (!this.active || !this.viewArea) return
|
||||
if (this.viewArea.style.marginLeft !== this.lastMarginLeft || this.viewArea.style.marginTop !== this.lastMarginTop) {
|
||||
if (this.viewArea.style.marginLeft !== this.lastMarginLeft
|
||||
|| this.viewArea.style.marginRight !== this.lastMarginRight
|
||||
|| this.viewArea.style.marginTop !== this.lastMarginTop) {
|
||||
this.close()
|
||||
}
|
||||
})
|
||||
@@ -217,12 +256,14 @@ export const splitStore: SplitState = {
|
||||
// 恢复旧视图区 margin(若仍连接),锚定到新会话根
|
||||
if (this.viewArea && this.viewArea.isConnected && this.viewArea !== viewArea) {
|
||||
this.viewArea.style.marginLeft = this.savedMarginLeft
|
||||
this.viewArea.style.marginRight = this.savedMarginRight
|
||||
this.viewArea.style.marginTop = this.savedMarginTop
|
||||
}
|
||||
this.root = next
|
||||
this.header = header
|
||||
this.viewArea = viewArea
|
||||
this.savedMarginLeft = viewArea.style.marginLeft
|
||||
this.savedMarginRight = viewArea.style.marginRight
|
||||
this.savedMarginTop = viewArea.style.marginTop
|
||||
this.observer?.disconnect()
|
||||
this.observer.observe(next)
|
||||
@@ -252,11 +293,14 @@ export const splitStore: SplitState = {
|
||||
const topH = hasTop
|
||||
? clamp(this.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
|
||||
: 0
|
||||
const ml = Math.max(0, colW - chatW) + 'px'
|
||||
const gap = Math.max(0, colW - chatW) + 'px'
|
||||
const mt = (BAR_H + topH) + 'px'
|
||||
this.lastMarginLeft = ml
|
||||
const chatLeft = spec.chatSide === 'left'
|
||||
this.lastMarginLeft = chatLeft ? '' : gap
|
||||
this.lastMarginRight = chatLeft ? gap : ''
|
||||
this.lastMarginTop = mt
|
||||
viewArea.style.marginLeft = ml
|
||||
viewArea.style.marginLeft = this.lastMarginLeft
|
||||
viewArea.style.marginRight = this.lastMarginRight
|
||||
viewArea.style.marginTop = mt
|
||||
},
|
||||
|
||||
@@ -323,6 +367,62 @@ export const splitStore: SplitState = {
|
||||
this.notify()
|
||||
},
|
||||
|
||||
setPaneContent(row, i, content) {
|
||||
const spec = this.spec
|
||||
if (!spec) return
|
||||
const top = [...(spec.top ?? [])]
|
||||
const main = [...spec.main]
|
||||
const arr = row === 'top' ? top : main
|
||||
if (!arr[i]) return
|
||||
arr[i] = { ...arr[i], content }
|
||||
this.spec = { ...spec, top: top.length > 0 ? top : null, 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 arrA = aRow === 'top' ? top : main
|
||||
const arrB = bRow === 'top' ? top : main
|
||||
const a = arrA[aI]
|
||||
const b = arrB[bI]
|
||||
if (!a || !b) return
|
||||
arrA[aI] = b
|
||||
arrB[bI] = a
|
||||
if (aRow === bRow) {
|
||||
const ws = aRow === 'top' ? this.topWs.slice() : this.paneWs.slice()
|
||||
const t = ws[aI]; ws[aI] = ws[bI]; ws[bI] = t
|
||||
if (aRow === 'top') this.topWs = ws
|
||||
else this.paneWs = ws
|
||||
} else {
|
||||
const aWs = aRow === 'top' ? this.topWs.slice() : this.paneWs.slice()
|
||||
const bWs = bRow === 'top' ? this.topWs.slice() : this.paneWs.slice()
|
||||
const ta = aWs[aI]; const tb = bWs[bI]
|
||||
aWs[aI] = tb
|
||||
bWs[bI] = ta
|
||||
if (aRow === 'top') { this.topWs = aWs; this.paneWs = bWs }
|
||||
else { this.topWs = bWs; this.paneWs = aWs }
|
||||
}
|
||||
this.spec = { ...spec, top: top.length > 0 ? top : null, main }
|
||||
this.onSpecMutated?.(this.spec)
|
||||
this.persist()
|
||||
this.notify()
|
||||
},
|
||||
|
||||
setChatSide(side) {
|
||||
const spec = this.spec
|
||||
if (!spec) 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, paneWs: this.paneWs, topWs: this.topWs })
|
||||
@@ -331,6 +431,7 @@ export const splitStore: SplitState = {
|
||||
close() {
|
||||
if (this.viewArea) {
|
||||
this.viewArea.style.marginLeft = this.savedMarginLeft
|
||||
this.viewArea.style.marginRight = this.savedMarginRight
|
||||
this.viewArea.style.marginTop = this.savedMarginTop
|
||||
}
|
||||
this.observer?.disconnect()
|
||||
@@ -404,6 +505,100 @@ function makeDividerHandler(kind: 'chat' | 'top' | 'pane' | 'topPane', index?: n
|
||||
}
|
||||
}
|
||||
|
||||
/** 浏览器内置窗:地址栏 + iframe */
|
||||
function BrowserPane() {
|
||||
const [url, setUrl] = useState('https://www.bing.com')
|
||||
const [src, setSrc] = useState('https://www.bing.com')
|
||||
const go = () => {
|
||||
const u = url.trim()
|
||||
setSrc(/^(\/|https?:\/\/)/i.test(u) ? u : 'about:blank')
|
||||
}
|
||||
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 className="dsh-wt_paneFrame" src={src} title="browser" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 窗内容三态渲染 */
|
||||
function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
|
||||
const { pane, row, index } = props
|
||||
const content = pane.content
|
||||
if (content && content.kind === 'iframe') {
|
||||
return <iframe className="dsh-wt_paneFrame" src={content.url} title={pane.title} />
|
||||
}
|
||||
if (content && content.kind === 'builtin' && content.type === 'browser') {
|
||||
return <BrowserPane />
|
||||
}
|
||||
if (content && content.kind === 'builtin') {
|
||||
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>
|
||||
)
|
||||
}
|
||||
return <PanePicker row={row} index={index} />
|
||||
}
|
||||
|
||||
/** 未指派内容:6 选 1 选择器(better-sidebar 风格 5 项 + 自定义) */
|
||||
function PanePicker(props: { row: PaneRow; index: number }) {
|
||||
const [custom, setCustom] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const pick = (content: SplitContent) => splitStore.setPaneContent(props.row, props.index, content)
|
||||
const applyCustom = () => {
|
||||
const u = url.trim()
|
||||
if (/^(\/|https?:\/\/)/i.test(u)) pick({ kind: 'iframe', url: u })
|
||||
}
|
||||
if (custom) {
|
||||
return (
|
||||
<div className="dsh-wt_paneCustom">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={T('pane.customUrlPh')}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') applyCustom() }}
|
||||
/>
|
||||
<button type="button" className="dsh-wt_paneCustomGo" onClick={applyCustom}>{T('pane.open')}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="dsh-wt_panePicker">
|
||||
<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: 'explorer' })}>
|
||||
<span aria-hidden>📁</span>{T('pane.explorer')}
|
||||
</button>
|
||||
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'scm' })}>
|
||||
<span aria-hidden>🔀</span>{T('pane.scm')}
|
||||
</button>
|
||||
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'tasks' })}>
|
||||
<span aria-hidden>✅</span>{T('pane.tasks')}
|
||||
</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={() => setCustom(true)}>
|
||||
<span aria-hidden>✨</span>{T('pane.custom')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 分栏工作区浮层(shell.overlay 座位;订阅 splitStore 快照渲染) */
|
||||
function SplitWorkspace() {
|
||||
const [snap, setSnap] = useState({
|
||||
@@ -443,6 +638,7 @@ function SplitWorkspace() {
|
||||
const top = spec.top ?? []
|
||||
const main = spec.main ?? []
|
||||
const hasTop = top.length > 0
|
||||
const chatLeft = spec.chatSide === 'left'
|
||||
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))
|
||||
@@ -450,6 +646,7 @@ function SplitWorkspace() {
|
||||
? 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 contentX = chatLeft ? g.left + chatW : g.left
|
||||
|
||||
const topItems = allocate(top, snap.topWs, colW)
|
||||
const mainItems = allocate(main, snap.paneWs, contentW)
|
||||
@@ -460,27 +657,45 @@ function SplitWorkspace() {
|
||||
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) => (
|
||||
<div key={it.pane.id} className="dsh-wt_pane" style={{ position: 'fixed', left: x + it.left, top: y, width: it.width, height: h, zIndex: 68 }}>
|
||||
<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} />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 标题栏 */}
|
||||
<div className="dsh-wt_splitBar" style={{ position: 'fixed', left: g.left, top: barTop, width: hasTop ? colW : contentW, zIndex: 70 }}>
|
||||
<div className="dsh-wt_splitBar" style={{ position: 'fixed', left: contentX, top: barTop, width: hasTop ? colW : contentW, zIndex: 70 }}>
|
||||
<span className="dsh-wt_splitTitle">{spec.title}</span>
|
||||
<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>
|
||||
{/* 顶部通栏行 */}
|
||||
{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>
|
||||
))}
|
||||
{hasTop && topItems.map((it, i) => renderPane(it, 'top', i, g.left, topY, topH))}
|
||||
{/* 主行内容窗 */}
|
||||
{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>
|
||||
))}
|
||||
{mainItems.map((it, i) => renderPane(it, 'main', i, contentX, bodyTop, mainH))}
|
||||
{/* 顶部/主行水平分隔线 */}
|
||||
{hasTop && (
|
||||
<div
|
||||
@@ -509,7 +724,7 @@ function SplitWorkspace() {
|
||||
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 }}
|
||||
style={{ position: 'fixed', left: contentX + it.left + it.width + DIVIDER / 2, top: bodyTop, width: DIVIDER, height: mainH, zIndex: 72 }}
|
||||
onPointerDown={makeDividerHandler('pane', i)}
|
||||
/>
|
||||
))}
|
||||
@@ -518,7 +733,7 @@ function SplitWorkspace() {
|
||||
className="dsh-wt_splitDivider"
|
||||
role="separator"
|
||||
title="拖动调整聊天宽度"
|
||||
style={{ position: 'fixed', left: g.right - chatW - DIVIDER / 2, top: bodyTop, width: DIVIDER, height: mainH, zIndex: 72 }}
|
||||
style={{ position: 'fixed', left: (chatLeft ? g.left + chatW : g.right - chatW) - DIVIDER / 2, top: bodyTop, width: DIVIDER, height: mainH, zIndex: 72 }}
|
||||
onPointerDown={makeDividerHandler('chat')}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -83,10 +83,16 @@ export const css = [
|
||||
'.dsh-wt_splitDividerH{cursor:row-resize}',
|
||||
'.dsh-wt_splitDivider:hover,.dsh-wt_splitDividerH:hover{background:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
|
||||
// 新建工作区(拓扑预设)
|
||||
'.dsh-wt_presets{display:flex;gap:5px;padding:2px 8px 4px}',
|
||||
'.dsh-wt_preset{flex:1;padding:5px 4px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:11px;line-height:15px;cursor:pointer}',
|
||||
'.dsh-wt_presets{display:flex;gap:6px;padding:2px 8px 6px}',
|
||||
'.dsh-wt_preset{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 2px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:10px;line-height:13px;cursor:pointer}',
|
||||
'.dsh-wt_preset:hover{border-color:var(--dsw-alias-border-l2,#3a4150)}',
|
||||
'.dsh-wt_preset[data-on=true]{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
|
||||
'.dsh-wt_thumb{display:flex;flex-direction:column;width:54px;height:36px;border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:3px;overflow:hidden;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03))}',
|
||||
'.dsh-wt_thumbRow{flex:1;display:flex;min-height:0}',
|
||||
'.dsh-wt_thumbRow:last-child .dsh-wt_thumbCell{border-bottom:none}',
|
||||
'.dsh-wt_thumbCell{flex:1;min-width:0;border-right:1px solid var(--dsw-alias-border-l1,#262b36);border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);display:flex;align-items:center;justify-content:center;font-size:8px;line-height:1}',
|
||||
'.dsh-wt_thumbRow .dsh-wt_thumbCell:last-child{border-right:none}',
|
||||
'.dsh-wt_thumbChat{background:var(--dsw-alias-state-accent-primary,#4f8ef7);color:#fff}',
|
||||
// 布局条目卡片
|
||||
'.dsh-wt_layout{display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02));color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;cursor:pointer;text-align:left}',
|
||||
'.dsh-wt_layout:hover{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
|
||||
@@ -98,4 +104,22 @@ export const css = [
|
||||
'.dsh-wt_layoutDesc{font-size:10px;line-height:14px;color:var(--dsw-alias-label-tertiary,#6e7683);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||
'.dsh-wt_layoutBadge{flex:none;font-size:9px;line-height:14px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06));color:var(--dsw-alias-label-tertiary,#6e7683)}',
|
||||
'.dsh-wt_layoutArrow{flex:none;color:var(--dsw-alias-label-tertiary,#6e7683);font-size:14px;line-height:20px}',
|
||||
// 窗内容:选择器 / 自定义 / 浏览器 / 占位
|
||||
'.dsh-wt_panePicker{flex:1;min-height:0;display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:10px;overflow:auto;align-content:start}',
|
||||
'.dsh-wt_panePick{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;padding:10px 4px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02));color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11px;line-height:15px;cursor:pointer}',
|
||||
'.dsh-wt_panePick:hover{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
|
||||
'.dsh-wt_panePick span{font-size:15px;line-height:18px}',
|
||||
'.dsh-wt_paneCustom{flex:1;min-height:0;display:flex;align-items:center;gap:6px;padding:10px}',
|
||||
'.dsh-wt_paneCustom input{flex:1;min-width:0;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;padding:5px 8px;outline:none}',
|
||||
'.dsh-wt_paneCustom input:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
|
||||
'.dsh-wt_paneCustomGo{flex:none;padding:5px 10px;border:none;border-radius:6px;background:var(--dsw-alias-state-accent-primary,#4f8ef7);color:#fff;font:inherit;font-size:12px;cursor:pointer}',
|
||||
'.dsh-wt_browserBar{flex:none;display:flex;align-items:center;gap:5px;padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02))}',
|
||||
'.dsh-wt_browserInput{flex:1;min-width:0;background:transparent;border:none;outline:none;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11px;line-height:16px}',
|
||||
'.dsh-wt_browserGo{flex:none;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_browserGo:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
|
||||
'.dsh-wt_paneWip{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--dsw-alias-label-tertiary,#6e7683)}',
|
||||
'.dsh-wt_paneWipIcon{font-size:26px;line-height:30px}',
|
||||
'.dsh-wt_paneWipText{font-size:11px}',
|
||||
'.dsh-wt_splitFlip{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:13px;cursor:pointer}',
|
||||
'.dsh-wt_splitFlip:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
|
||||
].join('\n')
|
||||
|
||||
Reference in New Issue
Block a user