dsh-sidebar-suite 0.2.9 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -1
- package/DETAIL.md +39 -0
- package/README.md +47 -0
- package/lib/client.js +622 -2
- package/package.json +2 -2
- package//344/275/277/347/224/250/350/257/264/346/230/216.md +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
# 更新日志 / Changelog
|
|
2
2
|
|
|
3
|
-
> 版本号以 npm registry **实际发布记录**为准(当前:`0.0.0-stage`、`0.1.3`、`0.2.4`、`0.2.5`、`0.2.6`、`0.2.7`、`0.2.8`、`0.2.9`)。
|
|
3
|
+
> 版本号以 npm registry **实际发布记录**为准(当前:`0.0.0-stage`、`0.1.3`、`0.2.4`、`0.2.5`、`0.2.6`、`0.2.7`、`0.2.8`、`0.2.9`、`0.3.0`)。
|
|
4
4
|
> `0.2.0`、`0.2.1`、`0.2.2`、`0.2.3` 是开发过程中的本地迭代号,**从未单独发布**;它们的功能一次性随 `0.2.4` 上线,所以在下面合并记录。
|
|
5
5
|
>
|
|
6
6
|
> Versions mirror the npm registry. `0.2.0`–`0.2.3` were local iteration numbers that were never published on their own; all of their features shipped together in `0.2.4`.
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## 0.3.0 — 第三方插件可以注册自己的页签 / Third-party tabs(2026-11-11)
|
|
11
|
+
|
|
12
|
+
**这次改了运行时代码**(`lib/client.js`),所以**升级后要托盘退出 DSH 再重开一次**。This release touches `lib/client.js`; quit DSH from the tray and reopen once after upgrading.
|
|
13
|
+
|
|
14
|
+
- **侧栏变成可插拔的页容器** — 客户端通过 `ctx.provide('sidebarSuite', …)` 发布一个服务,别的插件 `inject = ['sidebarSuite']` 之后就能 `registerTab({ id, title, icon, order, mount })` 把自己的界面挂进侧栏。The sidebar is now a pluggable page container: a `sidebarSuite` service is published on the client context, and any plugin injecting it can register its own page.
|
|
15
|
+
- **给的是普通 DOM 容器,不是 React 组件** — `mount(container, tab)` 拿到一个空 `<div>`,原生 DOM / React / canvas 都行,消费方不必和本插件的框架版本对齐。Consumers get a plain container, so any stack works and no React version has to line up.
|
|
16
|
+
- **懒挂载 + 自带清理** — `mount` 只在页签第一次被打开时执行;返回的清理函数在页签移除、消费方卸载、DSH 退出时都会调用。`registerTab` 返回自己的 dispose 函数,正好能直接塞进 `ctx.effect`。Mount runs on first activation, and the returned disposer plugs straight into `ctx.effect`.
|
|
17
|
+
- **同 id 重注册 = 原地替换** — 客户端热重载会重复注册,替换保证不会叠出两个同名页签,且旧页签的清理函数先跑。Re-registering an id replaces in place, which is what a hot reload needs.
|
|
18
|
+
- **失败只烂在自己那一页** — `mount` 抛异常时错误写进它自己的容器并 `console.error` 一次,侧栏其余部分照常工作。A throwing consumer is contained inside its own page.
|
|
19
|
+
- **页签条按布局出现** — 布局 B 下是 `[扩展][工作区][插件页…]` 并排;布局 A / C 下只有插件页进条(内置两组仍由原按钮控制,那两种布局不是页签模型)。**一个消费方都没有时这排页签是 `hidden` 的,不占高度**,看到的和 0.2.9 完全一样。Layout B lists the built-in groups alongside the plugin pages; layouts A and C list plugin pages only. With no consumers the strip is `hidden`: zero height, no visual change from 0.2.9.
|
|
20
|
+
- **选中态是宿主主题色** — 当前页签底部一条 2px 强调色(`--dsw-alias-state-business-primary`),跟随工作区主题;窄侧栏(< 300px)自动只留图标,没给图标的页签保留文字而不是变成空白方块。The active page carries a 2 px accent underline in the workspace's theme colour; narrow columns go icon-only, and a tab without an icon keeps its label.
|
|
21
|
+
- **选中哪一页跟着侧栏走** — 存 `localStorage["dsh-sidebar-suite:active-tab"]`,不写插件配置;再点一次当前页签退回原来的组。The selection lives in its own storage key, and re-clicking the active page returns to the groups.
|
|
22
|
+
- **点了内置页签也会通知订阅者** — 布局 B 下点「扩展 / 工作区」等于把这一列从插件页手里拿走,`subscribe` 现在会像其它切换一样发出 `('active', null)`;反过来,在两个内置组之间来回切是静默的,不会假装有插件页移动。没有这一条,靠订阅停表 / 停轮询 / 停画布的消费方会以为自己还挂在屏幕上。A click on a built-in page notifies subscribers exactly like any other switch; moving between two built-ins stays silent.
|
|
23
|
+
- **测试** — 新增两套:`tests/test_030.mjs` 在 vm 沙箱里按消费方的方式调服务(注册、列表、激活、订阅、注销、重复注册、非法入参,外加渲染与样式的源码契约、文档与版本联动,70 项);`tests/test_031.mjs` 自己搭了一个符合宿主契约的侧栏 DOM,跑真实 `lib/client.js`,然后像用户那样点页签、再点一次退回、切布局 A/C、注销、点内置页签看通知(53 项,含「连续 60 次重绘不新增节点」的防抖动断言——`paint()` 每次插入节点的话,它自己装的 `MutationObserver` 会把它重新触发成死循环)。`npm test` 链扩为 9 套。Two new suites: one drives the service the way a consumer would, the other runs the real bundle against a synthetic host sidebar and clicks through it, including a repaint-loop guard.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
10
27
|
## 0.2.9 — 布局栏重做 + 视觉精修 / Toolbar rebuilt as a segmented control(2026-11-11)
|
|
11
28
|
|
|
12
29
|
**这次改了运行时代码**(`lib/client.js`),所以**升级后要托盘退出 DSH 再重开一次**。This release touches `lib/client.js`; quit DSH from the tray and reopen once after upgrading.
|
package/DETAIL.md
CHANGED
|
@@ -81,6 +81,45 @@ The toolbar is one segmented control: `[fold | tabs | overlay] │ actions`. The
|
|
|
81
81
|
|
|
82
82
|
`换布局 / Cycle` 默认不出现(`showQuickSwitch`),打开后和其它动作按钮共用同一套图标。`Cycle` is off by default and shares the icon set when enabled.
|
|
83
83
|
|
|
84
|
+
## 第三方页签 / Third-party tabs(0.3.0)
|
|
85
|
+
|
|
86
|
+
侧栏从 0.3.0 起是一个**可插拔的页容器**:别的插件通过 `ctx.sidebarSuite` 注册自己的页,用户点一下就能在你的界面和你自己的界面之间切换。
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
export const inject = ['sidebarSuite']
|
|
90
|
+
|
|
91
|
+
export function apply(ctx) {
|
|
92
|
+
ctx.effect(() => ctx.sidebarSuite.registerTab({
|
|
93
|
+
id: 'my-plugin',
|
|
94
|
+
title: { zh: '我的面板', en: 'My panel' },
|
|
95
|
+
icon: '<svg viewBox="0 0 16 16">…</svg>', // 或一个 emoji
|
|
96
|
+
order: 100,
|
|
97
|
+
mount(container, tab) {
|
|
98
|
+
container.textContent = 'hello'
|
|
99
|
+
return () => { /* 清理 */ }
|
|
100
|
+
},
|
|
101
|
+
}))
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
设计取舍 / Why it looks like this:
|
|
106
|
+
|
|
107
|
+
- **给 DOM 容器,不给 React 组件** — 消费方可以是任何技术栈写的插件,不必和本插件的 React 版本对齐。A plain container works for every consumer regardless of framework.
|
|
108
|
+
- **懒挂载** — `mount` 只在页签第一次被打开时执行,没打开过的页签不跑任何代码。Mount runs on first activation only.
|
|
109
|
+
- **`registerTab` 返回自己的 dispose** — 正好是 `ctx.effect` 要的形状,消费方不用自己维护注销;消费方卸载时页签自动消失。The returned disposer plugs straight into `ctx.effect`.
|
|
110
|
+
- **同 id 重注册 = 原地替换** — 客户端热重载会重复注册,替换能保证只有一个页签。Re-registering an id replaces in place, which is what a hot client reload needs.
|
|
111
|
+
- **失败只烂在自己那一页** — `mount` 抛异常时,错误写进它自己的容器并 `console.error` 一次,侧栏其余部分不受影响。A throwing consumer is contained inside its own page.
|
|
112
|
+
- **"失去这一列"只有一种通知方式** — 不管是被另一个插件页顶掉、被用户再点一次退回、还是布局 B 下点了「扩展 / 工作区」,走的都是同一次 `subscribe('active', null)`;在两个内置组之间来回切则不通知,因为对消费方来说什么都没变。订阅者不必自己推断"我现在还在不在屏幕上"。Losing the column always arrives as one notification, whoever took it; switching between two built-ins stays silent because nothing changed for the consumer.
|
|
113
|
+
- **进不去的边界** — 页签只拿到发给它的容器,没有能力移动、覆盖或重构宿主 DOM。A tab owns its container and nothing else.
|
|
114
|
+
|
|
115
|
+
三种布局的差异 / Under the three layouts:
|
|
116
|
+
|
|
117
|
+
- B 标签页:`[扩展][工作区][你的页…]` 并排,都是页。
|
|
118
|
+
- A 折叠、C 浮层:**只有第三方页**进条;内置两组仍由原按钮控制,因为这两种布局不是页签模型。
|
|
119
|
+
- 没有任何第三方注册:页签条本身带 `hidden`,不占高度、不参与布局,看到的和 0.2.x 一模一样(只是多一个隐藏的空容器)。With no consumer the strip carries `hidden`: zero height, no layout impact — visually identical to 0.2.x.
|
|
120
|
+
|
|
121
|
+
选中态存 `localStorage["dsh-sidebar-suite:active-tab"]`,不写插件配置;再点当前页签退回原来的组。
|
|
122
|
+
|
|
84
123
|
## 安装与生效 / Install & apply
|
|
85
124
|
|
|
86
125
|
```sh
|
package/README.md
CHANGED
|
@@ -104,6 +104,53 @@ The toolbar is one segmented control: the three mutually exclusive layouts share
|
|
|
104
104
|
`换布局 / Cycle` 默认不出现,可用 `showQuickSwitch: true` 打开;它和其余动作按钮一起走同一套图标。
|
|
105
105
|
`Cycle` stays hidden unless `showQuickSwitch: true`; it uses the same icon set as the other action buttons.
|
|
106
106
|
|
|
107
|
+
## 第三方插件接入:注册自己的页签 / Third-party tabs(0.3.0)
|
|
108
|
+
|
|
109
|
+
别的插件可以往侧栏注册一页自己的界面。客户端会拿到一个 `sidebarSuite` 服务:
|
|
110
|
+
|
|
111
|
+
Any other plugin can put its own page in the sidebar through one client service.
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
// 在消费方插件里 / in the consumer plugin
|
|
115
|
+
export const inject = ['sidebarSuite']
|
|
116
|
+
|
|
117
|
+
export function apply(ctx) {
|
|
118
|
+
ctx.effect(() => ctx.sidebarSuite.registerTab({
|
|
119
|
+
id: 'my-plugin', // 必填,[A-Za-z0-9._-],≤48 字符
|
|
120
|
+
title: { zh: '我的面板', en: 'My panel' }, // 必填,或直接给字符串
|
|
121
|
+
icon: '<svg viewBox="0 0 16 16">…</svg>', // 可选,也可以是一个 emoji
|
|
122
|
+
order: 100, // 可选,越小越靠左,默认 100
|
|
123
|
+
mount(container, tab) { // 必填(别名 render)
|
|
124
|
+
container.textContent = 'hello'
|
|
125
|
+
return () => { /* 清理 */ } // 可选:移除/卸载时调用
|
|
126
|
+
},
|
|
127
|
+
}))
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- `mount` 拿到的是**一个空的普通 `<div>`**,原生 DOM、React、canvas 都行,不要求你用哪套框架。
|
|
132
|
+
- **首次点开才调用 `mount`**:没被打开过的页签不执行任何代码。
|
|
133
|
+
- 返回的清理函数在页签被移除、消费方插件卸载、DSH 退出这三种情况下都会执行。
|
|
134
|
+
- `registerTab` 返回它自己的 dispose 函数,所以 `ctx.effect(() => api.registerTab(...))` 就是完整写法,不用自己记着取消。
|
|
135
|
+
- 同一个 id 重复注册 = **热重载**,会原地替换而不是叠出两个页签。
|
|
136
|
+
|
|
137
|
+
页签出现在哪儿 / Where the page shows up:
|
|
138
|
+
|
|
139
|
+
| 布局 | 表现 |
|
|
140
|
+
|---|---|
|
|
141
|
+
| B 标签页 | 和控制栏下面那排「扩展 / 工作区」并排 |
|
|
142
|
+
| A 折叠、C 浮层 | 只有插件页签进条;内置两组仍由原按钮控制(这两种布局本来就不是"页签"模型) |
|
|
143
|
+
| 没人注册 | 这排页签**完全不出现**,界面和 0.2.x 一模一样 |
|
|
144
|
+
|
|
145
|
+
选中哪一页记在 `localStorage["dsh-sidebar-suite:active-tab"]`;**再点一次当前页签就退回原来的组**(布局 A / C 下这是唯一的退出口)。
|
|
146
|
+
|
|
147
|
+
服务其余方法:`unregisterTab(id)`、`getTabs()`、`activateTab(id)`、`getActiveTab()`、`subscribe(fn)`(`fn(tabs, reason, activeId)`)、`getLayout()`。
|
|
148
|
+
|
|
149
|
+
`reason` 的取值是 `register` / `unregister` / `active` / `orphan`。**点内置页签(布局 B 下的「扩展 / 工作区」)也算一次 `active`**(`activeId` 为 `null`),所以靠订阅停表、停轮询、停动画的代码不需要为内置页签另写一套判断;反过来,在两个内置组之间切换是静默的。
|
|
150
|
+
|
|
151
|
+
> 边界是刻意的:一个页签只拥有发给它的那个容器,没有能力移动或重构侧栏,也不能覆盖宿主自己的界面。
|
|
152
|
+
> A tab owns its container and nothing else — it cannot restructure the sidebar.
|
|
153
|
+
|
|
107
154
|
## 实现要点 / How it works
|
|
108
155
|
|
|
109
156
|
- 所有持久状态写在 `<html>` 属性上:`data-dsbs-mode`、`data-dsbs-tab`、`data-dsbs-fold-ext`、`data-dsbs-fold-ws`、`data-dsbs-pop`、`data-dsbs-quick`、`data-dsbs-rail`、`data-dsbs-narrow`。CSS 负责显隐,插件不重构宿主 DOM。All state lives in `<html>` attributes; CSS owns visibility.
|
package/lib/client.js
CHANGED
|
@@ -19,8 +19,15 @@ window.__ModuleLoader__.load({
|
|
|
19
19
|
'use strict'
|
|
20
20
|
|
|
21
21
|
var ENTRY = 'dsh-sidebar-suite'
|
|
22
|
+
// Reported to tab consumers so they can feature-detect. Kept in lockstep
|
|
23
|
+
// with package.json by tests/test_030.mjs.
|
|
24
|
+
var VERSION = '0.3.0'
|
|
22
25
|
var NS = 'sidebar-suite'
|
|
23
26
|
var LS_KEY = 'dsh-sidebar-suite:state'
|
|
27
|
+
// Where the third-party tab choice lives. Kept out of LS_KEY on purpose:
|
|
28
|
+
// the config form describes the sidebar, this describes the user's
|
|
29
|
+
// current position inside it, and the two have different lifetimes.
|
|
30
|
+
var TAB_STORE_KEY = 'dsh-sidebar-suite:active-tab'
|
|
24
31
|
var STYLE_ID = 'dsh-sidebar-suite-style'
|
|
25
32
|
// Where feedback goes. A plugin bundle cannot open a mail client or post a
|
|
26
33
|
// form without a server, so the entry point assembles a ready-to-send
|
|
@@ -79,6 +86,10 @@ window.__ModuleLoader__.load({
|
|
|
79
86
|
'tab.to.ext.hint': 'Switch the shared space to the panels group.',
|
|
80
87
|
'tab.to.ws': 'Show workspaces',
|
|
81
88
|
'tab.to.ws.hint': 'Switch the shared space to the workspaces group.',
|
|
89
|
+
'tab.ext': 'Panels',
|
|
90
|
+
'tab.ws': 'Workspaces',
|
|
91
|
+
'tabs.label': 'Sidebar pages',
|
|
92
|
+
'tabs.own': 'This page is provided by {name}.',
|
|
82
93
|
'pop.open': 'Open panels',
|
|
83
94
|
'pop.close': 'Close panels',
|
|
84
95
|
'pop.hint': 'Open or close the panels overlay.',
|
|
@@ -129,6 +140,10 @@ window.__ModuleLoader__.load({
|
|
|
129
140
|
'tab.to.ext.hint': '把共用的一栏切换到扩展区。',
|
|
130
141
|
'tab.to.ws': '显示工作区',
|
|
131
142
|
'tab.to.ws.hint': '把共用的一栏切换到工作区。',
|
|
143
|
+
'tab.ext': '扩展',
|
|
144
|
+
'tab.ws': '工作区',
|
|
145
|
+
'tabs.label': '侧栏页签',
|
|
146
|
+
'tabs.own': '这一页由 {name} 提供。',
|
|
132
147
|
'pop.open': '展开扩展',
|
|
133
148
|
'pop.close': '收起扩展',
|
|
134
149
|
'pop.hint': '打开或收起扩展浮层。',
|
|
@@ -356,6 +371,34 @@ window.__ModuleLoader__.load({
|
|
|
356
371
|
'[data-row-key^="session:"]{transition:background-color .13s ease,box-shadow .13s ease,color .13s ease;font-variant-numeric:tabular-nums}',
|
|
357
372
|
'[data-row-key^="session:"][aria-selected="true"]{box-shadow:inset 2px 0 0 var(--dsw-alias-state-business-primary,var(--dsw-alias-label-primary,#4d6bfe))!important}',
|
|
358
373
|
'html[data-dsbs-rail="true"] [data-row-key^="session:"][aria-selected="true"]{box-shadow:none!important}',
|
|
374
|
+
|
|
375
|
+
// ---- third-party page strip (0.3.0) ------------------------------
|
|
376
|
+
// The strip only exists while at least one plugin registered a tab, so a
|
|
377
|
+
// stock profile never sees it. Everything inherits currentColor, which
|
|
378
|
+
// is what keeps it legible in both host themes without a palette here.
|
|
379
|
+
'[data-dsbs-tabs]{display:flex;align-items:center;gap:2px;padding:3px 4px 0;flex:none;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}',
|
|
380
|
+
'[data-dsbs-tabs][hidden]{display:none!important}',
|
|
381
|
+
'[data-dsbs-tabs]::-webkit-scrollbar{display:none}',
|
|
382
|
+
'[data-dsbs-tabs] button{position:relative;display:inline-flex;align-items:center;gap:5px;flex:none;min-width:0;max-width:150px;padding:4px 9px;border:0;border-radius:7px 7px 0 0;background:transparent;color:inherit;font:inherit;font-size:11px;line-height:1.3;opacity:.6;cursor:pointer;white-space:nowrap;transition:opacity .14s ease,background-color .14s ease}',
|
|
383
|
+
'[data-dsbs-tabs] button:hover{opacity:.92;background:color-mix(in srgb,currentColor 8%,transparent)}',
|
|
384
|
+
'[data-dsbs-tabs] button[aria-selected="true"]{opacity:1;font-weight:600;background:color-mix(in srgb,currentColor 12%,transparent);box-shadow:inset 0 -2px 0 var(--dsw-alias-state-business-primary,currentColor)}',
|
|
385
|
+
'[data-dsbs-tablabel]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}',
|
|
386
|
+
'[data-dsbs-tabicon]{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;flex:none}',
|
|
387
|
+
'[data-dsbs-tabicon] svg{width:14px;height:14px;display:block;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}',
|
|
388
|
+
'[data-dsbs-tabicon-text]{font-size:12px;line-height:1}',
|
|
389
|
+
'[data-dsbs-tabbadge]{flex:none;min-width:15px;padding:0 4px;border-radius:7px;font-size:10px;line-height:15px;text-align:center;background:color-mix(in srgb,currentColor 16%,transparent);font-variant-numeric:tabular-nums}',
|
|
390
|
+
// The tab's own container inherits the column the two groups gave up.
|
|
391
|
+
'[data-dsbs-tabpanel]{display:none;flex:1 1 auto;min-height:0;min-width:0;overflow:auto;flex-direction:column}',
|
|
392
|
+
'[data-dsbs-tabpanel][data-dsbs-visible="true"]{display:flex}',
|
|
393
|
+
'html[data-dsbs-ptab="true"] nav[data-dsbs-ext]{display:none!important}',
|
|
394
|
+
'html[data-dsbs-ptab="true"] div:has(> [data-slot="sidebar.workspaces"]){display:none!important}',
|
|
395
|
+
// Narrow: icon-only pages, the same rule the control bar follows. A tab
|
|
396
|
+
// without an icon keeps its label instead of becoming an empty box.
|
|
397
|
+
'html[data-dsbs-narrow="true"] [data-dsbs-tabs] button{max-width:64px;padding:4px 6px}',
|
|
398
|
+
'html[data-dsbs-narrow="true"] [data-dsbs-tabs] button[data-dsbs-hasicon] [data-dsbs-tablabel]{display:none}',
|
|
399
|
+
'html[data-dsbs-narrow="true"] [data-dsbs-tabs] button[data-dsbs-hasicon] [data-dsbs-tabbadge]{display:none}',
|
|
400
|
+
'html[data-dsbs-rail="true"] [data-dsbs-tabs]{padding:2px 2px 0}',
|
|
401
|
+
'html[data-dsbs-rail="true"] [data-dsbs-tabs] button{max-width:40px;padding:4px 5px}',
|
|
359
402
|
].join('\n')
|
|
360
403
|
|
|
361
404
|
function assign(target) {
|
|
@@ -504,6 +547,11 @@ window.__ModuleLoader__.load({
|
|
|
504
547
|
return DICTS[lang][key] || DICTS.en[key] || key
|
|
505
548
|
}
|
|
506
549
|
t.refresh = refresh
|
|
550
|
+
// Third-party tab titles may arrive as `{ zh, en }`; resolving them needs
|
|
551
|
+
// the same detector the dictionaries use, not a second guess.
|
|
552
|
+
t.langName = function () {
|
|
553
|
+
return lang
|
|
554
|
+
}
|
|
507
555
|
return t
|
|
508
556
|
}
|
|
509
557
|
|
|
@@ -559,6 +607,100 @@ window.__ModuleLoader__.load({
|
|
|
559
607
|
resizeObserver: null,
|
|
560
608
|
mutationObserver: null,
|
|
561
609
|
raf: 0,
|
|
610
|
+
// 0.3.0 — third-party tabs. `activeTab` holds a registered id (or null
|
|
611
|
+
// for the built-in groups); `tabPanels` caches one live container per
|
|
612
|
+
// tab so a mount callback runs once and its disposer stays reachable.
|
|
613
|
+
activeTab: readActiveTab(),
|
|
614
|
+
seenTabs: Object.create(null),
|
|
615
|
+
tabStrip: null,
|
|
616
|
+
tabPanels: Object.create(null),
|
|
617
|
+
tabSignature: '',
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// The public surface other plugins see. Everything here is either a
|
|
621
|
+
// function declaration (hoisted, so the object can be built before the
|
|
622
|
+
// implementations read below) or a getter that touches live state only
|
|
623
|
+
// when called.
|
|
624
|
+
var sidebarService = {
|
|
625
|
+
version: VERSION,
|
|
626
|
+
// Returns its own disposer, which is exactly what `ctx.effect(() => …)`
|
|
627
|
+
// wants, so a consumer never has to remember to unregister.
|
|
628
|
+
registerTab: function (descriptor) {
|
|
629
|
+
return registerTab(descriptor)
|
|
630
|
+
},
|
|
631
|
+
unregisterTab: function (id) {
|
|
632
|
+
return unregisterTab(id)
|
|
633
|
+
},
|
|
634
|
+
getTabs: function () {
|
|
635
|
+
return tabSnapshot()
|
|
636
|
+
},
|
|
637
|
+
activateTab: function (id) {
|
|
638
|
+
return activateTab(id)
|
|
639
|
+
},
|
|
640
|
+
getActiveTab: function () {
|
|
641
|
+
return runtime.activeTab && findTab(runtime.activeTab) ? runtime.activeTab : null
|
|
642
|
+
},
|
|
643
|
+
// A consumer can follow the strip instead of polling it: called with
|
|
644
|
+
// (tabs, reason, activeId) whenever the list or the selection changes.
|
|
645
|
+
subscribe: function (listener) {
|
|
646
|
+
if (typeof listener !== 'function') return function () {}
|
|
647
|
+
tabListeners.push(listener)
|
|
648
|
+
var off = false
|
|
649
|
+
return function () {
|
|
650
|
+
if (off) return
|
|
651
|
+
off = true
|
|
652
|
+
var at = tabListeners.indexOf(listener)
|
|
653
|
+
if (at >= 0) tabListeners.splice(at, 1)
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
// Reading the current shape lets a consumer align with the layout
|
|
657
|
+
// instead of sniffing classes off <html>.
|
|
658
|
+
getLayout: function () {
|
|
659
|
+
return {
|
|
660
|
+
mode: config.values.mode,
|
|
661
|
+
tab: config.values.tab,
|
|
662
|
+
activeTab: runtime.activeTab,
|
|
663
|
+
narrow: runtime.narrow === true,
|
|
664
|
+
rail: runtime.rail === true,
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Publish the service once the slot system is live.
|
|
670
|
+
//
|
|
671
|
+
// Two dead ends measured against this client context, both of which take
|
|
672
|
+
// the whole sidebar down:
|
|
673
|
+
// * ctx.provide() directly inside apply() stalls the plugin loader on
|
|
674
|
+
// "Loading plugins..." forever;
|
|
675
|
+
// * a static `inject: ['slots']` never activates, so apply() never runs.
|
|
676
|
+
// Publishing from inside a dependency callback is the shape that works.
|
|
677
|
+
function publishService() {
|
|
678
|
+
try {
|
|
679
|
+
if (typeof ctx.provide === 'function') ctx.provide('sidebarSuite', sidebarService)
|
|
680
|
+
} catch (error) {
|
|
681
|
+
try {
|
|
682
|
+
console.warn('[dsh-sidebar-suite] could not publish the sidebarSuite service:', error)
|
|
683
|
+
} catch (inner) {
|
|
684
|
+
void inner
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (typeof ctx.inject === 'function') {
|
|
689
|
+
try {
|
|
690
|
+
ctx.inject(['slots'], function () { publishService() })
|
|
691
|
+
} catch (error) {
|
|
692
|
+
publishService()
|
|
693
|
+
}
|
|
694
|
+
} else {
|
|
695
|
+
publishService()
|
|
696
|
+
}
|
|
697
|
+
// One window handle for the same object. Not a second API: it exists so a
|
|
698
|
+
// consumer can reach the service from a plain browser context (and so the
|
|
699
|
+
// release checks can drive the strip over CDP).
|
|
700
|
+
try {
|
|
701
|
+
window.__dshSidebarSuite = sidebarService
|
|
702
|
+
} catch (error) {
|
|
703
|
+
void error
|
|
562
704
|
}
|
|
563
705
|
|
|
564
706
|
var manualCleanups = []
|
|
@@ -1163,6 +1305,430 @@ window.__ModuleLoader__.load({
|
|
|
1163
1305
|
return bar
|
|
1164
1306
|
}
|
|
1165
1307
|
|
|
1308
|
+
// ---- third-party tabs (0.3.0) --------------------------------------
|
|
1309
|
+
// Another plugin cannot reach into this bundle, so the whole feature is
|
|
1310
|
+
// one service object handed to the client context (`ctx.provide` at the
|
|
1311
|
+
// bottom of this file). The contract is deliberately DOM-shaped rather
|
|
1312
|
+
// than React-shaped: a consumer receives a plain container and paints
|
|
1313
|
+
// into it, which works for every plugin no matter how it was built.
|
|
1314
|
+
//
|
|
1315
|
+
// Deliberately absent: any way for a tab to restructure the sidebar.
|
|
1316
|
+
// A tab owns its own container and nothing else.
|
|
1317
|
+
var TAB_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/
|
|
1318
|
+
var tabRecords = []
|
|
1319
|
+
var tabListeners = []
|
|
1320
|
+
var tabSeq = 0
|
|
1321
|
+
|
|
1322
|
+
function readActiveTab() {
|
|
1323
|
+
try {
|
|
1324
|
+
var raw = localStorage.getItem(TAB_STORE_KEY)
|
|
1325
|
+
return typeof raw === 'string' && raw ? raw : null
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
return null
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function writeActiveTab(id) {
|
|
1332
|
+
try {
|
|
1333
|
+
if (id) localStorage.setItem(TAB_STORE_KEY, id)
|
|
1334
|
+
else localStorage.removeItem(TAB_STORE_KEY)
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
void error
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function findTab(id) {
|
|
1341
|
+
for (var i = 0; i < tabRecords.length; i++) {
|
|
1342
|
+
if (tabRecords[i].id === id) return tabRecords[i]
|
|
1343
|
+
}
|
|
1344
|
+
return null
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// `title` may be a plain string or `{ zh, en }`. Resolving it here keeps
|
|
1348
|
+
// the two languages in one descriptor instead of making every consumer
|
|
1349
|
+
// read the locale itself.
|
|
1350
|
+
function tabTitle(record) {
|
|
1351
|
+
var title = record.title
|
|
1352
|
+
if (title && typeof title === 'object') {
|
|
1353
|
+
var zh = typeof title.zh === 'string' ? title.zh : ''
|
|
1354
|
+
var en = typeof title.en === 'string' ? title.en : ''
|
|
1355
|
+
var preferred = t.langName() === 'zh' ? zh || en : en || zh
|
|
1356
|
+
return preferred || record.id
|
|
1357
|
+
}
|
|
1358
|
+
return typeof title === 'string' && title ? title : record.id
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function tabSnapshot() {
|
|
1362
|
+
var out = []
|
|
1363
|
+
for (var i = 0; i < tabRecords.length; i++) {
|
|
1364
|
+
var record = tabRecords[i]
|
|
1365
|
+
out.push({
|
|
1366
|
+
id: record.id,
|
|
1367
|
+
title: tabTitle(record),
|
|
1368
|
+
icon: record.icon,
|
|
1369
|
+
order: record.order,
|
|
1370
|
+
badge: record.badge,
|
|
1371
|
+
hidden: record.hidden,
|
|
1372
|
+
owner: record.owner,
|
|
1373
|
+
})
|
|
1374
|
+
}
|
|
1375
|
+
return out
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function notifyTabs(reason) {
|
|
1379
|
+
if (!tabListeners.length) return
|
|
1380
|
+
var list = tabSnapshot()
|
|
1381
|
+
var active = runtime.activeTab
|
|
1382
|
+
for (var i = 0; i < tabListeners.length; i++) {
|
|
1383
|
+
try {
|
|
1384
|
+
tabListeners[i](list, reason, active)
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
void error
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function tabContext(record) {
|
|
1392
|
+
return {
|
|
1393
|
+
id: record.id,
|
|
1394
|
+
t: function (key) {
|
|
1395
|
+
return t(key)
|
|
1396
|
+
},
|
|
1397
|
+
lang: function () {
|
|
1398
|
+
return t.langName()
|
|
1399
|
+
},
|
|
1400
|
+
// Lets a consumer close its own tab once the user is done with it,
|
|
1401
|
+
// rather than leaving a dead page in the strip.
|
|
1402
|
+
close: function () {
|
|
1403
|
+
if (runtime.activeTab === record.id) activateTab(null)
|
|
1404
|
+
},
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function createTabPanel(record) {
|
|
1409
|
+
var panel = document.createElement('div')
|
|
1410
|
+
panel.setAttribute('data-dsbs-tabpanel', record.id)
|
|
1411
|
+
panel.setAttribute('role', 'tabpanel')
|
|
1412
|
+
panel.setAttribute('aria-label', tabTitle(record))
|
|
1413
|
+
panel.setAttribute('data-dsbs-visible', 'false')
|
|
1414
|
+
var entry = { el: panel, cleanup: null, record: record, mounted: false }
|
|
1415
|
+
runtime.tabPanels[record.id] = entry
|
|
1416
|
+
return entry
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Mounting is deferred until the first activation: a tab that is never
|
|
1420
|
+
// opened should cost nothing, and a container that is still `display:
|
|
1421
|
+
// none` has no geometry to measure.
|
|
1422
|
+
function mountTabPanel(entry) {
|
|
1423
|
+
if (!entry || entry.mounted) return
|
|
1424
|
+
entry.mounted = true
|
|
1425
|
+
var record = entry.record
|
|
1426
|
+
if (typeof record.mount !== 'function') return
|
|
1427
|
+
try {
|
|
1428
|
+
var result = record.mount(entry.el, tabContext(record))
|
|
1429
|
+
if (typeof result === 'function') entry.cleanup = result
|
|
1430
|
+
else if (result && typeof result.dispose === 'function') {
|
|
1431
|
+
entry.cleanup = function () {
|
|
1432
|
+
result.dispose()
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
// A broken consumer must never take the sidebar down with it: the
|
|
1437
|
+
// failure is reported inside its own tab and logged once.
|
|
1438
|
+
try {
|
|
1439
|
+
entry.el.textContent = '[dsh-sidebar-suite] tab "' + record.id + '" failed to mount: '
|
|
1440
|
+
+ (error && error.message ? error.message : String(error))
|
|
1441
|
+
} catch (inner) {
|
|
1442
|
+
void inner
|
|
1443
|
+
}
|
|
1444
|
+
try {
|
|
1445
|
+
console.error('[dsh-sidebar-suite] tab mount failed:', record.id, error)
|
|
1446
|
+
} catch (inner) {
|
|
1447
|
+
void inner
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function disposeTabPanel(id) {
|
|
1453
|
+
var entry = runtime.tabPanels[id]
|
|
1454
|
+
if (!entry) return
|
|
1455
|
+
delete runtime.tabPanels[id]
|
|
1456
|
+
try {
|
|
1457
|
+
if (typeof entry.cleanup === 'function') entry.cleanup()
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
void error
|
|
1460
|
+
}
|
|
1461
|
+
try {
|
|
1462
|
+
if (entry.el && entry.el.parentNode) entry.el.parentNode.removeChild(entry.el)
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
void error
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function disposeAllTabPanels() {
|
|
1469
|
+
var ids = []
|
|
1470
|
+
for (var id in runtime.tabPanels) ids.push(id)
|
|
1471
|
+
for (var i = 0; i < ids.length; i++) disposeTabPanel(ids[i])
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function unregisterTab(id) {
|
|
1475
|
+
var record = findTab(id)
|
|
1476
|
+
if (!record) return false
|
|
1477
|
+
tabRecords.splice(tabRecords.indexOf(record), 1)
|
|
1478
|
+
disposeTabPanel(id)
|
|
1479
|
+
if (runtime.activeTab === id) {
|
|
1480
|
+
runtime.activeTab = null
|
|
1481
|
+
writeActiveTab(null)
|
|
1482
|
+
}
|
|
1483
|
+
notifyTabs('unregister')
|
|
1484
|
+
scheduleSync()
|
|
1485
|
+
return true
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function registerTab(descriptor) {
|
|
1489
|
+
if (!descriptor || typeof descriptor !== 'object') {
|
|
1490
|
+
throw new TypeError('[dsh-sidebar-suite] registerTab(descriptor) needs an object')
|
|
1491
|
+
}
|
|
1492
|
+
var id = String(descriptor.id == null ? '' : descriptor.id).trim()
|
|
1493
|
+
if (!TAB_ID_RE.test(id)) {
|
|
1494
|
+
throw new TypeError('[dsh-sidebar-suite] registerTab: id must match ' + TAB_ID_RE + ', got "' + id + '"')
|
|
1495
|
+
}
|
|
1496
|
+
if (id === 'ext' || id === 'ws') {
|
|
1497
|
+
throw new TypeError('[dsh-sidebar-suite] registerTab: "' + id + '" is reserved for a built-in group')
|
|
1498
|
+
}
|
|
1499
|
+
var mount = descriptor.mount || descriptor.render
|
|
1500
|
+
if (typeof mount !== 'function') {
|
|
1501
|
+
throw new TypeError('[dsh-sidebar-suite] registerTab: "' + id + '" needs mount(container) or render(container)')
|
|
1502
|
+
}
|
|
1503
|
+
var record = {
|
|
1504
|
+
id: id,
|
|
1505
|
+
title: descriptor.title,
|
|
1506
|
+
icon: typeof descriptor.icon === 'string' ? descriptor.icon : null,
|
|
1507
|
+
order: typeof descriptor.order === 'number' && isFinite(descriptor.order) ? descriptor.order : 100,
|
|
1508
|
+
badge: descriptor.badge == null ? null : descriptor.badge,
|
|
1509
|
+
hidden: descriptor.hidden === true,
|
|
1510
|
+
mount: mount,
|
|
1511
|
+
owner: descriptor.owner == null ? null : String(descriptor.owner),
|
|
1512
|
+
seq: (tabSeq += 1),
|
|
1513
|
+
}
|
|
1514
|
+
// Re-registering the same id is exactly what a hot client reload looks
|
|
1515
|
+
// like, so it replaces in place instead of stacking a duplicate page.
|
|
1516
|
+
var previous = findTab(id)
|
|
1517
|
+
if (previous) {
|
|
1518
|
+
disposeTabPanel(id)
|
|
1519
|
+
tabRecords.splice(tabRecords.indexOf(previous), 1)
|
|
1520
|
+
}
|
|
1521
|
+
tabRecords.push(record)
|
|
1522
|
+
runtime.seenTabs[id] = true
|
|
1523
|
+
notifyTabs('register')
|
|
1524
|
+
scheduleSync()
|
|
1525
|
+
var disposed = false
|
|
1526
|
+
return function disposeTab() {
|
|
1527
|
+
if (disposed) return
|
|
1528
|
+
disposed = true
|
|
1529
|
+
unregisterTab(id)
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Built-in groups only appear as pages in layout b. Layout a folds the
|
|
1534
|
+
// two groups independently and layout c floats the panels, so a page
|
|
1535
|
+
// switcher for them there would contradict what those layouts mean --
|
|
1536
|
+
// third-party tabs still work in all three.
|
|
1537
|
+
function tabEntries() {
|
|
1538
|
+
var out = []
|
|
1539
|
+
var showBuiltin = config.values.mode === 'b' && !!runtime.nav
|
|
1540
|
+
if (showBuiltin) {
|
|
1541
|
+
out.push({ id: 'ext', builtin: true, title: t('tab.ext'), icon: null, order: -20, badge: null })
|
|
1542
|
+
out.push({ id: 'ws', builtin: true, title: t('tab.ws'), icon: null, order: -10, badge: null })
|
|
1543
|
+
}
|
|
1544
|
+
for (var i = 0; i < tabRecords.length; i++) {
|
|
1545
|
+
var record = tabRecords[i]
|
|
1546
|
+
if (record.hidden === true) continue
|
|
1547
|
+
out.push({
|
|
1548
|
+
id: record.id,
|
|
1549
|
+
builtin: false,
|
|
1550
|
+
title: tabTitle(record),
|
|
1551
|
+
icon: record.icon,
|
|
1552
|
+
order: record.order,
|
|
1553
|
+
seq: record.seq,
|
|
1554
|
+
badge: record.badge,
|
|
1555
|
+
})
|
|
1556
|
+
}
|
|
1557
|
+
out.sort(function (left, right) {
|
|
1558
|
+
if (left.builtin !== right.builtin) return left.builtin ? -1 : 1
|
|
1559
|
+
if (left.order !== right.order) return left.order - right.order
|
|
1560
|
+
return (left.seq || 0) - (right.seq || 0)
|
|
1561
|
+
})
|
|
1562
|
+
return out
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function ensureTabStrip(fx) {
|
|
1566
|
+
var strip = runtime.tabStrip
|
|
1567
|
+
if (strip && strip.parentNode !== fx.root) {
|
|
1568
|
+
try {
|
|
1569
|
+
strip.parentNode.removeChild(strip)
|
|
1570
|
+
} catch (error) {
|
|
1571
|
+
void error
|
|
1572
|
+
}
|
|
1573
|
+
strip = null
|
|
1574
|
+
}
|
|
1575
|
+
if (!strip) {
|
|
1576
|
+
strip = document.createElement('div')
|
|
1577
|
+
strip.setAttribute('data-dsbs-tabs', '')
|
|
1578
|
+
strip.setAttribute('role', 'tablist')
|
|
1579
|
+
strip.setAttribute('aria-label', t('tabs.label'))
|
|
1580
|
+
runtime.tabStrip = strip
|
|
1581
|
+
}
|
|
1582
|
+
// Sits under the control bar so the two read as one header. The
|
|
1583
|
+
// anchor matters: the host rebuilds the groups, never the bar.
|
|
1584
|
+
if (strip.parentNode === fx.root && runtime.bar && strip.previousSibling === runtime.bar) return strip
|
|
1585
|
+
var anchor = runtime.bar ? runtime.bar.nextSibling : (fx.nav || fx.regionArea)
|
|
1586
|
+
if (anchor && anchor.parentNode === fx.root && anchor !== strip) fx.root.insertBefore(strip, anchor)
|
|
1587
|
+
else if (strip.parentNode !== fx.root) fx.root.appendChild(strip)
|
|
1588
|
+
return strip
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
function buildTabButton(entry, selected) {
|
|
1592
|
+
var button = document.createElement('button')
|
|
1593
|
+
button.setAttribute('type', 'button')
|
|
1594
|
+
button.setAttribute('data-dsbs-tabid', entry.id)
|
|
1595
|
+
button.setAttribute('role', 'tab')
|
|
1596
|
+
button.setAttribute('aria-selected', selected ? 'true' : 'false')
|
|
1597
|
+
button.setAttribute('title', entry.title)
|
|
1598
|
+
if (selected) button.setAttribute('data-dsbs-selected', 'true')
|
|
1599
|
+
if (entry.builtin) button.setAttribute('data-dsbs-builtin', entry.id)
|
|
1600
|
+
|
|
1601
|
+
if (typeof entry.icon === 'string' && entry.icon) {
|
|
1602
|
+
var glyph = document.createElement('span')
|
|
1603
|
+
glyph.setAttribute('data-dsbs-tabicon', '')
|
|
1604
|
+
glyph.setAttribute('aria-hidden', 'true')
|
|
1605
|
+
button.setAttribute('data-dsbs-hasicon', '')
|
|
1606
|
+
if (entry.icon.charAt(0) === '<') {
|
|
1607
|
+
// A consumer-supplied mark. Plugins already run arbitrary code in
|
|
1608
|
+
// this page, so this grants nothing new -- but the icon still gets
|
|
1609
|
+
// its own element so the label stays a text node.
|
|
1610
|
+
glyph.innerHTML = entry.icon
|
|
1611
|
+
} else {
|
|
1612
|
+
glyph.textContent = entry.icon
|
|
1613
|
+
glyph.setAttribute('data-dsbs-tabicon-text', '')
|
|
1614
|
+
}
|
|
1615
|
+
button.appendChild(glyph)
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
var label = document.createElement('span')
|
|
1619
|
+
label.setAttribute('data-dsbs-tablabel', '')
|
|
1620
|
+
label.textContent = entry.title
|
|
1621
|
+
button.appendChild(label)
|
|
1622
|
+
|
|
1623
|
+
if (entry.badge != null && entry.badge !== '') {
|
|
1624
|
+
var badge = document.createElement('span')
|
|
1625
|
+
badge.setAttribute('data-dsbs-tabbadge', '')
|
|
1626
|
+
badge.textContent = String(entry.badge)
|
|
1627
|
+
button.appendChild(badge)
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
button.addEventListener('click', function () {
|
|
1631
|
+
if (entry.builtin) {
|
|
1632
|
+
// Built-ins drive the existing group switcher, so a click keeps
|
|
1633
|
+
// meaning what the same click meant before 0.3.0.
|
|
1634
|
+
var tookOver = runtime.activeTab !== null
|
|
1635
|
+
runtime.activeTab = null
|
|
1636
|
+
writeActiveTab(null)
|
|
1637
|
+
setField('tab', entry.id)
|
|
1638
|
+
// A click here takes the column away from a consumer tab exactly
|
|
1639
|
+
// like clicking another tab does, so subscribers hear about it the
|
|
1640
|
+
// same way. Without this a consumer that trusts `subscribe` would
|
|
1641
|
+
// keep believing it is still on screen.
|
|
1642
|
+
if (tookOver) notifyTabs('active')
|
|
1643
|
+
} else {
|
|
1644
|
+
activateTab(entry.id)
|
|
1645
|
+
}
|
|
1646
|
+
})
|
|
1647
|
+
return button
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function renderTabStrip() {
|
|
1651
|
+
var strip = runtime.tabStrip
|
|
1652
|
+
if (!strip) return
|
|
1653
|
+
var entries = tabEntries()
|
|
1654
|
+
var hasPlugin = false
|
|
1655
|
+
for (var p = 0; p < entries.length; p++) {
|
|
1656
|
+
if (!entries[p].builtin) hasPlugin = true
|
|
1657
|
+
}
|
|
1658
|
+
// Nothing registered: stay out of the way entirely, so a profile with
|
|
1659
|
+
// no tab consumers looks exactly like 0.2.x.
|
|
1660
|
+
if (!hasPlugin) {
|
|
1661
|
+
strip.setAttribute('hidden', '')
|
|
1662
|
+
runtime.tabSignature = ''
|
|
1663
|
+
return
|
|
1664
|
+
}
|
|
1665
|
+
strip.removeAttribute('hidden')
|
|
1666
|
+
|
|
1667
|
+
var activePlugin = runtime.activeTab && findTab(runtime.activeTab) ? runtime.activeTab : null
|
|
1668
|
+
var signature = (activePlugin || '-') + '|' + config.values.mode + '|' + config.values.tab + '|'
|
|
1669
|
+
for (var s = 0; s < entries.length; s++) {
|
|
1670
|
+
signature += entries[s].id + ':' + entries[s].title + ':' + entries[s].icon + ':' + entries[s].badge + ';'
|
|
1671
|
+
}
|
|
1672
|
+
if (signature === runtime.tabSignature) return
|
|
1673
|
+
runtime.tabSignature = signature
|
|
1674
|
+
|
|
1675
|
+
while (strip.firstChild) strip.removeChild(strip.firstChild)
|
|
1676
|
+
for (var i = 0; i < entries.length; i++) {
|
|
1677
|
+
var entry = entries[i]
|
|
1678
|
+
var selected = entry.builtin
|
|
1679
|
+
? (!activePlugin && config.values.tab === entry.id)
|
|
1680
|
+
: (activePlugin === entry.id)
|
|
1681
|
+
strip.appendChild(buildTabButton(entry, selected))
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function placeTabPanel(panel, fx) {
|
|
1686
|
+
if (panel.parentNode === fx.root) return
|
|
1687
|
+
var anchor = fx.nav || fx.regionArea
|
|
1688
|
+
if (anchor && anchor.parentNode === fx.root) fx.root.insertBefore(panel, anchor)
|
|
1689
|
+
else fx.root.appendChild(panel)
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function syncTabPanels(fx, activeId) {
|
|
1693
|
+
var i
|
|
1694
|
+
// The container is created the first time its tab owns the column, not
|
|
1695
|
+
// at registration: a tab that is never opened should cost nothing, and
|
|
1696
|
+
// a display:none container has no geometry to measure anyway.
|
|
1697
|
+
if (activeId && !runtime.tabPanels[activeId]) {
|
|
1698
|
+
var owning = findTab(activeId)
|
|
1699
|
+
if (owning) createTabPanel(owning)
|
|
1700
|
+
}
|
|
1701
|
+
// Panels that are no longer registered are gone already; the ones that
|
|
1702
|
+
// remain are shown only while they own the column.
|
|
1703
|
+
for (var id in runtime.tabPanels) {
|
|
1704
|
+
var entry = runtime.tabPanels[id]
|
|
1705
|
+
if (!entry || !entry.el) continue
|
|
1706
|
+
if (id === activeId) {
|
|
1707
|
+
placeTabPanel(entry.el, fx)
|
|
1708
|
+
entry.el.setAttribute('data-dsbs-visible', 'true')
|
|
1709
|
+
entry.el.removeAttribute('aria-hidden')
|
|
1710
|
+
if (!entry.mounted) mountTabPanel(entry)
|
|
1711
|
+
} else {
|
|
1712
|
+
entry.el.setAttribute('data-dsbs-visible', 'false')
|
|
1713
|
+
entry.el.setAttribute('aria-hidden', 'true')
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
void i
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
function activateTab(id) {
|
|
1720
|
+
if (id != null && id !== '' && !findTab(id)) return false
|
|
1721
|
+
var next = id == null || id === '' ? null : String(id)
|
|
1722
|
+
// Clicking the tab that already owns the column returns to the groups,
|
|
1723
|
+
// which is the only way back out of a tab in layouts a and c.
|
|
1724
|
+
if (next && runtime.activeTab === next) next = null
|
|
1725
|
+
runtime.activeTab = next
|
|
1726
|
+
writeActiveTab(next)
|
|
1727
|
+
notifyTabs('active')
|
|
1728
|
+
scheduleSync()
|
|
1729
|
+
return true
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1166
1732
|
function barButton(key) {
|
|
1167
1733
|
if (key === 'temp') return tempButton
|
|
1168
1734
|
if (!runtime.bar) return null
|
|
@@ -1271,6 +1837,8 @@ window.__ModuleLoader__.load({
|
|
|
1271
1837
|
|
|
1272
1838
|
if (fx.nav && !fx.nav.hasAttribute('data-dsbs-ext')) fx.nav.setAttribute('data-dsbs-ext', '')
|
|
1273
1839
|
ensureBar(fx)
|
|
1840
|
+
ensureTabStrip(fx)
|
|
1841
|
+
renderTabStrip()
|
|
1274
1842
|
placeTempButton()
|
|
1275
1843
|
applyTempButtonVisibility()
|
|
1276
1844
|
loadTempStatus()
|
|
@@ -1339,7 +1907,28 @@ window.__ModuleLoader__.load({
|
|
|
1339
1907
|
|
|
1340
1908
|
var extHidden
|
|
1341
1909
|
var wsHidden
|
|
1342
|
-
|
|
1910
|
+
// A registered tab owning the column outranks every layout rule: the
|
|
1911
|
+
// two groups step aside and the tab's own container takes the space.
|
|
1912
|
+
var pluginTab = null
|
|
1913
|
+
if (runtime.activeTab) {
|
|
1914
|
+
if (findTab(runtime.activeTab)) {
|
|
1915
|
+
pluginTab = runtime.activeTab
|
|
1916
|
+
} else if (runtime.seenTabs[runtime.activeTab]) {
|
|
1917
|
+
// It registered once and is gone now: its owner unloaded while the
|
|
1918
|
+
// page was open. Fall back instead of leaving an empty column with
|
|
1919
|
+
// no way out of it.
|
|
1920
|
+
runtime.activeTab = null
|
|
1921
|
+
writeActiveTab(null)
|
|
1922
|
+
notifyTabs('orphan')
|
|
1923
|
+
}
|
|
1924
|
+
// Not seen yet: another plugin may still be registering during
|
|
1925
|
+
// startup, so the stored id is kept and retried on the next paint.
|
|
1926
|
+
}
|
|
1927
|
+
setFlag(html, 'data-dsbs-ptab', !!pluginTab)
|
|
1928
|
+
if (pluginTab) {
|
|
1929
|
+
extHidden = true
|
|
1930
|
+
wsHidden = true
|
|
1931
|
+
} else if (state.mode === 'a') {
|
|
1343
1932
|
extHidden = state.foldExt === true
|
|
1344
1933
|
wsHidden = state.foldWs === true
|
|
1345
1934
|
} else if (state.mode === 'b') {
|
|
@@ -1352,6 +1941,13 @@ window.__ModuleLoader__.load({
|
|
|
1352
1941
|
}
|
|
1353
1942
|
setGroupHidden(fx.nav, 'ext', extHidden)
|
|
1354
1943
|
setGroupHidden(fx.regionArea, 'ws', wsHidden)
|
|
1944
|
+
syncTabPanels(fx, pluginTab)
|
|
1945
|
+
// The strip is the only affordance that survives a hidden bar, so it
|
|
1946
|
+
// is repainted on every sync rather than only when its content changed.
|
|
1947
|
+
if (runtime.tabStrip && pluginTab) {
|
|
1948
|
+
var selectedTab = runtime.tabStrip.querySelector('[data-dsbs-tabid="' + pluginTab + '"]')
|
|
1949
|
+
if (selectedTab) selectedTab.setAttribute('aria-selected', 'true')
|
|
1950
|
+
}
|
|
1355
1951
|
|
|
1356
1952
|
observeRoot(fx.root)
|
|
1357
1953
|
positionPop(fx)
|
|
@@ -2500,11 +3096,35 @@ window.__ModuleLoader__.load({
|
|
|
2500
3096
|
} catch (error) {
|
|
2501
3097
|
void error
|
|
2502
3098
|
}
|
|
3099
|
+
try {
|
|
3100
|
+
// Unmount every live tab first: a consumer's disposer may still
|
|
3101
|
+
// touch its container, so removing the DOM afterwards is safer
|
|
3102
|
+
// than yanking it out from under a cleanup callback.
|
|
3103
|
+
disposeAllTabPanels()
|
|
3104
|
+
if (runtime.tabStrip && runtime.tabStrip.parentNode) runtime.tabStrip.parentNode.removeChild(runtime.tabStrip)
|
|
3105
|
+
} catch (error) {
|
|
3106
|
+
void error
|
|
3107
|
+
}
|
|
3108
|
+
try {
|
|
3109
|
+
runtime.tabStrip = null
|
|
3110
|
+
runtime.tabSignature = ''
|
|
3111
|
+
tabListeners.length = 0
|
|
3112
|
+
tabRecords.length = 0
|
|
3113
|
+
} catch (error) {
|
|
3114
|
+
void error
|
|
3115
|
+
}
|
|
2503
3116
|
try {
|
|
2504
3117
|
if (window[OWNER_KEY] === owner) delete window[OWNER_KEY]
|
|
2505
3118
|
} catch (error) {
|
|
2506
3119
|
void error
|
|
2507
3120
|
}
|
|
3121
|
+
try {
|
|
3122
|
+
// Only drop the handle if it is still ours: a newer generation may
|
|
3123
|
+
// already have replaced it, and that one must keep working.
|
|
3124
|
+
if (window.__dshSidebarSuite === sidebarService) delete window.__dshSidebarSuite
|
|
3125
|
+
} catch (error) {
|
|
3126
|
+
void error
|
|
3127
|
+
}
|
|
2508
3128
|
try {
|
|
2509
3129
|
if (runtime.root) runtime.root.style.removeProperty('--dsbs-pop-top')
|
|
2510
3130
|
} catch (error) {
|
|
@@ -2538,6 +3158,7 @@ window.__ModuleLoader__.load({
|
|
|
2538
3158
|
'data-dsbs-narrow',
|
|
2539
3159
|
'data-dsbs-temp-solo',
|
|
2540
3160
|
'data-dsbs-temp-style',
|
|
3161
|
+
'data-dsbs-ptab',
|
|
2541
3162
|
]
|
|
2542
3163
|
for (var i = 0; i < attributes.length; i++) {
|
|
2543
3164
|
html.removeAttribute(attributes[i])
|
|
@@ -2564,7 +3185,6 @@ window.__ModuleLoader__.load({
|
|
|
2564
3185
|
}, 'sidebar-suite: teardown')
|
|
2565
3186
|
owner.teardown = teardown
|
|
2566
3187
|
}
|
|
2567
|
-
|
|
2568
3188
|
return { inject: ['locale'], apply: apply }
|
|
2569
3189
|
},
|
|
2570
3190
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-sidebar-suite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Yonan",
|
|
6
6
|
"repository": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"type": "module",
|
|
29
29
|
"scripts": {
|
|
30
|
-
"test": "node tests/test_020.mjs && node tests/test_022.mjs && node tests/test_023.mjs && node tests/test_024.mjs && node tests/test_025.mjs && node tests/test_028.mjs && node tests/test_029.mjs",
|
|
30
|
+
"test": "node tests/test_020.mjs && node tests/test_022.mjs && node tests/test_023.mjs && node tests/test_024.mjs && node tests/test_025.mjs && node tests/test_028.mjs && node tests/test_029.mjs && node tests/test_030.mjs && node tests/test_031.mjs",
|
|
31
31
|
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/temp-session-host.mjs && npm pack --dry-run"
|
|
32
32
|
},
|
|
33
33
|
"main": "lib/index.js",
|
|
@@ -117,6 +117,29 @@
|
|
|
117
117
|
|
|
118
118
|
---
|
|
119
119
|
|
|
120
|
+
### 3.4 第三方插件加的页签(0.3.0)
|
|
121
|
+
|
|
122
|
+
这一版给**别的插件**开了一个口:它们可以往你侧栏里加自己的一页。
|
|
123
|
+
|
|
124
|
+
你不需要做任何设置。什么时候会看到:
|
|
125
|
+
|
|
126
|
+
- 装了某个使用这个接口的插件之后,**控制栏下面会多出一排页签**,比如 `扩展 │ 工作区 │ 那个插件的名字`。
|
|
127
|
+
- 点哪个,侧栏中间那块就换成哪一页。
|
|
128
|
+
- **再点一次当前页签**,就退回原来的会话列表 / 扩展列表。
|
|
129
|
+
- 你在哪一页会被记住,下次打开还在那一页。
|
|
130
|
+
|
|
131
|
+
三种布局下的差别:
|
|
132
|
+
|
|
133
|
+
| 你选的布局 | 页签条长什么样 |
|
|
134
|
+
|---|---|
|
|
135
|
+
| 标签页(默认) | `扩展 / 工作区 / 插件页` 三种并排 |
|
|
136
|
+
| 折叠 | 只有插件页进条;扩展和工作区还是用原来那两个折叠按钮 |
|
|
137
|
+
| 浮层 | 只有插件页进条;扩展还是用「浮出面板」按钮 |
|
|
138
|
+
|
|
139
|
+
**一个都没装的话,这排页签根本不会出现** —— 界面和 0.2.x 一模一样,不会平白多一行东西。
|
|
140
|
+
|
|
141
|
+
> 想自己写一个?接口是 `ctx.sidebarSuite.registerTab({ id, title, icon, mount })`,`mount` 拿到一个空容器,你爱画什么画什么。完整示例在 README 和 DETAIL 里。
|
|
142
|
+
|
|
120
143
|
## 四、临时会话(重点)
|
|
121
144
|
|
|
122
145
|
### 4.1 它到底是什么
|