dsh-client-ui-quote 0.11.1 → 0.11.3
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/README.md +8 -5
- package/lib/client.js +173 -62
- package/package.json +1 -1
- package/test/capsule.mjs +159 -0
- package/test/dom.mjs +27 -3
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ DSH 会把包记进 profile 的依赖并加入组合包列表,装完在侧边
|
|
|
55
55
|
|
|
56
56
|
### 方式三:npm 包(已上架)
|
|
57
57
|
|
|
58
|
-
已发布到 npm:`dsh-client-ui-quote
|
|
58
|
+
已发布到 npm:`dsh-client-ui-quote`(0.10.0 起上架;每个 `v*` 标签由下面的 workflow 自动发布,本仓库当前 0.11.3)。发布由 GitHub Actions 完成(`.github/workflows/publish.yml`):推 `v*` 标签或手动 Run `publish` workflow,`prepublishOnly` 的 306 项断言会先在 runner 上跑完,任何一项失败都不会发出去。`npm pack --dry-run` 可核对内容(0.10.0 = 14 个文件 / 97.6 kB)。
|
|
59
59
|
|
|
60
60
|
装:
|
|
61
61
|
|
|
@@ -227,7 +227,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
|
|
|
227
227
|
1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
|
|
228
228
|
2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标不随引用文本发给模型(不污染 prompt)。
|
|
229
229
|
3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在 4471 条里没有对应物。
|
|
230
|
-
4. **工程化**:
|
|
230
|
+
4. **工程化**:306 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里多数是一两百行、没有测试的插件。
|
|
231
231
|
|
|
232
232
|
反过来也要说清楚:**本插件不做的**——不做翻译/解释类选区动作(81 条同类在做)、不做多选合并引用(7 条有)、不做选区折叠/行内 diff 这类渲染花样。它只把「引用」这一件事做穿。
|
|
233
233
|
|
|
@@ -260,7 +260,7 @@ locale/zh.json 插件页卡片文案(中文)
|
|
|
260
260
|
locale/en.json 插件页卡片文案(英文)
|
|
261
261
|
test/check.mjs 离线自检(清单 + 组件契约)
|
|
262
262
|
test/dom.mjs 给离线自检用的最小 DOM / MutationObserver 垫片
|
|
263
|
-
test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径 +
|
|
263
|
+
test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径 + 逐条回应的回复徽章 + 浮层不随座位失效,210 项断言)
|
|
264
264
|
test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 侧边线程回流,96 项断言)
|
|
265
265
|
test/capsule.html 真浏览器里跑同一套胶囊断言(可选)
|
|
266
266
|
```
|
|
@@ -308,7 +308,10 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
|
|
|
308
308
|
卡片给出 `跳回原文` 与 `改这条引用` 两个动作、点后者把栏切到评论模式并**预填旧评论与旧正文**、
|
|
309
309
|
按确认后 `insertReference` 恰好一次(新芯片的 `ref` 带改后的评论)、旧引用被记为「已修订」并落进 `localStorage`、
|
|
310
310
|
旧胶囊挂上 `已修订` 徽标、它的卡片显示改后的评论(连正文改过的连改后原文一起显示)、空修订不算修订、
|
|
311
|
-
|
|
311
|
+
修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出;
|
|
312
|
+
**浮层不跟着座位生死**(`conversation.input.overlay` 没挂载也能选中出栏、挂上两个会话座位既不叠加也不丢监听、
|
|
313
|
+
座位不在时用输入框自己的 `actions` 落芯片、无处可插时给 toast 而不是静默返回、effect 卸载/重装后监听恰好一份);
|
|
314
|
+
(共 210 项,含文件路径与逐条回应的线上形态、回复徽章的建立/重建/卸载与悬停卡片复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
|
|
312
315
|
`test/capsule.html` 是同一套断言的真浏览器版,用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。
|
|
313
316
|
|
|
314
317
|
`test/panel.mjs` 单独跑引用清单页与侧边线程回流(96 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
|
|
@@ -327,7 +330,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
327
330
|
`bringThreadBack` 把结论写成**草稿**(原有草稿保留、空行隔开)、外壳没有 `setDraft` 时如实失败、没有线程时不乱写;
|
|
328
331
|
`exportThread` 的文件名与整条线程内容、以及 MIME 是 Markdown。
|
|
329
332
|
|
|
330
|
-
合计 **
|
|
333
|
+
合计 **306 项**(check.mjs 的契约断言 + capsule 210 + panel 96),`npm test` 全绿。
|
|
331
334
|
|
|
332
335
|
## 已知限制
|
|
333
336
|
|
package/lib/client.js
CHANGED
|
@@ -591,7 +591,15 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
591
591
|
let sessionId = null;
|
|
592
592
|
if (chat !== null) {
|
|
593
593
|
sessionId = chat.getAttribute("data-conversation-session");
|
|
594
|
-
if (typeof sessionId !== "string" ||
|
|
594
|
+
if (typeof sessionId !== "string" || sessionId === "") {
|
|
595
|
+
// A pane the host has not labelled yet: fall back to whichever
|
|
596
|
+
// conversation view is mounted, the way the preview path does.
|
|
597
|
+
// The bar used to demand a mounted slot entry here, which is why
|
|
598
|
+
// a selection could go dead after a session switch.
|
|
599
|
+
const active = document.querySelector(CONTENT_SELECTOR + "[data-conversation-session]");
|
|
600
|
+
sessionId = active === null ? null : active.getAttribute("data-conversation-session");
|
|
601
|
+
}
|
|
602
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
595
603
|
} else {
|
|
596
604
|
const active = document.querySelector(CONTENT_SELECTOR + "[data-conversation-session]");
|
|
597
605
|
sessionId = active === null ? null : active.getAttribute("data-conversation-session");
|
|
@@ -859,18 +867,19 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
859
867
|
sourceDisposer = typeof disposer === "function" ? disposer : () => {};
|
|
860
868
|
return true;
|
|
861
869
|
} catch (error) {
|
|
862
|
-
|
|
863
|
-
|
|
870
|
+
// "#quote is already registered" is the common case (a second copy of
|
|
871
|
+
// this plugin, or a re-apply): the source object is ours and the codec
|
|
872
|
+
// is stateless, so the resident registration is good enough. Treating
|
|
873
|
+
// it as a failure is what silently downgraded quotes to raw Markdown.
|
|
874
|
+
console.warn("[ui-quote] reference source already registered; reusing it:", error);
|
|
875
|
+
sourceDisposer = () => {};
|
|
876
|
+
return true;
|
|
864
877
|
}
|
|
865
878
|
}
|
|
866
879
|
|
|
867
|
-
/**
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
* optional — a missing service just falls back to the Markdown form.
|
|
871
|
-
*/
|
|
872
|
-
function insertChip(entry, sessionId, text, comment, code, meta) {
|
|
873
|
-
if (!ensureSource() || pluginCtx === null) return false;
|
|
880
|
+
/** The host's resident composer shell for a session, or null when absent. */
|
|
881
|
+
function inputShellFor(sessionId) {
|
|
882
|
+
if (pluginCtx === null || typeof sessionId !== "string" || sessionId === "") return null;
|
|
874
883
|
let hub = null;
|
|
875
884
|
try {
|
|
876
885
|
const conversation = pluginCtx.get("conversation");
|
|
@@ -878,14 +887,38 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
878
887
|
} catch (error) {
|
|
879
888
|
hub = null;
|
|
880
889
|
}
|
|
881
|
-
if (hub === null || hub === undefined || typeof hub.shell !== "function") return
|
|
882
|
-
let shell = null;
|
|
890
|
+
if (hub === null || hub === undefined || typeof hub.shell !== "function") return null;
|
|
883
891
|
try {
|
|
884
|
-
shell = hub.shell(sessionId);
|
|
892
|
+
const shell = hub.shell(sessionId);
|
|
893
|
+
return shell === null || shell === undefined ? null : shell;
|
|
885
894
|
} catch (error) {
|
|
886
|
-
|
|
895
|
+
return null;
|
|
887
896
|
}
|
|
888
|
-
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* The face that can put text into a session's draft: the mounted slot
|
|
901
|
+
* entry when there is one, else the host shell's own actions. The bar is
|
|
902
|
+
* document-level, so an action must never depend on a slot entry still
|
|
903
|
+
* being there — the composer outlives the entry across a session switch.
|
|
904
|
+
*/
|
|
905
|
+
function insertionFace(sessionId, shell) {
|
|
906
|
+
const entry = typeof sessionId === "string" ? sessions.get(sessionId) : undefined;
|
|
907
|
+
if (entry !== undefined && entry !== null) return entry;
|
|
908
|
+
const actions = shell === null || shell === undefined ? null : shell.actions;
|
|
909
|
+
if (actions === null || actions === undefined || typeof actions.captureInsertion !== "function") return null;
|
|
910
|
+
return { actions: () => actions };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Mint a chip through the conversation input service face: the resident
|
|
915
|
+
* shell owns the editor that can place an atomic node. Every step is
|
|
916
|
+
* optional — a missing service just falls back to the Markdown form.
|
|
917
|
+
*/
|
|
918
|
+
function insertChip(entry, sessionId, text, comment, code, meta) {
|
|
919
|
+
if (!ensureSource() || pluginCtx === null) return false;
|
|
920
|
+
const shell = inputShellFor(sessionId);
|
|
921
|
+
if (shell === null || typeof shell.insertReference !== "function") return false;
|
|
889
922
|
const model = chipRef(text, comment, code, meta);
|
|
890
923
|
const attachment = {
|
|
891
924
|
source: SOURCE_NAME,
|
|
@@ -1711,22 +1744,28 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1711
1744
|
}
|
|
1712
1745
|
|
|
1713
1746
|
function onCapsuleMutations(records) {
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1747
|
+
try {
|
|
1748
|
+
for (const record of records) {
|
|
1749
|
+
if (record.type === "characterData") {
|
|
1750
|
+
collectRuns(record.target, capsuleDirty);
|
|
1751
|
+
collectRefRuns(record.target, refDirty);
|
|
1752
|
+
continue;
|
|
1753
|
+
}
|
|
1754
|
+
for (const node of record.addedNodes) {
|
|
1755
|
+
collectRuns(node, capsuleDirty);
|
|
1756
|
+
collectRefRuns(node, refDirty);
|
|
1757
|
+
}
|
|
1758
|
+
for (const node of record.removedNodes) {
|
|
1759
|
+
if (node.nodeType === 1 && capsules.has(node)) capsuleDirty.add(node);
|
|
1760
|
+
}
|
|
1761
|
+
const target = elementOf(record.target);
|
|
1762
|
+
if (target !== null && capsules.has(target)) capsuleDirty.add(target);
|
|
1726
1763
|
}
|
|
1727
|
-
|
|
1728
|
-
|
|
1764
|
+
} catch (error) {
|
|
1765
|
+
console.warn("[ui-quote] capsule scan failed:", error);
|
|
1729
1766
|
}
|
|
1767
|
+
// Always reschedule: a throw inside the scan used to leave the pass
|
|
1768
|
+
// unscheduled and the transcript raw until the next reload.
|
|
1730
1769
|
scheduleCapsules();
|
|
1731
1770
|
}
|
|
1732
1771
|
|
|
@@ -1737,19 +1776,34 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1737
1776
|
|
|
1738
1777
|
function flushCapsules() {
|
|
1739
1778
|
capsuleTimer = null;
|
|
1740
|
-
dropDetachedPopover();
|
|
1741
|
-
for (const run of Array.from(capsules.keys())) {
|
|
1742
|
-
if (!run.isConnected) dropCapsule(run);
|
|
1743
|
-
}
|
|
1744
1779
|
const runs = Array.from(capsuleDirty);
|
|
1745
1780
|
capsuleDirty.clear();
|
|
1781
|
+
// A throw anywhere in this pass used to abort it silently: the rest of
|
|
1782
|
+
// the transcript stayed raw (`> ❝ …` text, no pills, no hover cards)
|
|
1783
|
+
// with no error the reader could see. Each stage stands on its own now.
|
|
1784
|
+
try {
|
|
1785
|
+
dropDetachedPopover();
|
|
1786
|
+
for (const run of Array.from(capsules.keys())) {
|
|
1787
|
+
if (!run.isConnected) dropCapsule(run);
|
|
1788
|
+
}
|
|
1789
|
+
} catch (error) {
|
|
1790
|
+
console.warn("[ui-quote] capsule cleanup failed:", error);
|
|
1791
|
+
}
|
|
1746
1792
|
for (const run of runs) {
|
|
1747
|
-
|
|
1748
|
-
|
|
1793
|
+
try {
|
|
1794
|
+
if (run.isConnected) enhance(run);
|
|
1795
|
+
else if (capsules.has(run)) dropCapsule(run);
|
|
1796
|
+
} catch (error) {
|
|
1797
|
+
console.warn("[ui-quote] capsule enhance failed:", error);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
try {
|
|
1801
|
+
// Badges come last: the quotes they link to must be in the ledger first.
|
|
1802
|
+
flushRefBadges();
|
|
1803
|
+
wireChips();
|
|
1804
|
+
} catch (error) {
|
|
1805
|
+
console.warn("[ui-quote] capsule tail pass failed:", error);
|
|
1749
1806
|
}
|
|
1750
|
-
// Badges come last: the quotes they link to must be in the ledger first.
|
|
1751
|
-
flushRefBadges();
|
|
1752
|
-
wireChips();
|
|
1753
1807
|
}
|
|
1754
1808
|
|
|
1755
1809
|
function ensureCapsuleStyle(doc) {
|
|
@@ -2018,6 +2072,14 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2018
2072
|
* ------------------------------------------------------------------ */
|
|
2019
2073
|
|
|
2020
2074
|
const sessions = new Map();
|
|
2075
|
+
/**
|
|
2076
|
+
* The bar's listeners are document singletons and live exactly as long as
|
|
2077
|
+
* the plugin. While they were reference-counted by the overlay slot they
|
|
2078
|
+
* could be removed for good by an overlapping mount pair, and then the bar
|
|
2079
|
+
* never appeared again until a page reload — the "quote plugin stopped
|
|
2080
|
+
* working" report.
|
|
2081
|
+
*/
|
|
2082
|
+
let listenersAttached = false;
|
|
2021
2083
|
const state = {
|
|
2022
2084
|
bar: null,
|
|
2023
2085
|
style: null,
|
|
@@ -2040,7 +2102,6 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2040
2102
|
/** The quote key being corrected, when the box was opened by 改这条引用. */
|
|
2041
2103
|
revising: null,
|
|
2042
2104
|
settle: null,
|
|
2043
|
-
listeners: 0,
|
|
2044
2105
|
pointerOnTranscript: false,
|
|
2045
2106
|
suppress: false
|
|
2046
2107
|
};
|
|
@@ -2160,6 +2221,29 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2160
2221
|
* its wording, not just its comment. Both halves map onto the wire form's
|
|
2161
2222
|
* two marks, top to bottom.
|
|
2162
2223
|
*/
|
|
2224
|
+
/**
|
|
2225
|
+
* Grow one field to its content. A textarea that is not laid out yet
|
|
2226
|
+
* reports a scrollHeight of 0 — writing that back as `0px` collapsed the
|
|
2227
|
+
* quote half of the box and clipped its first line, so an unmeasurable
|
|
2228
|
+
* field is left on `auto` until the bar is on screen.
|
|
2229
|
+
*/
|
|
2230
|
+
function growField(node) {
|
|
2231
|
+
node.style.height = "auto";
|
|
2232
|
+
const next = node.scrollHeight;
|
|
2233
|
+
if (!Number.isFinite(next) || next <= 0) return;
|
|
2234
|
+
node.style.height = Math.min(next, COMMENT_MAX_HEIGHT) + "px";
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/** Every field the bar is currently showing, re-measured. */
|
|
2238
|
+
function growInputs() {
|
|
2239
|
+
const bar = state.bar;
|
|
2240
|
+
if (bar === null || bar === undefined || typeof bar.querySelectorAll !== "function") return;
|
|
2241
|
+
for (const field of Array.from(bar.querySelectorAll(".dshq-input"))) {
|
|
2242
|
+
if (String(field.tagName).toLowerCase() !== "textarea") continue;
|
|
2243
|
+
growField(field);
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2163
2247
|
function renderComment(bar) {
|
|
2164
2248
|
const doc = bar.ownerDocument;
|
|
2165
2249
|
bar.setAttribute("role", "dialog");
|
|
@@ -2186,10 +2270,6 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2186
2270
|
"data-action": "confirm"
|
|
2187
2271
|
}, [el(doc, "span", { text: translate("addToChat") }), el(doc, "span", { class: "dshq-enter", text: "⏎" })]);
|
|
2188
2272
|
|
|
2189
|
-
const grow = (node) => {
|
|
2190
|
-
node.style.height = "auto";
|
|
2191
|
-
node.style.height = Math.min(node.scrollHeight, COMMENT_MAX_HEIGHT) + "px";
|
|
2192
|
-
};
|
|
2193
2273
|
// The quote half is mandatory now (an empty quote is nothing to send);
|
|
2194
2274
|
// the comment keeps Kimi's rule of being required in this box.
|
|
2195
2275
|
const syncConfirm = () => {
|
|
@@ -2197,12 +2277,12 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2197
2277
|
};
|
|
2198
2278
|
quoteInput.addEventListener("input", () => {
|
|
2199
2279
|
state.quote = quoteInput.value;
|
|
2200
|
-
|
|
2280
|
+
growField(quoteInput);
|
|
2201
2281
|
syncConfirm();
|
|
2202
2282
|
});
|
|
2203
2283
|
input.addEventListener("input", () => {
|
|
2204
2284
|
state.comment = input.value;
|
|
2205
|
-
|
|
2285
|
+
growField(input);
|
|
2206
2286
|
syncConfirm();
|
|
2207
2287
|
});
|
|
2208
2288
|
const onKey = (event) => {
|
|
@@ -2234,7 +2314,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2234
2314
|
el(doc, "span", { text: translate("replyToggle") })
|
|
2235
2315
|
]);
|
|
2236
2316
|
|
|
2237
|
-
grow(
|
|
2317
|
+
// No grow here: the fields are measured in `position()`, once the bar
|
|
2318
|
+
// takes part in layout. Reading them while detached returned 0.
|
|
2238
2319
|
syncConfirm();
|
|
2239
2320
|
|
|
2240
2321
|
bar.replaceChildren(
|
|
@@ -2258,6 +2339,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2258
2339
|
|
|
2259
2340
|
bar.style.visibility = "hidden";
|
|
2260
2341
|
bar.dataset.visible = "true";
|
|
2342
|
+
// The bar is in layout now (merely invisible), which is the first
|
|
2343
|
+
// moment its fields can be measured — and it has to happen before the
|
|
2344
|
+
// height below is read, or the box would be placed at the wrong place.
|
|
2345
|
+
growInputs();
|
|
2261
2346
|
|
|
2262
2347
|
const viewport = window.visualViewport;
|
|
2263
2348
|
const width = viewport === undefined || viewport === null ? window.innerWidth : viewport.width;
|
|
@@ -2385,10 +2470,19 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2385
2470
|
const revising = state.revising;
|
|
2386
2471
|
const code = state.code;
|
|
2387
2472
|
const meta = { path: state.path, reply: state.reply === true };
|
|
2388
|
-
|
|
2473
|
+
// Resolve the insertion face before the bar is torn down: the slot entry
|
|
2474
|
+
// may already be gone (a session switch) while the composer is still up.
|
|
2475
|
+
const shell = inputShellFor(sessionId);
|
|
2476
|
+
const entry = insertionFace(sessionId, shell);
|
|
2389
2477
|
const trimmed = typeof comment === "string" ? comment.trim() : "";
|
|
2390
2478
|
close();
|
|
2391
|
-
if (typeof text !== "string" || text.trim() === ""
|
|
2479
|
+
if (typeof text !== "string" || text.trim() === "") return;
|
|
2480
|
+
if (entry === null) {
|
|
2481
|
+
// Returning silently here is what made "clicked and nothing happened"
|
|
2482
|
+
// reports: the user gets told instead.
|
|
2483
|
+
toast(translate("failed"));
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2392
2486
|
|
|
2393
2487
|
// Remember the row this came from before anything can fail: the jump
|
|
2394
2488
|
// row is keyed by the quote's own wording, so it survives the send.
|
|
@@ -2785,6 +2879,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2785
2879
|
if (!state.pointerOnTranscript) return;
|
|
2786
2880
|
state.pointerOnTranscript = false;
|
|
2787
2881
|
if (insideBar(event.target)) return;
|
|
2882
|
+
// Self-healing: `close()` suppresses the settle-timer refresh so the
|
|
2883
|
+
// gesture that dismissed the bar cannot reopen it. A fresh pointer
|
|
2884
|
+
// gesture is always a fresh intention.
|
|
2885
|
+
state.suppress = false;
|
|
2788
2886
|
refresh();
|
|
2789
2887
|
}
|
|
2790
2888
|
|
|
@@ -2814,12 +2912,16 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2814
2912
|
else close();
|
|
2815
2913
|
}
|
|
2816
2914
|
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2915
|
+
/**
|
|
2916
|
+
* Install the document listeners. Called once for the plugin's lifetime
|
|
2917
|
+
* from `apply()`, never per slot: a counter kept them alive only while
|
|
2918
|
+
* some overlay happened to be mounted, and one bad count removed them
|
|
2919
|
+
* forever (the bar then stayed dead until a reload).
|
|
2920
|
+
*/
|
|
2921
|
+
function attachListeners() {
|
|
2922
|
+
if (listenersAttached) return;
|
|
2923
|
+
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return;
|
|
2924
|
+
listenersAttached = true;
|
|
2823
2925
|
document.addEventListener("selectionchange", onSelectionChange);
|
|
2824
2926
|
document.addEventListener("keyup", onKeyUp, true);
|
|
2825
2927
|
document.addEventListener("pointerdown", onPointerDown, true);
|
|
@@ -2828,13 +2930,14 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2828
2930
|
document.addEventListener("mousedown", onMouseDown, true);
|
|
2829
2931
|
document.addEventListener("keydown", onKeyDown, true);
|
|
2830
2932
|
document.addEventListener("scroll", onScrollOrResize, true);
|
|
2831
|
-
window.addEventListener
|
|
2933
|
+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
2934
|
+
window.addEventListener("resize", onScrollOrResize);
|
|
2935
|
+
}
|
|
2832
2936
|
}
|
|
2833
2937
|
|
|
2834
|
-
function
|
|
2835
|
-
if (
|
|
2836
|
-
|
|
2837
|
-
if (state.listeners > 0) return;
|
|
2938
|
+
function detachListeners() {
|
|
2939
|
+
if (!listenersAttached) return;
|
|
2940
|
+
listenersAttached = false;
|
|
2838
2941
|
document.removeEventListener("selectionchange", onSelectionChange);
|
|
2839
2942
|
document.removeEventListener("keyup", onKeyUp, true);
|
|
2840
2943
|
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
@@ -2843,8 +2946,12 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2843
2946
|
document.removeEventListener("mousedown", onMouseDown, true);
|
|
2844
2947
|
document.removeEventListener("keydown", onKeyDown, true);
|
|
2845
2948
|
document.removeEventListener("scroll", onScrollOrResize, true);
|
|
2846
|
-
window.removeEventListener
|
|
2949
|
+
if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
|
|
2950
|
+
window.removeEventListener("resize", onScrollOrResize);
|
|
2951
|
+
}
|
|
2847
2952
|
close();
|
|
2953
|
+
state.pointerOnTranscript = false;
|
|
2954
|
+
state.suppress = false;
|
|
2848
2955
|
if (state.bar !== null) {
|
|
2849
2956
|
state.bar.remove();
|
|
2850
2957
|
state.bar = null;
|
|
@@ -2882,10 +2989,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2882
2989
|
}
|
|
2883
2990
|
};
|
|
2884
2991
|
sessions.set(sessionId, entry);
|
|
2885
|
-
attach();
|
|
2886
2992
|
return () => {
|
|
2887
2993
|
if (sessions.get(sessionId) === entry) sessions.delete(sessionId);
|
|
2888
|
-
if (sessions.size === 0) detach();
|
|
2889
2994
|
};
|
|
2890
2995
|
}, [props.sessionId]);
|
|
2891
2996
|
|
|
@@ -3649,6 +3754,12 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
3649
3754
|
// The sent-message capsule lives outside the composer, so it is
|
|
3650
3755
|
// installed with the plugin and not with a session's slot entry.
|
|
3651
3756
|
ctx.effect(installCapsules, "ui-quote: sent-message capsules");
|
|
3757
|
+
// So does the bar: its listeners are document singletons and must not
|
|
3758
|
+
// depend on any composer staying mounted.
|
|
3759
|
+
ctx.effect(() => {
|
|
3760
|
+
attachListeners();
|
|
3761
|
+
return () => detachListeners();
|
|
3762
|
+
}, "ui-quote: selection listeners");
|
|
3652
3763
|
// The three rows are also three keys. Without the service the menu
|
|
3653
3764
|
// stays the only way in — nothing here is required to work.
|
|
3654
3765
|
ctx.effect(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-client-ui-quote",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"description": "Select text in the DSH transcript, a file preview or the terminal to quote it into the composer or into a side thread's composer, Kimi-Code style (comment / add to conversation / side chat), with Alt+Q/C/B, a jump back to the quoted source, a searchable quote list page that exports as Markdown, one-click back-flow of a side thread's conclusion, and revisions of a sent quote that mark the old capsule as superseded. The quoted text itself is editable in the comment box (trim a selection, correct a revision's wording), a selection from the file preview or the terminal travels to the model as a fenced code block carrying its file path and line range, and a per-quote reply toggle has the model answer quote by quote, each 「引用N」 label in the reply becoming a badge linked back to the quote.",
|
|
5
5
|
"icon": "icon.svg",
|
|
6
6
|
"license": "MIT",
|
package/test/capsule.mjs
CHANGED
|
@@ -914,6 +914,21 @@ try {
|
|
|
914
914
|
// this block is about what a mounted enhancer does with a revision.
|
|
915
915
|
capsuleEffect.fn();
|
|
916
916
|
|
|
917
|
+
// A textarea measures itself from its own text — but only while it is in the
|
|
918
|
+
// document, the way the real one reads 0 before the bar is laid out. That is
|
|
919
|
+
// what made the auto-grow collapse the quote field.
|
|
920
|
+
const makeTextarea = dom.document.createElement.bind(dom.document);
|
|
921
|
+
dom.document.createElement = (tag) => {
|
|
922
|
+
const node = makeTextarea(tag);
|
|
923
|
+
if (String(tag).toLowerCase() === "textarea") {
|
|
924
|
+
Object.defineProperty(node, "scrollHeight", {
|
|
925
|
+
configurable: true,
|
|
926
|
+
get: () => (node.isConnected ? 12 + 12 * String(node.value ?? "").split("\n").length : 0)
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
return node;
|
|
930
|
+
};
|
|
931
|
+
|
|
917
932
|
// A transcript of its own, with a capsule that has a comment.
|
|
918
933
|
const host2 = dom.document.createElement("div");
|
|
919
934
|
host2.setAttribute("data-conversation-content", "");
|
|
@@ -948,11 +963,16 @@ try {
|
|
|
948
963
|
const input = bar?.querySelector(".dshq-comment-input");
|
|
949
964
|
check("the box is prefilled with the old quote for editing", quoteBox?.value === original, JSON.stringify(quoteBox?.value));
|
|
950
965
|
check("the box is prefilled with the old comment", input?.value === oldComment, JSON.stringify(input?.value));
|
|
966
|
+
// Regression: the quote half used to be measured before it was attached, so
|
|
967
|
+
// it was written `height: 0px` and its first line showed up clipped.
|
|
968
|
+
check("the quote field is grown to its text, not collapsed", quoteBox?.style?.height === "24px", JSON.stringify(quoteBox?.style?.height));
|
|
969
|
+
check("the comment field is grown too", input?.style?.height === "24px", JSON.stringify(input?.style?.height));
|
|
951
970
|
|
|
952
971
|
// Correcting it writes a NEW quote (nothing is edited in place) and the old
|
|
953
972
|
// capsule learns it was superseded.
|
|
954
973
|
input.value = newComment;
|
|
955
974
|
input.dispatch("input", {});
|
|
975
|
+
check("typing in a field re-measures it", input?.style?.height === "24px", JSON.stringify(input?.style?.height));
|
|
956
976
|
// The bar handles clicks by delegation, so the click is dispatched on the bar
|
|
957
977
|
// with the button as its target — the shim has no bubbling of its own.
|
|
958
978
|
const confirm = bar.querySelector('[data-action="confirm"]');
|
|
@@ -1345,6 +1365,145 @@ try {
|
|
|
1345
1365
|
);
|
|
1346
1366
|
}
|
|
1347
1367
|
|
|
1368
|
+
/* ------------------------------------------------------------------ *
|
|
1369
|
+
* 浮层不跟着座位生死 — the bar is document-level, so neither its
|
|
1370
|
+
* listeners nor the action behind one of its rows may depend on an
|
|
1371
|
+
* overlay slot entry still being mounted.
|
|
1372
|
+
*
|
|
1373
|
+
* The listeners used to be reference-counted by the slot component: an
|
|
1374
|
+
* overlapping mount pair (a side thread's composer over a running
|
|
1375
|
+
* parent, a re-registered slot) could drive the count to zero, the
|
|
1376
|
+
* document listeners were then removed for good, and the bar never came
|
|
1377
|
+
* back until a page reload — the "引用插件经常失效" report. A commit
|
|
1378
|
+
* whose slot entry had gone also returned silently.
|
|
1379
|
+
* ------------------------------------------------------------------ */
|
|
1380
|
+
|
|
1381
|
+
{
|
|
1382
|
+
const listenersEffect = effects.find((entry) => String(entry.label).includes("selection listeners"));
|
|
1383
|
+
check("the bar's listeners are installed with the plugin, not with a slot", listenersEffect !== undefined);
|
|
1384
|
+
check(
|
|
1385
|
+
"and they are one document-level set, never a slot-scoped count",
|
|
1386
|
+
(dom.document.listeners.get("pointerup") ?? []).length === 1,
|
|
1387
|
+
JSON.stringify((dom.document.listeners.get("pointerup") ?? []).length)
|
|
1388
|
+
);
|
|
1389
|
+
|
|
1390
|
+
const pane = (sessionId, text) => {
|
|
1391
|
+
const node = dom.document.createElement("div");
|
|
1392
|
+
node.setAttribute("data-conversation-content", "");
|
|
1393
|
+
node.setAttribute("data-conversation-session", sessionId);
|
|
1394
|
+
const bubble = dom.document.createElement("div");
|
|
1395
|
+
bubble.className = "bubble";
|
|
1396
|
+
const run = dom.document.createElement("span");
|
|
1397
|
+
run.className = "plainRun";
|
|
1398
|
+
run.textContent = text;
|
|
1399
|
+
bubble.append(run);
|
|
1400
|
+
node.append(bubble);
|
|
1401
|
+
dom.document.body.append(node);
|
|
1402
|
+
return { node, run };
|
|
1403
|
+
};
|
|
1404
|
+
const selectIn = (run, text) => {
|
|
1405
|
+
dom.document.selection = {
|
|
1406
|
+
isCollapsed: false,
|
|
1407
|
+
rangeCount: 1,
|
|
1408
|
+
toString: () => text,
|
|
1409
|
+
removeAllRanges() {
|
|
1410
|
+
dom.document.selection = null;
|
|
1411
|
+
},
|
|
1412
|
+
getRangeAt: () => ({
|
|
1413
|
+
startContainer: run.firstChild,
|
|
1414
|
+
endContainer: run.firstChild,
|
|
1415
|
+
intersectsNode: () => false,
|
|
1416
|
+
getBoundingClientRect: () => ({ left: 100, top: 300, right: 220, bottom: 318, width: 120, height: 18 })
|
|
1417
|
+
})
|
|
1418
|
+
};
|
|
1419
|
+
dom.document.dispatch("pointerdown", { target: run.firstChild });
|
|
1420
|
+
dom.document.dispatch("pointerup", { target: run.firstChild });
|
|
1421
|
+
};
|
|
1422
|
+
const barOf = () => dom.document.getElementById("dsh-quote-bar");
|
|
1423
|
+
|
|
1424
|
+
// 1. The composer slot never mounted for this session at all.
|
|
1425
|
+
const firstPane = pane("session-bar", "浮层要跟着手走的原文");
|
|
1426
|
+
selectIn(firstPane.run, "浮层要跟着手走的原文");
|
|
1427
|
+
check(
|
|
1428
|
+
"a selection opens the bar even with no composer slot mounted for that session",
|
|
1429
|
+
barOf()?.dataset.visible === "true",
|
|
1430
|
+
JSON.stringify(barOf()?.dataset.visible)
|
|
1431
|
+
);
|
|
1432
|
+
check(
|
|
1433
|
+
"the bar offers the actions",
|
|
1434
|
+
barOf()?.querySelector('[data-action="add"]') !== null && barOf()?.querySelector('[data-action="comment"]') !== null
|
|
1435
|
+
);
|
|
1436
|
+
|
|
1437
|
+
// 2. A slot comes and goes — the listeners are not tied to it.
|
|
1438
|
+
const overlay = registered.find((entry) => entry.spec.name === "conversation.input.overlay");
|
|
1439
|
+
check("the overlay slot is registered for mounting", overlay !== undefined);
|
|
1440
|
+
dom.document.dispatch("keydown", { key: "Escape", isComposing: false, target: firstPane.run });
|
|
1441
|
+
check("Escape closes the bar", barOf()?.dataset.visible === "false", JSON.stringify(barOf()?.dataset.visible));
|
|
1442
|
+
if (overlay !== undefined) {
|
|
1443
|
+
overlay.Component({ sessionId: "session-bar", inputActions: { captureInsertion: () => ({ draftRev: 1 }) } });
|
|
1444
|
+
overlay.Component({ sessionId: "session-bar-2", inputActions: { captureInsertion: () => ({ draftRev: 2 }) } });
|
|
1445
|
+
}
|
|
1446
|
+
check(
|
|
1447
|
+
"mounting two composers does not stack or drop the listeners",
|
|
1448
|
+
(dom.document.listeners.get("pointerup") ?? []).length === 1,
|
|
1449
|
+
JSON.stringify((dom.document.listeners.get("pointerup") ?? []).length)
|
|
1450
|
+
);
|
|
1451
|
+
selectIn(firstPane.run, "浮层要跟着手走的原文");
|
|
1452
|
+
check("the bar still opens after a slot mounted", barOf()?.dataset.visible === "true", JSON.stringify(barOf()?.dataset.visible));
|
|
1453
|
+
|
|
1454
|
+
// 3. A session with no slot entry still lands its chip: the composer's own
|
|
1455
|
+
// actions stand in for the entry that is not there.
|
|
1456
|
+
const landed = [];
|
|
1457
|
+
shells.set("session-noslot", {
|
|
1458
|
+
actions: { captureInsertion: () => ({ draftRev: 7 }) },
|
|
1459
|
+
insertReference(attachment) {
|
|
1460
|
+
landed.push(attachment);
|
|
1461
|
+
return true;
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
const secondPane = pane("session-noslot", "没有座位的会话也要能引用");
|
|
1465
|
+
selectIn(secondPane.run, "没有座位的会话也要能引用");
|
|
1466
|
+
barOf().dispatch("click", { target: barOf().querySelector('[data-action="add"]') });
|
|
1467
|
+
check(
|
|
1468
|
+
"the chip lands through the composer's own actions",
|
|
1469
|
+
landed.length === 1 && String(landed[0]?.ref ?? "").includes("没有座位的会话也要能引用"),
|
|
1470
|
+
JSON.stringify([landed.length, String(landed[0]?.ref ?? "").slice(0, 24)])
|
|
1471
|
+
);
|
|
1472
|
+
|
|
1473
|
+
// 4. With nowhere to insert, the reader is told instead of left wondering.
|
|
1474
|
+
const thirdPane = pane("session-noshell", "没有输入框的会话");
|
|
1475
|
+
selectIn(thirdPane.run, "没有输入框的会话");
|
|
1476
|
+
const staleToast = dom.document.getElementById("dsh-quote-toast");
|
|
1477
|
+
if (staleToast !== null) staleToast.textContent = "";
|
|
1478
|
+
barOf().dispatch("click", { target: barOf().querySelector('[data-action="add"]') });
|
|
1479
|
+
const toastNode = dom.document.getElementById("dsh-quote-toast");
|
|
1480
|
+
check(
|
|
1481
|
+
"a dead end says so instead of doing nothing",
|
|
1482
|
+
String(toastNode?.textContent) === "插入失败,请重试",
|
|
1483
|
+
JSON.stringify(toastNode?.textContent)
|
|
1484
|
+
);
|
|
1485
|
+
|
|
1486
|
+
// 5. Disposal is honest: the effect takes its listeners with it, and
|
|
1487
|
+
// reinstalling puts exactly one set back.
|
|
1488
|
+
listenersEffect.dispose();
|
|
1489
|
+
check(
|
|
1490
|
+
"disposing the effect removes the listeners",
|
|
1491
|
+
(dom.document.listeners.get("pointerup") ?? []).length === 0,
|
|
1492
|
+
JSON.stringify((dom.document.listeners.get("pointerup") ?? []).length)
|
|
1493
|
+
);
|
|
1494
|
+
listenersEffect.fn();
|
|
1495
|
+
check(
|
|
1496
|
+
"reinstalling brings back exactly one set",
|
|
1497
|
+
(dom.document.listeners.get("pointerup") ?? []).length === 1,
|
|
1498
|
+
JSON.stringify((dom.document.listeners.get("pointerup") ?? []).length)
|
|
1499
|
+
);
|
|
1500
|
+
selectIn(firstPane.run, "浮层要跟着手走的原文");
|
|
1501
|
+
check("the bar works again after a reinstall", barOf()?.dataset.visible === "true", JSON.stringify(barOf()?.dataset.visible));
|
|
1502
|
+
|
|
1503
|
+
dom.document.selection = null;
|
|
1504
|
+
dom.document.dispatch("keydown", { key: "Escape", isComposing: false, target: firstPane.run });
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1348
1507
|
console.log(
|
|
1349
1508
|
failures.length === 0
|
|
1350
1509
|
? "\ndsh-client-ui-quote capsule: " + passed + " checks passed"
|
package/test/dom.mjs
CHANGED
|
@@ -31,6 +31,10 @@ class ShimNode {
|
|
|
31
31
|
return node.nodeType === 9 || node === this.ownerDocument?.documentElement;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
get firstChild() {
|
|
35
|
+
return this.childNodes.length === 0 ? null : this.childNodes[0];
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
get lastChild() {
|
|
35
39
|
return this.childNodes.length === 0 ? null : this.childNodes[this.childNodes.length - 1];
|
|
36
40
|
}
|
|
@@ -306,6 +310,10 @@ class ShimDocument extends ShimNode {
|
|
|
306
310
|
this.ownerDocument = this;
|
|
307
311
|
this.mutations = [];
|
|
308
312
|
this.observers = [];
|
|
313
|
+
/** Document-level listeners (the bar's own are installed here). */
|
|
314
|
+
this.listeners = new Map();
|
|
315
|
+
/** Set by a test to hand `document.getSelection()` a live selection. */
|
|
316
|
+
this.selection = null;
|
|
309
317
|
/** Maintained by `ShimElement.focus()` / `blur()`. */
|
|
310
318
|
this.activeElement = null;
|
|
311
319
|
this.documentElement = new ShimElement(this, 'html');
|
|
@@ -357,12 +365,28 @@ class ShimDocument extends ShimNode {
|
|
|
357
365
|
return found;
|
|
358
366
|
}
|
|
359
367
|
|
|
360
|
-
addEventListener() {
|
|
368
|
+
addEventListener(type, handler) {
|
|
369
|
+
// The bar's listeners are document singletons; the harness has to keep
|
|
370
|
+
// them so a test can drive a selection the way a browser would.
|
|
371
|
+
const list = this.listeners.get(type) ?? [];
|
|
372
|
+
list.push(handler);
|
|
373
|
+
this.listeners.set(type, list);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
removeEventListener(type, handler) {
|
|
377
|
+
const list = this.listeners.get(type) ?? [];
|
|
378
|
+
const index = list.indexOf(handler);
|
|
379
|
+
if (index !== -1) list.splice(index, 1);
|
|
380
|
+
}
|
|
361
381
|
|
|
362
|
-
|
|
382
|
+
dispatch(type, event) {
|
|
383
|
+
for (const handler of [...(this.listeners.get(type) ?? [])]) handler(event);
|
|
384
|
+
}
|
|
363
385
|
|
|
364
386
|
getSelection() {
|
|
365
|
-
// A collapsed selection that can be cleared, which is all the bar asks of
|
|
387
|
+
// A collapsed selection that can be cleared, which is all the bar asks of
|
|
388
|
+
// it — unless a test installs one with `document.selection = …`.
|
|
389
|
+
if (this.selection !== null && this.selection !== undefined) return this.selection;
|
|
366
390
|
return { isCollapsed: true, rangeCount: 0, removeAllRanges() {} };
|
|
367
391
|
}
|
|
368
392
|
}
|