dsh-surface-bridge 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +132 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +310 -0
- package/lib/types/client/SurfaceSelectionDock.d.ts +37 -0
- package/lib/types/client/SurfaceSelectionDock.js +125 -0
- package/lib/types/client/index.d.ts +40 -0
- package/lib/types/client/index.js +41 -0
- package/lib/types/client/locales.d.ts +30 -0
- package/lib/types/client/locales.js +36 -0
- package/lib/types/client/service.d.ts +46 -0
- package/lib/types/client/service.js +89 -0
- package/lib/types/client/transport.d.ts +19 -0
- package/lib/types/client/transport.js +38 -0
- package/lib/types/contract.d.ts +329 -0
- package/lib/types/contract.js +38 -0
- package/lib/types/host/narrow.d.ts +25 -0
- package/lib/types/host/narrow.js +193 -0
- package/lib/types/host/render.d.ts +63 -0
- package/lib/types/host/render.js +228 -0
- package/lib/types/host/routes.d.ts +31 -0
- package/lib/types/host/routes.js +108 -0
- package/lib/types/host/service.d.ts +41 -0
- package/lib/types/host/service.js +93 -0
- package/lib/types/host/store.d.ts +85 -0
- package/lib/types/host/store.js +206 -0
- package/lib/types/index.d.ts +93 -0
- package/lib/types/index.js +132 -0
- package/package.json +88 -0
- package/src/client/SurfaceSelectionDock.module.css +186 -0
- package/src/client/SurfaceSelectionDock.tsx +245 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +42 -0
- package/src/client/service.ts +110 -0
- package/src/client/transport.ts +39 -0
- package/src/contract.ts +351 -0
- package/src/css-modules.d.ts +10 -0
- package/src/host/narrow.ts +180 -0
- package/src/host/render.ts +226 -0
- package/src/host/routes.ts +117 -0
- package/src/host/service.ts +116 -0
- package/src/host/store.ts +236 -0
- package/src/index.ts +194 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NoneFire
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# dsh-surface-bridge
|
|
2
|
+
|
|
3
|
+
DSH 右侧栏的**业务面**(画布、表格、Notebook)与 **DSH 输入栏**之间的唯一接缝:一条 chip、一处上下文注入点、一条回写通道。
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
┌─────────────┐ publish(selection) ┌────────────────────────┐
|
|
7
|
+
│ 业务插件 │ ───────────────────► │ dsh-surface-bridge │
|
|
8
|
+
│ (画布/表格) │ ◄─────────────────── │ · 输入行上的 chip │
|
|
9
|
+
└─────────────┘ 同一个长轮询 │ · 注册表(仅本地) │
|
|
10
|
+
▲ └───────────┬────────────┘
|
|
11
|
+
│ read:现在选了什么? │ agent/pre-step
|
|
12
|
+
└──────────────────────────────────────────┘
|
|
13
|
+
▼
|
|
14
|
+
user/message(上下文)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**关键:选择是"被读"的,不是"被推"的。** 用户画图、框选时**没有任何网络流量**;只有当一条消息要发出去、模型步骤开始准备时才向浏览器要一次。`publish` 是纯本地动作(驱动 chip + 作为那次读取的答案),不碰网络。
|
|
18
|
+
|
|
19
|
+
## 为什么它必须是一个**已挂载的 bundle**,而不是 `packages/` 里的纯库
|
|
20
|
+
|
|
21
|
+
这是本包存在的全部理由。设想把联动逻辑写成一个库,让每个业务插件 `import`:
|
|
22
|
+
|
|
23
|
+
| 方案 | 结果 |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| 每个插件各写一套 chip + 注入 | 输入栏里 N 个 chip 抢位置;同一套 Host 注入被复制 N 次;视觉与行为各自漂移 |
|
|
26
|
+
| 联动逻辑做成纯库,各插件 import | 看起来更轻,但库会被**分别打进每个插件自己的 client bundle**。于是 `ctx` 上出现 N 份服务实例,各自注册一个 `conversation.input.dock` 条目 —— chip 依然是 N 个。**纯库无法保证"全局唯一"** |
|
|
27
|
+
| 联动逻辑做成独立 bundle(本包) | 唯一实例由 DSH 的 Loader 保证;业务插件只 `inject` 服务 + `registerSource(...)` |
|
|
28
|
+
|
|
29
|
+
因此本仓库对 `packages/` 与 `plugins/` 的分工是明确的:
|
|
30
|
+
|
|
31
|
+
- `packages/`:**无状态、可被任意打包**的纯代码(`ui`、`data-contracts`、`convert-core`)。
|
|
32
|
+
- `plugins/`:需要在 `ctx` 上**提供唯一服务**、参与 profile 组合的**运行单元**。
|
|
33
|
+
|
|
34
|
+
## 给业务插件用的 API
|
|
35
|
+
|
|
36
|
+
### 浏览器侧:`ctx.surfaceBridge`
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
export const inject = ['surfaceBridge', 'locale'] as const
|
|
40
|
+
|
|
41
|
+
export function apply(ctx: ClientContext): void {
|
|
42
|
+
ctx.effect(() => ctx.surfaceBridge.registerSource({
|
|
43
|
+
id: 'excalidraw',
|
|
44
|
+
label: '画布',
|
|
45
|
+
icon: IconEditOutlineRegular,
|
|
46
|
+
order: 10,
|
|
47
|
+
reveal: () => { ctx.sidebarRight.openTab('data-excalidraw') }, // chip 上的「定位」
|
|
48
|
+
focusElement: (id) => { canvas.focusElement(id) }, // 点摘要某一行
|
|
49
|
+
}), 'my-surface: source')
|
|
50
|
+
|
|
51
|
+
// 用户选择变化时:
|
|
52
|
+
ctx.surfaceBridge.publish('excalidraw', selectionOrNull)
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`selection` 的形状见 [`src/contract.ts`](src/contract.ts) 的 `SurfaceSelection`:源只投影**元素行**(id / 类型 / 文本 / 坐标 / 尺寸 / 非默认样式 / 连接关系),桥拥有信封(revision、count、bounds、模型文本、可选图片)。
|
|
57
|
+
|
|
58
|
+
**图片不走文本。** 大载荷放进 `selection.images[]`,每条用 `elementId` 指向自己的元素;Host 把它们落成 durable 附件并以真正的 `image` 块追加到上下文。没有图片元素的选择,一个字节的图片都不产生。
|
|
59
|
+
|
|
60
|
+
### Host 侧:`ctx.surfaceBridgeHost`
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
const live = ctx.surfaceBridgeHost.isSurfaceLive(sessionId) // 画布是否开着
|
|
64
|
+
const all = await ctx.surfaceBridgeHost.readSelections(sessionId, signal) // 现在选了什么
|
|
65
|
+
const one = await ctx.surfaceBridgeHost.readSelection(sessionId, 'excalidraw', signal)
|
|
66
|
+
const results = await ctx.surfaceBridgeHost.apply(sessionId, 'excalidraw', // 回写并等结果
|
|
67
|
+
[{ op: 'update', payload: { id, fields } }], exec.signal)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`apply` 在**画布未打开**或**超时未回传**时返回明确的失败结果,绝不假装成功。
|
|
71
|
+
|
|
72
|
+
## 上下文是怎么进模型的
|
|
73
|
+
|
|
74
|
+
Host 半在 `agent/pre-step` 里做三件事,而且只在**带着新用户输入的那一步**做:
|
|
75
|
+
|
|
76
|
+
1. 若该 Session 有面在轮询(`isSurfaceLive`),向浏览器**读一次**当前选择;
|
|
77
|
+
2. 每个有选择的面,在消息末尾追加一条 `createUserMessage`(`source.kind = 'surface-selection'`,`form = 'snapshot'`);
|
|
78
|
+
3. 记下这个 turn 已经读过,**同一 turn 的后续 step 不再读**。
|
|
79
|
+
|
|
80
|
+
- **只在发送时读取**:用户画图期间什么都不发。这也让"发送时看到的选择"就是消息真正带上的选择。
|
|
81
|
+
- **一个 turn 只读一次**:一个 turn 有多个 step(模型调用、工具结果、再调用),否则同一张图会被注入三遍。
|
|
82
|
+
- **不可见的面答空**:读取的答案来自同一个注册表,而注册表只列"在屏幕上且有选择"的面,所以切会话/切标签后,旧选择既不会显示也不会被发送。
|
|
83
|
+
- **无浏览器则跳过**:没有面在轮询(CLI、未打开过画布标签页)直接返回,不阻塞消息。
|
|
84
|
+
- 形态与 `dsh-time-context`、`dsh-session-reference` 一致:都是官方认可的"生产者注入 user-role 上下文"路径。
|
|
85
|
+
|
|
86
|
+
**在对话里长什么样(`form: 'notice'`)**:DSH 按消息源声明的 `form` 决定怎么渲染这条上下文,而**只有 `notice` 这一种 form 带 `summary`**,它的语义就是"不展开也要能读懂的一行"。所以选择集以一张带标题的卡片出现在对话里(`画布 · 2 个元素`),元素明细在展开里 —— 而不是一坨要滚过去的坐标文本。
|
|
87
|
+
|
|
88
|
+
这不是自造 UI:卡片由 shell 自己渲染(shadcn/lucide 那套),本包只提供一行 summary。要注意的取舍是:
|
|
89
|
+
|
|
90
|
+
- `notice` **不允许再带 `sections`**(那是 `snapshot` 的字段),两者互斥;`tests/context-augmentation.test.mjs` 钉住了这条,避免有人顺手加回去。
|
|
91
|
+
- summary 是**持久化在消息里的显示文本**,因此和注入正文一样是中文、不随 locale 变。模型读到的东西完全没变 —— 换的只是这一行怎么显示。
|
|
92
|
+
|
|
93
|
+
## 路由
|
|
94
|
+
|
|
95
|
+
全部挂在平台共享 API 通道(`ctx.connection.fetch.register`)上,因此继承平台的信任与鉴权围栏,不另开 webserver:
|
|
96
|
+
|
|
97
|
+
只有两条路由,服务于**一个双向长轮询**:
|
|
98
|
+
|
|
99
|
+
| 路由 | 方向 | 用途 |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `GET /api/data-canvas/ops?sessionId=&hold=` | 浏览器 ← Host | 长轮询(最长 20s):取待执行操作,**以及 Host 的读取请求** |
|
|
102
|
+
| `POST /api/data-canvas/settle` | 浏览器 → Host | 回传执行结果,或回传一次读取的答案 |
|
|
103
|
+
|
|
104
|
+
读取复用了同一条队列(`READ_SELECTION_OP`,不带 source —— 只有浏览器知道有哪些面、哪些在屏幕上),所以浏览器不需要第二个监听器,一次读取就是一个已经parked 的连接上的往返。路径常量在 `src/contract.ts` 里只写一遍。
|
|
105
|
+
|
|
106
|
+
校验没有消失,只是换了一端:旧设计要在 ingest 端校验推送来的选择,现在改为在**读取答案**上校验(`src/host/narrow.ts`)——浏览器仍然是边界,只是方向反了。
|
|
107
|
+
|
|
108
|
+
## 扩展一个新业务面
|
|
109
|
+
|
|
110
|
+
1. 建一个 `plugins/dsh-data-*` bundle,`dsh.client.inject` 里加上 `dsh-surface-bridge`(Loader 会保证桥先注册)。
|
|
111
|
+
2. `inject: ['surfaceBridge', ...]`,`registerSource` 一个描述符,并在可见性变化时 `setVisible`。
|
|
112
|
+
3. 用户选择变化时把投影好的 `SurfaceSelection` `publish` 到注册表(本地)。
|
|
113
|
+
4. 自己的操作循环里把 `READ_SELECTION_OP` 答成 `[selection]` 或 `[]`。
|
|
114
|
+
5. Host 侧工具通过 `ctx.surfaceBridgeHost` 读选择、送操作。
|
|
115
|
+
|
|
116
|
+
chip、输入行占位、`agent/pre-step` 注入、图片附件化、长轮询回写全部由本包负责。
|
|
117
|
+
|
|
118
|
+
## 已知边界
|
|
119
|
+
|
|
120
|
+
- **读取窗口是 600ms。** 一次读取最多等这么久就拿不到答案(消息照发,只是不带选择)。本地环回上是毫秒级;设短是为了不为了上下文而拖延用户的消息。
|
|
121
|
+
- **读取靠"有面在轮询"判定。** 从未打开过画布标签页的会话读不到东西——这是对的,那张画布根本不在屏幕上。
|
|
122
|
+
- **每个面各自轮询。** 多个面同时挂在一个会话上时,读取请求会被先轮询到的那个取走。当前只有一个面,此路径正确;接第二个面时应把轮询收归桥的客户端半边(按 `operation.source` 分派)。
|
|
123
|
+
- **回写不做持久队列。** 操作队列是同一个进程里两半之间的交接通道,画布关掉即失去意义;因此工具失败要响,而不是排队等。
|
|
124
|
+
|
|
125
|
+
## 构建与测试
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pnpm build # tsc(Host 半 + 类型)+ esbuild(唯一 client.js)
|
|
129
|
+
pnpm test # lib/types 上的 Node 规格:状态机、渲染、边界校验、打包形状守卫
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`tests/context-augmentation.test.mjs` 里有一条**必须保留的守卫**:cordis 的 `Context` augmentation 只能写在包入口模块。放进 `contract.ts` 之类的共享类型模块时,同一段 `declare module` 会退化成*环境声明*并**替换**掉 cordis 的 `Context`,导致整张图里的 `ctx.effect` / `ctx.on` 全部不再类型检查。该用例会在它被"顺手整理"走时失败。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# dsh-surface-bridge bundle patch: mount the shared surface↔composer seam.
|
|
2
|
+
#
|
|
3
|
+
# One plugin instance provides `ctx.surfaceBridge` (browser) and
|
|
4
|
+
# `ctx.surfaceBridgeHost` (Host), and registers the single
|
|
5
|
+
# `conversation.input.dock` entry every business surface's selection renders
|
|
6
|
+
# through. Business plugins depend on it; they never re-implement the chip.
|
|
7
|
+
- insert:
|
|
8
|
+
- id: dsh-surface-bridge
|
|
9
|
+
name: 'dsh-surface-bridge'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({ id: "dsh-surface-bridge", factory: (require) => {
|
|
2
|
+
var module = { exports: {} }; var exports = module.exports;
|
|
3
|
+
|
|
4
|
+
"use strict";
|
|
5
|
+
var __defProp = Object.defineProperty;
|
|
6
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
7
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __export = (target, all) => {
|
|
10
|
+
for (var name in all)
|
|
11
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
12
|
+
};
|
|
13
|
+
var __copyProps = (to, from, except, desc) => {
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (let key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
17
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
18
|
+
}
|
|
19
|
+
return to;
|
|
20
|
+
};
|
|
21
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
22
|
+
|
|
23
|
+
// src/client/index.ts
|
|
24
|
+
var index_exports = {};
|
|
25
|
+
__export(index_exports, {
|
|
26
|
+
apply: () => apply,
|
|
27
|
+
inject: () => inject
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(index_exports);
|
|
30
|
+
|
|
31
|
+
// src/client/service.ts
|
|
32
|
+
var SurfaceBridgeRegistry = class {
|
|
33
|
+
entries = /* @__PURE__ */ new Map();
|
|
34
|
+
listeners = /* @__PURE__ */ new Set();
|
|
35
|
+
revision = 0;
|
|
36
|
+
/** @inheritdoc */
|
|
37
|
+
registerSource(descriptor) {
|
|
38
|
+
this.entries.set(descriptor.id, { descriptor, selection: null, visible: false });
|
|
39
|
+
this.bump();
|
|
40
|
+
return () => {
|
|
41
|
+
this.entries.delete(descriptor.id);
|
|
42
|
+
this.bump();
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** @inheritdoc */
|
|
46
|
+
setVisible(source, visible) {
|
|
47
|
+
const entry = this.entries.get(source);
|
|
48
|
+
if (entry === void 0 || entry.visible === visible) return;
|
|
49
|
+
entry.visible = visible;
|
|
50
|
+
this.bump();
|
|
51
|
+
}
|
|
52
|
+
/** @inheritdoc */
|
|
53
|
+
isVisible(source) {
|
|
54
|
+
return this.entries.get(source)?.visible === true;
|
|
55
|
+
}
|
|
56
|
+
/** @inheritdoc */
|
|
57
|
+
publish(source, selection) {
|
|
58
|
+
const entry = this.entries.get(source);
|
|
59
|
+
if (entry === void 0) return;
|
|
60
|
+
entry.selection = selection;
|
|
61
|
+
this.bump();
|
|
62
|
+
}
|
|
63
|
+
/** @inheritdoc */
|
|
64
|
+
read(source) {
|
|
65
|
+
return this.entries.get(source)?.selection ?? null;
|
|
66
|
+
}
|
|
67
|
+
/** @inheritdoc */
|
|
68
|
+
active() {
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const entry of this.entries.values()) {
|
|
71
|
+
if (entry.selection === null || !entry.visible) continue;
|
|
72
|
+
out.push({ descriptor: entry.descriptor, selection: entry.selection });
|
|
73
|
+
}
|
|
74
|
+
return out.sort((left, right) => {
|
|
75
|
+
const order = (left.descriptor.order ?? 0) - (right.descriptor.order ?? 0);
|
|
76
|
+
if (order !== 0) return order;
|
|
77
|
+
return left.descriptor.id < right.descriptor.id ? -1 : left.descriptor.id > right.descriptor.id ? 1 : 0;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** @inheritdoc */
|
|
81
|
+
subscribe(listener) {
|
|
82
|
+
this.listeners.add(listener);
|
|
83
|
+
return () => {
|
|
84
|
+
this.listeners.delete(listener);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** @inheritdoc */
|
|
88
|
+
version() {
|
|
89
|
+
return this.revision;
|
|
90
|
+
}
|
|
91
|
+
/** Advance the version and notify every subscriber. */
|
|
92
|
+
bump() {
|
|
93
|
+
this.revision += 1;
|
|
94
|
+
for (const listener of [...this.listeners]) listener();
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/client/SurfaceSelectionDock.tsx
|
|
99
|
+
var import_react = require("react");
|
|
100
|
+
var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
101
|
+
|
|
102
|
+
// surface-bridge-css:/Users/fuzz/Documents/github/dsh-personal/plugins/dsh-surface-bridge/src/client/SurfaceSelectionDock.module.css
|
|
103
|
+
var css = "[data-composer-card]:has([data-surface-chip]) [data-input-scroll]{text-indent:var(--surface-bridge-indent,0px)}.peUXVa_anchor{z-index:2;pointer-events:none;align-items:center;gap:6px;width:max-content;max-width:calc(100% - 26px);display:flex;position:absolute;top:13px;left:14px}.peUXVa_chip{border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-interactive-bg-hover);min-width:0;max-width:100%;height:22px;color:var(--dsw-alias-label-secondary);white-space:nowrap;cursor:pointer;pointer-events:auto;border-radius:999px;align-items:center;gap:6px;padding:0 8px 0 7px;font-family:inherit;font-size:12px;line-height:16px;display:inline-flex}.peUXVa_chip:hover{border-color:var(--dsw-alias-border-l4);color:var(--dsw-alias-label-primary)}.peUXVa_chip:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}.peUXVa_glyph{color:currentColor;opacity:.75;flex:none;display:inline-flex}.peUXVa_label{flex:none;font-weight:500}.peUXVa_dot{color:var(--dsw-alias-label-caption);flex:none}.peUXVa_count{min-width:0;color:var(--dsw-alias-state-business-primary);font-variant-numeric:tabular-nums;text-overflow:ellipsis;overflow:hidden}.peUXVa_chevron{color:var(--dsw-alias-label-caption);flex:none;display:inline-flex}.peUXVa_clear{width:18px;height:18px;color:var(--dsw-alias-label-caption);cursor:pointer;pointer-events:auto;background:0 0;border:0;border-radius:999px;flex:none;justify-content:center;align-items:center;margin-left:-3px;padding:0;display:inline-flex}.peUXVa_clear:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.peUXVa_clear:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}.peUXVa_row{align-items:baseline;gap:8px;min-width:0;display:flex}.peUXVa_rowType{color:var(--dsw-alias-label-caption);flex:none;font-size:11px;line-height:16px}.peUXVa_rowLabel{min-width:0;color:var(--dsw-alias-label-primary);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.peUXVa_rowGeom{color:var(--dsw-alias-label-caption);font-family:var(--font-tita-mono);flex:none;margin-left:auto;font-size:11px;line-height:16px}.peUXVa_more{color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px}";
|
|
104
|
+
var tagId = "dsh-surface-bridge//Users/fuzz/Documents/github/dsh-personal/plugins/dsh-surface-bridge/src/client/SurfaceSelectionDock.module.css";
|
|
105
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
106
|
+
const tag = document.createElement("style");
|
|
107
|
+
tag.dataset.plugin = "dsh-surface-bridge";
|
|
108
|
+
tag.dataset.pluginCss = tagId;
|
|
109
|
+
tag.textContent = css;
|
|
110
|
+
document.head.appendChild(tag);
|
|
111
|
+
}
|
|
112
|
+
var SurfaceSelectionDock_default = { "anchor": "peUXVa_anchor", "chevron": "peUXVa_chevron", "chip": "peUXVa_chip", "clear": "peUXVa_clear", "count": "peUXVa_count", "dot": "peUXVa_dot", "glyph": "peUXVa_glyph", "label": "peUXVa_label", "more": "peUXVa_more", "row": "peUXVa_row", "rowGeom": "peUXVa_rowGeom", "rowLabel": "peUXVa_rowLabel", "rowType": "peUXVa_rowType" };
|
|
113
|
+
|
|
114
|
+
// src/client/SurfaceSelectionDock.tsx
|
|
115
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
116
|
+
var MAX_SUMMARY_ROWS = 24;
|
|
117
|
+
var ELEMENT_PREFIX = "element:";
|
|
118
|
+
var ACTION_REVEAL = "action:reveal";
|
|
119
|
+
var ACTION_CLEAR = "action:clear";
|
|
120
|
+
function elementRowLabel(selection, index) {
|
|
121
|
+
const element = selection.elements[index];
|
|
122
|
+
if (element === void 0) return null;
|
|
123
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: SurfaceSelectionDock_default.row, children: [
|
|
124
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: SurfaceSelectionDock_default.rowType, children: element.type }),
|
|
125
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: SurfaceSelectionDock_default.rowLabel, children: element.label.length > 0 ? element.label : element.id }),
|
|
126
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: SurfaceSelectionDock_default.rowGeom, children: `${Math.round(element.width)}\xD7${Math.round(element.height)}` })
|
|
127
|
+
] });
|
|
128
|
+
}
|
|
129
|
+
function SurfaceChip({
|
|
130
|
+
bridge,
|
|
131
|
+
descriptor,
|
|
132
|
+
selection,
|
|
133
|
+
t
|
|
134
|
+
}) {
|
|
135
|
+
const [open, setOpen] = (0, import_react.useState)(false);
|
|
136
|
+
const Glyph = descriptor.icon ?? import_dsh_client_ui_primitives.IconEditOutlineRegular;
|
|
137
|
+
const shown = selection.elements.slice(0, MAX_SUMMARY_ROWS);
|
|
138
|
+
const rows = [
|
|
139
|
+
{ id: "note:title", label: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: SurfaceSelectionDock_default.more, children: String(t("menu.title", { count: selection.count })) }), disabled: true }
|
|
140
|
+
];
|
|
141
|
+
for (const [index] of shown.entries()) {
|
|
142
|
+
const element = selection.elements[index];
|
|
143
|
+
if (element === void 0) continue;
|
|
144
|
+
rows.push({ id: `${ELEMENT_PREFIX}${element.id}`, label: elementRowLabel(selection, index) });
|
|
145
|
+
}
|
|
146
|
+
if (shown.length === 0) {
|
|
147
|
+
rows.push({ id: "element:none", label: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: SurfaceSelectionDock_default.more, children: String(t("menu.empty")) }), disabled: true });
|
|
148
|
+
} else if (selection.elements.length > shown.length) {
|
|
149
|
+
rows.push({
|
|
150
|
+
id: "element:more",
|
|
151
|
+
label: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: SurfaceSelectionDock_default.more, children: String(t("menu.more", { count: selection.count - shown.length })) }),
|
|
152
|
+
disabled: true
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const footer = [];
|
|
156
|
+
if (descriptor.reveal !== void 0) {
|
|
157
|
+
footer.push({ id: ACTION_REVEAL, label: String(t("action.reveal")), icon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.IconFullscreenOutlineRegular, { size: 14 }) });
|
|
158
|
+
}
|
|
159
|
+
footer.push({ id: ACTION_CLEAR, label: String(t("action.clear")), danger: true });
|
|
160
|
+
const clear = () => {
|
|
161
|
+
bridge.publish(descriptor.id, null);
|
|
162
|
+
setOpen(false);
|
|
163
|
+
};
|
|
164
|
+
const onSelect = (id) => {
|
|
165
|
+
if (id === ACTION_REVEAL) {
|
|
166
|
+
descriptor.reveal?.();
|
|
167
|
+
setOpen(false);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (id === ACTION_CLEAR) {
|
|
171
|
+
clear();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!id.startsWith(ELEMENT_PREFIX)) return;
|
|
175
|
+
const elementId = id.slice(ELEMENT_PREFIX.length);
|
|
176
|
+
if (elementId === "more" || elementId === "none") return;
|
|
177
|
+
descriptor.reveal?.();
|
|
178
|
+
descriptor.focusElement?.(elementId);
|
|
179
|
+
setOpen(false);
|
|
180
|
+
};
|
|
181
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
182
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
183
|
+
import_dsh_client_ui_primitives.Menu,
|
|
184
|
+
{
|
|
185
|
+
open,
|
|
186
|
+
anchor: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
187
|
+
"button",
|
|
188
|
+
{
|
|
189
|
+
type: "button",
|
|
190
|
+
className: SurfaceSelectionDock_default.chip,
|
|
191
|
+
"data-surface-chip": "",
|
|
192
|
+
"aria-label": String(t("chip.aria", { count: selection.count })),
|
|
193
|
+
"aria-expanded": open,
|
|
194
|
+
onClick: () => {
|
|
195
|
+
setOpen((value) => !value);
|
|
196
|
+
},
|
|
197
|
+
children: [
|
|
198
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: SurfaceSelectionDock_default.glyph, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Glyph, { size: 12 }) }),
|
|
199
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: SurfaceSelectionDock_default.label, children: descriptor.label }),
|
|
200
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: SurfaceSelectionDock_default.dot, "aria-hidden": "true", children: "\xB7" }),
|
|
201
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: SurfaceSelectionDock_default.count, children: String(t("chip.count", { count: selection.count })) }),
|
|
202
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: SurfaceSelectionDock_default.chevron, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 12 }) })
|
|
203
|
+
]
|
|
204
|
+
}
|
|
205
|
+
),
|
|
206
|
+
items: rows,
|
|
207
|
+
footer,
|
|
208
|
+
onSelect,
|
|
209
|
+
onClose: () => {
|
|
210
|
+
setOpen(false);
|
|
211
|
+
},
|
|
212
|
+
side: "bottom",
|
|
213
|
+
align: "start",
|
|
214
|
+
portal: true
|
|
215
|
+
}
|
|
216
|
+
),
|
|
217
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
218
|
+
"button",
|
|
219
|
+
{
|
|
220
|
+
type: "button",
|
|
221
|
+
className: SurfaceSelectionDock_default.clear,
|
|
222
|
+
"aria-label": String(t("action.clear")),
|
|
223
|
+
onClick: clear,
|
|
224
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.IconCloseOutlineRegular, { size: 12 })
|
|
225
|
+
}
|
|
226
|
+
)
|
|
227
|
+
] });
|
|
228
|
+
}
|
|
229
|
+
function SurfaceSelectionChip({
|
|
230
|
+
bridge,
|
|
231
|
+
t
|
|
232
|
+
}) {
|
|
233
|
+
(0, import_react.useSyncExternalStore)(
|
|
234
|
+
(listener) => bridge.subscribe(listener),
|
|
235
|
+
() => bridge.version(),
|
|
236
|
+
() => bridge.version()
|
|
237
|
+
);
|
|
238
|
+
const active = bridge.active();
|
|
239
|
+
const anchorRef = (0, import_react.useRef)(null);
|
|
240
|
+
(0, import_react.useLayoutEffect)(() => {
|
|
241
|
+
const anchor = anchorRef.current;
|
|
242
|
+
if (anchor === null) return;
|
|
243
|
+
const card = anchor.closest("[data-composer-card]");
|
|
244
|
+
if (card === null) return;
|
|
245
|
+
const apply2 = () => {
|
|
246
|
+
const width = anchor.getBoundingClientRect().width;
|
|
247
|
+
if (width <= 0) return;
|
|
248
|
+
card.style.setProperty("--surface-bridge-indent", `${Math.round(width) + 8}px`);
|
|
249
|
+
};
|
|
250
|
+
apply2();
|
|
251
|
+
if (typeof ResizeObserver === "undefined") return () => {
|
|
252
|
+
card.style.removeProperty("--surface-bridge-indent");
|
|
253
|
+
};
|
|
254
|
+
const observer = new ResizeObserver(apply2);
|
|
255
|
+
observer.observe(anchor);
|
|
256
|
+
return () => {
|
|
257
|
+
observer.disconnect();
|
|
258
|
+
card.style.removeProperty("--surface-bridge-indent");
|
|
259
|
+
};
|
|
260
|
+
}, [active.length]);
|
|
261
|
+
if (active.length === 0) return null;
|
|
262
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: SurfaceSelectionDock_default.anchor, "data-surface-bridge": "", ref: anchorRef, children: active.map(({ descriptor, selection }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
263
|
+
SurfaceChip,
|
|
264
|
+
{
|
|
265
|
+
bridge,
|
|
266
|
+
descriptor,
|
|
267
|
+
selection,
|
|
268
|
+
t
|
|
269
|
+
},
|
|
270
|
+
descriptor.id
|
|
271
|
+
)) });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/client/locales.ts
|
|
275
|
+
var NS = "surface-bridge";
|
|
276
|
+
var zh = {
|
|
277
|
+
"chip.aria": "\u5DF2\u9009\u62E9 {count} \u4E2A\u5143\u7D20\uFF0C\u5C55\u5F00\u67E5\u770B",
|
|
278
|
+
"chip.count": "{count} \u4E2A\u5143\u7D20",
|
|
279
|
+
"menu.title": "\u5DF2\u9009\u62E9 {count} \u4E2A\u5143\u7D20",
|
|
280
|
+
"menu.empty": "\uFF08\u6CA1\u6709\u5143\u7D20\u660E\u7EC6\uFF09",
|
|
281
|
+
"menu.more": "\u8FD8\u6709 {count} \u4E2A\u5143\u7D20\u672A\u5217\u51FA",
|
|
282
|
+
"action.reveal": "\u5B9A\u4F4D\u5230\u753B\u5E03",
|
|
283
|
+
"action.clear": "\u6E05\u7A7A\u9009\u62E9"
|
|
284
|
+
};
|
|
285
|
+
var en = {
|
|
286
|
+
"chip.aria": "{count} elements selected, expand for details",
|
|
287
|
+
"chip.count": "{count} elements",
|
|
288
|
+
"menu.title": "{count} elements selected",
|
|
289
|
+
"menu.empty": "(no element details)",
|
|
290
|
+
"menu.more": "{count} more elements not listed",
|
|
291
|
+
"action.reveal": "Reveal surface",
|
|
292
|
+
"action.clear": "Clear selection"
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// src/client/index.ts
|
|
296
|
+
var inject = ["slots", "locale"];
|
|
297
|
+
var CHIP_ENTRY_ID = "surface-bridge";
|
|
298
|
+
function apply(ctx) {
|
|
299
|
+
const bridge = new SurfaceBridgeRegistry();
|
|
300
|
+
ctx.effect(() => ctx.reflect.provide("surfaceBridge", bridge), "dsh-surface-bridge: client registry");
|
|
301
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-surface-bridge: dictionaries");
|
|
302
|
+
ctx.slots.inject("conversation.input.overlay", () => ctx.slots.register({
|
|
303
|
+
name: "conversation.input.overlay",
|
|
304
|
+
id: CHIP_ENTRY_ID,
|
|
305
|
+
order: 5,
|
|
306
|
+
locale: NS,
|
|
307
|
+
inject: () => ({ bridge })
|
|
308
|
+
}, SurfaceSelectionChip));
|
|
309
|
+
}
|
|
310
|
+
return module.exports; } });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one composer chip for every surface selection.
|
|
3
|
+
*
|
|
4
|
+
* Exactly one entry is registered into `conversation.input.overlay`, and it renders
|
|
5
|
+
* one capsule per source that currently has a selection. That is the whole reason the
|
|
6
|
+
* bridge is a mounted bundle: with the registry inlined per consumer, two surfaces
|
|
7
|
+
* would each register their own entry and the composer would grow a second chip with
|
|
8
|
+
* a different shape.
|
|
9
|
+
*
|
|
10
|
+
* Why the overlay seat and not the dock seat it used to occupy: the dock renders
|
|
11
|
+
* *above* the composer card, in the same stack as the to-do dock — so anything there
|
|
12
|
+
* shares the task list's form and reflows the whole input every time it appears. The
|
|
13
|
+
* overlay seat is inside the card, on the shell's own top-edge anchor, which is where
|
|
14
|
+
* the stylesheet puts the capsule on the input line itself.
|
|
15
|
+
*
|
|
16
|
+
* The chip is a pure view of local state. Nothing here talks to the Host: the
|
|
17
|
+
* selection is read from this registry when a model step asks for it, which is why
|
|
18
|
+
* the chip has no syncing, pending, or failed states to twitch between.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-surface-bridge/client/SurfaceSelectionDock
|
|
21
|
+
*/
|
|
22
|
+
import type { ReactNode } from 'react';
|
|
23
|
+
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
24
|
+
import type { SurfaceBridgeService } from './service.ts';
|
|
25
|
+
import { NS } from './locales.ts';
|
|
26
|
+
/** Injected face of the chip entry. */
|
|
27
|
+
export interface SurfaceDockInjected {
|
|
28
|
+
readonly bridge: SurfaceBridgeService;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The chip entry: one capsule per surface that has a selection and is on screen.
|
|
32
|
+
*
|
|
33
|
+
* Hiding is not done here — a source that goes off screen (a tab switch, a Session
|
|
34
|
+
* switch, a collapsed column) drops out of the registry's `active`, which both hides
|
|
35
|
+
* the capsule and removes it from the answer a read gets.
|
|
36
|
+
*/
|
|
37
|
+
export declare function SurfaceSelectionChip({ bridge, t, }: PropsRuntime<'conversation.input.overlay'> & InjectFace<SurfaceDockInjected> & PropsLocale<typeof NS>): ReactNode;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
3
|
+
import { IconChevronDownOutlineRegular, IconCloseOutlineRegular, IconEditOutlineRegular, IconFullscreenOutlineRegular, Menu, } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
4
|
+
import styles from './SurfaceSelectionDock.module.css';
|
|
5
|
+
/** Maximum element rows the summary menu lists. */
|
|
6
|
+
const MAX_SUMMARY_ROWS = 24;
|
|
7
|
+
/** Menu row id prefixes, so one `onSelect` can tell element rows from actions. */
|
|
8
|
+
const ELEMENT_PREFIX = 'element:';
|
|
9
|
+
const ACTION_REVEAL = 'action:reveal';
|
|
10
|
+
const ACTION_CLEAR = 'action:clear';
|
|
11
|
+
/** One element's row label: kind, name, and size in a single scannable line. */
|
|
12
|
+
function elementRowLabel(selection, index) {
|
|
13
|
+
const element = selection.elements[index];
|
|
14
|
+
if (element === undefined)
|
|
15
|
+
return null;
|
|
16
|
+
return (_jsxs("span", { className: styles.row, children: [_jsx("span", { className: styles.rowType, children: element.type }), _jsx("span", { className: styles.rowLabel, children: element.label.length > 0 ? element.label : element.id }), _jsx("span", { className: styles.rowGeom, children: `${Math.round(element.width)}×${Math.round(element.height)}` })] }));
|
|
17
|
+
}
|
|
18
|
+
/** One capsule: a single source's selection and its summary menu. */
|
|
19
|
+
function SurfaceChip({ bridge, descriptor, selection, t, }) {
|
|
20
|
+
const [open, setOpen] = useState(false);
|
|
21
|
+
const Glyph = descriptor.icon ?? IconEditOutlineRegular;
|
|
22
|
+
const shown = selection.elements.slice(0, MAX_SUMMARY_ROWS);
|
|
23
|
+
const rows = [
|
|
24
|
+
{ id: 'note:title', label: _jsx("span", { className: styles.more, children: String(t('menu.title', { count: selection.count })) }), disabled: true },
|
|
25
|
+
];
|
|
26
|
+
for (const [index] of shown.entries()) {
|
|
27
|
+
const element = selection.elements[index];
|
|
28
|
+
if (element === undefined)
|
|
29
|
+
continue;
|
|
30
|
+
rows.push({ id: `${ELEMENT_PREFIX}${element.id}`, label: elementRowLabel(selection, index) });
|
|
31
|
+
}
|
|
32
|
+
if (shown.length === 0) {
|
|
33
|
+
rows.push({ id: 'element:none', label: _jsx("span", { className: styles.more, children: String(t('menu.empty')) }), disabled: true });
|
|
34
|
+
}
|
|
35
|
+
else if (selection.elements.length > shown.length) {
|
|
36
|
+
rows.push({
|
|
37
|
+
id: 'element:more',
|
|
38
|
+
label: _jsx("span", { className: styles.more, children: String(t('menu.more', { count: selection.count - shown.length })) }),
|
|
39
|
+
disabled: true,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const footer = [];
|
|
43
|
+
if (descriptor.reveal !== undefined) {
|
|
44
|
+
footer.push({ id: ACTION_REVEAL, label: String(t('action.reveal')), icon: _jsx(IconFullscreenOutlineRegular, { size: 14 }) });
|
|
45
|
+
}
|
|
46
|
+
footer.push({ id: ACTION_CLEAR, label: String(t('action.clear')), danger: true });
|
|
47
|
+
const clear = () => {
|
|
48
|
+
// Local only: the surface keeps its own canvas selection, and the next change to
|
|
49
|
+
// it republishes. Clearing here means "do not carry this into the message".
|
|
50
|
+
bridge.publish(descriptor.id, null);
|
|
51
|
+
setOpen(false);
|
|
52
|
+
};
|
|
53
|
+
const onSelect = (id) => {
|
|
54
|
+
if (id === ACTION_REVEAL) {
|
|
55
|
+
descriptor.reveal?.();
|
|
56
|
+
setOpen(false);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (id === ACTION_CLEAR) {
|
|
60
|
+
clear();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!id.startsWith(ELEMENT_PREFIX))
|
|
64
|
+
return;
|
|
65
|
+
const elementId = id.slice(ELEMENT_PREFIX.length);
|
|
66
|
+
if (elementId === 'more' || elementId === 'none')
|
|
67
|
+
return;
|
|
68
|
+
descriptor.reveal?.();
|
|
69
|
+
descriptor.focusElement?.(elementId);
|
|
70
|
+
setOpen(false);
|
|
71
|
+
};
|
|
72
|
+
return (_jsxs(_Fragment, { children: [_jsx(Menu, { open: open, anchor: (_jsxs("button", { type: "button", className: styles.chip, "data-surface-chip": "", "aria-label": String(t('chip.aria', { count: selection.count })), "aria-expanded": open, onClick: () => { setOpen(value => !value); }, children: [_jsx("span", { className: styles.glyph, "aria-hidden": "true", children: _jsx(Glyph, { size: 12 }) }), _jsx("span", { className: styles.label, children: descriptor.label }), _jsx("span", { className: styles.dot, "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: styles.count, children: String(t('chip.count', { count: selection.count })) }), _jsx("span", { className: styles.chevron, "aria-hidden": "true", children: _jsx(IconChevronDownOutlineRegular, { size: 12 }) })] })), items: rows, footer: footer, onSelect: onSelect, onClose: () => { setOpen(false); }, side: "bottom", align: "start", portal: true }), _jsx("button", { type: "button", className: styles.clear, "aria-label": String(t('action.clear')), onClick: clear, children: _jsx(IconCloseOutlineRegular, { size: 12 }) })] }));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The chip entry: one capsule per surface that has a selection and is on screen.
|
|
76
|
+
*
|
|
77
|
+
* Hiding is not done here — a source that goes off screen (a tab switch, a Session
|
|
78
|
+
* switch, a collapsed column) drops out of the registry's `active`, which both hides
|
|
79
|
+
* the capsule and removes it from the answer a read gets.
|
|
80
|
+
*/
|
|
81
|
+
export function SurfaceSelectionChip({ bridge, t, }) {
|
|
82
|
+
useSyncExternalStore(listener => bridge.subscribe(listener), () => bridge.version(), () => bridge.version());
|
|
83
|
+
const active = bridge.active();
|
|
84
|
+
const anchorRef = useRef(null);
|
|
85
|
+
/*
|
|
86
|
+
* Tell the card how far to move its input line, so the text starts after the
|
|
87
|
+
* capsule instead of under it.
|
|
88
|
+
*
|
|
89
|
+
* This is the one place the bridge writes to the shell's DOM, and it is deliberate:
|
|
90
|
+
* the composer's scroll container is the only element that can move its own text,
|
|
91
|
+
* and the distance depends on the capsule's rendered width — which depends on the
|
|
92
|
+
* locale and the digit count, so a constant in the stylesheet would be wrong in at
|
|
93
|
+
* least one language. One scoped custom property, set while a chip is mounted and
|
|
94
|
+
* removed when it is not.
|
|
95
|
+
*/
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
const anchor = anchorRef.current;
|
|
98
|
+
if (anchor === null)
|
|
99
|
+
return;
|
|
100
|
+
// `closest` is not generic over attribute selectors, so the cast is the element
|
|
101
|
+
// the shell documents with that attribute.
|
|
102
|
+
const card = anchor.closest('[data-composer-card]');
|
|
103
|
+
if (card === null)
|
|
104
|
+
return;
|
|
105
|
+
const apply = () => {
|
|
106
|
+
const width = anchor.getBoundingClientRect().width;
|
|
107
|
+
if (width <= 0)
|
|
108
|
+
return;
|
|
109
|
+
// 8px of air between the capsule and the first character.
|
|
110
|
+
card.style.setProperty('--surface-bridge-indent', `${Math.round(width) + 8}px`);
|
|
111
|
+
};
|
|
112
|
+
apply();
|
|
113
|
+
if (typeof ResizeObserver === 'undefined')
|
|
114
|
+
return () => { card.style.removeProperty('--surface-bridge-indent'); };
|
|
115
|
+
const observer = new ResizeObserver(apply);
|
|
116
|
+
observer.observe(anchor);
|
|
117
|
+
return () => {
|
|
118
|
+
observer.disconnect();
|
|
119
|
+
card.style.removeProperty('--surface-bridge-indent');
|
|
120
|
+
};
|
|
121
|
+
}, [active.length]);
|
|
122
|
+
if (active.length === 0)
|
|
123
|
+
return null;
|
|
124
|
+
return (_jsx("div", { className: styles.anchor, "data-surface-bridge": "", ref: anchorRef, children: active.map(({ descriptor, selection }) => (_jsx(SurfaceChip, { bridge: bridge, descriptor: descriptor, selection: selection, t: t }, descriptor.id))) }));
|
|
125
|
+
}
|