one-design-next 0.0.114 → 0.0.116

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.
@@ -122,6 +122,19 @@ export interface ComposerEditorRef {
122
122
  * 配合浮层"用户选了某个 skill"的回调使用。
123
123
  */
124
124
  replaceTriggerWithChip: (data: ChipData) => void;
125
+ /**
126
+ * 在内容末尾插入 raw value + chips(`ComposerRef.insertContent` 的编辑器实现)。
127
+ *
128
+ * 与粘贴 / 草稿还原共用 `insertSerializedAtRange`:一次 mutation 只抛一次
129
+ * onChange,派生 hint / 高度 / trigger 也只重算一次。
130
+ *
131
+ * - `focus: false`:不聚焦、不滚动,也不主动改写 selection(后台预填草稿)
132
+ */
133
+ insertSerializedAtEnd: (value: string, chips: ChipData[], options?: {
134
+ focus?: boolean;
135
+ }) => void;
136
+ /** 当前 raw value(含 marker,DOM 实时值;不依赖 React state 是否已 flush) */
137
+ getValue: () => string;
125
138
  /** 主动取消当前触发态(不修改 DOM,只清状态 + 抛 onTriggerChange(null))。 */
126
139
  cancelTrigger: () => void;
127
140
  /** 取最新的 trigger rect,便于浮层在滚动 / resize 时刷新位置。 */
@@ -23,13 +23,13 @@ import _slicedToArray from "@babel/runtime/helpers/esm/slicedToArray";
23
23
  import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react';
24
24
  import { createPortal } from 'react-dom';
25
25
  import ScrollArea from "../scroll-area";
26
- import { caretBeforeHintIn, caretRangeFromPoint, findChipHostBeforeCaret, getCurrentLine, isCaretBeforeHint, isCaretOnHintLine, isPointOnHint, isPositionAfterHint, mergeLineIntoPreviousAtCaretStart, normalizeCollapsedCaretAtChipBoundary, normalizeCollapsedCaretBeforeHint, resolveCaretMove, splitLineAt, wouldArrowCrossHint } from "./caret";
26
+ import { caretBeforeHintIn, caretRangeFromPoint, findChipHostBeforeCaret, getCaretBeforeHint, getCurrentLine, isCaretBeforeHint, isCaretOnHintLine, isPointOnHint, isPositionAfterHint, mergeLineIntoPreviousAtCaretStart, normalizeCollapsedCaretAtChipBoundary, normalizeCollapsedCaretBeforeHint, resolveCaretMove, splitLineAt, wouldArrowCrossHint } from "./caret";
27
27
  import { buildClipboardPayload, COMPOSER_CLIPBOARD_MIME, parseClipboardPayload, plainTextFromClipboardPayload, remapClipboardPayload } from "./clipboard";
28
28
  import { useChipManager } from "./hooks/useChipManager";
29
29
  import { useChipSelectionMarker } from "./hooks/useChipSelectionMarker";
30
30
  import { chipHasEditableParams, ComposerInlineRef } from "./inline-ref";
31
31
  import { resolveHintTarget } from "./segments";
32
- import { COMPOSER_HINT_ATTR, createLineRoot, createLineRootWithText, ensureLineRoots, getPlainText, isEditorEmpty, LINE_ATTR_NAME, normalizeEditorDom, probeTrigger, stripInvisibleChars, ZWSP } from "./utils";
32
+ import { COMPOSER_HINT_ACCEPT_ATTR, COMPOSER_HINT_ACCEPT_SELECTOR, COMPOSER_HINT_ATTR, createLineRoot, createLineRootWithText, ensureCaretAnchorAfter, ensureLineRoots, getPlainText, isEditorEmpty, isLineRoot, LINE_ATTR_NAME, normalizeEditorDom, probeTrigger, stripInvisibleChars, ZWSP } from "./utils";
33
33
 
34
34
  /** Composer editor 触发态对外信号——告诉父组件该弹浮层选 skill 了。 */
35
35
 
@@ -82,6 +82,137 @@ var scrollCaretIntoView = function scrollCaretIntoView(viewport) {
82
82
  viewport.scrollTop -= viewportRect.top - rect.top + 4;
83
83
  }
84
84
  };
85
+
86
+ /** insertContent 的目标行:最后一个行容器;没有行模型时退回 editor 本身。 */
87
+ var resolveEndLine = function resolveEndLine(editor) {
88
+ var last = editor.lastElementChild;
89
+ return isLineRoot(last) ? last : editor;
90
+ };
91
+
92
+ /** 行内直接子节点的派生 hint(不是正文,插入必须落在它之前)。 */
93
+ var findDirectHint = function findDirectHint(line) {
94
+ for (var _i = 0, _Array$from = Array.from(line.children); _i < _Array$from.length; _i++) {
95
+ var child = _Array$from[_i];
96
+ if (child.hasAttribute(COMPOSER_HINT_ATTR)) return child;
97
+ }
98
+ return null;
99
+ };
100
+
101
+ /**
102
+ * hint 末尾的「TAB 采纳」小块:点它 = 按 Tab 采纳这段模板文案。
103
+ *
104
+ * 与 hint 文本是同级节点(见 `COMPOSER_HINT_ACCEPT_ATTR` 的说明),由
105
+ * `syncHintNode` 一起插入 / 移除,不进 value / 剪贴板 / segments。
106
+ * 视觉在 `style/index.scss`(`[data-odn-composer-hint-accept]`)。
107
+ */
108
+ var createHintAcceptNode = function createHintAcceptNode() {
109
+ var accept = document.createElement('span');
110
+ accept.setAttribute(COMPOSER_HINT_ACCEPT_ATTR, '');
111
+ accept.contentEditable = 'false';
112
+ /* 刻意不加 role="button" / aria-label / title:
113
+ * - 它不可聚焦、也没有 Enter/Space 激活逻辑,挂 button 语义等于给读屏用户
114
+ * 一个按不动的控件(无障碍反而变成障碍)。真正的键盘路径就是它写着的
115
+ * Tab 键本身,可见文案已经说清动作。
116
+ * - aria-label 会盖掉可见文案,反而违反「可见标签即无障碍名」。
117
+ * - title 是浏览器原生 tooltip:延迟出现、样式不可控、会盖住灰字。 */
118
+
119
+ var key = document.createElement('span');
120
+ key.setAttribute('data-odn-composer-hint-accept-key', '');
121
+ key.textContent = 'TAB';
122
+ var label = document.createElement('span');
123
+ label.setAttribute('data-odn-composer-hint-accept-label', '');
124
+ label.textContent = '采纳';
125
+ accept.append(key, label);
126
+ return accept;
127
+ };
128
+
129
+ /**
130
+ * insertContent 的插入点:最后一个行容器的内容末尾。
131
+ *
132
+ * 不能直接 `selectNodeContents(editor).collapse(false)`:
133
+ * - 行尾的派生 hint 不是内容:落在 hint 之前,避免内容插到灰字后面
134
+ * - 行尾的占位 `<br>` 不是换行:插到它前面。插到后面会让它从「占位」变成
135
+ * 真实换行,`getPlainText` 就会凭空多输出一个 '\n'
136
+ */
137
+ var resolveEndRange = function resolveEndRange(editor) {
138
+ var line = resolveEndLine(editor);
139
+ var range = document.createRange();
140
+ var hint = findDirectHint(line);
141
+ if (hint) {
142
+ var before = getCaretBeforeHint(hint);
143
+ if (before) {
144
+ range.setStart(before.node, before.offset);
145
+ range.collapse(true);
146
+ return range;
147
+ }
148
+ }
149
+ var offset = line.childNodes.length;
150
+ while (offset > 0) {
151
+ var node = line.childNodes[offset - 1];
152
+ if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') {
153
+ offset -= 1;
154
+ continue;
155
+ }
156
+ break;
157
+ }
158
+ range.setStart(line, offset);
159
+ range.collapse(true);
160
+ return range;
161
+ };
162
+
163
+ /**
164
+ * insertContent 之后把 caret 落到新内容末尾。
165
+ *
166
+ * 行尾是 chip 时补一颗不进 value 的 ZWSP 锚点(与 insertChip 一致):
167
+ * 只靠元素偏移落位时,部分浏览器会把 caret 带到结构块之后。
168
+ * 行尾有派生 hint 时仍落在 hint 之前(hint 之后不是合法插入点)。
169
+ */
170
+ var placeCaretAtEnd = function placeCaretAtEnd(editor) {
171
+ var sel = window.getSelection();
172
+ if (!sel) return;
173
+ var line = resolveEndLine(editor);
174
+ var target;
175
+ var hint = findDirectHint(line);
176
+ var beforeHint = hint ? getCaretBeforeHint(hint) : null;
177
+ if (beforeHint) {
178
+ target = beforeHint;
179
+ } else {
180
+ var offset = line.childNodes.length;
181
+ while (offset > 0) {
182
+ var node = line.childNodes[offset - 1];
183
+ if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') {
184
+ offset -= 1;
185
+ continue;
186
+ }
187
+ break;
188
+ }
189
+ var tail = offset > 0 ? line.childNodes[offset - 1] : null;
190
+ if ((tail === null || tail === void 0 ? void 0 : tail.nodeType) === Node.TEXT_NODE) {
191
+ var _tail$textContent;
192
+ target = {
193
+ node: tail,
194
+ offset: ((_tail$textContent = tail.textContent) !== null && _tail$textContent !== void 0 ? _tail$textContent : '').length
195
+ };
196
+ } else if (tail instanceof HTMLElement && tail.hasAttribute('data-odn-composer-chip-host')) {
197
+ var _anchor$textContent;
198
+ var _anchor = ensureCaretAnchorAfter(tail);
199
+ target = {
200
+ node: _anchor,
201
+ offset: ((_anchor$textContent = _anchor.textContent) !== null && _anchor$textContent !== void 0 ? _anchor$textContent : '').length
202
+ };
203
+ } else {
204
+ target = {
205
+ node: line,
206
+ offset: offset
207
+ };
208
+ }
209
+ }
210
+ var range = document.createRange();
211
+ range.setStart(target.node, target.offset);
212
+ range.collapse(true);
213
+ sel.removeAllRanges();
214
+ sel.addRange(range);
215
+ };
85
216
  var LINE_HEIGHT = 24;
