dsh-select-quote 0.3.11 → 0.3.12

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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/lib/client.js +196 -49
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  - **右键菜单**:在选区矩形内点右键,同一套「复制 / 打开链接 / 添加到任务」出现在指针处;选区之外、其它插件已接管的右键一律不碰,桌面端未接管时保留宿主的原生 Copy 菜单
7
7
  - **快捷键**:选中文字后一键打开菜单或直接添加;桌面 macOS 是 `⌘L` / `⇧⌘L`,其它平台的默认键位见下表;可在设置里单独开关
8
8
  - **批注摘要**:添加后输入框上方出现一块「N 条批注」摘要,草稿里不会出现任何协议文本
9
- - **正文标记**:被划中的文字在原消息里留下淡色底、虚线下划线和序号角标,点摘要条目会滚回那段文字;点标记或角标会打开该条批注的预览(原文 + 评论),角标也能用 Tab 聚焦、Enter 打开。角标始终压在选区高亮之上,多条批注结尾重合时会自动向右/向上错开
9
+ - **正文标记**:被划中的文字在原消息里留下淡色底、虚线下划线和序号角标,点摘要条目会滚回那段文字;点角标会打开该条批注的预览(原文 + 评论),标记本身保持可选中、不抢点击,角标也能用 Tab 聚焦、Enter 打开。角标始终压在选区高亮之上,多条批注结尾重合时会自动向右/向上错开
10
10
  - **模型回指**:模型回答里指向某条批注的位置,渲染成带预览的 chip
11
11
  - **对话记录**:已发送消息仍由宿主气泡渲染(时间、复制、撤回都在),批注收成一块摘要,悬停(触屏点按)展开列表
12
12
  - **界面语言**:跟随 dsh 自己的语言设置,简体中文 / English / Русский
@@ -44,7 +44,7 @@ dsh web --patch "$PWD/dev.patch.yml"
44
44
  1. 在对话区选中一段文本,点「添加到任务」——到这一步批注就已经保存,输入框上方同步出现批注摘要。触屏下操作条固定在屏幕底部,不跟随选区挤在系统选择菜单旁边
45
45
  2. 想补充的话在弹出的评论框里写一句,点「添加评论」或 `⌘/Ctrl + Enter`;不写就直接点空白处或「暂不评论」,批注同样保留。触屏下评论在底部抽屉里完成,抽屉顶部会带上选中的原文
46
46
  3. 悬停(触屏点按)输入框上方的摘要展开列表,可逐条编辑、删除或一键清空,点条目跳回正文对应文字;对话记录里的摘要只提供列表和跳转。键盘聚焦摘要后按 Enter / ↓ 展开,Tab 在面板内移动,Esc 关闭并回到摘要
47
- 4. 点正文里的标记或角标(角标可用 Tab 聚焦后按 Enter)打开批注预览,查看原文和评论;点浮层外或按 Esc 关闭
47
+ 4. 点正文里的角标(可用 Tab 聚焦后按 Enter)打开批注预览,查看原文和评论;正文标记本身仍可正常鼠标划选,点浮层外或按 Esc 关闭
48
48
  5. 正常打字发送,批注随消息一起交给模型,摘要随之消失
49
49
  6. 模型回答里引用某条批注的位置显示为 chip:悬停、点击(触屏点按)或键盘聚焦都会打开预览,显示原文和评论;预览默认在 chip 上方,右上角「原文」按钮回到正文;键盘聚焦 chip 后按 ↓ 进入按钮,Esc 关闭
50
50
 
package/lib/client.js CHANGED
@@ -1408,7 +1408,9 @@ ${atomRules}
1408
1408
  font-size: 10px;
1409
1409
  font-weight: 600;
1410
1410
  line-height: 1;
1411
- pointer-events: none;
1411
+ /* The only click target on a mark; the prose itself stays selectable. */
1412
+ pointer-events: auto;
1413
+ cursor: pointer;
1412
1414
  }
1413
1415
 
