@walkerxie/dsh-plugin-quote 0.1.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/LICENSE +21 -0
- package/README.md +118 -0
- package/README.zh.md +118 -0
- package/lib/client.js +299 -0
- package/lib/client.js.map +7 -0
- package/lib/index.js +7 -0
- package/lib/types/client/QuoteControl.d.ts +20 -0
- package/lib/types/client/index.d.ts +24 -0
- package/lib/types/client/layer.d.ts +36 -0
- package/lib/types/client/locales.d.ts +12 -0
- package/lib/types/client/placement.d.ts +51 -0
- package/lib/types/client/quote-text.d.ts +18 -0
- package/lib/types/client/selection.d.ts +30 -0
- package/lib/types/client/slots.d.ts +15 -0
- package/lib/types/index.d.ts +9 -0
- package/package.json +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xieweikang
|
|
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,118 @@
|
|
|
1
|
+
# dsh-plugin-quote
|
|
2
|
+
|
|
3
|
+
A DeepSeek Harness Web plugin. Select text in the conversation, click the button that appears, and the selection lands in the composer as a markdown blockquote.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
my question
|
|
7
|
+
|
|
8
|
+
> the paragraphs you selected
|
|
9
|
+
> keep their line breaks
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Nothing new reaches the model: the quote is ordinary user text, exactly as if you had typed it.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- DeepSeek Harness with the **Web** profile (`dsh web`). There is no composer in the `headless`, `sdk`, or `acp` profiles, so the plugin is inert there.
|
|
18
|
+
- Built and tested against **dsh `0.1.5-rc.1`**. Harness plugin APIs are pre-stable — see [Compatibility](#compatibility).
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install @walkerxie/dsh-plugin-quote
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then add one row to your Web profile's patch file (`~/.dsh/profiles/web/cordis.patch.yml`), pointing at the installed package:
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
- insert:
|
|
30
|
+
- id: ui-quote
|
|
31
|
+
name: '@walkerxie/dsh-plugin-quote'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
To run from a checkout instead — useful while developing the plugin itself — build it and point the row at the file:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
git clone https://github.com/Xieweikang123/dsh-plugin-quote.git
|
|
38
|
+
cd dsh-plugin-quote
|
|
39
|
+
npm install
|
|
40
|
+
npm run build
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
- insert:
|
|
45
|
+
- id: ui-quote
|
|
46
|
+
name: 'file:///absolute/path/to/dsh-plugin-quote/lib/index.js'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The profile reloads its patch live, so a page refresh is enough. If the button does not appear, restart `dsh web` once so the loader picks the new row up from boot.
|
|
50
|
+
|
|
51
|
+
## Uninstall
|
|
52
|
+
|
|
53
|
+
Delete the `- insert:` block — or set `disabled: true` on the row — and refresh. The plugin owns nothing else: no config, no session data, no files of its own.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
1. Select text inside a message. Selections inside the composer itself never offer the button, because that is you editing your own draft.
|
|
58
|
+
2. Click **引用选中文字** / **Quote selection**. It appears below the selection, right-aligned to the last selected line.
|
|
59
|
+
3. The selection is appended to the draft as a blockquote, the control disappears, and focus moves to the composer with the caret at the end of the draft. Keep typing.
|
|
60
|
+
|
|
61
|
+
Escape dismisses the control without touching your selection.
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
- **One entry, no host behavior.** The plugin registers a single `conversation.input.overlay` entry and its host half is an empty `apply()`. It adds no tool, no prompt section, and no session event.
|
|
66
|
+
- **Selection tracking.** One document-level source listens for `selectionchange`, pointer and key gestures, scroll, and resize, and publishes only what changed. A selection is quotable only inside the conversation transcript. The anchor is the last line's box with a non-zero area: engines emit a zero-size rect for a trailing line break, and anchoring to it would pin the control to the viewport's top-left corner.
|
|
67
|
+
- **The plugin owns its coordinate frame.** The control renders into a zero-size host the plugin creates as a direct child of `<html>` and pins to the viewport origin with `position: fixed`. The button inside it is `position: absolute`, so its coordinates are true viewport coordinates — the same space `getBoundingClientRect()` reports the selection in. Nothing above the host can reinterpret them: `position`, `transform`, `filter`, `perspective`, and `contain` on any ancestor all change what `fixed` resolves against, and the document element has no ancestor to do that. The host is created during the first render rather than in an effect, so the very first paint is already in the right space.
|
|
68
|
+
- **The draft write.** The control calls the composer's own public `inputActions.setDraft()`. It reads and writes no state of its own — the draft stays where it already lives.
|
|
69
|
+
- **Focus hand-off.** `setDraft` leaves the editor's selection at the end of the draft but moves no DOM focus, so the control focuses the composer's editor itself and collapses the caret at the end. The card to focus is read from the control's own slot seat, not by searching the document, because the button now lives outside it and a settled message's editor would otherwise be found first.
|
|
70
|
+
- **Fixed contrast, not themed.** The control's fill and text are hard-coded black on white rather than drawn from theme tokens. It floats over conversation text it does not own, so it must stay legible over whatever is behind it; a themed pair would flip with the host theme and could land light-on-light or dark-on-dark. The border and shadow are still themed, because they only soften the edge. This is also why the control stays readable without the harness defining any particular token.
|
|
71
|
+
|
|
72
|
+
## Compatibility
|
|
73
|
+
|
|
74
|
+
DeepSeek Harness plugin APIs are **pre-stable**: nothing below is a compatibility promise, and a harness update can break this plugin silently — the quote still lands, or the button simply stops appearing.
|
|
75
|
+
|
|
76
|
+
This plugin depends on:
|
|
77
|
+
|
|
78
|
+
| Dependency | Kind |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| The `conversation.input.overlay` slot, declared by `@deepseek-ai/dsh-client-ui-conversation` | slot name |
|
|
81
|
+
| The `useInput` and `inputActions` props that every session-scope slot receives | framework contract |
|
|
82
|
+
| `ctx.slots` and `ctx.locale` services | framework contract |
|
|
83
|
+
| `[data-conversation-scroll]`, `[data-composer-card]`, and `[data-composer-input]` DOM markers | **not a contract** — internal markers |
|
|
84
|
+
| `react-dom`'s `createPortal` and a document root to portal into | framework contract |
|
|
85
|
+
|
|
86
|
+
The first three failing is loud. The DOM markers failing is silent, and the focus hand-off is a documented workaround rather than an API.
|
|
87
|
+
|
|
88
|
+
## Known limits
|
|
89
|
+
|
|
90
|
+
- **The control covers the line below the selection.** Placement is a fixed offset below the selection's last line, so the button sits over whatever follows it. Reserving layout space instead would reflow the transcript on every selection.
|
|
91
|
+
- **Append only.** The public composer API exposes whole-draft replacement and no insert-at-caret verb, so a quote always lands at the end of the draft.
|
|
92
|
+
- **Plain text only.** Quoting a fenced code block drops its fences and quoting a table drops its cell separators, because the browser's text serialization returns rendered text.
|
|
93
|
+
- **Transcript only.** Selections in the trajectory or waterfall views offer nothing; they are separate surfaces.
|
|
94
|
+
- **One action.** This is a quote button, not a selection toolbar. Copy, search, and annotation belong to whichever surface owns those decisions.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
npm install
|
|
100
|
+
npm test # unit + real-cordis registration tests
|
|
101
|
+
npm run typecheck
|
|
102
|
+
npm run build # lib/index.js (host half) + lib/client.js (browser half)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`lib/client.js` is not a plain bundle: the Web shell mounts plugins through a closure-factory contract, so the build wraps the browser half in `window.__ModuleLoader__.load({ id, factory })` and resolves React and the other platform modules through the injected `require` instead of inlining them. `scripts/build.mjs` is that whole build, with a small CSS Modules compiler in front of it.
|
|
106
|
+
|
|
107
|
+
### The CSS Modules compiler
|
|
108
|
+
|
|
109
|
+
Two details of that compiler are load-bearing, and both fail silently when wrong:
|
|
110
|
+
|
|
111
|
+
- **The class map must hold plain strings.** lightningcss reports each export as `{ name, composes, isReferenced }`, and handing that object to `className` renders `[object Object]`, which no selector matches — every rule in the file is dropped. The build flattens the records to names.
|
|
112
|
+
- **The style tag is keyed by a content hash.** HMR re-runs the module factory in the live page, so a path-only key would find the previous sheet still tagged and skip injecting the new one.
|
|
113
|
+
|
|
114
|
+
`tests/build.client.spec.ts` asserts both against the built bundle, because neither is visible in the sources.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# dsh-plugin-quote
|
|
2
|
+
|
|
3
|
+
一个 DeepSeek Harness 的 Web 插件。在对话里选中一段文字,点浮现出来的按钮,那段文字就以 Markdown 引用块落进输入框。
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
我的问题
|
|
7
|
+
|
|
8
|
+
> 你选中的段落
|
|
9
|
+
> 按行保留换行
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
没有任何新东西进入模型:引用就是普通用户文本,和你手打进去完全一样。
|
|
14
|
+
|
|
15
|
+
## 环境要求
|
|
16
|
+
|
|
17
|
+
- DeepSeek Harness,且使用 **Web** profile(`dsh web`)。`headless` / `sdk` / `acp` 没有输入框,插件在那里不起作用。
|
|
18
|
+
- 在 **dsh `0.1.5-rc.1`** 上构建与验证。Harness 的插件 API 是 pre-stable 的,见[兼容性](#兼容性)。
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install @walkerxie/dsh-plugin-quote
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
然后在你 Web profile 的 patch 文件(`~/.dsh/profiles/web/cordis.patch.yml`)里加一行,指向已安装的包:
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
- insert:
|
|
30
|
+
- id: ui-quote
|
|
31
|
+
name: '@walkerxie/dsh-plugin-quote'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
若要从源码检出运行——开发插件本身时更方便——构建后把该行指向文件:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
git clone https://github.com/Xieweikang123/dsh-plugin-quote.git
|
|
38
|
+
cd dsh-plugin-quote
|
|
39
|
+
npm install
|
|
40
|
+
npm run build
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
- insert:
|
|
45
|
+
- id: ui-quote
|
|
46
|
+
name: 'file:///你的绝对路径/dsh-plugin-quote/lib/index.js'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
profile 的 patch 是热重载的,刷新页面即可。如果按钮没出现,重启一次 `dsh web`,让 Loader 在启动时把新行读进去。
|
|
50
|
+
|
|
51
|
+
## 卸载
|
|
52
|
+
|
|
53
|
+
删掉那段 `- insert:`(或者给该行加 `disabled: true`),刷新即可。插件不拥有任何别的东西:没有配置、没有会话数据、没有自己的文件。
|
|
54
|
+
|
|
55
|
+
## 使用
|
|
56
|
+
|
|
57
|
+
1. 在**消息**里选中文字。输入框内部的选区永远不会给出按钮——那是你在编辑自己的草稿。
|
|
58
|
+
2. 点 **引用选中文字**(英文 `Quote selection`)。它出现在选区下方,与最后一行右边缘对齐。
|
|
59
|
+
3. 选区以引用块形式追加进草稿,控件消失,焦点交给输入框、光标落在草稿末尾。接着打字就行。
|
|
60
|
+
|
|
61
|
+
按 Escape 可以关闭控件,且不改动你的选区。
|
|
62
|
+
|
|
63
|
+
## 实现要点
|
|
64
|
+
|
|
65
|
+
- **一个条目,没有 host 行为。** 插件只注册一个 `conversation.input.overlay` 条目,host 半边是空的 `apply()`。它不添加工具、不加提示词段落、不写会话事件。
|
|
66
|
+
- **选区追踪。** 一个文档级来源监听 `selectionchange`、指针与按键手势、滚动与缩放,只在快照确实变化时发布。只有落在对话记录内的选区才可引用。锚点是最后一行中面积非零的那个盒子:浏览器会为行尾换行吐出一个零尺寸矩形,锚到它上面会让控件被钉在视口左上角。
|
|
67
|
+
- **坐标系由插件自己拥有。** 控件渲染进一个由插件创建的零尺寸宿主,它是 `<html>` 的直接子节点,并用 `position: fixed` 钉在视口原点上。其中的按钮是 `position: absolute`,因此它的坐标就是真正的视口坐标——与 `getBoundingClientRect()` 报告选区时所用的空间一致。宿主之上的任何东西都无法重新解释这些坐标:祖先上的 `position`、`transform`、`filter`、`perspective`、`contain` 都会改变 `fixed` 的解析基准,而文档元素没有祖先。宿主在首次渲染时就创建(而不是在 effect 里),所以第一帧就已经落在正确的空间里。
|
|
68
|
+
- **写入草稿。** 控件调用 composer 自己的公开 `inputActions.setDraft()`。它自己不读取也不持有任何状态——草稿仍然存在它原本该在的地方。
|
|
69
|
+
- **焦点交接。** `setDraft` 会把编辑器的选区留在草稿末尾,但不移动 DOM 焦点,所以控件自己聚焦 composer 的编辑器,并把光标收拢到末尾。要聚焦的卡片取自控件自己的 slot 座位,而不是在文档里搜出来的:按钮现在不在卡片里了,否则一条已结束消息的编辑器会先被找到。
|
|
70
|
+
- **对比度固定,不跟随主题。** 控件的底色与文字硬编码为黑底白字,而不是取自主题令牌。它浮在并不属于它的对话文字之上,必须在其背后的任何内容上都保持可读;跟随主题的一对颜色会随宿主主题翻转,可能落成浅底浅字或深底深字。边框与阴影仍走主题,因为它们只负责柔化边缘。这也是为什么控件不需要宿主定义任何特定令牌就能保持可读。
|
|
71
|
+
|
|
72
|
+
## 兼容性
|
|
73
|
+
|
|
74
|
+
DeepSeek Harness 的插件 API 是 **pre-stable** 的:下面这些东西都不是兼容性承诺,Harness 的一次升级就可能让它静默失效——引用照落,或者按钮干脆不再出现。
|
|
75
|
+
|
|
76
|
+
本插件依赖:
|
|
77
|
+
|
|
78
|
+
| 依赖对象 | 性质 |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| 由 `@deepseek-ai/dsh-client-ui-conversation` 声明的 `conversation.input.overlay` slot | slot 名称 |
|
|
81
|
+
| 每个 session 作用域 slot 都会收到的 `useInput` 与 `inputActions` | 框架契约 |
|
|
82
|
+
| `ctx.slots` 与 `ctx.locale` 两个服务 | 框架契约 |
|
|
83
|
+
| `[data-conversation-scroll]`、`[data-composer-card]`、`[data-composer-input]` 三个 DOM 标记 | **不是契约**——内部标记 |
|
|
84
|
+
| `react-dom` 的 `createPortal` 与一个可 portal 的文档根 | 框架契约 |
|
|
85
|
+
|
|
86
|
+
前三项失效是响的;DOM 标记失效是静默的,而焦点交接是一处记录在案的工作区绕行做法,不是 API。
|
|
87
|
+
|
|
88
|
+
## 已知限制
|
|
89
|
+
|
|
90
|
+
- **控件会盖住选区下方那一行。** 定位就是相对选区最后一行的一个固定偏移,所以按钮会压在它后面的内容上。另一个选择——预留布局空间——会让对话记录在每次选区变化时重排。
|
|
91
|
+
- **只能追加。** composer 的公开 API 提供整段替换,没有"在光标处插入"的动词,所以引用总是落在草稿末尾。
|
|
92
|
+
- **仅纯文本。** 引用带围栏的代码块会丢掉围栏,引用表格会丢掉单元格分隔,因为浏览器返回的是渲染后的文字。
|
|
93
|
+
- **只覆盖对话记录。** trajectory 与 waterfall 视图是另外的界面,其中的选区不提供任何操作。
|
|
94
|
+
- **只有一个动作。** 这是一个引用按钮,不是选区工具栏。复制、搜索与批注属于拥有那些决策的界面。
|
|
95
|
+
|
|
96
|
+
## 开发
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
npm install
|
|
100
|
+
npm test # 单元测试 + 真实 Cordis 上下文的注册测试
|
|
101
|
+
npm run typecheck
|
|
102
|
+
npm run build # lib/index.js(host 半边)+ lib/client.js(浏览器半边)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`lib/client.js` 不是普通 bundle:Web shell 通过闭包工厂契约挂载插件,所以构建会把浏览器半边包成 `window.__ModuleLoader__.load({ id, factory })`,并让 React 等平台模块走注入的 `require` 而不是内联进来。整个构建就是 `scripts/build.mjs`,前面挂了一个很小的 CSS Modules 编译器。
|
|
106
|
+
|
|
107
|
+
### CSS Modules 编译器
|
|
108
|
+
|
|
109
|
+
那个编译器里有两个细节是承重的,而且**出错时都是静默的**:
|
|
110
|
+
|
|
111
|
+
- **类名映射必须是纯字符串。** lightningcss 把每个导出报成 `{ name, composes, isReferenced }`,把这个对象交给 `className` 会渲染出 `[object Object]`,没有任何选择器能匹配它——整个文件的规则全部失效。构建负责把记录压平成名字。
|
|
112
|
+
- **样式标签以内容哈希为键。** HMR 会在活着的页面里重新执行模块工厂,所以只以路径为键会命中上一次的标签、跳过注入新样式。
|
|
113
|
+
|
|
114
|
+
`tests/build.client.spec.ts` 针对**构建产物**断言这两点,因为它们在源码层面都看不出来。
|
|
115
|
+
|
|
116
|
+
## 许可证
|
|
117
|
+
|
|
118
|
+
MIT
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@walkerxie/dsh-plugin-quote",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
"use strict";
|
|
8
|
+
var __defProp = Object.defineProperty;
|
|
9
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
10
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
11
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
var __copyProps = (to, from, except, desc) => {
|
|
17
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
18
|
+
for (let key of __getOwnPropNames(from))
|
|
19
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
20
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
21
|
+
}
|
|
22
|
+
return to;
|
|
23
|
+
};
|
|
24
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
25
|
+
|
|
26
|
+
// src/client/index.ts
|
|
27
|
+
var index_exports = {};
|
|
28
|
+
__export(index_exports, {
|
|
29
|
+
apply: () => apply,
|
|
30
|
+
inject: () => inject
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(index_exports);
|
|
33
|
+
|
|
34
|
+
// src/client/QuoteControl.tsx
|
|
35
|
+
var import_react2 = require("react");
|
|
36
|
+
|
|
37
|
+
// src/client/placement.ts
|
|
38
|
+
function placeQuoteControl(anchor, panel, viewport, gap = 6, margin = 8) {
|
|
39
|
+
return {
|
|
40
|
+
left: clamp(anchor.right - panel.width, margin, viewport.width - panel.width - margin),
|
|
41
|
+
top: clamp(anchor.bottom + gap, margin, viewport.height - panel.height - margin)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function clamp(value, min, max) {
|
|
45
|
+
return Math.min(Math.max(value, min), Math.max(min, max));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/client/quote-text.ts
|
|
49
|
+
var QUOTE_PREFIX = "> ";
|
|
50
|
+
function composeQuote(draft, selection) {
|
|
51
|
+
const quoted = selection.split("\n").map((line) => `${QUOTE_PREFIX}${line}`.trimEnd()).join("\n");
|
|
52
|
+
const head = draft.trimEnd();
|
|
53
|
+
return head === "" ? `${quoted}
|
|
54
|
+
|
|
55
|
+
` : `${head}
|
|
56
|
+
|
|
57
|
+
${quoted}
|
|
58
|
+
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/client/layer.tsx
|
|
63
|
+
var import_react = require("react");
|
|
64
|
+
var import_react_dom = require("react-dom");
|
|
65
|
+
var HOST_ATTRIBUTE = "data-quote-layer";
|
|
66
|
+
var HOST_STYLE = {
|
|
67
|
+
position: "fixed",
|
|
68
|
+
top: "0",
|
|
69
|
+
left: "0",
|
|
70
|
+
width: "0",
|
|
71
|
+
height: "0",
|
|
72
|
+
// Above the composer and the transcript, below modal surfaces the host owns.
|
|
73
|
+
zIndex: "60",
|
|
74
|
+
// The host is a pure coordinate frame: it must never intercept a pointer.
|
|
75
|
+
pointerEvents: "none"
|
|
76
|
+
};
|
|
77
|
+
function ensureLayerHost() {
|
|
78
|
+
if (typeof document === "undefined") return null;
|
|
79
|
+
const existing = document.querySelector(`[${HOST_ATTRIBUTE}]`);
|
|
80
|
+
if (existing !== null) return existing;
|
|
81
|
+
const host = document.createElement("div");
|
|
82
|
+
host.setAttribute(HOST_ATTRIBUTE, "");
|
|
83
|
+
Object.assign(host.style, HOST_STYLE);
|
|
84
|
+
document.documentElement.appendChild(host);
|
|
85
|
+
return host;
|
|
86
|
+
}
|
|
87
|
+
function ViewportLayer({ children }) {
|
|
88
|
+
const [host, setHost] = (0, import_react.useState)(ensureLayerHost);
|
|
89
|
+
(0, import_react.useEffect)(() => {
|
|
90
|
+
if (host !== null && host.isConnected) return;
|
|
91
|
+
setHost(ensureLayerHost());
|
|
92
|
+
}, [host]);
|
|
93
|
+
if (host === null) return null;
|
|
94
|
+
return (0, import_react_dom.createPortal)(children, host);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/client/QuoteControl.module.css
|
|
98
|
+
var css = "._3OiQSq_control{z-index:60;border:.5px solid var(--dsw-alias-border-l1);color:#fff;white-space:nowrap;cursor:pointer;box-shadow:0 4px 12px var(--dsw-alias-bg-mask-2);background:#000;border-radius:8px;align-items:center;padding:5px 10px;font-family:inherit;font-size:12px;line-height:16px;transition:transform .12s,box-shadow .12s;display:inline-flex;position:absolute}._3OiQSq_control:hover{box-shadow:0 8px 20px var(--dsw-alias-bg-mask-2);transform:translateY(-2px)}";
|
|
99
|
+
var styleKey = "@walkerxie/dsh-plugin-quote/QuoteControl.module.css";
|
|
100
|
+
var tagId = "@walkerxie/dsh-plugin-quote/QuoteControl.module.css#aa03c92b";
|
|
101
|
+
if (typeof document !== "undefined") {
|
|
102
|
+
for (const stale of document.querySelectorAll("style[data-plugin-css]")) {
|
|
103
|
+
const id = stale.dataset.pluginCss ?? "";
|
|
104
|
+
if (id !== tagId && (id === styleKey || id.startsWith(styleKey + "#"))) stale.remove();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (typeof document !== "undefined" && document.querySelector('style[data-plugin-css="' + tagId + '"]') === null) {
|
|
108
|
+
const tag = document.createElement("style");
|
|
109
|
+
tag.dataset.plugin = "@walkerxie/dsh-plugin-quote";
|
|
110
|
+
tag.dataset.pluginCss = tagId;
|
|
111
|
+
tag.textContent = css;
|
|
112
|
+
document.head.appendChild(tag);
|
|
113
|
+
}
|
|
114
|
+
var QuoteControl_default = { "control": "_3OiQSq_control" };
|
|
115
|
+
|
|
116
|
+
// src/client/QuoteControl.tsx
|
|
117
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
118
|
+
var UNMEASURED = { width: 0, height: 0 };
|
|
119
|
+
function QuoteControl({ useQuoteSelection, useInput, inputActions, t }) {
|
|
120
|
+
const selection = useQuoteSelection((snapshot) => snapshot);
|
|
121
|
+
const draft = useInput((state) => state.draft);
|
|
122
|
+
const panelRef = (0, import_react2.useRef)(null);
|
|
123
|
+
const seatRef = (0, import_react2.useRef)(null);
|
|
124
|
+
const [size, setSize] = (0, import_react2.useState)(UNMEASURED);
|
|
125
|
+
const [dismissed, setDismissed] = (0, import_react2.useState)(null);
|
|
126
|
+
const anchor = selection.anchor;
|
|
127
|
+
const quotable = selection.text !== "" && anchor !== null;
|
|
128
|
+
const shown = quotable && dismissed !== selection;
|
|
129
|
+
(0, import_react2.useLayoutEffect)(() => {
|
|
130
|
+
if (!shown) return;
|
|
131
|
+
const panel = panelRef.current;
|
|
132
|
+
if (panel === null) return;
|
|
133
|
+
const width = panel.offsetWidth;
|
|
134
|
+
const height = panel.offsetHeight;
|
|
135
|
+
if (width === 0 && height === 0) return;
|
|
136
|
+
if (width !== size.width || height !== size.height) setSize({ width, height });
|
|
137
|
+
}, [shown, selection, size.width, size.height]);
|
|
138
|
+
(0, import_react2.useEffect)(() => {
|
|
139
|
+
if (!shown) return;
|
|
140
|
+
const onKeyDown = (event) => {
|
|
141
|
+
if (event.key === "Escape") setDismissed(selection);
|
|
142
|
+
};
|
|
143
|
+
document.addEventListener("keydown", onKeyDown);
|
|
144
|
+
return () => {
|
|
145
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
146
|
+
};
|
|
147
|
+
}, [shown, selection]);
|
|
148
|
+
if (!shown || anchor === null) return null;
|
|
149
|
+
const position = placeQuoteControl(anchor, size, {
|
|
150
|
+
width: window.innerWidth,
|
|
151
|
+
height: window.innerHeight
|
|
152
|
+
});
|
|
153
|
+
const style = { ...position, pointerEvents: "auto" };
|
|
154
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
155
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { ref: seatRef, hidden: true }),
|
|
156
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ViewportLayer, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
157
|
+
"button",
|
|
158
|
+
{
|
|
159
|
+
ref: panelRef,
|
|
160
|
+
type: "button",
|
|
161
|
+
className: QuoteControl_default.control,
|
|
162
|
+
style,
|
|
163
|
+
"aria-label": t("action.quote"),
|
|
164
|
+
onMouseDown: (event) => {
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
},
|
|
167
|
+
onClick: () => {
|
|
168
|
+
inputActions.setDraft(composeQuote(draft, selection.text));
|
|
169
|
+
window.getSelection()?.removeAllRanges();
|
|
170
|
+
focusComposerEnd(seatRef.current?.closest("[data-composer-card]") ?? null);
|
|
171
|
+
},
|
|
172
|
+
children: t("action.quote")
|
|
173
|
+
}
|
|
174
|
+
) })
|
|
175
|
+
] });
|
|
176
|
+
}
|
|
177
|
+
function focusComposerEnd(card) {
|
|
178
|
+
const editable = card?.querySelector("[data-composer-input]");
|
|
179
|
+
if (editable === null || editable === void 0) return;
|
|
180
|
+
editable.focus({ preventScroll: true });
|
|
181
|
+
const range = document.createRange();
|
|
182
|
+
range.selectNodeContents(editable);
|
|
183
|
+
range.collapse(false);
|
|
184
|
+
const selection = window.getSelection();
|
|
185
|
+
selection?.removeAllRanges();
|
|
186
|
+
selection?.addRange(range);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/client/selection.ts
|
|
190
|
+
var NO_SELECTION = { text: "", anchor: null };
|
|
191
|
+
var TRANSCRIPT = "[data-conversation-scroll]";
|
|
192
|
+
var COMPOSER = "[data-composer-card]";
|
|
193
|
+
function elementOf(node) {
|
|
194
|
+
return node instanceof Element ? node : node.parentElement;
|
|
195
|
+
}
|
|
196
|
+
function boxOf(rect) {
|
|
197
|
+
return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom };
|
|
198
|
+
}
|
|
199
|
+
function lastRect(range) {
|
|
200
|
+
const rects = typeof range.getClientRects === "function" ? range.getClientRects() : null;
|
|
201
|
+
let last = null;
|
|
202
|
+
if (rects !== null) {
|
|
203
|
+
for (const rect of rects) {
|
|
204
|
+
if (rect.width <= 0 && rect.height <= 0) continue;
|
|
205
|
+
last = boxOf(rect);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (last !== null) return last;
|
|
209
|
+
if (typeof range.getBoundingClientRect !== "function") return null;
|
|
210
|
+
const bound = range.getBoundingClientRect();
|
|
211
|
+
if (bound.width <= 0 && bound.height <= 0) return null;
|
|
212
|
+
return boxOf(bound);
|
|
213
|
+
}
|
|
214
|
+
function sameAnchor(left, right) {
|
|
215
|
+
return left.left === right.left && left.right === right.right && left.top === right.top && left.bottom === right.bottom;
|
|
216
|
+
}
|
|
217
|
+
function sameSelection(left, right) {
|
|
218
|
+
if (left.text !== right.text) return false;
|
|
219
|
+
if (left.anchor === null || right.anchor === null) return left.anchor === right.anchor;
|
|
220
|
+
return sameAnchor(left.anchor, right.anchor);
|
|
221
|
+
}
|
|
222
|
+
function readSelection(doc) {
|
|
223
|
+
const selection = doc.getSelection();
|
|
224
|
+
if (selection === null || selection.isCollapsed || selection.rangeCount === 0) return NO_SELECTION;
|
|
225
|
+
const text = selection.toString().trim();
|
|
226
|
+
if (text === "") return NO_SELECTION;
|
|
227
|
+
const range = selection.getRangeAt(0);
|
|
228
|
+
const host = elementOf(range.commonAncestorContainer);
|
|
229
|
+
if (host === null) return NO_SELECTION;
|
|
230
|
+
if (host.closest(TRANSCRIPT) === null) return NO_SELECTION;
|
|
231
|
+
if (host.closest(COMPOSER) !== null) return NO_SELECTION;
|
|
232
|
+
const anchor = lastRect(range);
|
|
233
|
+
if (anchor === null) return NO_SELECTION;
|
|
234
|
+
return { text, anchor };
|
|
235
|
+
}
|
|
236
|
+
function createQuoteSelectionSource(doc) {
|
|
237
|
+
let snapshot = NO_SELECTION;
|
|
238
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
239
|
+
const publish = () => {
|
|
240
|
+
const next = readSelection(doc);
|
|
241
|
+
if (sameSelection(snapshot, next)) return;
|
|
242
|
+
snapshot = next;
|
|
243
|
+
for (const listener of listeners) listener();
|
|
244
|
+
};
|
|
245
|
+
const win = doc.defaultView;
|
|
246
|
+
doc.addEventListener("selectionchange", publish);
|
|
247
|
+
doc.addEventListener("pointerup", publish);
|
|
248
|
+
doc.addEventListener("keyup", publish);
|
|
249
|
+
win?.addEventListener("scroll", publish, true);
|
|
250
|
+
win?.addEventListener("resize", publish);
|
|
251
|
+
return {
|
|
252
|
+
source: {
|
|
253
|
+
getSnapshot: () => snapshot,
|
|
254
|
+
subscribe: (listener) => {
|
|
255
|
+
listeners.add(listener);
|
|
256
|
+
return () => {
|
|
257
|
+
listeners.delete(listener);
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
dispose: () => {
|
|
262
|
+
doc.removeEventListener("selectionchange", publish);
|
|
263
|
+
doc.removeEventListener("pointerup", publish);
|
|
264
|
+
doc.removeEventListener("keyup", publish);
|
|
265
|
+
win?.removeEventListener("scroll", publish, true);
|
|
266
|
+
win?.removeEventListener("resize", publish);
|
|
267
|
+
listeners.clear();
|
|
268
|
+
snapshot = NO_SELECTION;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/client/locales.ts
|
|
274
|
+
var zh = {
|
|
275
|
+
"action.quote": "\u5F15\u7528\u9009\u4E2D\u6587\u5B57"
|
|
276
|
+
};
|
|
277
|
+
var en = {
|
|
278
|
+
"action.quote": "Quote selection"
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// src/client/index.ts
|
|
282
|
+
var NS = "quote";
|
|
283
|
+
var inject = ["slots", "locale"];
|
|
284
|
+
function apply(ctx) {
|
|
285
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "ui-quote: dictionaries");
|
|
286
|
+
const selection = createQuoteSelectionSource(document);
|
|
287
|
+
ctx.effect(() => selection.dispose, "ui-quote: selection tracking");
|
|
288
|
+
ctx.slots.inject("conversation.input.overlay", () => ctx.slots.register({
|
|
289
|
+
name: "conversation.input.overlay",
|
|
290
|
+
id: "quote",
|
|
291
|
+
order: 20,
|
|
292
|
+
locale: NS,
|
|
293
|
+
inject: () => ({ hooks: { quoteSelection: selection.source } })
|
|
294
|
+
}, QuoteControl));
|
|
295
|
+
}
|
|
296
|
+
return module.exports;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/client/index.ts", "../src/client/QuoteControl.tsx", "../src/client/placement.ts", "../src/client/quote-text.ts", "../src/client/layer.tsx", "../src/client/QuoteControl.module.css", "../src/client/selection.ts", "../src/client/locales.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Quote surface plugin, browser half: one control in the composer overlay that\n * turns the current conversation text selection into a markdown blockquote\n * appended to the draft. The selection is tracked once for the plugin fiber and\n * published as an inject-face hook source; the draft is read and written\n * through the framework's Session standard shares, so this plugin owns no\n * composer state and adds no model-visible vocabulary.\n */\nimport type { Context as ClientContext } from '@deepseek-ai/cordis'\n// Type-only: pulls the conversation overlay slot, the Session standard shares\n// (useInput/inputActions), and their declaration merges into the Client program.\nimport type {} from '@deepseek-ai/dsh-client-ui-conversation/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale).\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\n// Type-only: pulls the renderer-owned slots service.\nimport type {} from '@deepseek-ai/dsh-client-ui-renderer/client'\nimport type { QuoteInjected } from './slots.ts'\nimport { QuoteControl } from './QuoteControl.tsx'\nimport { createQuoteSelectionSource } from './selection.ts'\nimport { en, zh, type QuoteKey } from './locales.ts'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** The quote control's copy. */\n quote: QuoteKey\n }\n}\n\n/** Dictionary namespace owned by this plugin. */\nconst NS = 'quote'\n\n/** Required services: the slot registry and the plugin's own dictionaries. */\nexport const inject = ['slots', 'locale']\n\n/**\n * Client plugin body: the overlay entry over one document-level selection source.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-quote: dictionaries')\n\n // One source per plugin fiber, not per session binding: the selection is a\n // document fact, and a per-session source would leak a listener set per\n // session switch.\n const selection = createQuoteSelectionSource(document)\n ctx.effect(() => selection.dispose, 'ui-quote: selection tracking')\n\n ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({\n name: 'conversation.input.overlay',\n id: 'quote',\n order: 20,\n locale: NS,\n inject: (): QuoteInjected => ({ hooks: { quoteSelection: selection.source } }),\n }, QuoteControl))\n}\n", "/**\n * The floating quote control: a button placed beside the conversation text\n * selection. Visibility, placement, and the appended blockquote all derive from\n * the injected selection hook and the framework's own composer shares; the\n * component holds no subscription machinery and never touches ctx. The button\n * itself renders into the viewport layer, because the composer card this slot\n * lives in is a containing block for fixed positioning.\n */\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { QuoteInjected } from './slots.ts'\nimport type { QuoteAnchor, PanelSize } from './placement.ts'\nimport { placeQuoteControl } from './placement.ts'\nimport { composeQuote } from './quote-text.ts'\nimport { ViewportLayer } from './layer.tsx'\nimport css from './QuoteControl.module.css'\n\n/** Full props of the overlay entry: overlay owner share + injected selection hook + the locale seat. */\nexport type QuoteControlProps =\n import('@deepseek-ai/dsh-client-ui-slots').PropsRuntime<'conversation.input.overlay'>\n & InjectFace<QuoteInjected>\n & PropsLocale<'quote'>\n\n/** Size published before the first layout pass, where the clamps are skipped. */\nconst UNMEASURED: PanelSize = { width: 0, height: 0 }\n\n/**\n * The quote control. It renders nothing while the selection is empty, while\n * the user has dismissed the current selection, or on hosts whose composer\n * exposes no draft actions \u2014 the button never appears as a dead control.\n * @param props - the overlay entry's derived shares plus the injected hook.\n */\nexport function QuoteControl({ useQuoteSelection, useInput, inputActions, t }: QuoteControlProps) {\n const selection = useQuoteSelection(snapshot => snapshot)\n const draft = useInput(state => state.draft)\n const panelRef = useRef<HTMLButtonElement | null>(null)\n // The button renders in the viewport layer, so the composer card it belongs to\n // is no longer an ancestor of it. The card is resolved through this seat, which\n // stays in the slot's own position: React attaches refs only after it has moved\n // the button into the portal, so by then the button's ancestor chain is already\n // the document root.\n const seatRef = useRef<HTMLSpanElement | null>(null)\n const [size, setSize] = useState<PanelSize>(UNMEASURED)\n // Dismissal is keyed by snapshot identity: the source republishes only when\n // the selection moves, so a new selection shows the control again.\n const [dismissed, setDismissed] = useState<unknown>(null)\n\n const anchor: QuoteAnchor | null = selection.anchor\n const quotable = selection.text !== '' && anchor !== null\n const shown = quotable && dismissed !== selection\n\n // The panel is measured in the same commit that mounts it, so the clamps use\n // real dimensions before anything paints. jsdom reports zero offset sizes,\n // which leaves the unmeasured size in place and is why the clamps themselves\n // are covered through placeQuoteControl. The effect re-runs on every snapshot\n // while the panel is still unmeasured, because a size of zero is not a\n // measurement: it would right-align the control to the selection's right edge\n // and skip the bottom clamp entirely.\n useLayoutEffect(() => {\n if (!shown) return\n const panel = panelRef.current\n /* v8 ignore next -- the panel is mounted in the same commit that reveals the control. */\n if (panel === null) return\n const width = panel.offsetWidth\n const height = panel.offsetHeight\n if (width === 0 && height === 0) return\n if (width !== size.width || height !== size.height) setSize({ width, height })\n }, [shown, selection, size.width, size.height])\n\n // Escape hides the control without clearing the browser selection, which is\n // the user's own to keep.\n useEffect(() => {\n if (!shown) return\n const onKeyDown = (event: KeyboardEvent): void => {\n if (event.key === 'Escape') setDismissed(selection)\n }\n document.addEventListener('keydown', onKeyDown)\n return () => { document.removeEventListener('keydown', onKeyDown) }\n }, [shown, selection])\n\n if (!shown || anchor === null) return null\n\n const position = placeQuoteControl(anchor, size, {\n width: window.innerWidth,\n height: window.innerHeight,\n })\n\n // The layer host is a pure coordinate frame with `pointer-events: none`, so the\n // control re-enables them inline: this must hold even if the plugin's\n // stylesheet fails to load or is overridden.\n const style = { ...position, pointerEvents: 'auto' as const }\n\n return (\n <>\n {/* The seat: this component's own position in the composer card, the one\n place the owning card can still be read from once the button is\n portalled away. It carries no box, so the card's layout is untouched. */}\n <span ref={seatRef} hidden />\n <ViewportLayer>\n <button\n ref={panelRef}\n type=\"button\"\n className={css.control}\n style={style}\n aria-label={t('action.quote')}\n // The press must not collapse the selection: the click that follows would\n // otherwise land on a control that a selectionchange already unmounted.\n onMouseDown={event => { event.preventDefault() }}\n onClick={() => {\n inputActions.setDraft(composeQuote(draft, selection.text))\n // Drop the selection so the control leaves with it instead of lingering\n // over text that already reached the draft.\n window.getSelection()?.removeAllRanges()\n focusComposerEnd(seatRef.current?.closest('[data-composer-card]') ?? null)\n }}\n >\n {t('action.quote')}\n </button>\n </ViewportLayer>\n </>\n )\n}\n\n/**\n * Hand DOM focus to the composer that owns this control and collapse the caret\n * at the end of the draft. `setDraft` already put Lexical's selection there,\n * but Lexical skips DOM selection reconciliation while its editor is unfocused,\n * and a browser focusing an editor whose DOM selection sits elsewhere puts the\n * caret at the element's start.\n * @param card - the composer card holding this control's slot seat, resolved\n * through the seat because the button itself now lives in the viewport layer.\n */\nfunction focusComposerEnd(card: Element | null): void {\n const editable = card?.querySelector<HTMLElement>('[data-composer-input]')\n if (editable === null || editable === undefined) return\n editable.focus({ preventScroll: true })\n const range = document.createRange()\n range.selectNodeContents(editable)\n range.collapse(false)\n const selection = window.getSelection()\n selection?.removeAllRanges()\n selection?.addRange(range)\n}\n", "/**\n * Placement math for the floating quote control. The anchor is a selection\n * rect, not an element, so the shared element-anchored position hook does not\n * apply; this keeps the arithmetic pure so jsdom (which reports no layout) can\n * still cover the viewport clamps.\n *\n * The result is in VIEWPORT coordinates, matching the rect the anchor came\n * from: the control is absolutely positioned inside a layer host pinned to the\n * viewport origin, so no scroll offset is involved.\n * @module @deepseek-ai/dsh-client-ui-quote/placement\n */\n\n/** Viewport-relative box of the selection's last line, as the DOM reports it. */\nexport interface QuoteAnchor {\n readonly left: number\n readonly right: number\n readonly top: number\n readonly bottom: number\n}\n\n/** Measured size of the floating control. */\nexport interface PanelSize {\n readonly width: number\n readonly height: number\n}\n\n/** Viewport extent in CSS pixels. */\nexport interface ViewportSize {\n readonly width: number\n readonly height: number\n}\n\n/** Resolved viewport coordinates. */\nexport interface QuotePlacement {\n readonly left: number\n readonly top: number\n}\n\n/**\n * Place the control below the selection's last line, right edges aligned to it,\n * clamped inside the viewport on both axes, and expressed in document\n * coordinates by adding the scroll offset.\n *\n * The result is in VIEWPORT coordinates, matching the rect the anchor came\n * from. The control is absolutely positioned inside a layer host that is itself\n * pinned to the viewport origin, so viewport coordinates are the right space and\n * no scroll offset is involved.\n * @param anchor - viewport rect of the selection's last line.\n * @param panel - measured panel size; zero before the first layout pass.\n * @param viewport - current viewport extent.\n * @param gap - distance kept between the anchor's bottom edge and the panel.\n * @param margin - distance kept between the panel and each viewport edge.\n * @returns the control's viewport coordinates.\n */\nexport function placeQuoteControl(\n anchor: QuoteAnchor,\n panel: PanelSize,\n viewport: ViewportSize,\n gap = 6,\n margin = 8,\n): QuotePlacement {\n return {\n left: clamp(anchor.right - panel.width, margin, viewport.width - panel.width - margin),\n top: clamp(anchor.bottom + gap, margin, viewport.height - panel.height - margin),\n }\n}\n\n/** Clamp `value` into `[min, max]`, tolerating a max below min (an oversized panel). */\nfunction clamp(value: number, min: number, max: number): number {\n return Math.min(Math.max(value, min), Math.max(min, max))\n}\n", "/**\n * Quote text composition: the one transformation between a conversation text\n * selection and the composer draft. Pure so the wire-visible result (the\n * model reads the draft as plain user text) is testable without a DOM.\n * @module @deepseek-ai/dsh-client-ui-quote/quote-text\n */\n\n/** Markdown blockquote line prefix. */\nconst QUOTE_PREFIX = '> '\n\n/**\n * Append one selection to the draft as a markdown blockquote, leaving a blank\n * line after it so the caret's line is the continuation the user types next.\n * Every selected line keeps its own prefix, and a blank selected line becomes a\n * bare `>` \u2014 the blockquote form that keeps the paragraph break inside the\n * quote instead of ending it.\n * @param draft - current composer draft, already in wire (plain text) form.\n * @param selection - selected conversation text.\n * @returns the replacement draft.\n */\nexport function composeQuote(draft: string, selection: string): string {\n const quoted = selection\n .split('\\n')\n .map(line => `${QUOTE_PREFIX}${line}`.trimEnd())\n .join('\\n')\n const head = draft.trimEnd()\n return head === '' ? `${quoted}\\n\\n` : `${head}\\n\\n${quoted}\\n\\n`\n}\n", "/**\n * Viewport layer for the floating quote control.\n *\n * The control must be positioned against a coordinate space the plugin owns,\n * because every coordinate it computes comes from `getBoundingClientRect()`,\n * which is viewport-relative. Two host behaviours make that hard, and both are\n * outside the plugin's control:\n *\n * - an ancestor with `transform`, `filter`, `perspective`, `contain`, or a\n * non-static `position` becomes the containing block for `position: fixed`,\n * silently reinterpreting every viewport coordinate; and\n * - the document body is the host's element, so its `position` is the host's\n * business and may change under the plugin at any time.\n *\n * The fix is to own the containing block outright. The portal target is a\n * dedicated, plugin-created element that is itself `position: fixed` at the\n * viewport origin with no size, so it is pinned to (0, 0) regardless of what\n * any ancestor does. Children positioned absolutely inside it are therefore\n * placed in true viewport coordinates, and the document body is never touched.\n * @module @deepseek-ai/dsh-client-ui-quote/layer\n */\n\nimport { useEffect, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { ReactNode } from 'react'\n\n/** Attribute identifying the plugin's own layer host element. */\nconst HOST_ATTRIBUTE = 'data-quote-layer'\n\n/** Inline styles for the layer host: a zero-size box pinned to the viewport origin. */\nconst HOST_STYLE = {\n position: 'fixed',\n top: '0',\n left: '0',\n width: '0',\n height: '0',\n // Above the composer and the transcript, below modal surfaces the host owns.\n zIndex: '60',\n // The host is a pure coordinate frame: it must never intercept a pointer.\n pointerEvents: 'none',\n} as const\n\n/**\n * Find or create the plugin's layer host. It is created on demand and reused\n * across mounts, so a remount does not stack hosts, and it is attached to the\n * document element rather than the body so a body-level layout change cannot\n * move it.\n * @returns the host element, or null when no document is available.\n */\nfunction ensureLayerHost(): HTMLElement | null {\n if (typeof document === 'undefined') return null\n const existing = document.querySelector<HTMLElement>(`[${HOST_ATTRIBUTE}]`)\n if (existing !== null) return existing\n const host = document.createElement('div')\n host.setAttribute(HOST_ATTRIBUTE, '')\n Object.assign(host.style, HOST_STYLE)\n // `documentElement`, not `body`: the host's own containing block only needs to\n // be the initial one, and nothing above the document element can exist.\n document.documentElement.appendChild(host)\n return host\n}\n\n/**\n * Render children in the plugin's own viewport layer.\n *\n * The host is resolved during the first render rather than in an effect, so the\n * control is portalled into its final coordinate frame in the very commit that\n * mounts it; a layer introduced one commit later would place the first paint in\n * the wrong space.\n * @param props - the control to lift out of the composer subtree.\n * @returns the portalled children, or null when the host cannot host a portal.\n */\nexport function ViewportLayer({ children }: { children: ReactNode }) {\n const [host, setHost] = useState(ensureLayerHost)\n\n // A host that leaves the document (an HMR shell swap, a test teardown) would\n // strand the control, so it is re-created when that happens.\n useEffect(() => {\n if (host !== null && host.isConnected) return\n setHost(ensureLayerHost())\n }, [host])\n\n if (host === null) return null\n return createPortal(children, host)\n}\n", "const css = \"._3OiQSq_control{z-index:60;border:.5px solid var(--dsw-alias-border-l1);color:#fff;white-space:nowrap;cursor:pointer;box-shadow:0 4px 12px var(--dsw-alias-bg-mask-2);background:#000;border-radius:8px;align-items:center;padding:5px 10px;font-family:inherit;font-size:12px;line-height:16px;transition:transform .12s,box-shadow .12s;display:inline-flex;position:absolute}._3OiQSq_control:hover{box-shadow:0 8px 20px var(--dsw-alias-bg-mask-2);transform:translateY(-2px)}\";\nconst styleKey = \"@walkerxie/dsh-plugin-quote/QuoteControl.module.css\";\nconst tagId = \"@walkerxie/dsh-plugin-quote/QuoteControl.module.css#aa03c92b\";\nif (typeof document !== 'undefined') {\n for (const stale of document.querySelectorAll('style[data-plugin-css]')) {\n const id = stale.dataset.pluginCss ?? '';\n if (id !== tagId && (id === styleKey || id.startsWith(styleKey + '#'))) stale.remove();\n }\n}\nif (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=\"' + tagId + '\"]') === null) {\n const tag = document.createElement('style');\n tag.dataset.plugin = \"@walkerxie/dsh-plugin-quote\";\n tag.dataset.pluginCss = tagId;\n tag.textContent = css;\n document.head.appendChild(tag);\n}\nexport default {\"control\":\"_3OiQSq_control\"};", "/**\n * Conversation selection tracking: the registrant-private source behind the\n * floating quote control. The selection is a document-level fact that changes\n * outside React, so it is published as an observable the renderer binds to\n * `useQuoteSelection` rather than mirrored into component state.\n * @module @deepseek-ai/dsh-client-ui-quote/selection\n */\n\nimport type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { QuoteAnchor } from './placement.ts'\n\n/** The current quotable selection. */\nexport interface QuoteSelection {\n /** Selected text, trimmed; empty when nothing is quotable. */\n readonly text: string\n /** Viewport rect of the selection's last line; null when nothing is quotable. */\n readonly anchor: QuoteAnchor | null\n}\n\n/** Published while no quotable selection exists. */\nconst NO_SELECTION: QuoteSelection = { text: '', anchor: null }\n\n/** The conversation transcript's scrollport marker, rendered by the conversation root. */\nconst TRANSCRIPT = '[data-conversation-scroll]'\n\n/**\n * The composer card's marker, rendered by the input bar: a selection inside the\n * draft is the user editing their own text, never a thing to quote.\n */\nconst COMPOSER = '[data-composer-card]'\n\n/** Selection source handed to the slot component through the inject face. */\nexport interface QuoteSelectionSource {\n /** The observable the renderer binds to `useQuoteSelection`. */\n readonly source: HostObservable<QuoteSelection>\n /** Detach every listener and reset the published snapshot. */\n dispose(): void\n}\n\n/** The element a range endpoint belongs to, or null when it has left the document. */\nfunction elementOf(node: Node): Element | null {\n return node instanceof Element ? node : node.parentElement\n}\n\n/** Project one DOM rect onto the anchor fields the placement reads. */\nfunction boxOf(rect: { left: number, right: number, top: number, bottom: number }): QuoteAnchor {\n return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }\n}\n\n/**\n * The selection's last line box: the control hangs below it, and\n * `getBoundingClientRect()` spans every line of a multi-line selection, which\n * would hang it from a box taller than the text. An engine that implements\n * neither rect API reports no geometry, and a selection with no geometry has\n * nothing to hang the control from.\n *\n * Empty rects are skipped rather than trusted: engines emit a zero-size rect\n * for a trailing line break, and taking one as the anchor would clamp the\n * control to the viewport's top-left corner instead of the selection. A rect\n * with no area is not a line of text.\n * @param range - the selected range.\n * @returns the last non-empty line's box, or null when the range reports none.\n */\nfunction lastRect(range: Range): QuoteAnchor | null {\n const rects = typeof range.getClientRects === 'function' ? range.getClientRects() : null\n let last: QuoteAnchor | null = null\n if (rects !== null) {\n for (const rect of rects) {\n if (rect.width <= 0 && rect.height <= 0) continue\n last = boxOf(rect)\n }\n }\n if (last !== null) return last\n // Every rect was empty, so the bounding box is the only remaining opinion \u2014\n // and an empty bounding box is still no anchor at all.\n if (typeof range.getBoundingClientRect !== 'function') return null\n const bound = range.getBoundingClientRect()\n if (bound.width <= 0 && bound.height <= 0) return null\n return boxOf(bound)\n}\n\n/** Whether two anchors describe the same box, so snapshot identity can stay stable. */\nfunction sameAnchor(left: QuoteAnchor, right: QuoteAnchor): boolean {\n return left.left === right.left\n && left.right === right.right\n && left.top === right.top\n && left.bottom === right.bottom\n}\n\n/** Whether two snapshots describe the same selection. */\nfunction sameSelection(left: QuoteSelection, right: QuoteSelection): boolean {\n if (left.text !== right.text) return false\n if (left.anchor === null || right.anchor === null) return left.anchor === right.anchor\n return sameAnchor(left.anchor, right.anchor)\n}\n\n/** Read the document selection and decide whether it is quotable. */\nfunction readSelection(doc: Document): QuoteSelection {\n const selection = doc.getSelection()\n if (selection === null || selection.isCollapsed || selection.rangeCount === 0) return NO_SELECTION\n const text = selection.toString().trim()\n if (text === '') return NO_SELECTION\n const range = selection.getRangeAt(0)\n const host = elementOf(range.commonAncestorContainer)\n if (host === null) return NO_SELECTION\n if (host.closest(TRANSCRIPT) === null) return NO_SELECTION\n if (host.closest(COMPOSER) !== null) return NO_SELECTION\n const anchor = lastRect(range)\n if (anchor === null) return NO_SELECTION\n return { text, anchor }\n}\n\n/**\n * Track the document selection and publish each distinct quotable snapshot.\n * @param doc - the document whose selection is tracked.\n * @returns the source plus its disposer; the disposer detaches every listener.\n */\nexport function createQuoteSelectionSource(doc: Document): QuoteSelectionSource {\n let snapshot = NO_SELECTION\n const listeners = new Set<() => void>()\n const publish = (): void => {\n const next = readSelection(doc)\n if (sameSelection(snapshot, next)) return\n snapshot = next\n for (const listener of listeners) listener()\n }\n const win = doc.defaultView\n doc.addEventListener('selectionchange', publish)\n // Pointer and key gestures end a selection without a further selectionchange\n // in some engines, and a scroll moves the anchor rects under a selection that\n // itself never changed.\n doc.addEventListener('pointerup', publish)\n doc.addEventListener('keyup', publish)\n win?.addEventListener('scroll', publish, true)\n win?.addEventListener('resize', publish)\n return {\n source: {\n getSnapshot: () => snapshot,\n subscribe: (listener: () => void) => {\n listeners.add(listener)\n return () => { listeners.delete(listener) }\n },\n },\n dispose: () => {\n doc.removeEventListener('selectionchange', publish)\n doc.removeEventListener('pointerup', publish)\n doc.removeEventListener('keyup', publish)\n win?.removeEventListener('scroll', publish, true)\n win?.removeEventListener('resize', publish)\n listeners.clear()\n snapshot = NO_SELECTION\n },\n }\n}\n", "/** `quote` namespace dictionaries. */\n\n/** Simplified Chinese dictionary (the key-set source of truth). */\nexport const zh = {\n 'action.quote': '\u5F15\u7528\u9009\u4E2D\u6587\u5B57',\n} satisfies Record<string, string>\n\n/** The quote namespace key union. */\nexport type QuoteKey = keyof typeof zh\n\n/** English dictionary, checked complete against the zh key set. */\nexport const en = {\n 'action.quote': 'Quote selection',\n} satisfies Record<QuoteKey, string>\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["import_react"]
|
|
7
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The floating quote control: a button placed beside the conversation text
|
|
3
|
+
* selection. Visibility, placement, and the appended blockquote all derive from
|
|
4
|
+
* the injected selection hook and the framework's own composer shares; the
|
|
5
|
+
* component holds no subscription machinery and never touches ctx. The button
|
|
6
|
+
* itself renders into the viewport layer, because the composer card this slot
|
|
7
|
+
* lives in is a containing block for fixed positioning.
|
|
8
|
+
*/
|
|
9
|
+
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
|
|
10
|
+
import type { QuoteInjected } from './slots.ts';
|
|
11
|
+
/** Full props of the overlay entry: overlay owner share + injected selection hook + the locale seat. */
|
|
12
|
+
export type QuoteControlProps = import('@deepseek-ai/dsh-client-ui-slots').PropsRuntime<'conversation.input.overlay'> & InjectFace<QuoteInjected> & PropsLocale<'quote'>;
|
|
13
|
+
/**
|
|
14
|
+
* The quote control. It renders nothing while the selection is empty, while
|
|
15
|
+
* the user has dismissed the current selection, or on hosts whose composer
|
|
16
|
+
* exposes no draft actions — the button never appears as a dead control.
|
|
17
|
+
* @param props - the overlay entry's derived shares plus the injected hook.
|
|
18
|
+
*/
|
|
19
|
+
export declare function QuoteControl({ useQuoteSelection, useInput, inputActions, t }: QuoteControlProps): import("react").JSX.Element | null;
|
|
20
|
+
//# sourceMappingURL=QuoteControl.d.ts.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote surface plugin, browser half: one control in the composer overlay that
|
|
3
|
+
* turns the current conversation text selection into a markdown blockquote
|
|
4
|
+
* appended to the draft. The selection is tracked once for the plugin fiber and
|
|
5
|
+
* published as an inject-face hook source; the draft is read and written
|
|
6
|
+
* through the framework's Session standard shares, so this plugin owns no
|
|
7
|
+
* composer state and adds no model-visible vocabulary.
|
|
8
|
+
*/
|
|
9
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis';
|
|
10
|
+
import { type QuoteKey } from './locales.ts';
|
|
11
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
12
|
+
interface LocaleNamespaceMap {
|
|
13
|
+
/** The quote control's copy. */
|
|
14
|
+
quote: QuoteKey;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Required services: the slot registry and the plugin's own dictionaries. */
|
|
18
|
+
export declare const inject: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Client plugin body: the overlay entry over one document-level selection source.
|
|
21
|
+
* @param ctx - client root context.
|
|
22
|
+
*/
|
|
23
|
+
export declare function apply(ctx: ClientContext): void;
|
|
24
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewport layer for the floating quote control.
|
|
3
|
+
*
|
|
4
|
+
* The control must be positioned against a coordinate space the plugin owns,
|
|
5
|
+
* because every coordinate it computes comes from `getBoundingClientRect()`,
|
|
6
|
+
* which is viewport-relative. Two host behaviours make that hard, and both are
|
|
7
|
+
* outside the plugin's control:
|
|
8
|
+
*
|
|
9
|
+
* - an ancestor with `transform`, `filter`, `perspective`, `contain`, or a
|
|
10
|
+
* non-static `position` becomes the containing block for `position: fixed`,
|
|
11
|
+
* silently reinterpreting every viewport coordinate; and
|
|
12
|
+
* - the document body is the host's element, so its `position` is the host's
|
|
13
|
+
* business and may change under the plugin at any time.
|
|
14
|
+
*
|
|
15
|
+
* The fix is to own the containing block outright. The portal target is a
|
|
16
|
+
* dedicated, plugin-created element that is itself `position: fixed` at the
|
|
17
|
+
* viewport origin with no size, so it is pinned to (0, 0) regardless of what
|
|
18
|
+
* any ancestor does. Children positioned absolutely inside it are therefore
|
|
19
|
+
* placed in true viewport coordinates, and the document body is never touched.
|
|
20
|
+
* @module @deepseek-ai/dsh-client-ui-quote/layer
|
|
21
|
+
*/
|
|
22
|
+
import type { ReactNode } from 'react';
|
|
23
|
+
/**
|
|
24
|
+
* Render children in the plugin's own viewport layer.
|
|
25
|
+
*
|
|
26
|
+
* The host is resolved during the first render rather than in an effect, so the
|
|
27
|
+
* control is portalled into its final coordinate frame in the very commit that
|
|
28
|
+
* mounts it; a layer introduced one commit later would place the first paint in
|
|
29
|
+
* the wrong space.
|
|
30
|
+
* @param props - the control to lift out of the composer subtree.
|
|
31
|
+
* @returns the portalled children, or null when the host cannot host a portal.
|
|
32
|
+
*/
|
|
33
|
+
export declare function ViewportLayer({ children }: {
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}): import("react").ReactPortal | null;
|
|
36
|
+
//# sourceMappingURL=layer.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** `quote` namespace dictionaries. */
|
|
2
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
3
|
+
export declare const zh: {
|
|
4
|
+
'action.quote': string;
|
|
5
|
+
};
|
|
6
|
+
/** The quote namespace key union. */
|
|
7
|
+
export type QuoteKey = keyof typeof zh;
|
|
8
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
9
|
+
export declare const en: {
|
|
10
|
+
'action.quote': string;
|
|
11
|
+
};
|
|
12
|
+
//# sourceMappingURL=locales.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placement math for the floating quote control. The anchor is a selection
|
|
3
|
+
* rect, not an element, so the shared element-anchored position hook does not
|
|
4
|
+
* apply; this keeps the arithmetic pure so jsdom (which reports no layout) can
|
|
5
|
+
* still cover the viewport clamps.
|
|
6
|
+
*
|
|
7
|
+
* The result is in VIEWPORT coordinates, matching the rect the anchor came
|
|
8
|
+
* from: the control is absolutely positioned inside a layer host pinned to the
|
|
9
|
+
* viewport origin, so no scroll offset is involved.
|
|
10
|
+
* @module @deepseek-ai/dsh-client-ui-quote/placement
|
|
11
|
+
*/
|
|
12
|
+
/** Viewport-relative box of the selection's last line, as the DOM reports it. */
|
|
13
|
+
export interface QuoteAnchor {
|
|
14
|
+
readonly left: number;
|
|
15
|
+
readonly right: number;
|
|
16
|
+
readonly top: number;
|
|
17
|
+
readonly bottom: number;
|
|
18
|
+
}
|
|
19
|
+
/** Measured size of the floating control. */
|
|
20
|
+
export interface PanelSize {
|
|
21
|
+
readonly width: number;
|
|
22
|
+
readonly height: number;
|
|
23
|
+
}
|
|
24
|
+
/** Viewport extent in CSS pixels. */
|
|
25
|
+
export interface ViewportSize {
|
|
26
|
+
readonly width: number;
|
|
27
|
+
readonly height: number;
|
|
28
|
+
}
|
|
29
|
+
/** Resolved viewport coordinates. */
|
|
30
|
+
export interface QuotePlacement {
|
|
31
|
+
readonly left: number;
|
|
32
|
+
readonly top: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Place the control below the selection's last line, right edges aligned to it,
|
|
36
|
+
* clamped inside the viewport on both axes, and expressed in document
|
|
37
|
+
* coordinates by adding the scroll offset.
|
|
38
|
+
*
|
|
39
|
+
* The result is in VIEWPORT coordinates, matching the rect the anchor came
|
|
40
|
+
* from. The control is absolutely positioned inside a layer host that is itself
|
|
41
|
+
* pinned to the viewport origin, so viewport coordinates are the right space and
|
|
42
|
+
* no scroll offset is involved.
|
|
43
|
+
* @param anchor - viewport rect of the selection's last line.
|
|
44
|
+
* @param panel - measured panel size; zero before the first layout pass.
|
|
45
|
+
* @param viewport - current viewport extent.
|
|
46
|
+
* @param gap - distance kept between the anchor's bottom edge and the panel.
|
|
47
|
+
* @param margin - distance kept between the panel and each viewport edge.
|
|
48
|
+
* @returns the control's viewport coordinates.
|
|
49
|
+
*/
|
|
50
|
+
export declare function placeQuoteControl(anchor: QuoteAnchor, panel: PanelSize, viewport: ViewportSize, gap?: number, margin?: number): QuotePlacement;
|
|
51
|
+
//# sourceMappingURL=placement.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote text composition: the one transformation between a conversation text
|
|
3
|
+
* selection and the composer draft. Pure so the wire-visible result (the
|
|
4
|
+
* model reads the draft as plain user text) is testable without a DOM.
|
|
5
|
+
* @module @deepseek-ai/dsh-client-ui-quote/quote-text
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Append one selection to the draft as a markdown blockquote, leaving a blank
|
|
9
|
+
* line after it so the caret's line is the continuation the user types next.
|
|
10
|
+
* Every selected line keeps its own prefix, and a blank selected line becomes a
|
|
11
|
+
* bare `>` — the blockquote form that keeps the paragraph break inside the
|
|
12
|
+
* quote instead of ending it.
|
|
13
|
+
* @param draft - current composer draft, already in wire (plain text) form.
|
|
14
|
+
* @param selection - selected conversation text.
|
|
15
|
+
* @returns the replacement draft.
|
|
16
|
+
*/
|
|
17
|
+
export declare function composeQuote(draft: string, selection: string): string;
|
|
18
|
+
//# sourceMappingURL=quote-text.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation selection tracking: the registrant-private source behind the
|
|
3
|
+
* floating quote control. The selection is a document-level fact that changes
|
|
4
|
+
* outside React, so it is published as an observable the renderer binds to
|
|
5
|
+
* `useQuoteSelection` rather than mirrored into component state.
|
|
6
|
+
* @module @deepseek-ai/dsh-client-ui-quote/selection
|
|
7
|
+
*/
|
|
8
|
+
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots';
|
|
9
|
+
import type { QuoteAnchor } from './placement.ts';
|
|
10
|
+
/** The current quotable selection. */
|
|
11
|
+
export interface QuoteSelection {
|
|
12
|
+
/** Selected text, trimmed; empty when nothing is quotable. */
|
|
13
|
+
readonly text: string;
|
|
14
|
+
/** Viewport rect of the selection's last line; null when nothing is quotable. */
|
|
15
|
+
readonly anchor: QuoteAnchor | null;
|
|
16
|
+
}
|
|
17
|
+
/** Selection source handed to the slot component through the inject face. */
|
|
18
|
+
export interface QuoteSelectionSource {
|
|
19
|
+
/** The observable the renderer binds to `useQuoteSelection`. */
|
|
20
|
+
readonly source: HostObservable<QuoteSelection>;
|
|
21
|
+
/** Detach every listener and reset the published snapshot. */
|
|
22
|
+
dispose(): void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Track the document selection and publish each distinct quotable snapshot.
|
|
26
|
+
* @param doc - the document whose selection is tracked.
|
|
27
|
+
* @returns the source plus its disposer; the disposer detaches every listener.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createQuoteSelectionSource(doc: Document): QuoteSelectionSource;
|
|
30
|
+
//# sourceMappingURL=selection.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The quote control's injected face: the registrant-private selection source
|
|
3
|
+
* the slot renderer binds to `useQuoteSelection`. The control has no business
|
|
4
|
+
* verbs — the draft write goes through the framework's own `inputActions`
|
|
5
|
+
* share, which every session-scope slot receives.
|
|
6
|
+
*/
|
|
7
|
+
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots';
|
|
8
|
+
import type { QuoteSelection } from './selection.ts';
|
|
9
|
+
/** Injected face of the quote control. */
|
|
10
|
+
export interface QuoteInjected {
|
|
11
|
+
readonly hooks: {
|
|
12
|
+
readonly quoteSelection: HostObservable<QuoteSelection>;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=slots.d.ts.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote control plugin, node half. Pure UI plugin: the empty apply exists so
|
|
3
|
+
* the plugin appears in the host cordis.yml / Loader; the browser half ships
|
|
4
|
+
* via exports["./client"], discovered through the package.json dsh.client
|
|
5
|
+
* declaration.
|
|
6
|
+
*/
|
|
7
|
+
/** Host plugin body — no host-side behavior for this surface plugin. */
|
|
8
|
+
export declare function apply(): void;
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@walkerxie/dsh-plugin-quote",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Select text in the DeepSeek Harness Web conversation and append it to the composer as a markdown blockquote.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"types": "lib/types/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./lib/types/index.d.ts",
|
|
12
|
+
"default": "./lib/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./client": {
|
|
15
|
+
"types": "./lib/types/client/index.d.ts",
|
|
16
|
+
"default": "./lib/client.js"
|
|
17
|
+
},
|
|
18
|
+
"./src/*": "./src/*",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"dsh": {
|
|
22
|
+
"client": {
|
|
23
|
+
"inject": [
|
|
24
|
+
"@deepseek-ai/dsh-client-locale",
|
|
25
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
26
|
+
],
|
|
27
|
+
"platform": "web"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"lib/index.js",
|
|
32
|
+
"lib/client.js",
|
|
33
|
+
"lib/client.js.map",
|
|
34
|
+
"lib/types/**/*.d.ts",
|
|
35
|
+
"README.md",
|
|
36
|
+
"README.zh.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "node scripts/build.mjs && tsc --emitDeclarationOnly",
|
|
41
|
+
"prepublishOnly": "npm run build",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"test": "vitest run"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"deepseek-harness",
|
|
47
|
+
"dsh",
|
|
48
|
+
"dsh-plugin",
|
|
49
|
+
"cordis",
|
|
50
|
+
"quote"
|
|
51
|
+
],
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/Xieweikang123/dsh-plugin-quote.git"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/Xieweikang123/dsh-plugin-quote#readme",
|
|
57
|
+
"bugs": {
|
|
58
|
+
"url": "https://github.com/Xieweikang123/dsh-plugin-quote/issues"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
71
|
+
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.1",
|
|
72
|
+
"@deepseek-ai/dsh-client-store": "0.1.5-rc.1",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.1",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.1.5-rc.1",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.1",
|
|
76
|
+
"@testing-library/react": "^16.1.0",
|
|
77
|
+
"@types/react": "~18.3.1",
|
|
78
|
+
"@types/react-dom": "~18.3.0",
|
|
79
|
+
"esbuild": "^0.25.0",
|
|
80
|
+
"jsdom": "^29.1.1",
|
|
81
|
+
"lightningcss": "^1.32.0",
|
|
82
|
+
"react": "^18.3.1",
|
|
83
|
+
"react-dom": "^18.3.1",
|
|
84
|
+
"typescript": "^5.6.0",
|
|
85
|
+
"vitest": "^4.1.8"
|
|
86
|
+
}
|
|
87
|
+
}
|