86
217
  /** 与 SCSS `padding: 2px … 0` 对齐。10 行 = 10 * 24 + 2 = 242px。 */
87
218
  var EDITOR_PADDING_BLOCK = 2;
@@ -256,12 +387,18 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
256
387
  * 就不存在了,caret 只能落在整个 host 之后(= 提示文本末尾)。
257
388
  *
258
389
  * 它不归 React 管(editor 的 children 由 contenteditable 自治),由 fireChange
259
- * 按结构谓词增删/挪位;`getPlainText` 会整棵子树跳过,所以不进 value / 剪贴板。 */
390
+ * 按结构谓词增删/挪位;`getPlainText` 会整棵子树跳过,所以不进 value / 剪贴板。
391
+ *
392
+ * 末尾的「TAB 采纳」小块是 hint 文本的**兄弟节点**(不是子节点):hint 的
393
+ * textContent 必须保持纯提示文案(采纳时直接落成正文,调试面板也按它读)。 */
260
394
  var hintNodeRef = useRef(null);
395
+ var hintAcceptNodeRef = useRef(null);
261
396
  var removeHintNode = useCallback(function () {
262
- var _hintNodeRef$current;
397
+ var _hintNodeRef$current, _hintAcceptNodeRef$cu;
263
398
  (_hintNodeRef$current = hintNodeRef.current) === null || _hintNodeRef$current === void 0 || _hintNodeRef$current.remove();
264
399
  hintNodeRef.current = null;
400
+ (_hintAcceptNodeRef$cu = hintAcceptNodeRef.current) === null || _hintAcceptNodeRef$cu === void 0 || _hintAcceptNodeRef$cu.remove();
401
+ hintAcceptNodeRef.current = null;
265
402
  }, []);
266
403
  var syncHintNode = useCallback(function (target) {
267
404
  var _Array$from$find, _blankTail;
@@ -281,6 +418,11 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
281
418
  hintNodeRef.current = node;
282
419
  }
283
420
  if (node.textContent !== target.text) node.textContent = target.text;
421
+ var accept = hintAcceptNodeRef.current;
422
+ if (!accept || !accept.isConnected) {
423
+ accept = createHintAcceptNode();
424
+ hintAcceptNodeRef.current = accept;
425
+ }
284
426
 
285
427
  /* 落位:命中 chip 之后、**紧随其后的不可见锚点文本(ZWSP)之后**。
286
428
  *
@@ -295,8 +437,9 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
295
437
  var cur = host.nextSibling;
296
438
  while (cur) {
297
439
  var _cur$textContent;
298
- // 跳过 hint 自身:上一轮若把它排到了锚点前面,这里顺手挪回末尾
299
- if (cur === node) {
440
+ // 跳过 hint 自身与它的「TAB 采纳」小块:上一轮若排到了锚点前面,
441
+ // 这里顺手挪回末尾
442
+ if (cur === node || cur === accept) {
300
443
  cur = cur.nextSibling;
301
444
  continue;
302
445
  }
@@ -323,6 +466,12 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
323
466
  if (node.parentNode !== parent || node.previousSibling !== anchor) {
324
467
  parent.insertBefore(node, anchor.nextSibling);
325
468
  }
469
+ /* 「TAB 采纳」小块紧跟 hint 文案之后(同一套行内流),顺序必须是
470
+ * hint → 小块:caret 的合法落点只到 hint 之前,块本身
471
+ * contentEditable=false,不会成为落点。 */
472
+ if (accept.parentNode !== parent || accept.previousSibling !== node) {
473
+ parent.insertBefore(accept, node.nextSibling);
474
+ }
326
475
  }, [removeHintNode]);
327
476
 
328
477
  /** 把 caret 直接落到 hint 之前(= chip 右侧);没有 hint 时不动。 */
@@ -427,26 +576,45 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
427
576
  recomputeTriggerRef.current = recomputeTrigger;
428
577
 
429
578
  /* ─────────────────────────────────────
430
- * Tab 采纳 hint
579
+ * 采纳 hint(Tab / 点击「TAB 采纳」小块)
431
580
  *
432
581
  * hint 只是结构投影(不进 value);caret 恰好停在 chip 与 hint 之间
433
582
  * (= 内容末尾)时按 Tab,表示用户接受这段模板,于是把提示文案落成正文:
434
583
  * chip 之后出现内容 → 结构谓词不再命中 → 灰字自动移除,onChange 抛出的
435
584
  * value 里带上这段文案,与用户自己敲进去的效果一致。
585
+ *
586
+ * 鼠标点末尾的「TAB 采纳」小块走**同一条链路**,只是带 `force`:
587
+ * 不要求点击前 caret 就停在 hint 之前,先把 caret 归位到 chip 右侧再落。
436
588
  * ───────────────────────────────────── */