1414
1416
  .dsq_annMark:hover {
@@ -1807,13 +1809,41 @@ const styles = {
1807
1809
  segOption: "dsq_segOption",
1808
1810
  srOnly: "dsq_srOnly"
1809
1811
  };
1810
- //#endregion
1811
- //#region src/client/dom/badge-layout.ts
1812
+ const BADGE_MIN_WIDTH = 15;
1813
+ /** 10px/600 digits plus the 3px side padding. */
1814
+ const BADGE_DIGIT_WIDTH = 6;
1815
+ const BADGE_PADDING = 6;
1816
+ const HIT_SLOP = 3;
1812
1817
  const STEP_X = 28;
1813
1818
  const STEP_Y = 18;
1814
1819
  /** Rest, then two steps right; after that the stack grows upward. */
1815
1820
  const MAX_COLUMNS = 3;
1816
1821
  const MAX_ROWS = 24;
1822
+ /** Rendered badge width, matching `min-width` + digit advance + padding. */
1823
+ function badgeWidth(index) {
1824
+ return Math.max(BADGE_MIN_WIDTH, String(index).length * BADGE_DIGIT_WIDTH + BADGE_PADDING);
1825
+ }
1826
+ /**
1827
+ * Hit box of a badge as painted: anchored to the final fragment's bottom-right
1828
+ * corner, then shifted by the collision offset. Used to tell a badge click from
1829
+ * a click on the marked prose.
1830
+ */
1831
+ function badgeHitBox(anchor, index, offset = {
1832
+ dx: 0,
1833
+ dy: 0
1834
+ }) {
1835
+ const left = anchor.right + 2 + offset.dx;
1836
+ const top = anchor.bottom - 8 - 15 + offset.dy;
1837
+ return {
1838
+ left: left - HIT_SLOP,
1839
+ top: top - HIT_SLOP,
1840
+ right: left + badgeWidth(index) + HIT_SLOP,
1841
+ bottom: top + 15 + HIT_SLOP
1842
+ };
1843
+ }
1844
+ function badgeContainsPoint(box, x, y) {
1845
+ return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
1846
+ }
1817
1847
  function overlaps(a, b) {
1818
1848
  return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
1819
1849
  }
@@ -1993,20 +2023,83 @@ function cleanTextOf(item) {
1993
2023
  const MARK_ATTR = "data-dsq-ann-mark";
1994
2024
  const INDEX_ATTR = "data-dsq-ann-index";
1995
2025
  const FLASH_MS = 1500;
1996
- /** Distance from the mark's bottom edge to the badge's bottom edge (CSS). */
1997
- const BADGE_BOTTOM = 8;
1998
- const BADGE_MARGIN = 2;
1999
2026
  const jobs = /* @__PURE__ */ new Map();
2000
2027
  let pendingIds = /* @__PURE__ */ new Set();
2001
2028
  function attrValue(value) {
2002
2029
  return value.replace(/["\\]/g, "\\$&");
2003
2030
  }
2004
- function flowItems(messageId, scope) {
2005
- if (messageId !== "") {
2006
- const item = scope.querySelector(`[data-chat-flow-key="${attrValue(messageId)}"]`);
2007
- return item instanceof HTMLElement ? [item] : [];
2031
+ const FLOW_SELECTOR = "[data-chat-flow-key]";
2032
+ /**
2033
+ * One pass over the rendered messages instead of one query per annotation.
2034
+ * `querySelectorAll` preserves document order, so the first copy of a key wins,
2035
+ * matching the old per-job `querySelector`.
2036
+ */
2037
+ function flowLookup(scope) {
2038
+ const all = [...scope.querySelectorAll(FLOW_SELECTOR)];
2039
+ const byKey = /* @__PURE__ */ new Map();
2040
+ for (const item of all) {
2041
+ const key = item.getAttribute("data-chat-flow-key");
2042
+ if (key && !byKey.has(key)) byKey.set(key, item);
2043
+ }
2044
+ return {
2045
+ all,
2046
+ byKey
2047
+ };
2048
+ }
2049
+ function flowKeyOf(item) {
2050
+ return item.getAttribute("data-chat-flow-key") ?? "";
2051
+ }
2052
+ /**
2053
+ * An annotation's source may sit in a block nested inside its message (for
2054
+ * example a reasoning panel), so a touched flow item answers to its own key
2055
+ * and every enclosing message key.
2056
+ */
2057
+ function anchorKeysOf(item) {
2058
+ const keys = [];
2059
+ for (let el = item; el; el = el.parentElement?.closest(FLOW_SELECTOR) ?? null) {
2060
+ const key = el.getAttribute("data-chat-flow-key");
2061
+ if (key) keys.push(key);
2062
+ }
2063
+ return keys;
2064
+ }
2065
+ /** Items in a partial pass, grouped by flow key; other messages are not touched. */
2066
+ function groupByFlowKey(items) {
2067
+ const grouped = /* @__PURE__ */ new Map();
2068
+ for (const item of items) for (const key of anchorKeysOf(item)) {
2069
+ const list = grouped.get(key) ?? [];
2070
+ list.push(item);
2071
+ grouped.set(key, list);
2008
2072
  }
2009
- return [...scope.querySelectorAll("[data-chat-flow-key]")].filter((node) => node instanceof HTMLElement);
2073
+ return grouped;
2074
+ }
2075
+ /** The flow item that owns a mutated node, if any. */
2076
+ function ownerFlowItem(node) {
2077
+ if (!node) return null;
2078
+ if (node instanceof Element) return node.closest(FLOW_SELECTOR);
2079
+ return node.parentElement?.closest(FLOW_SELECTOR) ?? null;
2080
+ }
2081
+ function affectedFlowItems(records) {
2082
+ const items = /* @__PURE__ */ new Set();
2083
+ const addOwner = (node) => {
2084
+ const owner = ownerFlowItem(node);
2085
+ if (owner?.isConnected) items.add(owner);
2086
+ };
2087
+ /** An added wrapper can hold several newly rendered messages. */
2088
+ const addSubtree = (node) => {
2089
+ const owner = ownerFlowItem(node);
2090
+ if (owner) {
2091
+ if (owner.isConnected) items.add(owner);
2092
+ return;
2093
+ }
2094
+ if (!(node instanceof Element)) return;
2095
+ for (const nested of node.querySelectorAll(FLOW_SELECTOR)) if (nested.isConnected) items.add(nested);
2096
+ };
2097
+ for (const record of records) {
2098
+ addOwner(record.target);
2099
+ if (record.type !== "childList") continue;
2100
+ for (const node of record.addedNodes) addSubtree(node);
2101
+ }
2102
+ return items;
2010
2103
  }
2011
2104
  /**
2012
2105
  * Whitespace-insensitive comparison: `selection.toString()` inserts newlines
@@ -2133,7 +2226,8 @@ function refreshAnnotationMarkLabels() {
2133
2226
  * offset (nested ranges), which puts their pseudo-element badges on the same
2134
2227
  * pixel; nearby ends can also touch. Measure every last fragment, then write
2135
2228
  * the offset back as variables inherited by `::after`. The badge's transform
2136
- * never participates in text layout.
2229
+ * never participates in text layout. Collisions are resolved inside each
2230
+ * message block, and a stack may not climb into the message above.
2137
2231
  */
2138
2232
  function layoutBadges(scope) {
2139
2233
  const marks = Array.from(scope.querySelectorAll(`[${INDEX_ATTR}]`));
@@ -2145,37 +2239,57 @@ function layoutBadges(scope) {
2145
2239
  const scroller = marks[0]?.closest("[data-conversation-scroll]");
2146
2240
  const scrollerRect = scroller instanceof Element ? scroller.getBoundingClientRect() : null;
2147
2241
  const rightEdge = scrollerRect && scrollerRect.width > 0 ? scrollerRect.right : document.documentElement.clientWidth;
2148
- const topEdge = scrollerRect && scrollerRect.height > 0 ? scrollerRect.top : 0;
2149
- const anchors = marks.map((mark) => {
2150
- const rect = mark.getBoundingClientRect();
2151
- return {
2152
- mark,
2153
- index: Number(mark.getAttribute(INDEX_ATTR) ?? 0),
2154
- x: rect.right + BADGE_MARGIN,
2155
- y: rect.bottom - BADGE_BOTTOM - 15,
2156
- width: 24,
2157
- height: 15,
2158
- visible: rect.width > 0 && rect.height > 0
2159
- };
2160
- }).filter((anchor) => anchor.visible).sort((a, b) => a.index - b.index);
2161
- if (anchors.length < 2) return;
2162
- const offsets = placeBadges(anchors, {
2163
- right: rightEdge - 4,
2164
- top: topEdge + 4
2165
- });
2166
- anchors.forEach((anchor, i) => {
2167
- const { dx, dy } = offsets[i];
2168
- if (dx !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-x", `${dx}px`);
2169
- if (dy !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-y", `${dy}px`);
2170
- });
2242
+ const scrollerTop = scrollerRect && scrollerRect.height > 0 ? scrollerRect.top : 0;
2243
+ const groups = /* @__PURE__ */ new Map();
2244
+ for (const mark of marks) {
2245
+ const owner = mark.closest(FLOW_SELECTOR);
2246
+ const group = groups.get(owner);
2247
+ if (group) group.push(mark);
2248
+ else groups.set(owner, [mark]);
2249
+ }
2250
+ for (const [owner, group] of groups) {
2251
+ if (group.length < 2) continue;
2252
+ const ownerRect = owner?.getBoundingClientRect();
2253
+ const topEdge = Math.max(scrollerTop, ownerRect && ownerRect.height > 0 ? ownerRect.top : scrollerTop) + 4;
2254
+ const anchors = group.map((mark) => {
2255
+ const rect = mark.getBoundingClientRect();
2256
+ return {
2257
+ mark,
2258
+ index: Number(mark.getAttribute(INDEX_ATTR) ?? 0),
2259
+ x: rect.right + 2,
2260
+ y: rect.bottom - 8 - 15,
2261
+ width: 24,
2262
+ height: 15,
2263
+ visible: rect.width > 0 && rect.height > 0
2264
+ };
2265
+ }).filter((anchor) => anchor.visible).sort((a, b) => a.index - b.index);
2266
+ if (anchors.length < 2) continue;
2267
+ const offsets = placeBadges(anchors, {
2268
+ right: rightEdge - 4,
2269
+ top: topEdge
2270
+ });
2271
+ anchors.forEach((anchor, i) => {
2272
+ const { dx, dy } = offsets[i];
2273
+ if (dx !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-x", `${dx}px`);
2274
+ if (dy !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-y", `${dy}px`);
2275
+ });
2276
+ }
2171
2277
  }
2172
- /** Apply every registered job whose message is currently rendered. */
2173
- function applyAnnotationMarks(root) {
2278
+ /**
2279
+ * Apply registered jobs. With `only`, touch just those rendered messages — the
2280
+ * mutation-driven path — instead of resolving and scanning every message for
2281
+ * every job. Badge collisions live inside one message's text flow, so both
2282
+ * paths resolve them per message.
2283
+ */
2284
+ function applyAnnotationMarks(root, only) {
2174
2285
  if (jobs.size === 0) return;
2175
2286
  const scope = root ?? (typeof document === "undefined" ? void 0 : document.body);
2176
2287
  if (!scope) return;
2288
+ const lookup = only ? null : flowLookup(scope);
2289
+ const onlyByKey = only ? groupByFlowKey(only) : null;
2290
+ const onlyItems = only ? [...only].filter((item) => flowKeyOf(item) !== "") : [];
2177
2291
  for (const job of [...jobs.values()]) {
2178
- const items = flowItems(job.messageId, scope);
2292
+ const items = only ? job.messageId === "" ? onlyItems : onlyByKey?.get(job.messageId) ?? [] : job.messageId === "" ? lookup?.all ?? [] : [lookup?.byKey.get(job.messageId)].filter((item) => item !== void 0);
2179
2293
  let decorated = false;
2180
2294
  for (const item of items) {
2181
2295
  if (refreshMarkIndex(item, job)) {
@@ -2187,7 +2301,11 @@ function applyAnnotationMarks(root) {
2187
2301
  if (decorateJob(item, job)) decorated = true;
2188
2302
  }
2189
2303
  }
2190
- layoutBadges(scope);
2304
+ if (!only) {
2305
+ layoutBadges(scope);
2306
+ return;
2307
+ }
2308
+ for (const item of only) if (item.isConnected) layoutBadges(item);
2191
2309
  }
2192
2310
  /** Insert or refresh jobs; identical jobs are no-ops. */
2193
2311
  function upsertAnnotationJobs(next) {
@@ -2234,26 +2352,46 @@ function syncPendingAnnotationJobs(annotations) {
2234
2352
  endOffset: item.sources[0]?.endOffset
2235
2353
  })));
2236
2354
  }
2237
- /** Observe the transcript and re-apply marks after re-renders. Returns disposer. */
2355
+ /**
2356
+ * Observe the transcript and re-apply marks after re-renders. Mutations are
2357
+ * narrowed to the flow items that actually changed; streaming a reply no
2358
+ * longer walks every annotation in the conversation on each frame. Resizes
2359
+ * still force a full pass because line wrapping moves every badge anchor.
2360
+ */
2238
2361
  function watchAnnotationMarks(root) {
2239
2362
  const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
2240
2363
  if (!el || typeof MutationObserver === "undefined") return () => {};
2241
2364
  applyAnnotationMarks(el);
2242
2365
  let frame = 0;
2243
- const schedule = () => {
2244
- if (jobs.size === 0) return;
2245
- if (frame !== 0) return;
2246
- frame = window.requestAnimationFrame(() => {
2247
- frame = 0;
2248
- applyAnnotationMarks(el);
2249
- });
2366
+ let pending = /* @__PURE__ */ new Set();
2367
+ let needsFull = false;
2368
+ const run = () => {
2369
+ frame = 0;
2370
+ const only = pending;
2371
+ const full = needsFull;
2372
+ pending = /* @__PURE__ */ new Set();
2373
+ needsFull = false;
2374
+ if (full) applyAnnotationMarks(el);
2375
+ else if (only.size > 0) applyAnnotationMarks(el, only);
2250
2376
  };
2251
- const observer = new MutationObserver(schedule);
2377
+ const schedule = (records) => {
2378
+ if (jobs.size === 0) {
2379
+ pending.clear();
2380
+ needsFull = false;
2381
+ return;
2382
+ }
2383
+ if (records) for (const item of affectedFlowItems(records)) pending.add(item);
2384
+ if (frame === 0) frame = window.requestAnimationFrame(run);
2385
+ };
2386
+ const observer = new MutationObserver((records) => schedule(records));
2252
2387
  observer.observe(el, {
2253
2388
  childList: true,
2254
2389
  subtree: true
2255
2390
  });
2256
- const onResize = () => schedule();
2391
+ const onResize = () => {
2392
+ needsFull = true;
2393
+ schedule();
2394
+ };
2257
2395
  window.addEventListener("resize", onResize);
2258
2396
  return () => {
2259
2397
  observer.disconnect();
@@ -5676,6 +5814,15 @@ function bindAnnotationMarkPreview() {
5676
5814
  const onClick = (event) => {
5677
5815
  const mark = markOf(event.target);
5678
5816
  if (!mark) return;
5817
+ const index = Number(mark.getAttribute("data-dsq-ann-index"));
5818
+ if (!Number.isFinite(index) || index <= 0) return;
5819
+ if (event.detail !== 0) {
5820
+ const offset = {
5821
+ dx: Number.parseFloat(mark.style.getPropertyValue("--dsq-ann-badge-x")) || 0,
5822
+ dy: Number.parseFloat(mark.style.getPropertyValue("--dsq-ann-badge-y")) || 0
5823
+ };
5824
+ if (!badgeContainsPoint(badgeHitBox(mark.getBoundingClientRect(), index, offset), event.clientX, event.clientY)) return;
5825
+ }
5679
5826
  const selection = window.getSelection();
5680
5827
  if (selection && !selection.isCollapsed) return;
5681
5828
  showMarkPreview(mark, false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-select-quote",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "description": "DeepSeek Harness web plugin: annotate text selected in the conversation — the selection rides along with your next message, is marked in the source text, and the model points back at it.",
5
5
  "keywords": [
6
6
  "deepseek",