437
- var acceptHintAsText = useCallback(function () {
589
+ var acceptHintAsText = useCallback(function (options) {
438
590
  var _hint$textContent;
439
591
  var el = editorRef.current;
440
592
  var hint = hintNodeRef.current;
441
593
  var sel = window.getSelection();
442
- if (!el || !hint || !hint.isConnected || !sel || sel.rangeCount === 0) {
443
- return false;
594
+ if (!el || !hint || !hint.isConnected || !sel) return false;
595
+ var range;
596
+ if (options !== null && options !== void 0 && options.force) {
597
+ /* 点击路径:mousedown 已被 preventDefault,焦点 / caret 都还没进来,
598
+ * 先聚焦再把 caret 落到 hint 之前(= 内容末尾),避免原生落点乱跳。 */
599
+ el.focus({
600
+ preventScroll: true
601
+ });
602
+ var target = caretBeforeHintIn(el);
603
+ if (!target) return false;
604
+ range = document.createRange();
605
+ range.setStart(target.node, target.offset);
606
+ range.collapse(true);
607
+ sel.removeAllRanges();
608
+ sel.addRange(range);
609
+ lastEditorRangeRef.current = range.cloneRange();
610
+ } else {
611
+ if (sel.rangeCount === 0) return false;
612
+ range = sel.getRangeAt(0);
613
+ if (!range.collapsed || !el.contains(range.startContainer)) return false;
614
+ /* 只认「chip 之后、hint 之前」这一个落点;caret 在别处时 Tab 仍归浏览器,
615
+ * 避免在任意位置误吞焦点切换。 */
616
+ if (!isCaretBeforeHint(range, el)) return false;
444
617
  }
445
- var range = sel.getRangeAt(0);
446
- if (!range.collapsed || !el.contains(range.startContainer)) return false;
447
- /* 只认「chip 之后、hint 之前」这一个落点;caret 在别处时 Tab 仍归浏览器,
448
- * 避免在任意位置误吞焦点切换。 */
449
- if (!isCaretBeforeHint(range, el)) return false;
450
618
  var text = (_hint$textContent = hint.textContent) !== null && _hint$textContent !== void 0 ? _hint$textContent : '';
451
619
  if (!text) return false;
452
620
 
@@ -463,6 +631,7 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
463
631
  caret.collapse(true);
464
632
  sel.removeAllRanges();
465
633
  sel.addRange(caret);
634
+ lastEditorRangeRef.current = caret.cloneRange();
466
635
  }
467
636
  fireChange();
468
637
  recomputeTrigger();
@@ -536,6 +705,33 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
536
705
  onTriggerChange === null || onTriggerChange === void 0 || onTriggerChange(null);
537
706
  _insertChip(data, el);
538
707
  },
708
+ insertSerializedAtEnd: function insertSerializedAtEnd(value, nextChips, options) {
709
+ var el = editorRef.current;
710
+ if (!el || !value) return;
711
+ ensureLineRoots(el);
712
+ var range = resolveEndRange(el);
713
+ var shouldFocus = (options === null || options === void 0 ? void 0 : options.focus) !== false;
714
+ /* 拿不到 Selection 时仍要把内容插进去,只是不主动落 caret。 */
715
+ var sel = shouldFocus ? window.getSelection() : null;
716
+ if (sel) {
717
+ sel.removeAllRanges();
718
+ sel.addRange(range);
719
+ }
720
+ insertSerializedAtRange(value, nextChips, range, el, {
721
+ focus: shouldFocus,
722
+ caret: !!sel
723
+ });
724
+ if (!shouldFocus) return;
725
+ placeCaretAtEnd(el);
726
+ if (sel && sel.rangeCount > 0) {
727
+ lastEditorRangeRef.current = sel.getRangeAt(0).cloneRange();
728
+ }
729
+ requestScrollCaretIntoView();
730
+ },
731
+ getValue: function getValue() {
732
+ var el = editorRef.current;
733
+ return el ? getPlainText(el) : '';
734
+ },
539
735
  cancelTrigger: function cancelTrigger() {
540
736
  if (!triggerRef.current) return;
541
737
  triggerRef.current = null;
@@ -558,7 +754,7 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
558
754
  return editorRef.current;
559
755
  }
560
756
  };
561
- }, [onChange, _insertChip, removeChip, resetChips, onTriggerChange, getChips, updateChipData, getChipHost]);
757
+ }, [onChange, _insertChip, insertSerializedAtRange, requestScrollCaretIntoView, removeChip, resetChips, onTriggerChange, getChips, updateChipData, getChipHost]);
562
758
 
563
759
  /* ─────────────────────────────────────
564
760
  * 监听 selection 变化:方向键 / 鼠标点击移位都要重算 trigger
@@ -931,6 +1127,17 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
931
1127
  if (e.button !== 0 || disabled) return;
932
1128
  // shift+click 是扩选,不劫持
933
1129
  if (e.shiftKey) return;
1130
+
1131
+ /* 「TAB 采纳」小块:点它等价于按 Tab(同一条采纳链路),且不要求点击前
1132
+ * caret 就停在 hint 之前。在 mousedown 阶段接管 + preventDefault,
1133
+ * 原生 caret 一帧都不会落到 hint 之后。 */
1134
+ if (e.target instanceof Element && e.target.closest(COMPOSER_HINT_ACCEPT_SELECTOR)) {
1135
+ e.preventDefault();
1136
+ acceptHintAsText({
1137
+ force: true
1138
+ });
1139
+ return;
1140
+ }
934
1141
  var el = editorRef.current;
935
1142
  var hint = hintNodeRef.current;
936
1143
  if (!el || !hint || !hint.isConnected) return;
@@ -961,7 +1168,7 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
961
1168
  });
962
1169
  placeCaretBeforeHint();
963
1170
  requestScrollCaretIntoView();
964
- }, [disabled, handlePointerInput, placeCaretBeforeHint, requestScrollCaretIntoView]);
1171
+ }, [acceptHintAsText, disabled, handlePointerInput, placeCaretBeforeHint, requestScrollCaretIntoView]);
965
1172
  var writeClipboardPayload = useCallback(function (e, payload) {
966
1173
  if (!payload) return false;
967
1174
  e.clipboardData.setData(COMPOSER_CLIPBOARD_MIME, JSON.stringify(payload));
@@ -35,9 +35,13 @@ export interface UseChipManagerReturn {
35
35
  * `focus: false` 时插入后不把焦点交给编辑器。
36
36
  * 首屏还原 defaultValue / defaultChips 必须传 false:默认 focus() 会把
37
37
  * 页面滚动到编辑器(浏览器「聚焦元素进入视口」),导致进页面就跳位置。
38
+ *
39
+ * `caret: false` 时不主动改写 selection(后台预填用,避免抢走用户
40
+ * 在别处的光标)。单行路径由调用方传自己的 range,不受影响。
38
41
  */
39
42
  options?: {
40
43
  focus?: boolean;
44
+ caret?: boolean;
41
45
  }) => void;
42
46
  /** 移除已从 DOM 摘下的 chip 状态(cut / 选区删除后)。 */
43
47
  syncChipsAfterDomMutation: (editor: HTMLElement) => void;
@@ -16,7 +16,7 @@ import _slicedToArray from "@babel/runtime/helpers/esm/slicedToArray";
16
16
  */
17
17
 
18
18
  import { useCallback, useRef, useState } from 'react';
19
- import { createLineRoot, ensureLineRoots, forEachMarkerSegment, getCurrentLineRoot, LINE_ATTR_NAME, stripInvisibleChars, ZWSP } from "../utils";
19
+ import { createLineRoot, ensureCaretAnchorAfter, ensureLineRoots, forEachMarkerSegment, getCurrentLineRoot, LINE_ATTR_NAME, stripInvisibleChars } from "../utils";
20
20
 
21
21
  /** chip 在 React 状态中的形态:data + 对应的 DOM host。 */
22
22
 
@@ -27,24 +27,6 @@ function createChipHost(data) {
27
27
  host.setAttribute('data-odn-composer-chip-host', '');
28
28
  return host;
29
29
  }
30
-
31
- /**
32
- * 在 host 后面垫一个「不进 value」的 ZWSP 文本锚点并返回它。
33
- *
34
- * chip 后面没有正文时(行尾 / 后面只剩派生 hint / 后面紧跟着另一个 chip),
35
- * caret 若只靠「元素偏移」落位,部分浏览器会把它带到结构块之后;垫一个只含
36
- * ZWSP 的文本节点,caret 始终有真实文本落点,而 `getPlainText` 会把整段剔掉,
37
- * 因此不会进 value / segments / 剪贴板。已有锚点直接复用,避免越积越多。
38
- */
39
- function ensureCaretAnchorAfter(host) {
40
- var _tail$textContent, _tail$textContent2;
41
- var parent = host.parentNode;
42
- var tail = host.nextSibling;
43
- var reusable = (tail === null || tail === void 0 ? void 0 : tail.nodeType) === Node.TEXT_NODE && ((_tail$textContent = tail.textContent) !== null && _tail$textContent !== void 0 ? _tail$textContent : '') !== '' && stripInvisibleChars((_tail$textContent2 = tail.textContent) !== null && _tail$textContent2 !== void 0 ? _tail$textContent2 : '') === '' ? tail : null;
44
- var anchor = reusable !== null && reusable !== void 0 ? reusable : document.createTextNode(ZWSP);
45
- if (!reusable) parent.insertBefore(anchor, tail);
46
- return anchor;
47
- }
48
30
  function createCaretAnchorAtBoundary(parent, offset) {
49
31
  var _parent$childNodes, _parent$childNodes$sa;
50
32
  var len = parent.childNodes.length;
@@ -255,7 +237,7 @@ onAfterMutate) {
255
237
  }
256
238
 
257
239
  // caret 落「拼回点」——粘贴段末尾、原 caret 后内容的起点。
258
- var sel = window.getSelection();
240
+ var sel = (options === null || options === void 0 ? void 0 : options.caret) === false ? null : window.getSelection();
259
241
  if (sel && lastLine) {
260
242
  var r = document.createRange();
261
243
  r.setStart(lastLine, caretBoundaryChildCount);
@@ -271,7 +253,7 @@ onAfterMutate) {
271
253
  _line.appendChild(lineFrags[_i]);
272
254
  editor.appendChild(_line);
273
255
  }
274
- var _sel = window.getSelection();
256
+ var _sel = (options === null || options === void 0 ? void 0 : options.caret) === false ? null : window.getSelection();
275
257
  var _lastLine = editor.querySelector(":scope > [".concat(LINE_ATTR_NAME, "]:last-of-type"));
276
258
  if (_sel && _lastLine) {
277
259
  var _r = document.createRange();
@@ -2,8 +2,10 @@ import React from 'react';
2
2
  import type { Attachment, ChipData, ComposerSegment, SendMeta, SkillItem } from '../_genui-types';
3
3
  import type { IconName } from '../icon';
4
4
  import { type SkillSlotProps } from '../skill-slot';
5
+ import { type ComposerInsertOptions, type ComposerInsertPart, type ComposerInsertResult } from './insert';
5
6
  import type { ComposerParamPanelContext } from './param-panel';
6
7
  import './style';
8
+ export type { ComposerInsertOptions, ComposerInsertPart, ComposerInsertResult, } from './insert';
7
9
  export type { ComposerParamPanelContext } from './param-panel';
8
10
  export type ComposerTool = 'attachment' | 'webSearch' | 'skill';
9
11
  export type ComposerVariant = 'full' | 'lite';
@@ -48,6 +50,38 @@ export interface ComposerRef {
48
50
  * 返回插入的 chip 实例 id,可用于后续 `updateChipData` 更新状态。
49
51
  */
50
52
  insertSkill: (skill: SkillItem) => string;
53
+ /**
54
+ * 命令式:插入一段内容,支持文本 / skill / 自定义 chip 混合,且只触发一次 onChange。
55
+ *
56
+ * 插入位置由 `options.position` 显式声明(当前仅支持 `'end'`):忽略 caret,
57
+ * 始终落在已有内容末尾——适合 Welcome Card / 建议项 / 外部面板「把东西塞进
58
+ * 输入框」的场景。与 `insertSkill`(依赖当前 caret)互补。
59
+ *
60
+ * `parts` 也接受单个 part 或一个字符串(等价于单个 text part)。
61
+ *
62
+ * 约定:
63
+ * - **不自动补空格**:文本与 chip 的间隔由业务在 text part 里显式写。
64
+ * - `skill` 走与菜单插入相同的 chip 结构(含 `hint` / `initialState`);
65
+ * `initialState: 'pending'` 且提供 `renderParamPanel` 时自动打开参数面板
66
+ * (`focus: false` 的后台预填不打开)。
67
+ * - `chip` 是逃生舱:mention / 自定义 kind / 已存草稿都可以直接传。
68
+ * - chip id 与编辑器已有 chip 冲突时自动换新 id,以返回的 `chipIds` 为准。
69
+ * - 受控 `value` 场景请在 onChange 内回写,与手动输入一致(否则一帧后会被
70
+ * controlled sync 拉回旧值)。
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * composerRef.current?.insertContent(
75
+ * [
76
+ * { type: 'text', text: '请基于这份数据' },
77
+ * { type: 'skill', skill: reportSkill },
78
+ * { type: 'text', text: '生成复盘报告' },
79
+ * ],
80
+ * { position: 'end' },
81
+ * );
82
+ * ```
83
+ */
84
+ insertContent: (parts: ComposerInsertPart | ComposerInsertPart[] | string, options: ComposerInsertOptions) => ComposerInsertResult;
51
85
  /**
52
86
  * 更新编辑器内指定 chip 的数据(partial merge)。
53
87
  * 典型用法:`updateChipData(chipId, { state: 'pending' })` 将 chip 置为待补充态。
@@ -1,9 +1,9 @@
1
1
  import _extends from "@babel/runtime/helpers/esm/extends";
2
2
  import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2";
3
- import _toConsumableArray from "@babel/runtime/helpers/esm/toConsumableArray";
4
3
  import _createForOfIteratorHelper from "@babel/runtime/helpers/esm/createForOfIteratorHelper";
5
4
  import _regeneratorRuntime from "@babel/runtime/helpers/esm/regeneratorRuntime";
6
5
  import _asyncToGenerator from "@babel/runtime/helpers/esm/asyncToGenerator";
6
+ import _toConsumableArray from "@babel/runtime/helpers/esm/toConsumableArray";
7
7
  import _slicedToArray from "@babel/runtime/helpers/esm/slicedToArray";
8
8
  import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
9
9
  import { SKILL_CATEGORY_ICON } from "../_genui-inline-ref";
@@ -18,23 +18,10 @@ import SkillSlot from "../skill-slot";
18
18
  import Tooltip from "../tooltip";
19
19
  import { COMPOSER_EDITOR_MAX_ROWS_PX, ComposerEditor } from "./editor";
20
20
  import { chipHasEditableParams, isMentionChip } from "./inline-ref";
21
+ import { buildInsertPayload, createSkillChipData } from "./insert";
21
22
  import { rawValueToSegments, segmentsHasContent } from "./segments";
22
23
  import "./style";
23
24
  import { COMPOSER_SKILL_MENU_DENSITY_CAP_PX, filterSkillsByQuery, resolveComposerSkillMenuMaxHeight, resolveComposerSkillMenuWidth, shouldOpenSlashTriggerMenu } from "./utils";
24
- function createInvocationChipData(skill) {
25
- return {
26
- id: "skill-".concat(skill.id, "-").concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 6)),
27
- label: skill.label,
28
- icon: skill.icon,
29
- kind: 'invocation',
30
- state: skill.initialState,
31
- refId: skill.id,
32
- payload: {},
33
- /* 提示文案随 chip 快照下来(而不是渲染时回查技能目录):
34
- * 草稿用 defaultValue + defaultChips 还原时,目录可能已经变了。 */
35
- hint: skill.hint
36
- };
37
- }
38
25
  var TOOL_BTN_ACTIVE = 'color-mix(in srgb, var(--odn-color-brand-6) 10%, transparent)';
39
26
  var DEFAULT_ACCEPT = 'image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.py,.java,.go,.rs,.c,.cpp,.zip,.rar,.7z';
40
27
  function formatSizeMb(bytes) {
@@ -322,12 +309,49 @@ export var Composer = /*#__PURE__*/forwardRef(function Composer(_ref, ref) {
322
309
  },
323
310
  insertSkill: function insertSkill(skill) {
324
311
  var _editorRef$current8, _editorRef$current9;
325
- var data = createInvocationChipData(skill);
312
+ var data = createSkillChipData(skill);
326
313
  (_editorRef$current8 = editorRef.current) === null || _editorRef$current8 === void 0 || _editorRef$current8.insertChip(data);
327
314
  (_editorRef$current9 = editorRef.current) === null || _editorRef$current9 === void 0 || _editorRef$current9.focus();
328
315
  if (skill.initialState === 'pending') openParamPanel(data.id);
329
316
  return data.id;
330
317
  },
318
+ insertContent: function insertContent(parts, options) {
319
+ var editor = editorRef.current;
320
+ if (!editor) return {
321
+ chipIds: [],
322
+ value: ''
323
+ };
324
+
325
+ /* 类型层 position 目前只开 'end';这里兜住 JS / any 调用方,
326
+ * 避免未来扩 'caret' 前被静默当成末尾处理。 */
327
+ var position = options === null || options === void 0 ? void 0 : options.position;
328
+ if (process.env.NODE_ENV !== 'production' && position && position !== 'end') {
329
+ // eslint-disable-next-line no-console
330
+ console.warn("[Composer] insertContent \u5F53\u524D\u4EC5\u652F\u6301 position: \"end\"\uFF0C\u6536\u5230\u300C".concat(position, "\u300D\uFF0C\u5DF2\u6309\u672B\u5C3E\u63D2\u5165\u5904\u7406\u3002"));
331
+ }
332
+ var payload = buildInsertPayload(parts, editor.getChips());
333
+ if (!payload) {
334
+ return {
335
+ chipIds: [],
336
+ value: editor.getValue()
337
+ };
338
+ }
339
+ editor.insertSerializedAtEnd(payload.value, payload.chips, options);
340
+
341
+ /* 与 insertSkill 对齐:pending skill 插入后自动打开参数面板。
342
+ * 一次 insertContent 可能带多个 pending,只开最后一个(用户最后看到的那块)。
343
+ * `focus: false` 是后台预填语义,不主动弹面板。 */
344
+ var pendingChip = _toConsumableArray(payload.chips).reverse().find(function (chip) {
345
+ return chip.state === 'pending' && !isMentionChip(chip);
346
+ });
347
+ if (pendingChip && (options === null || options === void 0 ? void 0 : options.focus) !== false) {
348
+ openParamPanel(pendingChip.id);
349
+ }
350
+ return {
351
+ chipIds: payload.chipIds,
352
+ value: editor.getValue()
353
+ };
354
+ },
331
355
  updateChipData: function updateChipData(id, patch) {
332
356
  var _editorRef$current10;
333
357
  return (_editorRef$current10 = editorRef.current) === null || _editorRef$current10 === void 0 ? void 0 : _editorRef$current10.updateChipData(id, patch);
@@ -644,7 +668,7 @@ export var Composer = /*#__PURE__*/forwardRef(function Composer(_ref, ref) {
644
668
  * source = 'trigger' → editor.replaceTriggerWithChip 替换 `/` + query 整段
645
669
  * 任一入口选完后都关闭对应 menu。chip data 用 skill.id + 一次性实例 id。 */
646
670
  var handleSelectSkill = useCallback(function (skill, source) {
647
- var data = createInvocationChipData(skill);
671
+ var data = createSkillChipData(skill);
648
672
  if (source === 'trigger') {
649
673
  var _editorRef$current16;
650
674
  (_editorRef$current16 = editorRef.current) === null || _editorRef$current16 === void 0 || _editorRef$current16.replaceTriggerWithChip(data);
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Composer · 命令式插入内容(insertContent)
3
+ *
4
+ * 业务场景:Welcome Card / 建议项 / 外部面板希望在「已有输入内容的末尾」一次性
5
+ * 塞入文本、skill chip 等,而不是依赖当前 caret(用户可能停在任意位置)。
6
+ *
7
+ * 职责边界:
8
+ * - 本模块只把 parts 编成 `{ value, chips }`(含 marker 的 raw value + 新 chip
9
+ * 快照),真正的 DOM 插入由 editor 的 `insertSerializedAtEnd` →
10
+ * `insertSerializedAtRange` 完成——与粘贴 / 草稿还原共用同一条链路,
11
+ * 因此 onChange / 派生 hint / 高度 / trigger 只会重算一次。
12
+ * - **不自动补空格**:文本与 chip 之间的间隔由业务自己在 text part 里写,
13
+ * 避免 value / segments 里出现用户没输入过的字符(与插入 skill 的既有约定一致)。
14
+ */
15
+ import type { ChipData, SkillItem } from '../_genui-types';
16
+ /**
17
+ * insertContent 支持的内容片段:
18
+ * - `text`:普通文本(支持 `\n`;`\r\n` 会归一成 `\n`)
19
+ * - `skill`:按技能目录的条目生成一个 invocation chip(含 hint / initialState)
20
+ * - `chip`:逃生舱,业务自带完整 chip 数据(mention / 自定义 kind / 已存草稿)
21
+ */
22
+ export type ComposerInsertPart = {
23
+ type: 'text';
24
+ text: string;
25
+ } | {
26
+ type: 'skill';
27
+ skill: SkillItem;
28
+ } | {
29
+ type: 'chip';
30
+ chip: ChipData;
31
+ };
32
+ export interface ComposerInsertOptions {
33
+ /**
34
+ * 插入位置。
35
+ *
36
+ * 当前仅支持 `'end'`(已有内容末尾);`'caret'` 预留在同一入口,
37
+ * 未来扩展时**不会改变现有调用**,`'end'` 的语义也保持不变。
38
+ */
39
+ position: 'end';
40
+ /**
41
+ * 插入后是否聚焦编辑器,并把 caret 落到新内容末尾(同时滚到可见区)。
42
+ * 传 `false` 时只改内容,不动焦点、不滚动(如后台预填草稿)。
43
+ * @default true
44
+ */
45
+ focus?: boolean;
46
+ }
47
+ export interface ComposerInsertResult {
48
+ /** 实际插入的 chip id,顺序与 parts 中 chip 出现顺序一致;id 冲突时已换新 */
49
+ chipIds: string[];
50
+ /** 插入后的 raw value(含 marker),等价于本次 onChange 第一参 */
51
+ value: string;
52
+ }
53
+ /** buildInsertPayload 的产物:可直接交给 editor 插入。 */
54
+ export interface ComposerInsertPayload {
55
+ /** 待插入的 raw value(唯一数据源:marker 与 text 交替) */
56
+ value: string;
57
+ /** 待插入 chip 的完整数据(只含本次新增,不含编辑器里已有的) */
58
+ chips: ChipData[];
59
+ /** 本次实际使用的 chip id(与 chips 一一对应) */
60
+ chipIds: string[];
61
+ }
62
+ /**
63
+ * 按 SkillItem 生成 chip 实例数据。
64
+ *
65
+ * hint 随 chip 快照下来(而不是渲染时回查技能目录):草稿用
66
+ * `defaultValue + defaultChips` 还原时,目录可能已经变了。
67
+ */
68
+ export declare function createSkillChipData(skill: SkillItem): ChipData;
69
+ /**
70
+ * 把 parts 编成一次插入所需的 raw value + chips。
71
+ *
72
+ * 返回 `null` 表示没有可插入内容(空数组 / 全空文本),调用方应直接 no-op,
73
+ * 不要触发 onChange。
74
+ */
75
+ export declare function buildInsertPayload(parts: ComposerInsertPart | ComposerInsertPart[] | string, existingChips?: readonly ChipData[]): ComposerInsertPayload | null;
@@ -0,0 +1,130 @@
1
+ import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2";
2
+ import _typeof from "@babel/runtime/helpers/esm/typeof";
3
+ import _createForOfIteratorHelper from "@babel/runtime/helpers/esm/createForOfIteratorHelper";
4
+ /**
5
+ * Composer · 命令式插入内容(insertContent)
6
+ *
7
+ * 业务场景:Welcome Card / 建议项 / 外部面板希望在「已有输入内容的末尾」一次性
8
+ * 塞入文本、skill chip 等,而不是依赖当前 caret(用户可能停在任意位置)。
9
+ *
10
+ * 职责边界:
11
+ * - 本模块只把 parts 编成 `{ value, chips }`(含 marker 的 raw value + 新 chip
12
+ * 快照),真正的 DOM 插入由 editor 的 `insertSerializedAtEnd` →
13
+ * `insertSerializedAtRange` 完成——与粘贴 / 草稿还原共用同一条链路,
14
+ * 因此 onChange / 派生 hint / 高度 / trigger 只会重算一次。
15
+ * - **不自动补空格**:文本与 chip 之间的间隔由业务自己在 text part 里写,
16
+ * 避免 value / segments 里出现用户没输入过的字符(与插入 skill 的既有约定一致)。
17
+ */
18
+
19
+ import { createMarker, stripInvisibleChars } from "./utils";
20
+
21
+ /**
22
+ * insertContent 支持的内容片段:
23
+ * - `text`:普通文本(支持 `\n`;`\r\n` 会归一成 `\n`)
24
+ * - `skill`:按技能目录的条目生成一个 invocation chip(含 hint / initialState)
25
+ * - `chip`:逃生舱,业务自带完整 chip 数据(mention / 自定义 kind / 已存草稿)
26
+ */
27
+
28
+ /** buildInsertPayload 的产物:可直接交给 editor 插入。 */
29
+
30
+ var randomSuffix = function randomSuffix() {
31
+ return "".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 6));
32
+ };
33
+
34
+ /**
35
+ * 按 SkillItem 生成 chip 实例数据。
36
+ *
37
+ * hint 随 chip 快照下来(而不是渲染时回查技能目录):草稿用
38
+ * `defaultValue + defaultChips` 还原时,目录可能已经变了。
39
+ */
40
+ export function createSkillChipData(skill) {
41
+ return {
42
+ id: "skill-".concat(skill.id, "-").concat(randomSuffix()),
43
+ label: skill.label,
44
+ icon: skill.icon,
45
+ kind: 'invocation',
46
+ state: skill.initialState,
47
+ refId: skill.id,
48
+ payload: {},
49
+ hint: skill.hint
50
+ };
51
+ }
52
+
53
+ /**
54
+ * 文本预处理:
55
+ * - `\r\n` / `\r` → `\n`,交给行模型拆行
56
+ * - 剔除零宽 marker 字符,避免业务文案里混入 `\u200b\u2063` 后被解析成 chip
57
+ * - 不 trim:首尾空格是业务显式传入的内容,库不替它做决定
58
+ */
59
+ function normalizeText(text) {
60
+ return stripInvisibleChars(text.replace(/\r\n?/g, '\n'));
61
+ }
62
+
63
+ /**
64
+ * chip id 必须唯一:marker、Portal key、removeChip / updateChipData 都靠它定位。
65
+ * 冲突时换一个新 id,并在开发期提示——返回值里的 `chipIds` 才是最终生效的 id。
66
+ */
67
+ function resolveUniqueChipId(chip, usedIds) {
68
+ if (chip.id && !usedIds.has(chip.id)) return chip.id;
69
+ var nextId = "".concat(chip.id || 'chip', "-").concat(randomSuffix());
70
+ while (usedIds.has(nextId)) nextId = "chip-".concat(randomSuffix());
71
+ if (process.env.NODE_ENV !== 'production') {
72
+ // eslint-disable-next-line no-console
73
+ console.warn("[Composer] insertContent \u6536\u5230\u91CD\u590D\u6216\u4E3A\u7A7A\u7684 chip id\u300C".concat(chip.id, "\u300D\uFF0C") + "\u5DF2\u6539\u7528\u300C".concat(nextId, "\u300D\u3002\u8BF7\u4EE5\u8FD4\u56DE\u7684 chipIds \u4E3A\u51C6\u3002"));
74
+ }
75
+ return nextId;
76
+ }
77
+
78
+ /**
79
+ * 把 parts 编成一次插入所需的 raw value + chips。
80
+ *
81
+ * 返回 `null` 表示没有可插入内容(空数组 / 全空文本),调用方应直接 no-op,
82
+ * 不要触发 onChange。
83
+ */
84
+ export function buildInsertPayload(parts) {
85
+ var existingChips = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];
86
+ var list = typeof parts === 'string' ? [{
87
+ type: 'text',
88
+ text: parts
89
+ }] : Array.isArray(parts) ? parts : [parts];
90
+ var usedIds = new Set(existingChips.map(function (chip) {
91
+ return chip.id;
92
+ }));
93
+ var chips = [];
94
+ var chipIds = [];
95
+ var value = '';
96
+ var _iterator = _createForOfIteratorHelper(list),
97
+ _step;
98
+ try {
99
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
100
+ var part = _step.value;
101
+ if (!part || _typeof(part) !== 'object') continue;
102
+ if (part.type === 'text') {
103
+ if (typeof part.text === 'string') value += normalizeText(part.text);
104
+ continue;
105
+ }
106
+ var chip = null;
107
+ if (part.type === 'skill') {
108
+ chip = part.skill ? createSkillChipData(part.skill) : null;
109
+ } else if (part.chip) {
110
+ chip = _objectSpread({}, part.chip);
111
+ }
112
+ if (!chip) continue;
113
+ chip.id = resolveUniqueChipId(chip, usedIds);
114
+ usedIds.add(chip.id);
115
+ chips.push(chip);
116
+ chipIds.push(chip.id);
117
+ value += createMarker(chip.id);
118
+ }
119
+ } catch (err) {
120
+ _iterator.e(err);
121
+ } finally {
122
+ _iterator.f();
123
+ }
124
+ if (!value) return null;
125
+ return {
126
+ value: value,
127
+ chips: chips,
128
+ chipIds: chipIds
129
+ };
130
+ }
@@ -309,6 +309,74 @@
309
309
  white-space: pre-wrap;
310
310
  }
311
311
 
312
+ /* ------------------------------------------------------------------
313
+ * hint 末尾的「TAB 采纳」小块(点击 = 按 Tab 采纳,见 editor.tsx)
314
+ *
315
+ * 与 hint 文本是同级节点,一起增删。它不是正文:contentEditable=false、
316
+ * getPlainText 整棵跳过,点它走 force 版 acceptHintAsText。
317
+ * 尺寸对齐 Figma 9857:75425(62 × 22 / 圆角 6 / 内层键帽 28 × 16):
318
+ * - 编辑器固定 font-size 14 / line-height 24,块高 22 + top 1 → 视觉居中
319
+ * 且不撑高行盒(改编辑器行高时要同步调这个 1px)
320
+ * - 底色 black-2(5%)、键帽 bg-elevated + black-5(12%) 1px 描边
321
+ * ----------------------------------------------------------------- */
322
+ [data-odn-composer] [data-odn-composer-hint-accept] {
323
+ display: inline-flex;
324
+ align-items: center;
325
+ justify-content: center;
326
+ gap: 3px;
327
+ box-sizing: border-box;
328
+ height: 22px;
329
+ margin: 1px 0 0 4px;
330
+ padding: 0 4px 0 3px;
331
+ border-radius: 6px;
332
+ background: var(--odn-color-black-2);
333
+ color: var(--odn-color-solid-black-9);
334
+ font-size: 12px;
335
+ line-height: 20px;
336
+ white-space: nowrap;
337
+ vertical-align: top;
338
+ cursor: pointer;
339
+ user-select: none;
340
+ pointer-events: auto;
341
+ transition: background 0.15s ease-out, color 0.15s ease-out;
342
+ /* 底部 5% 太淡,hover 抬到 12%(约 2.4 倍)并加深文字,让「可点」更明确;
343
+ * active 再深一档。 */
344
+ }
345
+ [data-odn-composer] [data-odn-composer-hint-accept]:hover {
346
+ background: var(--odn-color-black-5);
347
+ color: var(--odn-color-solid-black-10);
348
+ }
349
+ [data-odn-composer] [data-odn-composer-hint-accept]:active {
350
+ background: var(--odn-color-black-6);
351
+ }
352
+
353
+ [data-odn-composer] [data-odn-composer-hint-accept-key] {
354
+ display: inline-flex;
355
+ align-items: center;
356
+ justify-content: center;
357
+ box-sizing: border-box;
358
+ height: 16px;
359
+ padding: 0 4px;
360
+ border: 1px solid var(--odn-color-black-5);
361
+ border-radius: 4px;
362
+ /* 与 composer box 同为「浮在底层之上的面」,跟随主题变量而不是写死白色 */
363
+ background: var(--odn-color-bg-elevated);
364
+ font-size: 10px;
365
+ line-height: 10px;
366
+ letter-spacing: 0;
367
+ }
368
+
369
+ [data-odn-composer] [data-odn-composer-hint-accept-label] {
370
+ font-size: 12px;
371
+ line-height: 20px;
372
+ }
373
+
374
+ /* 禁用态:hint 仍按内容结构展示,但小块不可点(与 editor 冻结一致) */
375
+ [data-odn-composer] [data-odn-composer-editor][data-odn-composer-editor-disabled] [data-odn-composer-hint-accept] {
376
+ cursor: not-allowed;
377
+ pointer-events: none;
378
+ }
379
+
312
380
  /* ------------------------------------------------------------------
313
381
  * Block 行模型(caret-rewrite step 1/5)
314
382
  * editor 直接子节点都是 [data-composer-line] div,每行渲染为独立 block。
@@ -22,6 +22,15 @@ export declare const LINE_ATTR_NAME = "data-composer-line";
22
22
  */
23
23
  export declare const COMPOSER_HINT_ATTR = "data-odn-composer-skill-hint";
24
24
  export declare const COMPOSER_HINT_SELECTOR = "[data-odn-composer-skill-hint]";
25
+ /**
26
+ * 派生 hint 末尾的「TAB 采纳」小块(可点击采纳)。
27
+ *
28
+ * 与 hint 文本是**兄弟节点**(不是子节点):hint 自身的 `textContent` 必须
29
+ * 保持纯提示文案——采纳时直接把这段文字落成正文,且 `getPlainText` /
30
+ * 调试面板都按 textContent 读它。小块同样不是正文,遍历时整棵跳过。
31
+ */
32
+ export declare const COMPOSER_HINT_ACCEPT_ATTR = "data-odn-composer-hint-accept";
33
+ export declare const COMPOSER_HINT_ACCEPT_SELECTOR = "[data-odn-composer-hint-accept]";
25
34
  /** 是否是行容器。 */
26
35
  export declare function isLineRoot(node: Node | null | undefined): node is HTMLDivElement;
27
36
  /** 创建一个新的空行容器。 */
@@ -113,6 +122,15 @@ export declare function getPlainText(editor: HTMLElement, chipIdAttr?: string):
113
122
  * 空格 / 全角空格也算"已输入",只要文本非空就该收起 placeholder。
114
123
  */
115
124
  export declare function isEditorEmpty(editor: HTMLElement, chipIdAttr?: string): boolean;
125
+ /**
126
+ * 在 host 后面垫一个「不进 value」的 ZWSP 文本锚点并返回它。
127
+ *
128
+ * chip 后面没有正文时(行尾 / 后面只剩派生 hint / 后面紧跟着另一个 chip),
129
+ * caret 若只靠「元素偏移」落位,部分浏览器会把它带到结构块之后;垫一个只含
130
+ * ZWSP 的文本节点,caret 始终有真实文本落点,而 `getPlainText` 会把整段剔掉,
131
+ * 因此不进 value / segments / 剪贴板。已有锚点直接复用,避免越积越多。
132
+ */
133
+ export declare function ensureCaretAnchorAfter(host: Node): Text;
116
134
  /**
117
135
  * 清理 contenteditable 删字后的幽灵节点:空 text、仅 chip 时的占位 <br>。
118
136
  * 避免「文字 + invocation」删光文字后出现多余换行,以及 caret 卡在 chip 后误删 invocation。
@@ -39,6 +39,16 @@ export var LINE_ATTR_NAME = 'data-composer-line';
39
39
  export var COMPOSER_HINT_ATTR = 'data-odn-composer-skill-hint';
40
40
  export var COMPOSER_HINT_SELECTOR = "[".concat(COMPOSER_HINT_ATTR, "]");
41
41
 
42
+ /**
43
+ * 派生 hint 末尾的「TAB 采纳」小块(可点击采纳)。
44
+ *
45
+ * 与 hint 文本是**兄弟节点**(不是子节点):hint 自身的 `textContent` 必须
46
+ * 保持纯提示文案——采纳时直接把这段文字落成正文,且 `getPlainText` /
47
+ * 调试面板都按 textContent 读它。小块同样不是正文,遍历时整棵跳过。
48
+ */
49
+ export var COMPOSER_HINT_ACCEPT_ATTR = 'data-odn-composer-hint-accept';
50
+ export var COMPOSER_HINT_ACCEPT_SELECTOR = "[".concat(COMPOSER_HINT_ACCEPT_ATTR, "]");
51
+
42
52
  /** 是否是行容器。 */
43
53
  export function isLineRoot(node) {
44
54
  var _hasAttribute, _ref;
@@ -292,8 +302,8 @@ export function getPlainText(editor) {
292
302
  var out = '';
293
303
  var visit = function visit(node) {
294
304
  if (node instanceof HTMLElement) {
295
- // 派生 hint 整棵子树都不是正文,直接跳过(含其内部文本节点)
296
- if (node.hasAttribute(COMPOSER_HINT_ATTR)) return;
305
+ // 派生 hint(含末尾的「TAB 采纳」小块)整棵子树都不是正文,直接跳过
306
+ if (node.hasAttribute(COMPOSER_HINT_ATTR) || node.hasAttribute(COMPOSER_HINT_ACCEPT_ATTR)) return;
297
307
  var id = node.dataset[chipIdAttr];
298
308
  if (id) {
299
309
  out += createMarker(id);
@@ -368,6 +378,24 @@ export function isEditorEmpty(editor) {
368
378
  * chip 前的 leading <br> / 空 text node。
369
379
  * ──────────────────────────────────────────────────────────────────── */
370
380
 
381
+ /**
382
+ * 在 host 后面垫一个「不进 value」的 ZWSP 文本锚点并返回它。
383
+ *
384
+ * chip 后面没有正文时(行尾 / 后面只剩派生 hint / 后面紧跟着另一个 chip),
385
+ * caret 若只靠「元素偏移」落位,部分浏览器会把它带到结构块之后;垫一个只含
386
+ * ZWSP 的文本节点,caret 始终有真实文本落点,而 `getPlainText` 会把整段剔掉,
387
+ * 因此不进 value / segments / 剪贴板。已有锚点直接复用,避免越积越多。
388
+ */
389
+ export function ensureCaretAnchorAfter(host) {
390
+ var _tail$textContent, _tail$textContent2;
391
+ var parent = host.parentNode;
392
+ var tail = host.nextSibling;
393
+ var reusable = (tail === null || tail === void 0 ? void 0 : tail.nodeType) === Node.TEXT_NODE && ((_tail$textContent = tail.textContent) !== null && _tail$textContent !== void 0 ? _tail$textContent : '') !== '' && stripInvisibleChars((_tail$textContent2 = tail.textContent) !== null && _tail$textContent2 !== void 0 ? _tail$textContent2 : '') === '' ? tail : null;
394
+ var anchor = reusable !== null && reusable !== void 0 ? reusable : document.createTextNode(ZWSP);
395
+ if (!reusable) parent.insertBefore(anchor, tail);
396
+ return anchor;
397
+ }
398
+
371
399
  /**
372
400
  * 清理 contenteditable 删字后的幽灵节点:空 text、仅 chip 时的占位 <br>。
373
401
  * 避免「文字 + invocation」删光文字后出现多余换行,以及 caret 卡在 chip 后误删 invocation。
package/dist/index.d.ts CHANGED
@@ -63,6 +63,7 @@ export { CHAT_LIST_PINNED_ID, CHAT_LIST_UNGROUPED_ID, default as ChatList, match
63
63
  export { CLARIFY_DOCK_MAX_OPTIONS, CLARIFY_DOCK_SKIPPED_LABEL, CLARIFY_DOCK_SKIPPED_VALUE, ClarifyDockPanel, ClarifyDockTrigger, clarifyDockLayoutId, parseClarifyBoundDate, toISODate, useClarifyDockState, type ClarifyDockAnswerItem, type ClarifyDockHostMessage, type ClarifyDockMessageState, type ClarifyDockPanelProps, type ClarifyDockQuestion, type ClarifyDockResolvePayload, type ClarifyDockSpec, type ClarifyDockState, type ClarifyDockTriggerProps, type ClarifyInput, type ClarifyOption, } from './clarify-dock';
64
64
  export { default as CodeBlock, type CodeBlockProps } from './code-block';
65
65
  export { default as Composer, type ComposerProps, type ComposerRef, type ComposerSkillSlotProps, type ComposerTool, type ComposerToolConfig, } from './composer';
66
+ export type { ComposerInsertOptions, ComposerInsertPart, ComposerInsertResult, } from './composer/insert';
66
67
  export { type ComposerParamPanelContext } from './composer/param-panel';
67
68
  export { default as ErrorBlock, type ErrorBlockProps } from './error-block';
68
69
  export { default as Fab, type FabProps } from './fab';
@@ -7,6 +7,7 @@ import Icon from "../icon";
7
7
  import Input from "../input";
8
8
  import Popover from "../popover";
9
9
  import { highlightQueryText } from "../stream-text/highlight-query-text";
10
+ import Tooltip from "../tooltip";
10
11
  import "./style";
11
12
  function skillMatchesQuery(skill, query) {
12
13
  var _skill$description;
@@ -367,8 +368,15 @@ export var SkillSlot = /*#__PURE__*/forwardRef(function SkillSlot(_ref2, ref) {
367
368
  };
368
369
  var renderBarItem = function renderBarItem(skill) {
369
370
  var disabled = !!skill.disabled;
370
- var barButton = /*#__PURE__*/React.createElement("button", {
371
+
372
+ /* bar 无行内描述:整条 bar 由外层 Tooltip.Group 共用一层浮层(暗色,见 style)。
373
+ * 这里只把「本项的描述」交给 Group,hover 绑定由 Group 自己挂到 button 上。 */
374
+ return /*#__PURE__*/React.createElement(Tooltip, {
371
375
  key: skill.id,
376
+ popup: skill.description ? /*#__PURE__*/React.createElement("span", {
377
+ "data-odn-skill-slot-desc-full": true
378
+ }, skill.description) : null
379
+ }, /*#__PURE__*/React.createElement("button", {
372
380
  type: "button",
373
381
  "data-odn-skill-slot-item": true,
374
382
  "data-odn-skill-slot-item-disabled": disabled || undefined,
@@ -379,21 +387,8 @@ export var SkillSlot = /*#__PURE__*/forwardRef(function SkillSlot(_ref2, ref) {
379
387
  }
380
388
  }, /*#__PURE__*/React.createElement(Icon, {
381
389
  name: skill.icon,
382
- size: 14
383
- }), skill.label);
384
-
385
- /* bar 无行内描述:整颗 chip hover 0.5s 后向上 Popover 出全文 */
386
- if (skill.description) {
387
- return /*#__PURE__*/React.createElement(Popover, {
388
- key: skill.id,
389
- placement: "top",
390
- mouseEnterDelay: 0.5,
391
- popup: /*#__PURE__*/React.createElement("span", {
392
- "data-odn-skill-slot-desc-full": true
393
- }, skill.description)
394
- }, barButton);
395
- }
396
- return barButton;
390
+ size: 16
391
+ }), skill.label));
397
392
  };
398
393
  var listBody = skills.map(function (skill, index) {
399
394
  if (variant === 'card') return renderCardItem(skill);
@@ -461,10 +456,27 @@ export var SkillSlot = /*#__PURE__*/forwardRef(function SkillSlot(_ref2, ref) {
461
456
  } : {}), variant !== 'menu' && itemWidth !== undefined && itemWidth !== null ? {
462
457
  '--odn-skill-slot-item-width': "".concat(itemWidth, "px")
463
458
  } : {}), style);
464
- if (variant !== 'menu') {
459
+
460
+ /* bar:整条 bar 是一组 Tooltip(与 preview-panel 右上角按钮同款手感)——
461
+ * 共用一层浮层,已展开时在块之间横移是 0 延迟换文案 + 浮层跟着滑,
462
+ * 移出整组 200ms 后才收起,不会一块一块地闪断。
463
+ * 容器本身就是 Tooltip.Group 的 Tag,data-* / className / style 一并落上去。 */
464
+ if (variant === 'bar') {
465
+ return /*#__PURE__*/React.createElement(Tooltip.Group, {
466
+ as: "div",
467
+ "data-odn-skill-slot": true,
468
+ "data-odn-skill-slot-variant": "bar",
469
+ className: className,
470
+ style: rootStyle,
471
+ placement: "top",
472
+ mouseEnterDelay: 0.1,
473
+ popupClassName: "odn-skill-slot-bar-popup"
474
+ }, listBody);
475
+ }
476
+ if (variant === 'card') {
465
477
  return /*#__PURE__*/React.createElement("div", {
466
478
  "data-odn-skill-slot": true,
467
- "data-odn-skill-slot-variant": variant,
479
+ "data-odn-skill-slot-variant": "card",
468
480
  className: className,
469
481
  style: rootStyle
470
482
  }, listBody);
@@ -237,39 +237,71 @@
237
237
  margin: 0;
238
238
  }
239
239
 
240
- /* ---- variant: bar ---- */
240
+ /* ---- variant: bar ----
241
+ * 白底胶囊 chip:999 圆角,14/22 文案 + 16 icon,块间距 12。
242
+ * 设计稿:默认态**无描边**(描边在稿里是隐藏的),hover 才浮出黑-3 细描边,
243
+ * 底色/文字不变——所以预置 1px 透明描边占位,避免 hover 时宽度跳 2px。 */
241
244
  [data-odn-skill-slot][data-odn-skill-slot-variant=bar] {
242
245
  display: flex;
243
246
  flex-wrap: wrap;
244
- gap: 8px;
247
+ gap: 12px;
245
248
  }
246
249
 
247
250
  [data-odn-skill-slot][data-odn-skill-slot-variant=bar] [data-odn-skill-slot-item] {
248
251
  display: inline-flex;
249
252
  align-items: center;
250
- gap: 6px;
251
- height: 32px;
253
+ gap: 4px;
254
+ height: 36px;
252
255
  padding: 0 12px;
253
- border-radius: var(--odn-border-radius);
254
- border: 1px solid var(--odn-color-black-6);
255
- background: var(--odn-color-bg-elevated);
256
- font-size: 13px;
257
- color: var(--odn-color-black-10);
256
+ border-radius: 999px;
257
+ border: 1px solid transparent;
258
+ background: var(--odn-color-white);
259
+ box-sizing: border-box;
260
+ /* 不参与压缩:容器放不下就整块换行。否则窄容器里 flex 会把块挤到
261
+ * 比文案还窄,14/22 的文案折成两行、溢出固定 36px 的胶囊。 */
262
+ flex: 0 0 auto;
263
+ white-space: nowrap;
264
+ font-size: 14px;
265
+ font-weight: 400;
266
+ line-height: 22px;
267
+ color: var(--odn-color-black-12);
258
268
  transition: border-color 0.15s, background 0.15s, color 0.15s;
259
269
  }
260
270
  [data-odn-skill-slot][data-odn-skill-slot-variant=bar] [data-odn-skill-slot-item]:not([data-odn-skill-slot-item-disabled]):hover {
261
- border-color: var(--odn-color-black-7);
262
- background: var(--odn-color-black-3);
263
- color: var(--odn-color-black-12);
271
+ border-color: var(--odn-color-black-3);
264
272
  }
265
273
  [data-odn-skill-slot][data-odn-skill-slot-variant=bar] [data-odn-skill-slot-item]:not([data-odn-skill-slot-item-disabled]):active {
266
- background: var(--odn-color-black-4);
274
+ border-color: var(--odn-color-black-4);
267
275
  }
268
276
  [data-odn-skill-slot][data-odn-skill-slot-variant=bar] [data-odn-skill-slot-item]:not([data-odn-skill-slot-item-disabled]):focus-visible {
269
277
  outline: none;
270
278
  border-color: var(--odn-color-brand-6);
271
- background: var(--odn-color-black-3);
272
- color: var(--odn-color-black-12);
279
+ }
280
+
281
+ /* bar chip hover 的全文提示:暗色浮层(设计稿「文字提示」:向上、多行)。
282
+ * 整条 bar 走 Tooltip.Group,浮层是 tooltip;popupClassName 落在
283
+ * [data-odn-popup-content] 上,用 [data-type] 兜住 pop-base 的
284
+ * popover 白底 / tooltip #404040 两套默认值。 */
285
+ [data-odn-popup][data-type] [data-odn-popup-content].odn-skill-slot-bar-popup {
286
+ max-width: 400px;
287
+ padding: 4px 8px;
288
+ border-radius: 4px;
289
+ background-color: var(--odn-color-solid-black-11);
290
+ color: var(--odn-color-white);
291
+ font-size: 14px;
292
+ font-weight: 400;
293
+ line-height: 22px;
294
+ }
295
+
296
+ [data-odn-popup][data-type] [data-odn-popup-content].odn-skill-slot-bar-popup [data-odn-popup-arrow-fill] {
297
+ fill: var(--odn-color-solid-black-11);
298
+ }
299
+
300
+ /* 浮层纯视觉遮挡,不接收鼠标:pointer-events 可继承,给外层一次即可。
301
+ * 多行 bar 里浮层会压在上一行的块上,若吃掉事件,块↔块之间就换不过去,
302
+ * 也会挡住下层页面/输入框。 */
303
+ [data-odn-popup]:has(> [data-odn-popup-content].odn-skill-slot-bar-popup) {
304
+ pointer-events: none;
273
305
  }
274
306
 
275
307
  /* ---- variant: card ---- */
@@ -366,4 +398,12 @@
366
398
  color: var(--odn-color-black-9);
367
399
  word-break: break-word;
368
400
  white-space: pre-wrap;
401
+ }
402
+
403
+ /* bar 的全文浮层里换成暗底白字 14/22(宽度由 .odn-skill-slot-bar-popup 的 400 兜住) */
404
+ .odn-skill-slot-bar-popup [data-odn-skill-slot-desc-full] {
405
+ max-width: 100%;
406
+ font-size: 14px;
407
+ line-height: 22px;
408
+ color: var(--odn-color-white);
369
409
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "one-design-next",
3
- "version": "0.0.114",
3
+ "version": "0.0.116",
4
4
  "description": "One Design Next from TAD@tencent.com",
5
5
  "license": "MIT",
6
6
  "type": "module",